Compare Revisions
Swap
Showing 33 commits
Merge Ancestor: 0b6edde60c40
33 Brett Smith 5a73d3d8f8d4
4 years ago
32 Brett Smith 77393ee80fde
4 years ago
31 Brett Smith c9382a26044a
4 years ago
30 Brett Smith ae3e4617d31e
4 years ago
29 Brett Smith 8dede9d1398c
4 years ago
28 Brett Smith c3fd55ec15b7
4 years ago
27 Brett Smith e158eae7d9ae
4 years ago
26 Brett Smith 30e9f1c1e88a
4 years ago
25 Brett Smith 5573caf7ee0e
4 years ago
24 Brett Smith 3a3afb79786b
4 years ago
23 Brett Smith 7c11ae408cc8
4 years ago
22 Brett Smith 2d753c31aacd
4 years ago
21 Brett Smith 71893ace4dc0
4 years ago
20 Brett Smith fb2114896d9d
4 years ago
19 Brett Smith 80fd49a98ad8
4 years ago
18 Brett Smith 27dbe14b94eb
4 years ago
17 Brett Smith 9b6d562d46f5
4 years ago
16 Brett Smith a3cc41a5cfea
6 years ago
15 Brett Smith 3b732505fa62
6 years ago
14 Brett Smith cb17033279fb
7 years ago
13 Brett Smith f7a57ded868c
7 years ago
12 Brett Smith d0f5f1547ced
7 years ago
11 Brett Smith b270db02e8d7
7 years ago
10 Brett Smith 8ab2373ba13e
7 years ago
9 tbm 419f52abe36c
7 years ago
8 Brett Smith 97222c2f7545
7 years ago
7 Brett Smith 936237eceb32
7 years ago
6 Brett Smith dfac0cf8539c
7 years ago
5 Brett Smith baa2e883cdfe
7 years ago
4 Brett Smith 55f5833aa071
7 years ago
3 Brett Smith 992c91fc90de
7 years ago
2 Brett Smith 385a492ae735
7 years ago
1 Brett Smith 17ff9a8b71d5
7 years ago
12 files changed with 613 insertions and 148 deletions:
.gitignore
Show inline comments
 
build/
 
.cache/
 
*.egg
 
*.egg-info/
 
.cache/
 
.eggs
 
.mypy_cache/
 
.tox/
 
__pycache__/
README.rst
Show inline comments
...
 
@@ -31,7 +31,7 @@ Here's an example of using the Python library, complete with caching results::
 
      hist_rate = rate.Rate.from_json_file(json_response)
 
  if loader.should_cache():
 
      cache_writer.save_rate(hist_rate)
 
  # Rates are available from the hist_rates.rates dict.
 
  # Rates are available from the hist_rate.rates dict.
 

	
 
Running tests
 
-------------
oxrlib/__main__.py
Show inline comments
...
 
@@ -7,8 +7,17 @@ import oxrlib.config
 
def decimal_context(base=decimal.BasicContext):
 
    context = base.copy()
 
    context.rounding = decimal.ROUND_HALF_EVEN
 
    context.traps[decimal.Inexact] = False
 
    context.traps[decimal.Rounded] = False
 
    context.traps = {
 
        decimal.Clamped: True,
 
        decimal.DivisionByZero: True,
 
        decimal.FloatOperation: True,
 
        decimal.Inexact: False,
 
        decimal.InvalidOperation: True,
 
        decimal.Overflow: True,
 
        decimal.Rounded: False,
 
        decimal.Subnormal: True,
 
        decimal.Underflow: True,
 
    }
 
    return context
 

	
 
def main(arglist=None, stdout=sys.stdout, stderr=sys.stderr):
oxrlib/commands/historical.py
Show inline comments
...
 
@@ -6,16 +6,26 @@ import babel.numbers
 

	
 
from .. import rate as oxrrate
 

	
 
try:
 
    import enum
 
except ImportError:
 
    import enum34 as enum
 

	
 
class Formatter:
 
    def __init__(self, rate, signed_currencies=(), base_fmt='#,##0.###'):
 
        self.rate = rate
 
    def __init__(self, cost_rates, price_rates=None,
 
                 signed_currencies=(), base_fmt='#,##0.###',
 
                 rate_precision=6, denomination=None):
 
        self.cost_rates = cost_rates
 
        self.price_rates = price_rates
 
        self.base_fmt = base_fmt
 
        self.base_fmt_noprec = base_fmt.rsplit('.', 1)[0]
 
        self.signed_currencies = set(code for code in signed_currencies
 
                                     if self.can_sign_currency(code))
 
        self.rate_prec = rate_precision
 
        self.denomination = denomination
 

	
 
    def can_sign_currency(self, code):
 
        return len(babel.numbers.get_currency_symbol(code)) == 1
 
        return False
 

	
 
    def format_currency(self, amount, code, currency_digits=True):
 
        if currency_digits:
