diff --git a/oxrlib/commands/historical.py b/oxrlib/commands/historical.py index e85569f20f5d8bf39b8fc3cd5edb82f22186ad31..0b0b58f39fe892b125bd8b9d6681dfc335212239 100644 --- a/oxrlib/commands/historical.py +++ b/oxrlib/commands/historical.py @@ -1,22 +1,99 @@ -from babel.numbers import format_currency +import decimal +import itertools +import operator + +import babel.numbers from .. import rate as oxrrate -CURRENCY_FMT = '#,##0.### ¤¤' -RATE_FMT = '{from_amt:g} {from_curr} = {to_amt:g} {to_curr}' +class Formatter: + def __init__(self, rate, signed_currencies=(), base_fmt='#,##0.###'): + self.rate = rate + 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)) + + def can_sign_currency(self, code): + return len(babel.numbers.get_currency_symbol(code)) == 1 + + def format_currency(self, amount, code, currency_digits=True): + if currency_digits: + fmt = self.base_fmt + else: + fmt = '{}.{}'.format(self.base_fmt_noprec, '#' * -amount.as_tuple().exponent) + if code in self.signed_currencies: + fmt = '¤' + fmt + else: + fmt = fmt + ' ¤¤' + return babel.numbers.format_currency(amount, code, fmt, currency_digits=currency_digits) + + def format_rate(self, rate): + return "{:g}".format(rate) + + 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, + ) + + def format_rate_pair_bidir(self, from_curr, to_curr, sep='\n'): + return "{}{}{}".format( + self.format_rate_pair(from_curr, to_curr), + sep, + self.format_rate_pair(to_curr, from_curr), + ) + + def format_conversion(self, from_amt, from_curr, to_curr): + to_amt = self.rate.convert(from_amt, from_curr, to_curr) + return "{} = {}".format( + self.format_currency(from_amt, from_curr), + self.format_currency(to_amt, to_curr), + ) + -def format_conversion(rate, from_amt, from_curr, to_curr): - to_amt = rate.convert(from_amt, from_curr, to_curr) - return "{} = {}".format( - format_currency(from_amt, from_curr, CURRENCY_FMT), - format_currency(to_amt, to_curr, CURRENCY_FMT), - ) +class LedgerFormatter(Formatter): + RATE_PREC = 5 + + 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 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)) + + 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), + ) -def format_rate_pair(rate, from_curr, to_curr): - amt = rate.convert(1, from_curr, to_curr) - yield RATE_FMT.format(from_amt=1, from_curr=from_curr, to_amt=amt, to_curr=to_curr) - amt = rate.convert(1, to_curr, from_curr) - yield RATE_FMT.format(from_amt=1, from_curr=to_curr, to_amt=amt, to_curr=from_curr) def run(config, stdout, stderr): loaders = config.get_loaders() @@ -24,14 +101,19 @@ def run(config, stdout, stderr): 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) + else: + formatter = Formatter(rate) if not config.args.from_currency: for from_curr in sorted(rate.rates): - print(*format_rate_pair(rate, from_curr, config.args.to_currency), - sep='\n', file=stdout) + print(formatter.format_rate_pair_bidir(from_curr, config.args.to_currency), + file=stdout) elif config.args.amount is None: - print(*format_rate_pair(rate, config.args.from_currency, config.args.to_currency), - sep='\n', file=stdout) + print(formatter.format_rate_pair_bidir(config.args.from_currency, config.args.to_currency), + file=stdout) else: - print(format_conversion(rate, config.args.amount, - config.args.from_currency, config.args.to_currency), + print(formatter.format_conversion(config.args.amount, + config.args.from_currency, + config.args.to_currency), file=stdout) diff --git a/oxrlib/config.py b/oxrlib/config.py index af712addd2b5a4253abf39d473ac6bac713b3141..a0e5ea6ce765519fbb5265c0e492ab8928e40749 100644 --- a/oxrlib/config.py +++ b/oxrlib/config.py @@ -8,16 +8,15 @@ import pathlib from . import cache, loaders HOME_PATH = pathlib.Path(os.path.expanduser('~')) -CONFFILE_SEED = """ -[Historical] -base=USD -""" 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(',')] + def date_from(fmt_s): def date_from_fmt(s): return datetime.datetime.strptime(s, fmt_s).date() @@ -66,12 +65,29 @@ class Configuration: command='historical', amount=None, from_currency=None, + ledger=None, ) hist_parser.add_argument( '--base', 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', + help="In Ledger output, use a sign for this currency if known. " + "Can be specified multiple times.", + ) hist_parser.add_argument( 'date', type=date_from('%Y-%m-%d'), @@ -96,9 +112,19 @@ class Configuration: return prog_parser def _build_conffile(self): - conffile = configparser.ConfigParser() - conffile.read_string(CONFFILE_SEED) - return conffile + 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: @@ -114,8 +140,9 @@ class Configuration: self.error(': '.join(errmsg)) def _post_hook_historical(self): - if self.args.base is None: - self.args.base = self.conffile.get('Historical', 'base') + 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 diff --git a/oxrlib_example.ini b/oxrlib_example.ini index 7bf1fedc5f1bd26a464cb8d505b070c168ca19ac..0e56338eb38e1be55ec1b83406f2d0e1817bed83 100644 --- a/oxrlib_example.ini +++ b/oxrlib_example.ini @@ -23,3 +23,10 @@ historical = {date}_{base}_rates.json # Set the base currency. # Note that setting a base other than USD requires a paid OXR account. base = USD + +# Write output in Ledger format. +ledger = no + +# Use signs for these currencies in Ledger output. +# If not specified, defaults to the base currency. +signed_currencies = USD, EUR diff --git a/setup.py b/setup.py index 8414c7c932e6ecfd28844d5abc3831239947d040..8743cb7a9df81127af7d4a96cda8cc493837d823 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ from setuptools import setup setup( name='oxrlib', description="Library to query the Open Exchange Rates (OXR) API", - version='1.1', + version='1.2', author='Brett Smith', author_email='brettcsmith@brettcsmith.org', license='GNU AGPLv3+', diff --git a/tests/test_historical.py b/tests/test_historical.py index 3becdba240954dcc11a39b0256e4a7d1394e34f8..7eaf13a1ca71f97aeb12a5b904af0b3c743a3c92 100644 --- a/tests/test_historical.py +++ b/tests/test_historical.py @@ -47,6 +47,8 @@ def build_config( amount=None, from_currency=None, to_currency=None, + ledger=False, + signed_currencies=None, base='USD', ): return FakeConfig(responder, { @@ -55,6 +57,8 @@ def build_config( '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, + 'signed_currencies': [base] if signed_currencies is None else signed_currencies, }) def lines_from_run(config, output): @@ -91,3 +95,25 @@ def test_back_conversion(historical1_responder, output): 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) + 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' + 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) + lines = lines_from_run(config, output) + assert next(lines) == '300 ALL {=$0.006919} @ $0.006919\n' + assert next(lines) == '$2.08\n' + 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']) + 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' + assert next(lines, None) is None