228 lines
8.8 KiB
Python
228 lines
8.8 KiB
Python
import argparse
|
|
import configparser
|
|
import datetime
|
|
import decimal
|
|
import os.path
|
|
import pathlib
|
|
|
|
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')
|
|
NO_DENOMINATION = 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)))
|
|
return self.TODAY.replace(**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.NO_DENOMINATION,
|
|
help="Turn off an earlier --denomination setting",
|
|
)
|
|
hist_parser.add_argument(
|
|
'--signed-currency', '--sign-currency',
|
|
type=currency_code, action='append', dest='signed_currencies',
|
|
metavar='CODE',
|
|
help="In Ledger output, use a sign for this currency if known. "
|
|
"Can be specified multiple times.",
|
|
)
|
|
hist_parser.add_argument(
|
|
'date',
|
|
type=self._date_from_s,
|
|
help="Use rates from this date, in YYYY-MM-DD format. "
|
|
"If you omit the year or month, it fills in the current year/month."
|
|
)
|
|
hist_parser.add_argument(
|
|
'word1', 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.",
|
|
)
|
|
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.",
|
|
)
|
|
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 _post_hook_historical(self):
|
|
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.NO_DENOMINATION:
|
|
self.args.denomination = None
|
|
else:
|
|
self._read_from_conffile('denomination', 'Historical', None, currency_code)
|
|
self._read_from_conffile('signed_currencies', 'Historical', self.args.base,
|
|
currency_list, convert_fallback=True)
|
|
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)
|
|
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)
|
|
|
|
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
|