...
 
@@ -28,15 +38,38 @@ class Formatter:
 
            fmt = fmt + ' ¤¤'
 
        return babel.numbers.format_currency(amount, code, fmt, currency_digits=currency_digits)
 

	
 
    def format_rate(self, rate):
 
        return "{:g}".format(rate)
 
    def currency_decimal(self, amount, currency):
 
        amt_s = babel.numbers.format_currency(amount, currency, '###0.###')
 
        return decimal.Decimal(amt_s)
 

	
 
    def normalize_rate(self, rate, prec=None):
 
        if prec is None:
 
            prec = self.rate_prec
 
        _, digits, exponent = rate.normalize().as_tuple()
 
        # Return ``prec`` nonzero digits of precision, if available.
 
        prec -= min(0, exponent + len(digits))
 
        quant_to = '1.{}'.format('0' * prec)
 
        try:
 
            qrate = rate.quantize(decimal.Decimal(quant_to))
 
        except decimal.InvalidOperation:
 
            # The original rate doesn't have that much precision, so use it raw.
 
            qrate = rate
 
        return qrate.normalize()
 

	
 
    def format_rate(self, rate, curr, fmt='{}', prec=None):
 
        rate_s = self.format_currency(
 
            self.normalize_rate(rate, prec),
 
            curr,
 
            currency_digits=False,
 
        )
 
        return fmt.format(rate_s)
 

	
 
    def format_rate_pair(self, from_curr, to_curr):
 
        from_amt = 1
 
        to_amt = self.rate.convert(from_amt, from_curr, to_curr)
 
        return "{} {} = {} {}".format(
 
            self.format_rate(from_amt), from_curr,
 
            self.format_rate(to_amt), to_curr,
 
        from_amt = decimal.Decimal(1)
 
        to_amt = self.cost_rates.convert(from_amt, from_curr, to_curr)
 
        return "{} = {}".format(
 
            self.format_rate(from_amt, from_curr),
 
            self.format_rate(to_amt, to_curr),
 
        )
 

	
 
    def format_rate_pair_bidir(self, from_curr, to_curr, sep='\n'):
...
 
@@ -47,66 +80,120 @@ class Formatter:
 
        )
 

	
 
    def format_conversion(self, from_amt, from_curr, to_curr):
 
        to_amt = self.rate.convert(from_amt, from_curr, to_curr)
 
        to_amt = self.cost_rates.convert(from_amt, from_curr, to_curr)
 
        return "{} = {}".format(
 
            self.format_currency(from_amt, from_curr),
 
            self.format_currency(to_amt, to_curr),
 
        )
 

	
 

	
 
class LedgerFormatter(Formatter):
 
    RATE_PREC = 5
 
