Changeset - 9be9b07a8d54
[Not reviewed]
0 5 0
Brett Smith - 7 years ago 2017-05-18 17:49:05
brettcsmith@brettcsmith.org
historical: Add Ledger output formatting.
5 files changed with 172 insertions and 30 deletions:
0 comments (0 inline, 0 general)
oxrlib/commands/historical.py
Show inline comments
 
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)
oxrlib/config.py
Show inline comments
...
 
@@ -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
oxrlib_example.ini
Show inline comments
...
 
@@ -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
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.1',
 
    version='1.2',
 
    author='Brett Smith',
 
    author_email='brettcsmith@brettcsmith.org',
 
    license='GNU AGPLv3+',
tests/test_historical.py
Show inline comments
...
 
@@ -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
0 comments (0 inline, 0 general)