hooks.ledger_entry: Bring in all Ledger-specific code.
This includes the old "template" module, plus the associated template-loading code from config.
This commit is contained in:
parent
6d1a7cb57d
commit
cdec3d9aab
6 changed files with 317 additions and 347 deletions
|
@ -9,7 +9,7 @@ import pathlib
|
||||||
|
|
||||||
import babel
|
import babel
|
||||||
import babel.numbers
|
import babel.numbers
|
||||||
from . import errors, strparse, template
|
from . import errors, strparse
|
||||||
|
|
||||||
class Configuration:
|
class Configuration:
|
||||||
HOME_PATH = pathlib.Path(os.path.expanduser('~'))
|
HOME_PATH = pathlib.Path(os.path.expanduser('~'))
|
||||||
|
@ -241,24 +241,6 @@ class Configuration:
|
||||||
path = self.get_output_path(section_name)
|
path = self.get_output_path(section_name)
|
||||||
return self._open_path(path, self.stdout, 'a')
|
return self._open_path(path, self.stdout, 'a')
|
||||||
|
|
||||||
def get_template(self, config_key, section_name=None, factory=template.Template):
|
|
||||||
section_config = self.get_section(section_name)
|
|
||||||
try:
|
|
||||||
template_s = section_config[config_key]
|
|
||||||
except KeyError:
|
|
||||||
raise errors.UserInputConfigurationError(
|
|
||||||
"template not defined in [{}]".format(section_name or self.args.use_config),
|
|
||||||
config_key,
|
|
||||||
)
|
|
||||||
return factory(
|
|
||||||
template_s,
|
|
||||||
date_fmt=section_config['date_format'],
|
|
||||||
signed_currencies=[code.strip().upper() for code in section_config['signed_currencies'].split(',')],
|
|
||||||
signed_currency_fmt=section_config['signed_currency_format'],
|
|
||||||
unsigned_currency_fmt=section_config['unsigned_currency_format'],
|
|
||||||
template_name=config_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
def setup_logger(self, logger, section_name=None):
|
def setup_logger(self, logger, section_name=None):
|
||||||
logger.setLevel(self.get_loglevel(section_name))
|
logger.setLevel(self.get_loglevel(section_name))
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,273 @@
|
||||||
from . import HOOK_KINDS
|
import collections
|
||||||
|
import datetime
|
||||||
|
import decimal
|
||||||
|
import functools
|
||||||
|
import io
|
||||||
|
import operator
|
||||||
|
import re
|
||||||
|
import tokenize
|
||||||
|
|
||||||
|
import babel.numbers
|
||||||
|
|
||||||
|
from . import HOOK_KINDS
|
||||||
|
from .. import errors, strparse
|
||||||
|
|
||||||
|
class TokenTransformer:
|
||||||
|
def __init__(self, source):
|
||||||
|
try:
|
||||||
|
source = source.readline
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
self.in_tokens = tokenize.tokenize(source)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_bytes(cls, b):
|
||||||
|
return cls(io.BytesIO(b).readline)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_str(cls, s, encoding='utf-8'):
|
||||||
|
return cls.from_bytes(s.encode(encoding))
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
for ttype, tvalue, _, _, _ in self.in_tokens:
|
||||||
|
try:
|
||||||
|
transformer = getattr(self, 'transform_' + tokenize.tok_name[ttype])
|
||||||
|
except AttributeError:
|
||||||
|
raise ValueError("{} token {!r} not supported".format(ttype, tvalue))
|
||||||
|
yield from transformer(ttype, tvalue)
|
||||||
|
|
||||||
|
def _noop_transformer(self, ttype, tvalue):
|
||||||
|
yield (ttype, tvalue)
|
||||||
|
|
||||||
|
transform_ENDMARKER = _noop_transformer
|
||||||
|
|
||||||
|
def transform_ENCODING(self, ttype, tvalue):
|
||||||
|
self.in_encoding = tvalue
|
||||||
|
return self._noop_transformer(ttype, tvalue)
|
||||||
|
|
||||||
|
def transform(self):
|
||||||
|
out_bytes = tokenize.untokenize(self)
|
||||||
|
return out_bytes.decode(self.in_encoding)
|
||||||
|
|
||||||
|
|
||||||
|
class AmountTokenTransformer(TokenTransformer):
|
||||||
|
SUPPORTED_OPS = frozenset('+-*/()')
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
tokens = super().__iter__()
|
||||||
|
for token in tokens:
|
||||||
|
yield token
|
||||||
|
if token[0] == tokenize.NAME:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError("no amount in expression")
|
||||||
|
yield from tokens
|
||||||
|
|
||||||
|
def transform_NUMBER(self, ttype, tvalue):
|
||||||
|
yield (tokenize.NAME, 'Decimal')
|
||||||
|
yield (tokenize.OP, '(')
|
||||||
|
yield (tokenize.STRING, repr(tvalue))
|
||||||
|
yield (tokenize.OP, ')')
|
||||||
|
|
||||||
|
def transform_OP(self, ttype, tvalue):
|
||||||
|
if tvalue == '{':
|
||||||
|
try:
|
||||||
|
name_type, name_value, _, _, _ = next(self.in_tokens)
|
||||||
|
close_type, close_value, _, _, _ = next(self.in_tokens)
|
||||||
|
if (name_type != tokenize.NAME
|
||||||
|
or name_value != name_value.lower()
|
||||||
|
or close_type != tokenize.OP
|
||||||
|
or close_value != '}'):
|
||||||
|
raise ValueError()
|
||||||
|
except (StopIteration, ValueError):
|
||||||
|
raise ValueError("opening { does not name variable")
|
||||||
|
yield (tokenize.NAME, name_value)
|
||||||
|
elif tvalue in self.SUPPORTED_OPS:
|
||||||
|
yield from self._noop_transformer(ttype, tvalue)
|
||||||
|
else:
|
||||||
|
raise ValueError("unsupported operator {!r}".format(tvalue))
|
||||||
|
|
||||||
|
|
||||||
|
class AccountSplitter:
|
||||||
|
TARGET_LINE_LEN = 78
|
||||||
|
# -4 because that's how many spaces prefix an account line.
|
||||||
|
TARGET_ACCTLINE_LEN = TARGET_LINE_LEN - 4
|
||||||
|
|
||||||
|
def __init__(self, signed_currencies, signed_currency_fmt, unsigned_currency_fmt,
|
||||||
|
template_name):
|
||||||
|
self.splits = []
|
||||||
|
self.metadata = []
|
||||||
|
self.signed_currency_fmt = signed_currency_fmt
|
||||||
|
self.unsigned_currency_fmt = unsigned_currency_fmt
|
||||||
|
self.signed_currencies = set(signed_currencies)
|
||||||
|
self.template_name = template_name
|
||||||
|
self._last_template_vars = object()
|
||||||
|
|
||||||
|
def is_empty(self):
|
||||||
|
return not self.splits
|
||||||
|
|
||||||
|
def add(self, account, amount_expr):
|
||||||
|
try:
|
||||||
|
clean_expr = AmountTokenTransformer.from_str(amount_expr).transform()
|
||||||
|
compiled_expr = compile(clean_expr, self.template_name, 'eval')
|
||||||
|
except (SyntaxError, tokenize.TokenError, ValueError) as error:
|
||||||
|
raise errors.UserInputConfigurationError(error.args[0], amount_expr)
|
||||||
|
else:
|
||||||
|
self.splits.append((account, compiled_expr))
|
||||||
|
self.metadata.append('')
|
||||||
|
|
||||||
|
def set_metadata(self, metadata_s):
|
||||||
|
self.metadata[-1] = metadata_s
|
||||||
|
|
||||||
|
def _currency_decimal(self, amount, currency):
|
||||||
|
return decimal.Decimal(babel.numbers.format_currency(amount, currency, '###0.###'))
|
||||||
|
|
||||||
|
def _balance_amounts(self, amounts, to_amount):
|
||||||
|
cmp_func = operator.lt if to_amount > 0 else operator.gt
|
||||||
|
should_balance = functools.partial(cmp_func, 0)
|
||||||
|
remainder = to_amount
|
||||||
|
balance_index = None
|
||||||
|
for index, (_, amount) in enumerate(amounts):
|
||||||
|
if should_balance(amount):
|
||||||
|
remainder -= amount
|
||||||
|
balance_index = index
|
||||||
|
if balance_index is None:
|
||||||
|
pass
|
||||||
|
elif (abs(remainder) / abs(to_amount)) >= decimal.Decimal('.1'):
|
||||||
|
raise errors.UserInputConfigurationError(
|
||||||
|
"template can't balance amounts to {}".format(to_amount),
|
||||||
|
self.template_name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
account_name, start_amount = amounts[balance_index]
|
||||||
|
amounts[balance_index] = (account_name, start_amount + remainder)
|
||||||
|
|
||||||
|
def _build_amounts(self, template_vars):
|
||||||
|
amount_vars = {k: v for k, v in template_vars.items() if isinstance(v, decimal.Decimal)}
|
||||||
|
amount_vars['Decimal'] = decimal.Decimal
|
||||||
|
amounts = [
|
||||||
|
(account, self._currency_decimal(eval(amount_expr, amount_vars),
|
||||||
|
template_vars['currency']))
|
||||||
|
for account, amount_expr in self.splits
|
||||||
|
]
|
||||||
|
self._balance_amounts(amounts, template_vars['amount'])
|
||||||
|
self._balance_amounts(amounts, -template_vars['amount'])
|
||||||
|
return amounts
|
||||||
|
|
||||||
|
def _iter_splits(self, template_vars):
|
||||||
|
amounts = self._build_amounts(template_vars)
|
||||||
|
if template_vars['currency'] in self.signed_currencies:
|
||||||
|
amt_fmt = self.signed_currency_fmt
|
||||||
|
else:
|
||||||
|
amt_fmt = self.unsigned_currency_fmt
|
||||||
|
for (account, amount), metadata in zip(amounts, self.metadata):
|
||||||
|
if amount == 0:
|
||||||
|
yield ''
|
||||||
|
else:
|
||||||
|
account_s = account.format_map(template_vars)
|
||||||
|
amount_s = babel.numbers.format_currency(amount, template_vars['currency'], amt_fmt)
|
||||||
|
sep_len = max(2, self.TARGET_ACCTLINE_LEN - len(account_s) - len(amount_s))
|
||||||
|
yield '\n {}{}{}{}'.format(
|
||||||
|
account_s, ' ' * sep_len, amount_s,
|
||||||
|
metadata.format_map(template_vars),
|
||||||
|
)
|
||||||
|
|
||||||
|
def render_next(self, template_vars):
|
||||||
|
if template_vars is not self._last_template_vars:
|
||||||
|
self._split_iter = self._iter_splits(template_vars)
|
||||||
|
self._last_template_vars = template_vars
|
||||||
|
return next(self._split_iter)
|
||||||
|
|
||||||
|
|
||||||
|
class Template:
|
||||||
|
ACCOUNT_SPLIT_RE = re.compile(r'(?:\t| )\s*')
|
||||||
|
DATE_FMT = '%Y/%m/%d'
|
||||||
|
PAYEE_LINE_RE = re.compile(r'\{(\w*_)*date\}')
|
||||||
|
SIGNED_CURRENCY_FMT = '¤#,##0.###;¤-#,##0.###'
|
||||||
|
UNSIGNED_CURRENCY_FMT = '#,##0.### ¤¤'
|
||||||
|
|
||||||
|
def __init__(self, template_s, signed_currencies=frozenset(),
|
||||||
|
date_fmt=DATE_FMT,
|
||||||
|
signed_currency_fmt=SIGNED_CURRENCY_FMT,
|
||||||
|
unsigned_currency_fmt=UNSIGNED_CURRENCY_FMT,
|
||||||
|
template_name='<template>'):
|
||||||
|
self.date_fmt = date_fmt
|
||||||
|
self.splitter = AccountSplitter(
|
||||||
|
signed_currencies, signed_currency_fmt, unsigned_currency_fmt, template_name)
|
||||||
|
|
||||||
|
lines = self._template_lines(template_s)
|
||||||
|
self.format_funcs = []
|
||||||
|
try:
|
||||||
|
self.format_funcs.append(next(lines).format_map)
|
||||||
|
except StopIteration:
|
||||||
|
return
|
||||||
|
metadata = []
|
||||||
|
for line in lines:
|
||||||
|
if line.startswith(';'):
|
||||||
|
metadata.append(line)
|
||||||
|
else:
|
||||||
|
self._add_str_func(metadata)
|
||||||
|
metadata = []
|
||||||
|
line = line.strip()
|
||||||
|
match = self.ACCOUNT_SPLIT_RE.search(line)
|
||||||
|
if match is None:
|
||||||
|
raise errors.UserInputError("no amount expression found", line)
|
||||||
|
account = line[:match.start()]
|
||||||
|
amount_expr = line[match.end():]
|
||||||
|
self.splitter.add(account, amount_expr)
|
||||||
|
self.format_funcs.append(self.splitter.render_next)
|
||||||
|
self._add_str_func(metadata)
|
||||||
|
self.format_funcs.append('\n'.format_map)
|
||||||
|
|
||||||
|
def _nonblank_lines(self, s):
|
||||||
|
for line in s.splitlines(True):
|
||||||
|
line = line.strip()
|
||||||
|
if line:
|
||||||
|
yield line
|
||||||
|
|
||||||
|
def _template_lines(self, template_s):
|
||||||
|
lines = self._nonblank_lines(template_s)
|
||||||
|
try:
|
||||||
|
line1 = next(lines)
|
||||||
|
except StopIteration:
|
||||||
|
return
|
||||||
|
if self.PAYEE_LINE_RE.match(line1):
|
||||||
|
yield '\n' + line1
|
||||||
|
else:
|
||||||
|
yield '\n{date} {payee}'
|
||||||
|
yield line1
|
||||||
|
yield from lines
|
||||||
|
|
||||||
|
def _add_str_func(self, str_seq):
|
||||||
|
str_flat = ''.join('\n ' + s for s in str_seq)
|
||||||
|
if not str_flat:
|
||||||
|
pass
|
||||||
|
elif self.splitter.is_empty():
|
||||||
|
self.format_funcs.append(str_flat.format_map)
|
||||||
|
else:
|
||||||
|
self.splitter.set_metadata(str_flat)
|
||||||
|
|
||||||
|
def render(self, template_vars):
|
||||||
|
# template_vars must have these keys. Raise a KeyError if not.
|
||||||
|
template_vars['currency']
|
||||||
|
template_vars['payee']
|
||||||
|
try:
|
||||||
|
date = template_vars['date']
|
||||||
|
except KeyError:
|
||||||
|
date = datetime.date.today()
|
||||||
|
render_vars = {
|
||||||
|
'amount': strparse.currency_decimal(template_vars['amount']),
|
||||||
|
'date': date.strftime(self.date_fmt),
|
||||||
|
}
|
||||||
|
for key, value in template_vars.items():
|
||||||
|
if key.endswith('_date'):
|
||||||
|
render_vars[key] = value.strftime(self.date_fmt)
|
||||||
|
all_vars = collections.ChainMap(render_vars, template_vars)
|
||||||
|
return ''.join(f(all_vars) for f in self.format_funcs)
|
||||||
|
|
||||||
|
def is_empty(self):
|
||||||
|
return not self.format_funcs
|
||||||
|
|
||||||
from .. import errors
|
|
||||||
|
|
||||||
class LedgerEntryHook:
|
class LedgerEntryHook:
|
||||||
KIND = HOOK_KINDS.OUTPUT
|
KIND = HOOK_KINDS.OUTPUT
|
||||||
|
@ -8,9 +275,29 @@ class LedgerEntryHook:
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@functools.lru_cache()
|
||||||
|
def _load_template(config, section_name, config_key):
|
||||||
|
section_config = config.get_section(section_name)
|
||||||
|
try:
|
||||||
|
template_s = section_config[config_key]
|
||||||
|
except KeyError:
|
||||||
|
raise errors.UserInputConfigurationError(
|
||||||
|
"template not defined in [{}]".format(section_name),
|
||||||
|
config_key,
|
||||||
|
)
|
||||||
|
return Template(
|
||||||
|
template_s,
|
||||||
|
date_fmt=section_config['date_format'],
|
||||||
|
signed_currencies=[code.strip().upper() for code in section_config['signed_currencies'].split(',')],
|
||||||
|
signed_currency_fmt=section_config['signed_currency_format'],
|
||||||
|
unsigned_currency_fmt=section_config['unsigned_currency_format'],
|
||||||
|
template_name=config_key,
|
||||||
|
)
|
||||||
|
|
||||||
def run(self, entry_data):
|
def run(self, entry_data):
|
||||||
try:
|
try:
|
||||||
template = self.config.get_template(entry_data['template'])
|
template = self._load_template(self.config, None, entry_data['template'])
|
||||||
except errors.UserInputConfigurationError as error:
|
except errors.UserInputConfigurationError as error:
|
||||||
if error.strerror.startswith('template not defined '):
|
if error.strerror.startswith('template not defined '):
|
||||||
have_template = False
|
have_template = False
|
||||||
|
|
|
@ -1,268 +0,0 @@
|
||||||
import collections
|
|
||||||
import datetime
|
|
||||||
import decimal
|
|
||||||
import functools
|
|
||||||
import io
|
|
||||||
import operator
|
|
||||||
import re
|
|
||||||
import tokenize
|
|
||||||
|
|
||||||
import babel.numbers
|
|
||||||
|
|
||||||
from . import errors, strparse
|
|
||||||
|
|
||||||
class TokenTransformer:
|
|
||||||
def __init__(self, source):
|
|
||||||
try:
|
|
||||||
source = source.readline
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
self.in_tokens = tokenize.tokenize(source)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_bytes(cls, b):
|
|
||||||
return cls(io.BytesIO(b).readline)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_str(cls, s, encoding='utf-8'):
|
|
||||||
return cls.from_bytes(s.encode(encoding))
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
for ttype, tvalue, _, _, _ in self.in_tokens:
|
|
||||||
try:
|
|
||||||
transformer = getattr(self, 'transform_' + tokenize.tok_name[ttype])
|
|
||||||
except AttributeError:
|
|
||||||
raise ValueError("{} token {!r} not supported".format(ttype, tvalue))
|
|
||||||
yield from transformer(ttype, tvalue)
|
|
||||||
|
|
||||||
def _noop_transformer(self, ttype, tvalue):
|
|
||||||
yield (ttype, tvalue)
|
|
||||||
|
|
||||||
transform_ENDMARKER = _noop_transformer
|
|
||||||
|
|
||||||
def transform_ENCODING(self, ttype, tvalue):
|
|
||||||
self.in_encoding = tvalue
|
|
||||||
return self._noop_transformer(ttype, tvalue)
|
|
||||||
|
|
||||||
def transform(self):
|
|
||||||
out_bytes = tokenize.untokenize(self)
|
|
||||||
return out_bytes.decode(self.in_encoding)
|
|
||||||
|
|
||||||
|
|
||||||
class AmountTokenTransformer(TokenTransformer):
|
|
||||||
SUPPORTED_OPS = frozenset('+-*/()')
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
tokens = super().__iter__()
|
|
||||||
for token in tokens:
|
|
||||||
yield token
|
|
||||||
if token[0] == tokenize.NAME:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise ValueError("no amount in expression")
|
|
||||||
yield from tokens
|
|
||||||
|
|
||||||
def transform_NUMBER(self, ttype, tvalue):
|
|
||||||
yield (tokenize.NAME, 'Decimal')
|
|
||||||
yield (tokenize.OP, '(')
|
|
||||||
yield (tokenize.STRING, repr(tvalue))
|
|
||||||
yield (tokenize.OP, ')')
|
|
||||||
|
|
||||||
def transform_OP(self, ttype, tvalue):
|
|
||||||
if tvalue == '{':
|
|
||||||
try:
|
|
||||||
name_type, name_value, _, _, _ = next(self.in_tokens)
|
|
||||||
close_type, close_value, _, _, _ = next(self.in_tokens)
|
|
||||||
if (name_type != tokenize.NAME
|
|
||||||
or name_value != name_value.lower()
|
|
||||||
or close_type != tokenize.OP
|
|
||||||
or close_value != '}'):
|
|
||||||
raise ValueError()
|
|
||||||
except (StopIteration, ValueError):
|
|
||||||
raise ValueError("opening { does not name variable")
|
|
||||||
yield (tokenize.NAME, name_value)
|
|
||||||
elif tvalue in self.SUPPORTED_OPS:
|
|
||||||
yield from self._noop_transformer(ttype, tvalue)
|
|
||||||
else:
|
|
||||||
raise ValueError("unsupported operator {!r}".format(tvalue))
|
|
||||||
|
|
||||||
|
|
||||||
class AccountSplitter:
|
|
||||||
TARGET_LINE_LEN = 78
|
|
||||||
# -4 because that's how many spaces prefix an account line.
|
|
||||||
TARGET_ACCTLINE_LEN = TARGET_LINE_LEN - 4
|
|
||||||
|
|
||||||
def __init__(self, signed_currencies, signed_currency_fmt, unsigned_currency_fmt,
|
|
||||||
template_name):
|
|
||||||
self.splits = []
|
|
||||||
self.metadata = []
|
|
||||||
self.signed_currency_fmt = signed_currency_fmt
|
|
||||||
self.unsigned_currency_fmt = unsigned_currency_fmt
|
|
||||||
self.signed_currencies = set(signed_currencies)
|
|
||||||
self.template_name = template_name
|
|
||||||
self._last_template_vars = object()
|
|
||||||
|
|
||||||
def is_empty(self):
|
|
||||||
return not self.splits
|
|
||||||
|
|
||||||
def add(self, account, amount_expr):
|
|
||||||
try:
|
|
||||||
clean_expr = AmountTokenTransformer.from_str(amount_expr).transform()
|
|
||||||
compiled_expr = compile(clean_expr, self.template_name, 'eval')
|
|
||||||
except (SyntaxError, tokenize.TokenError, ValueError) as error:
|
|
||||||
raise errors.UserInputConfigurationError(error.args[0], amount_expr)
|
|
||||||
else:
|
|
||||||
self.splits.append((account, compiled_expr))
|
|
||||||
self.metadata.append('')
|
|
||||||
|
|
||||||
def set_metadata(self, metadata_s):
|
|
||||||
self.metadata[-1] = metadata_s
|
|
||||||
|
|
||||||
def _currency_decimal(self, amount, currency):
|
|
||||||
return decimal.Decimal(babel.numbers.format_currency(amount, currency, '###0.###'))
|
|
||||||
|
|
||||||
def _balance_amounts(self, amounts, to_amount):
|
|
||||||
cmp_func = operator.lt if to_amount > 0 else operator.gt
|
|
||||||
should_balance = functools.partial(cmp_func, 0)
|
|
||||||
remainder = to_amount
|
|
||||||
balance_index = None
|
|
||||||
for index, (_, amount) in enumerate(amounts):
|
|
||||||
if should_balance(amount):
|
|
||||||
remainder -= amount
|
|
||||||
balance_index = index
|
|
||||||
if balance_index is None:
|
|
||||||
pass
|
|
||||||
elif (abs(remainder) / abs(to_amount)) >= decimal.Decimal('.1'):
|
|
||||||
raise errors.UserInputConfigurationError(
|
|
||||||
"template can't balance amounts to {}".format(to_amount),
|
|
||||||
self.template_name,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
account_name, start_amount = amounts[balance_index]
|
|
||||||
amounts[balance_index] = (account_name, start_amount + remainder)
|
|
||||||
|
|
||||||
def _build_amounts(self, template_vars):
|
|
||||||
amount_vars = {k: v for k, v in template_vars.items() if isinstance(v, decimal.Decimal)}
|
|
||||||
amount_vars['Decimal'] = decimal.Decimal
|
|
||||||
amounts = [
|
|
||||||
(account, self._currency_decimal(eval(amount_expr, amount_vars),
|
|
||||||
template_vars['currency']))
|
|
||||||
for account, amount_expr in self.splits
|
|
||||||
]
|
|
||||||
self._balance_amounts(amounts, template_vars['amount'])
|
|
||||||
self._balance_amounts(amounts, -template_vars['amount'])
|
|
||||||
return amounts
|
|
||||||
|
|
||||||
def _iter_splits(self, template_vars):
|
|
||||||
amounts = self._build_amounts(template_vars)
|
|
||||||
if template_vars['currency'] in self.signed_currencies:
|
|
||||||
amt_fmt = self.signed_currency_fmt
|
|
||||||
else:
|
|
||||||
amt_fmt = self.unsigned_currency_fmt
|
|
||||||
for (account, amount), metadata in zip(amounts, self.metadata):
|
|
||||||
if amount == 0:
|
|
||||||
yield ''
|
|
||||||
else:
|
|
||||||
account_s = account.format_map(template_vars)
|
|
||||||
amount_s = babel.numbers.format_currency(amount, template_vars['currency'], amt_fmt)
|
|
||||||
sep_len = max(2, self.TARGET_ACCTLINE_LEN - len(account_s) - len(amount_s))
|
|
||||||
yield '\n {}{}{}{}'.format(
|
|
||||||
account_s, ' ' * sep_len, amount_s,
|
|
||||||
metadata.format_map(template_vars),
|
|
||||||
)
|
|
||||||
|
|
||||||
def render_next(self, template_vars):
|
|
||||||
if template_vars is not self._last_template_vars:
|
|
||||||
self._split_iter = self._iter_splits(template_vars)
|
|
||||||
self._last_template_vars = template_vars
|
|
||||||
return next(self._split_iter)
|
|
||||||
|
|
||||||
|
|
||||||
class Template:
|
|
||||||
ACCOUNT_SPLIT_RE = re.compile(r'(?:\t| )\s*')
|
|
||||||
DATE_FMT = '%Y/%m/%d'
|
|
||||||
PAYEE_LINE_RE = re.compile(r'\{(\w*_)*date\}')
|
|
||||||
SIGNED_CURRENCY_FMT = '¤#,##0.###;¤-#,##0.###'
|
|
||||||
UNSIGNED_CURRENCY_FMT = '#,##0.### ¤¤'
|
|
||||||
|
|
||||||
def __init__(self, template_s, signed_currencies=frozenset(),
|
|
||||||
date_fmt=DATE_FMT,
|
|
||||||
signed_currency_fmt=SIGNED_CURRENCY_FMT,
|
|
||||||
unsigned_currency_fmt=UNSIGNED_CURRENCY_FMT,
|
|
||||||
template_name='<template>'):
|
|
||||||
self.date_fmt = date_fmt
|
|
||||||
self.splitter = AccountSplitter(
|
|
||||||
signed_currencies, signed_currency_fmt, unsigned_currency_fmt, template_name)
|
|
||||||
|
|
||||||
lines = self._template_lines(template_s)
|
|
||||||
self.format_funcs = []
|
|
||||||
try:
|
|
||||||
self.format_funcs.append(next(lines).format_map)
|
|
||||||
except StopIteration:
|
|
||||||
return
|
|
||||||
metadata = []
|
|
||||||
for line in lines:
|
|
||||||
if line.startswith(';'):
|
|
||||||
metadata.append(line)
|
|
||||||
else:
|
|
||||||
self._add_str_func(metadata)
|
|
||||||
metadata = []
|
|
||||||
line = line.strip()
|
|
||||||
match = self.ACCOUNT_SPLIT_RE.search(line)
|
|
||||||
if match is None:
|
|
||||||
raise errors.UserInputError("no amount expression found", line)
|
|
||||||
account = line[:match.start()]
|
|
||||||
amount_expr = line[match.end():]
|
|
||||||
self.splitter.add(account, amount_expr)
|
|
||||||
self.format_funcs.append(self.splitter.render_next)
|
|
||||||
self._add_str_func(metadata)
|
|
||||||
self.format_funcs.append('\n'.format_map)
|
|
||||||
|
|
||||||
def _nonblank_lines(self, s):
|
|
||||||
for line in s.splitlines(True):
|
|
||||||
line = line.strip()
|
|
||||||
if line:
|
|
||||||
yield line
|
|
||||||
|
|
||||||
def _template_lines(self, template_s):
|
|
||||||
lines = self._nonblank_lines(template_s)
|
|
||||||
try:
|
|
||||||
line1 = next(lines)
|
|
||||||
except StopIteration:
|
|
||||||
return
|
|
||||||
if self.PAYEE_LINE_RE.match(line1):
|
|
||||||
yield '\n' + line1
|
|
||||||
else:
|
|
||||||
yield '\n{date} {payee}'
|
|
||||||
yield line1
|
|
||||||
yield from lines
|
|
||||||
|
|
||||||
def _add_str_func(self, str_seq):
|
|
||||||
str_flat = ''.join('\n ' + s for s in str_seq)
|
|
||||||
if not str_flat:
|
|
||||||
pass
|
|
||||||
elif self.splitter.is_empty():
|
|
||||||
self.format_funcs.append(str_flat.format_map)
|
|
||||||
else:
|
|
||||||
self.splitter.set_metadata(str_flat)
|
|
||||||
|
|
||||||
def render(self, template_vars):
|
|
||||||
# template_vars must have these keys. Raise a KeyError if not.
|
|
||||||
template_vars['currency']
|
|
||||||
template_vars['payee']
|
|
||||||
try:
|
|
||||||
date = template_vars['date']
|
|
||||||
except KeyError:
|
|
||||||
date = datetime.date.today()
|
|
||||||
render_vars = {
|
|
||||||
'amount': strparse.currency_decimal(template_vars['amount']),
|
|
||||||
'date': date.strftime(self.date_fmt),
|
|
||||||
}
|
|
||||||
for key, value in template_vars.items():
|
|
||||||
if key.endswith('_date'):
|
|
||||||
render_vars[key] = value.strftime(self.date_fmt)
|
|
||||||
all_vars = collections.ChainMap(render_vars, template_vars)
|
|
||||||
return ''.join(f(all_vars) for f in self.format_funcs)
|
|
||||||
|
|
||||||
def is_empty(self):
|
|
||||||
return not self.format_funcs
|
|
|
@ -1,3 +1,9 @@
|
||||||
|
[DEFAULT]
|
||||||
|
date_format = %%Y-%%m-%%d
|
||||||
|
signed_currencies = USD, CAD
|
||||||
|
signed_currency_format = ¤#,##0.###
|
||||||
|
unsigned_currency_format = #,##0.### ¤¤
|
||||||
|
|
||||||
[Simplest]
|
[Simplest]
|
||||||
template = Accrued:Accounts Receivable {amount}
|
template = Accrued:Accounts Receivable {amount}
|
||||||
Income:Donations -{amount}
|
Income:Donations -{amount}
|
||||||
|
|
|
@ -21,32 +21,6 @@ def config_from_file(path, arglist=[], stdout=None, stderr=None):
|
||||||
arglist = ['-C', path.as_posix(), *arglist, os.devnull]
|
arglist = ['-C', path.as_posix(), *arglist, os.devnull]
|
||||||
return config.Configuration(arglist, stdout, stderr)
|
return config.Configuration(arglist, stdout, stderr)
|
||||||
|
|
||||||
def test_defaults():
|
|
||||||
config = config_from_file('test_config.ini', ['--sign', 'GBP', '-O', 'out_arg'])
|
|
||||||
factory = mock.Mock(name='Template')
|
|
||||||
template = config.get_template('one', 'Templates', factory)
|
|
||||||
assert factory.called
|
|
||||||
kwargs = factory.call_args[1]
|
|
||||||
assert list(kwargs.pop('signed_currencies', '')) == ['GBP']
|
|
||||||
assert kwargs == {
|
|
||||||
'date_fmt': '%Y-%m-%d',
|
|
||||||
'signed_currency_fmt': kwargs['signed_currency_fmt'],
|
|
||||||
'template_name': 'one',
|
|
||||||
'unsigned_currency_fmt': kwargs['unsigned_currency_fmt'],
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_template_parsing():
|
|
||||||
config = config_from_file('test_config.ini')
|
|
||||||
factory = mock.Mock(name='Template')
|
|
||||||
template = config.get_template('two', 'Templates', factory)
|
|
||||||
try:
|
|
||||||
tmpl_s = factory.call_args[0][0]
|
|
||||||
except IndexError as error:
|
|
||||||
assert False, error
|
|
||||||
assert "\n;Tag1: {value}\n" in tmpl_s
|
|
||||||
assert "\nIncome:Donations -{amount}\n" in tmpl_s
|
|
||||||
assert "\n;IncomeTag: Donations\n" in tmpl_s
|
|
||||||
|
|
||||||
def test_get_section():
|
def test_get_section():
|
||||||
config = config_from_file('test_config.ini', ['--date-format', '%m/%d/%Y'])
|
config = config_from_file('test_config.ini', ['--date-format', '%m/%d/%Y'])
|
||||||
section = config.get_section('Templates')
|
section = config.get_section('Templates')
|
||||||
|
@ -126,9 +100,3 @@ def test_bad_loglevel():
|
||||||
with bad_config('wraning'):
|
with bad_config('wraning'):
|
||||||
config = config_from_file('test_config.ini', ['-c', 'Bad Loglevel'])
|
config = config_from_file('test_config.ini', ['-c', 'Bad Loglevel'])
|
||||||
config.get_loglevel()
|
config.get_loglevel()
|
||||||
|
|
||||||
def test_undefined_template():
|
|
||||||
template_name = 'template nonexistent'
|
|
||||||
config = config_from_file(os.devnull)
|
|
||||||
with bad_config(template_name):
|
|
||||||
config.get_template(template_name)
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ import io
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from import2ledger import errors, template
|
from import2ledger import errors
|
||||||
from import2ledger.hooks import ledger_entry
|
from import2ledger.hooks import ledger_entry
|
||||||
|
|
||||||
from . import DATA_DIR, normalize_whitespace
|
from . import DATA_DIR, normalize_whitespace
|
||||||
|
@ -19,7 +19,7 @@ with pathlib.Path(DATA_DIR, 'templates.ini').open() as conffile:
|
||||||
config.read_file(conffile)
|
config.read_file(conffile)
|
||||||
|
|
||||||
def template_from(section_name, *args, **kwargs):
|
def template_from(section_name, *args, **kwargs):
|
||||||
return template.Template(config[section_name]['template'], *args, **kwargs)
|
return ledger_entry.Template(config[section_name]['template'], *args, **kwargs)
|
||||||
|
|
||||||
def template_vars(payee, amount, currency='USD', date=DATE, other_vars=None):
|
def template_vars(payee, amount, currency='USD', date=DATE, other_vars=None):
|
||||||
call_vars = {
|
call_vars = {
|
||||||
|
@ -27,6 +27,7 @@ def template_vars(payee, amount, currency='USD', date=DATE, other_vars=None):
|
||||||
'currency': currency,
|
'currency': currency,
|
||||||
'date': date,
|
'date': date,
|
||||||
'payee': payee,
|
'payee': payee,
|
||||||
|
'template': 'template',
|
||||||
}
|
}
|
||||||
if other_vars is None:
|
if other_vars is None:
|
||||||
return call_vars
|
return call_vars
|
||||||
|
@ -62,7 +63,7 @@ def test_currency_formatting():
|
||||||
assert_easy_render(tmpl, 'CC', '7.99', 'USD', '2015/03/14', '$7.99')
|
assert_easy_render(tmpl, 'CC', '7.99', 'USD', '2015/03/14', '$7.99')
|
||||||
|
|
||||||
def test_empty_template():
|
def test_empty_template():
|
||||||
tmpl = template.Template("\n \n")
|
tmpl = ledger_entry.Template("\n \n")
|
||||||
assert tmpl.render(template_vars('BB', '8.99')) == ''
|
assert tmpl.render(template_vars('BB', '8.99')) == ''
|
||||||
assert tmpl.is_empty()
|
assert tmpl.is_empty()
|
||||||
|
|
||||||
|
@ -201,49 +202,43 @@ def test_line1_not_custom_payee():
|
||||||
])
|
])
|
||||||
def test_bad_amount_expression(amount_expr):
|
def test_bad_amount_expression(amount_expr):
|
||||||
with pytest.raises(errors.UserInputError):
|
with pytest.raises(errors.UserInputError):
|
||||||
template.Template(" Income " + amount_expr)
|
ledger_entry.Template(" Income " + amount_expr)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
def __init__(self):
|
def __init__(self, use_section):
|
||||||
|
self.section_name = use_section
|
||||||
self.stdout = io.StringIO()
|
self.stdout = io.StringIO()
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def open_output_file(self):
|
def open_output_file(self):
|
||||||
yield self.stdout
|
yield self.stdout
|
||||||
|
|
||||||
def get_template(self, key):
|
def get_section(self, name=None):
|
||||||
try:
|
return config[self.section_name]
|
||||||
return template_from(key)
|
|
||||||
except KeyError:
|
|
||||||
raise errors.UserInputConfigurationError(
|
|
||||||
"template not defined in test config", key)
|
|
||||||
|
|
||||||
|
|
||||||
def run_hook(entry_data):
|
def run_hook(entry_data, config_section):
|
||||||
hook_config = Config()
|
hook_config = Config(config_section)
|
||||||
hook = ledger_entry.LedgerEntryHook(hook_config)
|
hook = ledger_entry.LedgerEntryHook(hook_config)
|
||||||
assert hook.run(entry_data) is None
|
assert hook.run(entry_data) is None
|
||||||
stdout = hook_config.stdout.getvalue()
|
stdout = hook_config.stdout.getvalue()
|
||||||
return normalize_whitespace(stdout).splitlines()
|
return normalize_whitespace(stdout).splitlines()
|
||||||
|
|
||||||
def hook_vars(template_key, payee, amount):
|
|
||||||
return template_vars(payee, amount, other_vars={'template': template_key})
|
|
||||||
|
|
||||||
def test_hook_renders_template():
|
def test_hook_renders_template():
|
||||||
entry_data = hook_vars('Simplest', 'BB', '0.99')
|
entry_data = template_vars('BB', '0.99')
|
||||||
lines = run_hook(entry_data)
|
lines = run_hook(entry_data, 'Simplest')
|
||||||
assert lines == [
|
assert lines == [
|
||||||
"",
|
"",
|
||||||
"2015/03/14 BB",
|
"2015-03-14 BB",
|
||||||
" Accrued:Accounts Receivable 0.99 USD",
|
" Accrued:Accounts Receivable $0.99",
|
||||||
" Income:Donations -0.99 USD",
|
" Income:Donations -$0.99",
|
||||||
]
|
]
|
||||||
|
|
||||||
def test_hook_handles_empty_template():
|
def test_hook_handles_empty_template():
|
||||||
entry_data = hook_vars('Empty', 'CC', 1)
|
entry_data = template_vars('CC', 1)
|
||||||
assert not run_hook(entry_data)
|
assert not run_hook(entry_data, 'Empty')
|
||||||
|
|
||||||
def test_hook_handles_template_undefined():
|
def test_hook_handles_template_undefined():
|
||||||
entry_data = hook_vars('Nonexistent', 'DD', 1)
|
entry_data = template_vars('DD', 1)
|
||||||
assert not run_hook(entry_data)
|
assert not run_hook(entry_data, 'Nonexistent')
|
||||||
|
|
Loading…
Reference in a new issue