Files @ 55f5833aa071
Branch filter:

Location: NPO-Accounting/oxrlib/oxrlib/config.py

Brett Smith
historical: Add a setting to denominate Ledger conversions.

This makes conversion output easier to add to ledgers directly.
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')
    NO_DENOMINATION = object()
    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(
            '--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.NO_DENOMINATION,
            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 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,
                            *, 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 _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)
        if self.args.denomination is self.NO_DENOMINATION:
            self.args.denomination = None
        else:
            self._read_from_conffile('denomination', 'Historical', None, currency_code)
        self._read_from_conffile('signed_currencies', 'Historical', self.args.base,
                                 currency_list, convert_fallback=True)
        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