import argparse import configparser import datetime import decimal import os.path import pathlib from . import cache, loaders HOME_PATH = pathlib.Path(os.path.expanduser('~')) def currency_code(s): if not ((len(s) == 3) and s.isalpha()): raise ValueError("bad currency code: {!r}".format(s)) return s.upper() def currency_list(s): return [currency_code(code.strip()) for code in s.split(',')] class Configuration: DATE_SEPS = frozenset('.-/ ') DEFAULT_CONFIG_PATH = pathlib.Path(HOME_PATH, '.config', 'oxrlib.ini') PREPOSITIONS = frozenset(['in', 'to', 'into']) TODAY = datetime.date.today() def __init__(self, arglist): argparser = self._build_argparser() self.error = argparser.error self.args = argparser.parse_args(arglist) if self.args.config_file is None: self.args.config_file = [self.DEFAULT_CONFIG_PATH] self.conffile = self._build_conffile() conffile_paths = [path.as_posix() for path in self.args.config_file] read_files = self.conffile.read(conffile_paths) for expected_path, read_path in zip(conffile_paths, read_files): if read_path != expected_path: self.error("failed to read configuration file {!r}".format(expected_path)) if not hasattr(self.args, 'command'): argparser.print_help() exit(2) try: post_hook = getattr(self, '_post_hook_' + self.args.command) except AttributeError: pass else: post_hook() def _date_from_s(self, s): clean_s = s.strip() numbers = [] seen_seps = set() start_index = 0 for index, c in (pair for pair in enumerate(clean_s) if pair[1] in self.DATE_SEPS): seen_seps.add(c) numbers.append(int(clean_s[start_index:index], 10)) start_index = index + 1 numbers.append(int(clean_s[start_index:], 10)) if (len(numbers) > 3) or (len(seen_seps) > 1): raise ValueError("can't parse date from {!r}".format(s)) replacements = dict(zip(['day', 'month', 'year'], reversed(numbers))) return self.TODAY.replace(**replacements) def _build_argparser(self): prog_parser = argparse.ArgumentParser() prog_parser.add_argument( '--config-file', '-c', action='append', type=pathlib.Path, help="Path of a configuration file to read", ) subparsers = prog_parser.add_subparsers() hist_parser = subparsers.add_parser( 'historical', aliases=['hist'], usage='%(prog)s [[YYYY-]MM-]DD [[amount] code] [[in] code]', help="Show a currency conversion or rate from a past date", ) hist_parser.set_defaults( command='historical', amount=None, from_currency=None, ledger=None, ) hist_parser.add_argument( '--base', metavar='CODE', type=currency_code, help="Base currency (default USD)", ) hist_parser.add_argument( '--ledger', '-L', action='store_true', help="Output the rate or conversion in Ledger format", ) hist_parser.add_argument( '--no-ledger', action='store_false', dest='ledger', help="Turn off an earlier --ledger setting", ) hist_parser.add_argument( '--signed-currency', '--sign-currency', type=currency_code, action='append', dest='signed_currencies', metavar='CODE', help="In Ledger output, use a sign for this currency if known. " "Can be specified multiple times.", ) hist_parser.add_argument( 'date', type=self._date_from_s, help="Use rates from this date, in YYYY-MM-DD format. " "If you omit the year or month, it fills in the current year/month." ) hist_parser.add_argument( 'word1', nargs='?', metavar='first code', help="Convert or show rates from this currency, in three-letter code format. " "If not specified, show all rates on the given date.", ) hist_parser.add_argument( 'word2', nargs='?', metavar='amount', help="Convert this amount of currency. If not specified, show rates.", ) hist_parser.add_argument( 'word3', nargs='?', metavar='second code', help="Convert to this currency, in three-letter code format. " "If not specified, defaults to the base currency.", ) hist_parser.add_argument('word4', nargs='?', help=argparse.SUPPRESS) return prog_parser def _build_conffile(self): return configparser.ConfigParser() def _read_from_conffile(self, argname, sectionname, fallback, convert_to=None, confname=None, getter='get', unset=None): if getattr(self.args, argname) is not unset: return elif confname is None: confname = argname get_method = getattr(self.conffile, getter) value = get_method(sectionname, confname, fallback=fallback) if convert_to is not None: value = self._convert_or_error(convert_to, value, confname) setattr(self.args, argname, value) def _convert_or_error(self, argtype, s_value, argname=None, typename=None): try: return argtype(s_value) except (decimal.InvalidOperation, TypeError, ValueError): errmsg = [] if argname: errmsg.append("argument {}".format(argname)) if typename is None: typename = argtype.__name__.replace('_', ' ') errmsg.append("invalid {} value".format(typename)) errmsg.append(repr(s_value)) self.error(': '.join(errmsg)) def _post_hook_historical(self): year = self.args.date.year if year < 100: # Don't let the user specify ambiguous dates. self.error("historical data not available from year {}".format(year)) self._read_from_conffile('base', 'Historical', 'USD', currency_code) self._read_from_conffile('signed_currencies', 'Historical', self.args.base, currency_list) self._read_from_conffile('ledger', 'Historical', False, getter='getboolean') self.args.to_currency = self.args.base if self.args.word4 and (self.args.word3.lower() in self.PREPOSITIONS): self.args.word3 = self.args.word4 if self.args.word1 is None: pass elif self.args.word2 is None: self.args.from_currency = self._convert_or_error( currency_code, self.args.word1) else: self.args.amount = self._convert_or_error( decimal.Decimal, self.args.word1) self.args.from_currency = self._convert_or_error( currency_code, self.args.word2) if self.args.word3 is not None: self.args.to_currency = self._convert_or_error( currency_code, self.args.word3 or self.args.base) def _build_cache_loader(self): kwargs = dict(self.conffile.items('Cache')) try: kwargs['dir_path'] = pathlib.Path(kwargs.pop('directory')) except KeyError: pass self.cache = cache.CacheWriter(**kwargs) return loaders.FileCache(**kwargs) def _build_oxrapi_loader(self): kwargs = dict(self.conffile.items('OXR')) return loaders.OXRAPIRequest(**kwargs) def get_loaders(self): loader_chain = loaders.LoaderChain() for build_func in [ self._build_cache_loader, self._build_oxrapi_loader, ]: try: loader = build_func() except (TypeError, ValueError, configparser.NoSectionError): pass else: loader_chain.add_loader(loader) return loader_chain