class BeancountFormatter(Formatter):
 
    COST_FMT = '{{{}}}'
 
    PRICE_FMT = ' @ {}'
 

	
 
    def normalize_rate(self, rate, prec=None):
 
        # Return prec nonzero digits of precision, if available.
 
        if prec is None:
 
            prec = self.RATE_PREC
 
        _, digits, exponent = rate.normalize().as_tuple()
 
        prec -= min(0, exponent + len(digits))
 
        quant_to = '1.{}'.format('0' * prec)
 
        try:
 
            qrate = rate.quantize(decimal.Decimal(quant_to))
 
        except decimal.InvalidOperation:
 
            # The original rate doesn't have that much precision, so use it raw.
 
            qrate = rate
 
        return qrate.normalize()
 

	
 
    def format_rate(self, rate, prec=None):
 
        return str(self.normalize_rate(rate, prec))
 

	
 
    def format_ledger_rate(self, rate, curr, prec=None):
 
        nrate = self.normalize_rate(rate, prec)
 
        rate_s = self.format_currency(nrate, curr, currency_digits=False)
 
        return "{{={0}}} @ {0}".format(rate_s)
 
    def price_rate(self, from_amt, from_curr, to_curr):
 
        if self.price_rates is None:
 
            return None
 
        else:
 
            return self.price_rates.convert(from_amt, from_curr, to_curr)
 

	
 
    def format_rate_pair(self, from_curr, to_curr):
 
        from_amt = 1
 
        to_amt = self.rate.convert(from_amt, from_curr, to_curr)
 
        return "{} {} {}".format(
 
            from_amt, from_curr, self.format_ledger_rate(to_amt, to_curr))
 
        cost = self.cost_rates.convert(from_amt, from_curr, to_curr)
 
        price = self.price_rate(from_amt, from_curr, to_curr)
 
        if price is None:
 
            price_s = ''
 
        else:
 
            price_s = self.format_rate(price, to_curr, self.PRICE_FMT)
 
        return "{} {} {}{}".format(
 
            from_amt,
 
            from_curr,
 
            self.format_rate(cost, to_curr, self.COST_FMT),
 
            price_s,
 
        )
 

	
 
    def _denomination_for(self, currency, default):
 
        if self.denomination is None:
 
            return default
 
        elif self.denomination == currency:
 
            return None
 
        else:
 
            return self.denomination
 

	
 
    def format_denominated_rate(self, amount, currency, default_denomination):
 
        denomination = self._denomination_for(currency, default_denomination)
 
        amt_s = self.format_currency(amount, currency)
 
        if denomination is None:
 
            return amt_s
 
        cost = self.cost_rates.convert(1, currency, denomination)
 
        price = self.price_rate(1, currency, denomination)
 
        to_amt = self.currency_decimal(amount * cost, denomination)
 
        if price is None:
 
            price_s = ''
 
        else:
 
            price_s = self.format_rate(price, denomination, self.PRICE_FMT)
 
        return "{} {}{}".format(
 
            amt_s,
 
            self.format_rate(cost, denomination, self.COST_FMT),
 
            price_s,
 
        )
 

	
 
    def format_conversion(self, from_amt, from_curr, to_curr):
 
        to_rate = self.rate.convert(1, from_curr, to_curr)
 
        to_amt = self.rate.convert(from_amt, from_curr, to_curr)
 
        return "{} {}\n{}".format(
 
            self.format_currency(from_amt, from_curr),
 
            self.format_ledger_rate(to_rate, to_curr),
 
            self.format_currency(to_amt, to_curr),
 
        to_amt = self.cost_rates.convert(from_amt, from_curr, to_curr)
 
        return "{}\n{}".format(
 
            self.format_denominated_rate(from_amt, from_curr, to_curr),
 
            self.format_denominated_rate(to_amt, to_curr, None),
 
        )
 

	
 

	
 
class LedgerFormatter(BeancountFormatter):
 
    COST_FMT = '{{={}}}'
 

	
 
    def can_sign_currency(self, code):
 
        return len(babel.numbers.get_currency_symbol(code)) == 1
 

	
 
    def price_rate(self, from_amt, from_curr, to_curr):
 
        if self.price_rates is None:
 
            rates = self.cost_rates
 
        else:
 
            rates = self.price_rates
 
        return rates.convert(from_amt, from_curr, to_curr)
 

	
 

	
 
class Formats(enum.Enum):
 
    RAW = Formatter
 
    LEDGER = LedgerFormatter
 
    BEANCOUNT = BeancountFormatter
 

	
 
    @classmethod
 
    def from_arg(cls, s):
 
        return cls[s.upper()]
 

	
 

	
 
def load_rates(config, loaders, date):
 
    with loaders.historical(date, config.args.base) as rate_json:
 
        rates = oxrrate.Rate.from_json_file(rate_json)
 
    if loaders.should_cache():
 
        config.cache.save_rate(rates)
 
    return rates
 

	
 
def run(config, stdout, stderr):
 
    loaders = config.get_loaders()
 
    with loaders.historical(config.args.date, config.args.base) as rate_json:
 
        rate = oxrrate.Rate.from_json_file(rate_json)
 
    if loaders.should_cache():
 
        config.cache.save_rate(rate)
 
    if config.args.ledger:
 
        formatter = LedgerFormatter(rate, config.args.signed_currencies)
 
    date_rates = load_rates(config, loaders, config.args.date)
 
    if config.args.from_date is None:
 
        cost_rates = date_rates
 
        price_rates = None
 
    else:
 
        formatter = Formatter(rate)
 
        cost_rates = load_rates(config, loaders, config.args.from_date)
 
        price_rates = date_rates
 
    formatter = config.args.output_format.value(
 
        cost_rates,
 
        price_rates,
 
        config.args.signed_currencies,
 
        denomination=config.args.denomination,
 
    )
 
    if not config.args.from_currency:
 
        for from_curr in sorted(rate.rates):
 
        for from_curr in sorted(cost_rates.rates):
 
            print(formatter.format_rate_pair_bidir(from_curr, config.args.to_currency),
 
                  file=stdout)
 
    elif config.args.amount is None:
oxrlib/config.py
Show inline comments
...
 
@@ -5,7 +5,12 @@ 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('~'))
 

	
...
 
@@ -17,14 +22,13 @@ def currency_code(s):
 
def currency_list(s):
 
    return [currency_code(code.strip()) for code in s.split(',')]
 

	
 
def date_from(fmt_s):
 
    def date_from_fmt(s):
 
        return datetime.datetime.strptime(s, fmt_s).date()
 
    return date_from_fmt
 

	
 
class Configuration:
 
    DATE_SEPS = frozenset('.-/ ')
 
    DEFAULT_CONFIG_PATH = pathlib.Path(HOME_PATH, '.config', 'oxrlib.ini')
 
    PREPOSITIONS = frozenset(['in', 'to', 'into'])
 
    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()
