historical: Add Ledger output formatting.
This commit is contained in:
parent
5b7f2b92a1
commit
9be9b07a8d
5 changed files with 173 additions and 31 deletions
|
@ -1,22 +1,99 @@
|
||||||
from babel.numbers import format_currency
|
import decimal
|
||||||
|
import itertools
|
||||||
|
import operator
|
||||||
|
|
||||||
|
import babel.numbers
|
||||||
|
|
||||||
from .. import rate as oxrrate
|
from .. import rate as oxrrate
|
||||||
|
|
||||||
CURRENCY_FMT = '#,##0.### ¤¤'
|
class Formatter:
|
||||||
RATE_FMT = '{from_amt:g} {from_curr} = {to_amt:g} {to_curr}'
|
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 format_conversion(rate, from_amt, from_curr, to_curr):
|
def can_sign_currency(self, code):
|
||||||
to_amt = rate.convert(from_amt, from_curr, to_curr)
|
return len(babel.numbers.get_currency_symbol(code)) == 1
|
||||||
return "{} = {}".format(
|
|
||||||
format_currency(from_amt, from_curr, CURRENCY_FMT),
|
def format_currency(self, amount, code, currency_digits=True):
|
||||||
format_currency(to_amt, to_curr, CURRENCY_FMT),
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
def run(config, stdout, stderr):
|
||||||
loaders = config.get_loaders()
|
loaders = config.get_loaders()
|
||||||
|
@ -24,14 +101,19 @@ def run(config, stdout, stderr):
|
||||||
rate = oxrrate.Rate.from_json_file(rate_json)
|
rate = oxrrate.Rate.from_json_file(rate_json)
|
||||||
if loaders.should_cache():
|
if loaders.should_cache():
|
||||||
config.cache.save_rate(rate)
|
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:
|
if not config.args.from_currency:
|
||||||
for from_curr in sorted(rate.rates):
|
for from_curr in sorted(rate.rates):
|
||||||
print(*format_rate_pair(rate, from_curr, config.args.to_currency),
|
print(formatter.format_rate_pair_bidir(from_curr, config.args.to_currency),
|
||||||
sep='\n', file=stdout)
|
file=stdout)
|
||||||
elif config.args.amount is None:
|
elif config.args.amount is None:
|
||||||
print(*format_rate_pair(rate, config.args.from_currency, config.args.to_currency),
|
print(formatter.format_rate_pair_bidir(config.args.from_currency, config.args.to_currency),
|
||||||
sep='\n', file=stdout)
|
file=stdout)
|
||||||
else:
|
else:
|
||||||
print(format_conversion(rate, config.args.amount,
|
print(formatter.format_conversion(config.args.amount,
|
||||||
config.args.from_currency, config.args.to_currency),
|
config.args.from_currency,
|
||||||
|
config.args.to_currency),
|
||||||
file=stdout)
|
file=stdout)
|
||||||
|
|
|
@ -8,16 +8,15 @@ import pathlib
|
||||||
from . import cache, loaders
|
from . import cache, loaders
|
||||||
|
|
||||||
HOME_PATH = pathlib.Path(os.path.expanduser('~'))
|
HOME_PATH = pathlib.Path(os.path.expanduser('~'))
|
||||||
CONFFILE_SEED = """
|
|
||||||
[Historical]
|
|
||||||
base=USD
|
|
||||||
"""
|
|
||||||
|
|
||||||
def currency_code(s):
|
def currency_code(s):
|
||||||
if not ((len(s) == 3) and s.isalpha()):
|
if not ((len(s) == 3) and s.isalpha()):
|
||||||
raise ValueError("bad currency code: {!r}".format(s))
|
raise ValueError("bad currency code: {!r}".format(s))
|
||||||
return s.upper()
|
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):
|
||||||
def date_from_fmt(s):
|
def date_from_fmt(s):
|
||||||
return datetime.datetime.strptime(s, fmt_s).date()
|
return datetime.datetime.strptime(s, fmt_s).date()
|
||||||
|
@ -66,12 +65,29 @@ class Configuration:
|
||||||
command='historical',
|
command='historical',
|
||||||
amount=None,
|
amount=None,
|
||||||
from_currency=None,
|
from_currency=None,
|
||||||
|
ledger=None,
|
||||||
)
|
)
|
||||||
hist_parser.add_argument(
|
hist_parser.add_argument(
|
||||||
'--base',
|
'--base',
|
||||||
type=currency_code,
|
type=currency_code,
|
||||||
help="Base currency (default USD)",
|
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(
|
hist_parser.add_argument(
|
||||||
'date',
|
'date',
|
||||||
type=date_from('%Y-%m-%d'),
|
type=date_from('%Y-%m-%d'),
|
||||||
|
@ -96,9 +112,19 @@ class Configuration:
|
||||||
return prog_parser
|
return prog_parser
|
||||||
|
|
||||||
def _build_conffile(self):
|
def _build_conffile(self):
|
||||||
conffile = configparser.ConfigParser()
|
return configparser.ConfigParser()
|
||||||
conffile.read_string(CONFFILE_SEED)
|
|
||||||
return conffile
|
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):
|
def _convert_or_error(self, argtype, s_value, argname=None, typename=None):
|
||||||
try:
|
try:
|
||||||
|
@ -114,8 +140,9 @@ class Configuration:
|
||||||
self.error(': '.join(errmsg))
|
self.error(': '.join(errmsg))
|
||||||
|
|
||||||
def _post_hook_historical(self):
|
def _post_hook_historical(self):
|
||||||
if self.args.base is None:
|
self._read_from_conffile('base', 'Historical', 'USD', currency_code)
|
||||||
self.args.base = self.conffile.get('Historical', 'base')
|
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
|
self.args.to_currency = self.args.base
|
||||||
if self.args.word4 and (self.args.word3.lower() in self.PREPOSITIONS):
|
if self.args.word4 and (self.args.word3.lower() in self.PREPOSITIONS):
|
||||||
self.args.word3 = self.args.word4
|
self.args.word3 = self.args.word4
|
||||||
|
|
|
@ -23,3 +23,10 @@ historical = {date}_{base}_rates.json
|
||||||
# Set the base currency.
|
# Set the base currency.
|
||||||
# Note that setting a base other than USD requires a paid OXR account.
|
# Note that setting a base other than USD requires a paid OXR account.
|
||||||
base = USD
|
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
|
||||||
|
|
2
setup.py
2
setup.py
|
@ -5,7 +5,7 @@ from setuptools import setup
|
||||||
setup(
|
setup(
|
||||||
name='oxrlib',
|
name='oxrlib',
|
||||||
description="Library to query the Open Exchange Rates (OXR) API",
|
description="Library to query the Open Exchange Rates (OXR) API",
|
||||||
version='1.1',
|
version='1.2',
|
||||||
author='Brett Smith',
|
author='Brett Smith',
|
||||||
author_email='brettcsmith@brettcsmith.org',
|
author_email='brettcsmith@brettcsmith.org',
|
||||||
license='GNU AGPLv3+',
|
license='GNU AGPLv3+',
|
||||||
|
|
|
@ -47,6 +47,8 @@ def build_config(
|
||||||
amount=None,
|
amount=None,
|
||||||
from_currency=None,
|
from_currency=None,
|
||||||
to_currency=None,
|
to_currency=None,
|
||||||
|
ledger=False,
|
||||||
|
signed_currencies=None,
|
||||||
base='USD',
|
base='USD',
|
||||||
):
|
):
|
||||||
return FakeConfig(responder, {
|
return FakeConfig(responder, {
|
||||||
|
@ -55,6 +57,8 @@ def build_config(
|
||||||
'amount': None if amount is None else decimal.Decimal(amount),
|
'amount': None if amount is None else decimal.Decimal(amount),
|
||||||
'from_currency': from_currency,
|
'from_currency': from_currency,
|
||||||
'to_currency': base if to_currency is None else to_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):
|
def lines_from_run(config, output):
|
||||||
|
@ -91,3 +95,25 @@ def test_back_conversion(historical1_responder, output):
|
||||||
lines = lines_from_run(config, output)
|
lines = lines_from_run(config, output)
|
||||||
assert next(lines) == '2.00 USD = 289 ALL\n'
|
assert next(lines) == '2.00 USD = 289 ALL\n'
|
||||||
assert next(lines, None) is None
|
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
|
||||||
|
|
Loading…
Reference in a new issue