Changeset - d0f5f1547ced
[Not reviewed]
0 3 0
Brett Smith - 7 years ago 2017-06-29 21:18:47
brettcsmith@brettcsmith.org
config: Error out when historical arguments are ambiguous.
3 files changed with 25 insertions and 2 deletions:
0 comments (0 inline, 0 general)
oxrlib/config.py
Show inline comments
 
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
 

	
 
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()
 
    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)))
 
        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
 
        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,
 
            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.SENTINEL,
 
            help="Turn off an earlier --denomination setting",
 
        )
 
        hist_parser.add_argument(
...
 
@@ -146,107 +147,120 @@ class Configuration:
 
        )
 
        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='?', 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('ledger', 'Historical', False, getter='getboolean')
 
        raw_words = iter(getattr(self.args, 'word' + c) for c in '1234')
 
        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)
 
            next_word = next(words)
 
            if next_word.lower() in self.PREPOSITIONS:
 
                next_word = next(words, next_word)
 
            self.args.to_currency = self._convert_or_error(currency_code, next_word)
 
        except StopIteration:
 
            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
setup.py
Show inline comments
 
#!/usr/bin/env python3
 

	
 
from setuptools import setup
 

	
 
setup(
 
    name='oxrlib',
 
    description="Library to query the Open Exchange Rates (OXR) API",
 
    version='1.6',
 
    version='1.7',
 
    author='Brett Smith',
 
    author_email='brettcsmith@brettcsmith.org',
 
    license='GNU AGPLv3+',
 

	
 
    install_requires=['babel'],
 
    setup_requires=['pytest-runner'],
 
    tests_require=['pytest'],
 

	
 
    packages=['oxrlib', 'oxrlib.commands'],
 
    entry_points={
 
        'console_scripts': ['oxrquery = oxrlib.__main__:main'],
 
    },
 
)
tests/test_Configuration.py
Show inline comments
...
 
@@ -83,48 +83,57 @@ def test_historical_argparsing_failure(arglist, any_date):
 
    arglist = ['historical', any_date.isoformat()] + arglist
 
    try:
 
        config = config_from(os.devnull, arglist)
 
    except SystemExit:
 
        pass
 
    else:
 
        assert not vars(config.args), "bad arglist succeeded"
 

	
 
@pytest.mark.parametrize('date_s,expect_year,expect_month,expect_day', [
 
    ('5', 1965, 7, 5),
 
    ('05', 1965, 7, 5),
 
    ('14', 1965, 7, 14),
 
    ('15', 1965, 7, 15),
 
    ('16', 1965, 6, 16),
 
    ('3-6', 1965, 3, 6),
 
    ('11.10', 1964, 11, 10),
 
    ('07-14', 1965, 7, 14),
 
    ('07/15', 1965, 7, 15),
 
    ('7.16', 1964, 7, 16),
 
    ('917/12/12', 917, 12, 12),
 
    ('2017-11-1', 2017, 11, 1),
 
])
 
def test_good_date_parsing(date_s, expect_year, expect_month, expect_day):
 
    oxrlib.config.Configuration.TODAY = datetime.date(1965, 7, 15)
 
    config = config_from(os.devnull, ['historical', date_s])
 
    actual_date = config.args.date
 
    assert actual_date.year == expect_year
 
    assert actual_date.month == expect_month
 
    assert actual_date.day == expect_day
 

	
 
@pytest.mark.parametrize('date_s', [
 
    '99',
 
    '8-88',
 
    '77-7',
 
    '0xf-1-2',
 
    '0b1-3-4',
 
    '2017/5.9',
 
    '2018-6/10',
 
    '1-2-3-4',
 
    '12/11/10',
 
])
 
def test_bad_date_parsing(date_s):
 
    try:
 
        config = config_from(os.devnull, ['historical', date_s])
 
    except SystemExit:
 
        pass
 
    else:
 
        assert not config.args.date, "date parsed from {!r}".format(date_s)
 

	
 
def test_ambiguous_arglist_failure():
 
    try:
 
        # It's ambiguous if "2" is "the 2nd" or "2 EUR".
 
        config = config_from(os.devnull, ['historical', '2', 'eur'])
 
    except SystemExit:
 
        pass
 
    else:
 
        assert not config.args, "ambiguous args parsed"
0 comments (0 inline, 0 general)