...
 
@@ -32,7 +36,9 @@ class Configuration:
 
        self.args = argparser.parse_args(arglist)
 

	
 
        if self.args.config_file is None:
 
            self.args.config_file = [self.DEFAULT_CONFIG_PATH]
 
            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)
...
 
@@ -50,6 +56,30 @@ class Configuration:
 
        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(
...
 
@@ -61,29 +91,54 @@ class Configuration:
 

	
 
        hist_parser = subparsers.add_parser(
 
            'historical', aliases=['hist'],
 
            usage='%(prog)s YYYY-MM-DD [[amount] code] [[in] code]',
 
            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,
 
            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_true',
 
            help="Output the rate or conversion in Ledger format",
 
            action='store_const',
 
            dest='output_format',
 
            const=historical.Formats.LEDGER,
 
            help=argparse.SUPPRESS,
 
        )
 
        hist_parser.add_argument(
 
            '--no-ledger',
 
            action='store_false', dest='ledger',
 
            help="Turn off an earlier --ledger setting",
 
            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',
...
 
@@ -94,24 +149,31 @@ class Configuration:
 
        )
 
        hist_parser.add_argument(
 
            'date',
 
            type=date_from('%Y-%m-%d'),
 
            help="Use rates from this date, in YYYY-MM-DD format"
 
            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(
 
            'word1', nargs='?', metavar='first code',
 
            '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(
 
            'word2', nargs='?', metavar='amount',
 
            help="Convert this amount of currency. If not specified, show rates.",
 
            '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(
 
            'word3', nargs='?', metavar='second code',
 
            help="Convert to this currency, in three-letter code format. "
 
            "If not specified, defaults to the base currency.",
 
            '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('word4', nargs='?', help=argparse.SUPPRESS)
 
        hist_parser.add_argument('word5', nargs='?', help=argparse.SUPPRESS)
 
        hist_parser.add_argument('word6', nargs='?', help=argparse.SUPPRESS)
 

	
 
        return prog_parser
 

	
...
 
@@ -119,14 +181,16 @@ class Configuration:
 
        return configparser.ConfigParser()
 

	
 
    def _read_from_conffile(self, argname, sectionname, fallback, convert_to=None,
 
                            confname=None, getter='get', unset=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:
 
        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)
 

	
...
 
@@ -143,26 +207,86 @@ class Configuration:
 
            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)
 
        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)
 
        if self.args.denomination is self.SENTINEL:
 
            self.args.denomination = None
 
        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)
 
            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'))
oxrlib_example.ini
Show inline comments
...
 
@@ -25,8 +25,19 @@ historical = {date}_{base}_rates.json
 
base = USD
 

	
 
# Write output in Ledger format.
 
ledger = no
 
output_format = ledger
 

	
 
# Denominate books in this currency.
 
# Formatted conversions will always show a rate to convert to this
 
# currency, even when converting between two other currencies.
 
# If not specified, output will show the rate for the currency you're
 
# converting to.
 
denomination = USD
 

	
 
# Use signs for these currencies in Ledger output.
 
# If not specified, defaults to the base currency.
 
# If not specified, defaults to the user's preferred currency, which is
 
# the first setting found from:
 
# 1. the denomination setting above
 
# 2. the user's locale
 
# 3. the base currency (which defaults to USD)
 
signed_currencies = USD, EUR
setup.py
Show inline comments
...
 
@@ -5,7 +5,7 @@ from setuptools import setup
 
setup(
 
    name='oxrlib',
 
    description="Library to query the Open Exchange Rates (OXR) API",
 
    version='1.2',
 
    version='2.2',
 
    author='Brett Smith',
 
    author_email='brettcsmith@brettcsmith.org',
 
    license='GNU AGPLv3+',
...
 
@@ -14,7 +14,7 @@ setup(
 
    setup_requires=['pytest-runner'],
 
    tests_require=['pytest'],
 

	
 
    packages=['oxrlib'],
 
    packages=['oxrlib', 'oxrlib.commands'],
 
    entry_points={
 
        'console_scripts': ['oxrquery = oxrlib.__main__:main'],
 
    },
tests/__init__.py
Show inline comments
 
import datetime
 
import decimal
 
import io
 
import pathlib
 
import random
 

	
 
import pytest
 

	
 
from oxrlib import __main__ as oxrmain
 

	
 
decimal.setcontext(oxrmain.decimal_context())
 

	
 
TEST_DIR = pathlib.Path(__file__).parent
 

	
 
class StringIO(io.StringIO):
tests/historical1.json
Show inline comments
...
 
@@ -7,6 +7,7 @@
 
        "AED": 3.67246,
 
        "ALL": 144.529793,
 
        "ANG": 1.79,
 
        "RUB": 57.0763,
 
        "USD": 1
 
    }
 
}
tests/historical2.json
Show inline comments
 
