import argparse import configparser import datetime import decimal import os.path import pathlib import babel import babel.dates import babel.numbers from . import cache, loaders from .commands import historical 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') LOCALE = babel.core.Locale.default() SENTINEL = object() CURRENCY_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 = [] if self.DEFAULT_CONFIG_PATH.exists(): self.args.config_file.append(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))) retval = self.TODAY.replace(**replacements) if retval > self.TODAY: if 'year' in replacements: pass elif 'month' in replacements: retval = retval.replace(year=retval.year - 1) else: retval -= datetime.timedelta(days=replacements['day']) retval = retval.replace(**replacements) return retval, 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, from_date=None, to_currency=None, output_format=None, ) hist_parser.add_argument( '--base', metavar='CODE', type=currency_code, help="Base currency (default USD)", ) hist_parser.add_argument( '--output-format', type=historical.Formats.from_arg, help="Output format." " Choices are `raw`, `ledger`, `beancount`." " Default `raw`.", ) # --ledger and --no-ledger predate --output-format. hist_parser.add_argument( '--ledger', '-L', action='store_const', dest='output_format', const=historical.Formats.LEDGER, help=argparse.SUPPRESS, ) hist_parser.add_argument( '--no-ledger', action='store_const', dest='output_format', const=historical.Formats.RAW, help=argparse.SUPPRESS, ) hist_parser.add_argument( '--denomination', metavar='CODE', type=currency_code, help="In Ledger conversion output, always show rates to convert " "to this currency", ) hist_parser.add_argument( '--no-denomination', dest='denomination', action='store_const', const=self.SENTINEL, help="Turn off an earlier --denomination 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 the year or month are not specified, defaults to today's year or month." ) hist_parser.add_argument( 'word1', nargs='?', metavar='amount', help="Convert this amount of currency. If not specified, show rates.", ) hist_parser.add_argument( 'word2', 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( 'word3', nargs='?', metavar='second code', help="Convert or show rates to this currency, in three-letter code format. " "If not specified, defaults to the user's preferred currency.", ) hist_parser.add_argument( 'word4', nargs='?', metavar='from date', help="Include source rates for this date, if provided. " "Raw output format does not show source rates.", ) hist_parser.add_argument('word5', nargs='?', help=argparse.SUPPRESS) hist_parser.add_argument('word6', 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, *, convert_fallback=False): 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 and (value is not fallback or convert_fallback)): 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 _user_currency(self, default=SENTINEL): try: return babel.numbers.get_territory_currencies( self.LOCALE.territory, start_date=self.TODAY)[0] except IndexError: return default def _post_hook_historical(self): self.args.date, date_spec = self.args.date 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) if self.args.denomination is self.SENTINEL: self.args.denomination = None else: self._read_from_conffile('denomination', 'Historical', None, currency_code) pref_currency = self.args.denomination or self._user_currency(self.args.base) self._read_from_conffile('signed_currencies', 'Historical', pref_currency, currency_list, convert_fallback=True) self._read_from_conffile('output_format', 'Historical', None, historical.Formats.from_arg) if self.args.output_format is None: if self.conffile.getboolean('Historical', 'ledger', fallback=None): self.args.output_format = historical.Formats.LEDGER else: self.args.output_format = historical.Formats.RAW raw_words = iter(getattr(self.args, 'word' + c) for c in '123456') words = iter(word for word in raw_words if word is not None) try: next_word = next(words) try: self.args.amount = decimal.Decimal(next_word) except decimal.InvalidOperation: pass else: # If an amount was given, a currency code must be given too. # If it wasn't, set a value that can't be parsed as a currency. next_word = next(words, 'none given') self.args.from_currency = self._convert_or_error(currency_code, next_word) except StopIteration: pass for next_word in words: next_lower = next_word.lower() if next_lower in self.CURRENCY_PREPOSITIONS: attr_to_set = 'to_currency' next_word = next(words, 'none given') elif next_lower == 'from': attr_to_set = 'from_date' next_word = next(words, 'none given') elif next_word.isalpha(): attr_to_set = 'to_currency' else: attr_to_set = 'from_date' have_arg = getattr(self.args, attr_to_set) if have_arg is not None: self.error(f"tried to set {attr_to_set.replace('_', ' ')} multiple times") elif attr_to_set == 'from_date': convert_func = lambda s: self._date_from_s(s)[0] typename = 'date' else: convert_func = currency_code typename = None setattr(self.args, attr_to_set, self._convert_or_error( convert_func, next_word, attr_to_set, typename, )) if self.args.to_currency is None: self.args.to_currency = pref_currency if ((len(date_spec) == 1) and self.args.from_currency and (self.args.amount is None)): self.error(("ambiguous input: " "Did you want rates for {args.from_currency} on {date}, " "or to convert {amt} {args.from_currency} to {args.to_currency}?\n" "Specify more of the date to disambiguate." ).format( args=self.args, date=babel.dates.format_date(self.args.date), amt=date_spec['day'], )) 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