new file 100644
 
{
 
    "disclaimer": "https://openexchangerates.org/terms/",
 
    "license": "https://openexchangerates.org/license/",
 
    "timestamp": 982256400,
 
    "base": "USD",
 
    "rates": {
 
        "AED": 3.76246,
 
        "ALL": 144.529739,
 
        "ANG": 1.97,
 
        "RUB": 57.0736,
 
        "USD": 1
 
    }
 
}
tests/test_Configuration.py
Show inline comments
 
import datetime
 
import decimal
 
import os
 

	
 
import babel
 
import pytest
 

	
 
from . import any_date, relpath
...
 
@@ -11,26 +13,24 @@ import oxrlib.loaders
 

	
 
INI_DIR_PATH = relpath('config_ini')
 

	
 
def config_from(ini_filename, arglist=None):
 
    if arglist is None:
 
        arglist = ['historical', any_date().isoformat()]
 
def config_from(ini_filename, arglist):
 
    ini_path = INI_DIR_PATH / ini_filename
 
    return oxrlib.config.Configuration(['--config-file', ini_path.as_posix()] + arglist)
 

	
 
def test_full_config():
 
    config = config_from('full.ini')
 
def test_full_config(any_date):
 
    config = config_from('full.ini', ['historical', any_date.isoformat()])
 
    loaders = config.get_loaders().loaders
 
    assert type(loaders[0]) is oxrlib.loaders.FileCache
 
    assert type(loaders[1]) is oxrlib.loaders.OXRAPIRequest
 
    assert len(loaders) == 2
 
    assert type(config.cache) is oxrlib.cache.CacheWriter
 

	
 
def test_incomplete_config():
 
    config = config_from('incomplete.ini')
 
def test_incomplete_config(any_date):
 
    config = config_from('incomplete.ini', ['historical', any_date.isoformat()])
 
    assert not config.get_loaders().loaders
 

	
 
def test_empty_config():
 
    config = config_from(os.devnull)
 
def test_empty_config(any_date):
 
    config = config_from(os.devnull, ['historical', any_date.isoformat()])
 
    assert not config.get_loaders().loaders
 

	
 
@pytest.mark.parametrize('ini_filename,expected_currency,use_switch', [
...
 
@@ -46,21 +46,34 @@ def test_historical_default_base(ini_filename, expected_currency, use_switch, an
 
    config = config_from(ini_filename, arglist)
 
    assert config.args.base == expected_currency
 

	
 
@pytest.mark.parametrize('amount,from_curr,preposition,to_curr', [
 
    (None, 'JPY', None, None),
 
    (decimal.Decimal('1000'), 'chf', None, None),
 
    (decimal.Decimal('999'), 'Eur', None, 'Chf'),
 
    (decimal.Decimal('12.34'), 'gbp', 'IN', 'eur'),
 
@pytest.mark.parametrize('amount,from_curr,prep1,to_curr,prep2,from_date', [
 
    (None, 'JPY', None, None, None, None),
 
    (None, 'gbp', None, 'Aud', None, None),
 
    (None, 'CHF', 'to', 'eur', None, None),
 
    (decimal.Decimal('1000'), 'chf', None, None, None, None),
 
    (decimal.Decimal('999'), 'Eur', None, 'Chf', None, None),
 
    (decimal.Decimal('12.34'), 'gbp', 'IN', 'eur', None, None),
 
    (None, 'JPY', None, None, None, '12-15'),
 
    (None, 'gbp', None, 'Aud', 'From', '12.15'),
 
    (None, 'CHF', 'to', 'eur', 'from', '15'),
 
    (decimal.Decimal('1000'), 'chf', None, None, None, '12-15'),
 
    (decimal.Decimal('999'), 'Eur', None, 'Chf', None, '2016.12.15'),
 
    (decimal.Decimal('12.34'), 'gbp', 'IN', 'eur', 'from', '2016-12-15'),
 
])
 
def test_historical_argparsing_success(amount, from_curr, preposition, to_curr, any_date):
 
def test_historical_argparsing_success(amount, from_curr, prep1, to_curr, prep2, from_date, any_date):
 
    oxrlib.config.Configuration.TODAY = datetime.date(2017, 1, 1)
 
    # This locale's currency should not be used in any test cases above.
 
    oxrlib.config.Configuration.LOCALE = babel.core.Locale('en', 'IN')
 
    arglist = ['historical', any_date.isoformat()]
 
    arglist.extend(str(s) for s in [amount, from_curr, preposition, to_curr]
 
    arglist.extend(str(s) for s in [amount, from_curr, prep1, to_curr, prep2, from_date]
 
                   if s is not None)
 
    config = config_from(os.devnull, arglist)
 
    expect_to_curr = 'INR' if to_curr is None else to_curr.upper()
 
    assert config.args.amount == amount
 
    assert config.args.from_currency == from_curr.upper()
 
    if to_curr is not None:
 
        assert config.args.to_currency == to_curr.upper()
 
    assert config.args.to_currency == expect_to_curr
 
    expect_from_date = None if from_date is None else datetime.date(2016, 12, 15)
 
    assert config.args.from_date == expect_from_date
 

	
 
@pytest.mark.parametrize('arglist', [
 
    ['100'],
...
 
@@ -69,6 +82,12 @@ def test_historical_argparsing_success(amount, from_curr, preposition, to_curr,
 
    ['99', 'usd', 'minus', 'jpy'],
 
    ['usdjpy'],
 
    ['44', 'eur', 'in', 'chf', 'pronto'],
 
    ['eur', 'into'],
 
    ['50', 'jpy', 'in'],
 
    ['115', 'usd', 'in', '12-15'],
 
    ['125', 'jpy', 'from', 'chf'],
 
    ['135', 'chf', 'eur', 'gbp'],
 
    ['145', 'brl', '12-16', '2020-12-18'],
 
])
 
def test_historical_argparsing_failure(arglist, any_date):
 
    arglist = ['historical', any_date.isoformat()] + arglist
...
 
@@ -78,3 +97,53 @@ def test_historical_argparsing_failure(arglist, any_date):
 
        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"
tests/test_historical.py
Show inline comments
 
import argparse
 
import decimal
 
import io
 
import itertools
 
import json
 
import re
 

	
 
import pytest
 

	
...
 
@@ -10,11 +12,11 @@ from . import any_date, relpath
 
import oxrlib.commands.historical as oxrhist
 

	
 
class FakeResponder:
 
    def __init__(self, response_path):
 
        self.response_path = response_path
 
    def __init__(self, *response_paths):
 
        self.paths = itertools.cycle(response_paths)
 

	
 
    def _respond(self, *args, **kwargs):
 
        return open(self.response_path)
 
        return next(self.paths).open()
 

	
 
    def __getattr__(self, name):
 
        return self._respond
...
 
@@ -36,29 +38,44 @@ class FakeConfig:
 

	
 

	
 
output = pytest.fixture(lambda: io.StringIO())
 
parametrize_format = pytest.mark.parametrize('output_format', [
 
    oxrhist.Formats.LEDGER,
 
    oxrhist.Formats.BEANCOUNT,
 
])
 

	
 
@pytest.fixture(scope='module')
 
def single_responder():
 
    return FakeResponder(relpath('historical1.json'))
 

	
 
@pytest.fixture
 
def historical1_responder():
 
    return FakeResponder(relpath('historical1.json').as_posix())
 
def alternate_responder():
 
    return FakeResponder(
 
        relpath('historical1.json'),
 
        relpath('historical2.json'),
 
    )
 

	
 
def build_config(
 
        responder,
 
        date=None,
 
        date,
 
        amount=None,
 
        from_currency=None,
 
        to_currency=None,
 
        ledger=False,
 
        from_date=None,
 
        output_format=oxrhist.Formats.RAW,
 
        signed_currencies=None,
 
        denomination=None,
 
        base='USD',
 
):
 
    return FakeConfig(responder, {
 
        'date': any_date() if date is None else date,
 
        'date': date,
 
        'base': base,
 
        'amount': None if amount is None else decimal.Decimal(amount),
 
        'from_currency': from_currency,
 
        'to_currency': base if to_currency is None else to_currency,
 
        'ledger': ledger,
 
        'from_date': from_date,
 
        'output_format': output_format,
 
        'signed_currencies': [base] if signed_currencies is None else signed_currencies,
 
        'denomination': denomination,
 
    })
 

	
 
def lines_from_run(config, output):
...
 
@@ -66,8 +83,42 @@ def lines_from_run(config, output):
 
    output.seek(0)
 
    return iter(output)
 

	
 
def test_rate_list(historical1_responder, output):
 
    config = build_config(historical1_responder)
 
def check_fx_amount(config, lines, amount, cost, fx_code, fx_sign=None, price=None):
 
    if price is None:
 
        price = cost
 
    rate_fmt = f'{{}} {re.escape(fx_code)}'
 
    cost = re.escape(cost) + r'\d*'
 
    price = re.escape(price) + r'\d*'
 
    if config.args.output_format is oxrhist.Formats.LEDGER:
 
        if fx_sign is not None and fx_code in config.args.signed_currencies:
 
            rate_fmt = f'{re.escape(fx_sign)}{{}}'
 
        cost_re = '{{={}}}'.format(rate_fmt.format(cost))
 
        price_re = ' @ {}'.format(rate_fmt.format(price))
 
    else:
 
        cost_re = '{{{}}}'.format(rate_fmt.format(cost))
 
        if config.args.from_date is None:
 
            price_re = ''
 
        else:
 
            price_re = ' @ {}'.format(rate_fmt.format(price))
 
    pattern = r'^{} {}{}$'.format(re.escape(amount), cost_re, price_re)
 
    line = next(lines, "<EOF>")
 
    assert re.match(pattern, line)
 

	
 
def check_nonfx_amount(config, lines, amount, code=None, sign=None):
 
    if config.args.output_format is oxrhist.Formats.LEDGER:
 
        if code is None:
 
            code = 'USD'
 
            sign = '$'
 
        if code in config.args.signed_currencies and sign is not None:
 
            expected = f'{sign}{amount}\n'
 
        else:
 
            expected = f'{amount} {code}\n'
 
    else:
 
        expected = f'{amount} {code or "USD"}\n'
 
    assert next(lines, "<EOF>") == expected
 

	
 
def test_rate_list(single_responder, output, any_date):
 
    config = build_config(single_responder, any_date)
 
    lines = lines_from_run(config, output)
 
    assert next(lines).startswith('1 AED = 0.27229')
 
    assert next(lines) == '1 USD = 3.67246 AED\n'
...
 
@@ -76,44 +127,135 @@ def test_rate_list(historical1_responder, output):
 
    assert next(lines).startswith('1 ANG = 0.55865')
 
    assert next(lines) == '1 USD = 1.79 ANG\n'
 

	
 
def test_one_rate(historical1_responder, output):
 
    config = build_config(historical1_responder, from_currency='ANG')
 
def test_one_rate(single_responder, output, any_date):
 
    config = build_config(single_responder, any_date, from_currency='ANG')
 
    lines = lines_from_run(config, output)
 
    assert next(lines).startswith('1 ANG = 0.55865')
 
    assert next(lines) == '1 USD = 1.79 ANG\n'
 
    assert next(lines, None) is None
 

	
 
def test_conversion(historical1_responder, output):
 
    config = build_config(historical1_responder, amount=10, from_currency='AED')
 
def test_conversion(single_responder, output, any_date):
 
    config = build_config(single_responder, any_date, amount=10, from_currency='AED')
 
    lines = lines_from_run(config, output)
 
    assert next(lines) == '10.00 AED = 2.72 USD\n'
 
    assert next(lines, None) is None
 

	
 
def test_back_conversion(historical1_responder, output):
 
    config = build_config(historical1_responder,
 
def test_back_conversion(single_responder, output, any_date):
 
    config = build_config(single_responder, any_date,
 
                          amount=2, from_currency='USD', to_currency='ALL')
 
    lines = lines_from_run(config, output)
 
    assert next(lines) == '2.00 USD = 289 ALL\n'
 
    assert next(lines, None) is None
 

	
 
def test_ledger_rate(historical1_responder, output):
 
    config = build_config(historical1_responder, from_currency='ANG', ledger=True)
 
@parametrize_format
 
def test_ledger_rate(single_responder, output, any_date, output_format):
 
    config = build_config(single_responder, any_date,
 
                          from_currency='ANG', output_format=output_format)
 
    lines = lines_from_run(config, output)
 
    check_fx_amount(config, lines, '1 ANG', '0.5586', 'USD', '$')
 
    check_fx_amount(config, lines, '1 USD', '1.79', 'ANG')
 
    assert next(lines, None) is None
 

	
 
@parametrize_format
 
def test_ledger_conversion(single_responder, output, any_date, output_format):
 
    config = build_config(single_responder, any_date, from_currency='ALL',
 
                          amount=300, output_format=output_format)
 
    lines = lines_from_run(config, output)
 
    check_fx_amount(config, lines, '300 ALL', '0.00691', 'USD', '$')
 
    check_nonfx_amount(config, lines, '2.08')
 
    assert next(lines, None) is None
 

	
 
@parametrize_format
 
def test_signed_currencies(single_responder, output, any_date, output_format):
 
    config = build_config(single_responder, any_date, from_currency='AED',
 
                          output_format=output_format, signed_currencies=['EUR'])
 
    lines = lines_from_run(config, output)
 
    check_fx_amount(config, lines, '1 AED', '0.272', 'USD', '$')
 
    check_fx_amount(config, lines, '1 USD', '3.672', 'AED')
 
    assert next(lines, None) is None
 

	
 
@parametrize_format
 
def test_denomination(single_responder, output, any_date, output_format):
 
    config = build_config(single_responder, any_date,
 
                          from_currency='ANG', to_currency='AED', amount=10,
 
                          output_format=output_format, denomination='USD')
 
    lines = lines_from_run(config, output)
 
    check_fx_amount(config, lines, '10.00 ANG', '0.558', 'USD', '$')
 
    check_fx_amount(config, lines, '20.52 AED', '0.272', 'USD', '$')
 
    assert next(lines, None) is None
 

	
 
@parametrize_format
 
def test_redundant_denomination(single_responder, output, any_date, output_format):
 
    config = build_config(single_responder, any_date,
 
                          from_currency='ANG', to_currency='USD', amount=10,
 
                          output_format=output_format, denomination='USD')
 
    lines = lines_from_run(config, output)
 
    assert next(lines) == '1 ANG {=$0.55866} @ $0.55866\n'
 
    assert next(lines) == '1 USD {=1.79 ANG} @ 1.79 ANG\n'
 
    check_fx_amount(config, lines, '10.00 ANG', '0.558', 'USD', '$')
 
    check_nonfx_amount(config, lines, '5.59')
 
    assert next(lines, None) is None
 

	
 
def test_ledger_conversion(historical1_responder, output):
 
    config = build_config(historical1_responder, from_currency='ALL', amount=300, ledger=True)
 
@parametrize_format
 
def test_from_denomination(single_responder, output, any_date, output_format):
 
    config = build_config(single_responder, any_date,
 
                          from_currency='USD', to_currency='ALL', amount=10,
 
                          output_format=output_format, denomination='USD')
 
    lines = lines_from_run(config, output)
 
    assert next(lines) == '300 ALL {=$0.006919} @ $0.006919\n'
 
    assert next(lines) == '$2.08\n'
 
    check_nonfx_amount(config, lines, '10.00')
 
    check_fx_amount(config, lines, '1,445 ALL', '0.00691', 'USD', '$')
 
    assert next(lines, None) is None
 

	
 
def test_signed_currencies(historical1_responder, output):
 
    config = build_config(historical1_responder, from_currency='AED',
 
                          ledger=True, signed_currencies=['EUR'])
 
@parametrize_format
 
def test_rate_precision_added_as_needed(single_responder, output, any_date, output_format):
 
    config = build_config(single_responder, any_date,
 
                          from_currency='RUB', to_currency='USD', amount=63805,
 
                          output_format=output_format, denomination='USD')
 
    lines = lines_from_run(config, output)
 
    assert next(lines) == '1 AED {=0.2723 USD} @ 0.2723 USD\n'
 
    assert next(lines) == '1 USD {=3.67246 AED} @ 3.67246 AED\n'
 
    # 63,805 / 57.0763 (the RUB rate) == $1,117.89
 
    # But using the truncated rate: 63,805 * .01752 == $1,117.86
 
    # Make sure the rate is specified with enough precision to get the
 
    # correct conversion amount.
 
    check_fx_amount(config, lines, '63,805.00 RUB', '0.0175204', 'USD', '$')
 
    check_nonfx_amount(config, lines, '1,117.89')
 
    assert next(lines, None) is None
 

	
 
@parametrize_format
 
def test_from_date_rates(alternate_responder, output, any_date, output_format):
 
    config = build_config(alternate_responder, any_date,
 
                          from_currency='ANG', to_currency='AED',
 
                          from_date=any_date, output_format=output_format,
 
                          denomination='USD')
 
    lines = lines_from_run(config, output)
 
    check_fx_amount(config, lines, '1 ANG', '1.909', 'AED', None, '2.051')
 
    check_fx_amount(config, lines, '1 AED', '0.523', 'ANG', None, '0.487')
 
    assert next(lines, None) is None
 

	
 
@parametrize_format
 
def test_from_date_conversion(alternate_responder, output, any_date, output_format):
 
    config = build_config(alternate_responder, any_date,
 
                          from_currency='ANG', to_currency='AED', amount=10,
 
                          from_date=any_date, output_format=output_format,
 
                          denomination='USD')
 
    lines = lines_from_run(config, output)
 
    check_fx_amount(config, lines, '10.00 ANG', '0.507', 'USD', '$', '0.558')
 
    check_fx_amount(config, lines, '19.10 AED', '0.265', 'USD', '$', '0.272')
 
    assert next(lines, None) is None
 

	
 
@parametrize_format
 
def test_rate_consistent_as_cost_and_price(alternate_responder, any_date, output_format):
 
    config_kwargs = {
 
        'responder': alternate_responder,
 
        'amount': 65000,
 
        'from_currency': 'RUB',
 
        'output_format': output_format,
 
        'signed_currencies': (),
 
    }
 
    config = build_config(date=any_date, **config_kwargs)
 
    with io.StringIO() as output:
 
        lines = lines_from_run(config, output)
 
        amount, _, _ = next(lines).partition('@')
 
        expected = amount.replace('\n', ' ')
 
    future_date = any_date.replace(year=any_date.year + 1)
 
    config = build_config(date=future_date, from_date=any_date, **config_kwargs)
 
    with io.StringIO() as output:
 
        lines = lines_from_run(config, output)
 
        assert next(lines, "<EOF>").startswith(expected)