89378cbf90
The hook now ensures it does not output whitespace that could be significant to Ledger, either because it's a newline or an account-amount separator.
365 lines
11 KiB
Python
365 lines
11 KiB
Python
import collections
|
|
import configparser
|
|
import contextlib
|
|
import datetime
|
|
import decimal
|
|
import io
|
|
import pathlib
|
|
|
|
import pytest
|
|
from import2ledger import errors
|
|
from import2ledger.hooks import ledger_entry
|
|
|
|
from . import DATA_DIR, normalize_whitespace
|
|
|
|
DATE = datetime.date(2015, 3, 14)
|
|
|
|
config = configparser.ConfigParser(comment_prefixes='#')
|
|
with pathlib.Path(DATA_DIR, 'templates.ini').open() as conffile:
|
|
config.read_file(conffile)
|
|
|
|
def template_from(section_name, *args, **kwargs):
|
|
return ledger_entry.Template(config[section_name]['template'], *args, **kwargs)
|
|
|
|
def template_vars(payee, amount, currency='USD', date=DATE, other_vars=None):
|
|
call_vars = {
|
|
'amount': amount,
|
|
'currency': currency,
|
|
'date': date,
|
|
'payee': payee,
|
|
'ledger template': 'template',
|
|
}
|
|
if other_vars is None:
|
|
return call_vars
|
|
else:
|
|
return collections.ChainMap(call_vars, other_vars)
|
|
|
|
def render_lines(render_vars, section_name, *args, **kwargs):
|
|
tmpl = template_from(section_name, *args, **kwargs)
|
|
rendered = tmpl.render(render_vars)
|
|
return [normalize_whitespace(s) for s in rendered.splitlines()]
|
|
|
|
def assert_easy_render(tmpl, entity, amount, currency, expect_date, expect_amt):
|
|
rendered = tmpl.render(template_vars(entity, amount, currency))
|
|
lines = [normalize_whitespace(s) for s in rendered.splitlines()]
|
|
assert lines == [
|
|
"",
|
|
"{} {}".format(expect_date, entity),
|
|
" Accrued:Accounts Receivable " + expect_amt,
|
|
" Income:Donations " + expect_amt.replace(amount, "-" + amount),
|
|
]
|
|
assert not tmpl.is_empty()
|
|
|
|
def test_easy_template():
|
|
tmpl = template_from('Simplest')
|
|
assert_easy_render(tmpl, 'JJ', '5.99', 'CAD', '2015/03/14', '5.99 CAD')
|
|
|
|
def test_date_formatting():
|
|
tmpl = template_from('Simplest', date_fmt='%Y-%m-%d')
|
|
assert_easy_render(tmpl, 'KK', '6.99', 'CAD', '2015-03-14', '6.99 CAD')
|
|
|
|
def test_currency_formatting():
|
|
tmpl = template_from('Simplest', signed_currencies=['USD'])
|
|
assert_easy_render(tmpl, 'CC', '7.99', 'USD', '2015/03/14', '$7.99')
|
|
|
|
def test_empty_template():
|
|
tmpl = ledger_entry.Template("\n \n")
|
|
assert tmpl.render(template_vars('BB', '8.99')) == ''
|
|
assert tmpl.is_empty()
|
|
|
|
def test_complex_template():
|
|
render_vars = template_vars('TT', '125.50', other_vars={
|
|
'entity': 'T-T',
|
|
'program': 'Spectrum Defense',
|
|
'txid': 'ABCDEF',
|
|
})
|
|
lines = render_lines(
|
|
render_vars, 'Complex',
|
|
date_fmt='%Y-%m-%d',
|
|
signed_currencies=['USD'],
|
|
)
|
|
assert lines == [
|
|
"",
|
|
"2015-03-14 TT",
|
|
" ;Tag: Value",
|
|
" ;TransactionID: ABCDEF",
|
|
" Accrued:Accounts Receivable $125.50",
|
|
" ;Entity: Supplier",
|
|
" Income:Donations:Spectrum Defense $-119.85",
|
|
" ;Program: Spectrum Defense",
|
|
" ;Entity: T-T",
|
|
" Income:Donations:General $-5.65",
|
|
" ;Entity: T-T",
|
|
]
|
|
|
|
def test_variable_whitespace_cleaned():
|
|
# There are two critical parts of this to avoid making malformed Ledger
|
|
# entries:
|
|
# * Ensure newlines become non-newline whitespace so we don't write
|
|
# malformed lines.
|
|
# * Collapse multiple spaces into one so variables in account names
|
|
# can't make malformed account lines by having a premature split
|
|
# between the account name and amount.
|
|
render_vars = template_vars('W\t\tS', '125.50', other_vars={
|
|
'entity': 'W\fS',
|
|
'program': 'Spectrum\r\nDefense',
|
|
'txid': 'ABC\v\tDEF',
|
|
})
|
|
lines = render_lines(
|
|
render_vars, 'Complex',
|
|
date_fmt='%Y-%m-%d',
|
|
signed_currencies=['USD'],
|
|
)
|
|
assert lines == [
|
|
"",
|
|
"2015-03-14 W S",
|
|
" ;Tag: Value",
|
|
" ;TransactionID: ABC DEF",
|
|
" Accrued:Accounts Receivable $125.50",
|
|
" ;Entity: Supplier",
|
|
" Income:Donations:Spectrum Defense $-119.85",
|
|
" ;Program: Spectrum Defense",
|
|
" ;Entity: W S",
|
|
" Income:Donations:General $-5.65",
|
|
" ;Entity: W S",
|
|
]
|
|
|
|
def test_balancing():
|
|
lines = render_lines(template_vars('FF', '1.01'), 'FiftyFifty')
|
|
assert lines == [
|
|
"",
|
|
"2015/03/14 FF",
|
|
" Accrued:Accounts Receivable 1.01 USD",
|
|
" Income:Donations -0.50 USD",
|
|
" Income:Sales -0.51 USD",
|
|
]
|
|
|
|
def test_multivalue():
|
|
render_vars = template_vars('DD', '150.00', other_vars={
|
|
'tax': decimal.Decimal('12.50'),
|
|
})
|
|
lines = render_lines(render_vars, 'Multivalue')
|
|
assert lines == [
|
|
"",
|
|
"2015/03/14 DD",
|
|
" Expenses:Taxes 12.50 USD",
|
|
" ;TaxAuthority: IRS",
|
|
" Accrued:Accounts Receivable 137.50 USD",
|
|
" Income:RBI -15.00 USD",
|
|
" Income:Donations -135.00 USD",
|
|
]
|
|
|
|
def test_zeroed_account_skipped():
|
|
render_vars = template_vars('GG', '110.00', other_vars={
|
|
'tax': decimal.Decimal(0),
|
|
})
|
|
lines = render_lines(render_vars, 'Multivalue')
|
|
assert lines == [
|
|
"",
|
|
"2015/03/14 GG",
|
|
" Accrued:Accounts Receivable 110.00 USD",
|
|
" Income:RBI -11.00 USD",
|
|
" Income:Donations -99.00 USD",
|
|
]
|
|
|
|
def test_zeroed_account_last():
|
|
render_vars = template_vars('JJ', '90.00', other_vars={
|
|
'item_sales': decimal.Decimal(0),
|
|
})
|
|
lines = render_lines(render_vars, 'Multisplit')
|
|
assert lines == [
|
|
"",
|
|
"2015/03/14 JJ",
|
|
" Assets:Cash 90.00 USD",
|
|
" Income:Sales -90.00 USD",
|
|
" ; :NonItem:",
|
|
]
|
|
|
|
def test_multiple_postings_same_account():
|
|
render_vars = template_vars('LL', '80.00', other_vars={
|
|
'item_sales': decimal.Decimal(30),
|
|
})
|
|
lines = render_lines(render_vars, 'Multisplit')
|
|
assert lines == [
|
|
"",
|
|
"2015/03/14 LL",
|
|
" Assets:Cash 80.00 USD",
|
|
" Income:Sales -50.00 USD",
|
|
" ; :NonItem:",
|
|
" Income:Sales -30.00 USD",
|
|
" ; :Item:",
|
|
]
|
|
|
|
def test_custom_payee_line():
|
|
render_vars = template_vars('ZZ', '10.00', other_vars={
|
|
'custom_date': datetime.date(2014, 2, 13),
|
|
})
|
|
lines = render_lines(render_vars, 'Custom Payee')
|
|
assert lines == [
|
|
"",
|
|
"2014/02/13 ZZ - Custom",
|
|
" Accrued:Accounts Receivable 10.00 USD",
|
|
" Income:Donations -10.00 USD",
|
|
]
|
|
|
|
def test_line1_not_custom_payee():
|
|
render_vars = template_vars('VV', '15.00', other_vars={
|
|
'custom_date': datetime.date(2014, 2, 12),
|
|
})
|
|
lines = render_lines(render_vars, 'Simplest')
|
|
assert lines == [
|
|
"",
|
|
"2015/03/14 VV",
|
|
" Accrued:Accounts Receivable 15.00 USD",
|
|
" Income:Donations -15.00 USD",
|
|
]
|
|
|
|
def test_only_payee_line_date_is_required():
|
|
render_vars = template_vars('VY', '17.50', other_vars={
|
|
'custom_date': datetime.date(2014, 2, 11),
|
|
})
|
|
del render_vars['date']
|
|
lines = render_lines(render_vars, 'Custom Payee')
|
|
assert lines == [
|
|
"",
|
|
"2014/02/11 VY - Custom",
|
|
" Accrued:Accounts Receivable 17.50 USD",
|
|
" Income:Donations -17.50 USD",
|
|
]
|
|
|
|
def test_dates_can_be_none_except_payee_line_date():
|
|
render_vars = template_vars('VZ', '18.00', date=None, other_vars={
|
|
'custom_date': datetime.date(2014, 2, 10),
|
|
})
|
|
lines = render_lines(render_vars, 'Custom Payee')
|
|
assert lines == [
|
|
"",
|
|
"2014/02/10 VZ - Custom",
|
|
" Accrued:Accounts Receivable 18.00 USD",
|
|
" Income:Donations -18.00 USD",
|
|
]
|
|
|
|
def test_custom_date_missing():
|
|
render_vars = template_vars('YY', '20.00')
|
|
with pytest.raises(errors.UserInputConfigurationError):
|
|
render_lines(render_vars, 'Custom Payee')
|
|
|
|
def test_custom_date_is_none():
|
|
render_vars = template_vars('YZ', '25.00', other_vars={
|
|
'custom_date': None,
|
|
})
|
|
with pytest.raises(errors.UserInputConfigurationError):
|
|
render_lines(render_vars, 'Custom Payee')
|
|
|
|
@pytest.mark.parametrize('amount,expect_fee', [
|
|
(40, 3),
|
|
(80, 6),
|
|
])
|
|
def test_conditional(amount, expect_fee):
|
|
expect_cash = amount - expect_fee
|
|
amount_s = '{:.02f}'.format(amount)
|
|
render_vars = template_vars('Buyer', amount_s)
|
|
lines = render_lines(render_vars, 'Conditional')
|
|
assert lines == [
|
|
"",
|
|
"2015/03/14 Buyer",
|
|
" Assets:Cash {:.02f} USD".format(expect_cash),
|
|
" Expenses:Banking Fees {:.02f} USD".format(expect_fee),
|
|
" Income:Sales -{} USD".format(amount_s),
|
|
]
|
|
|
|
def test_string_conditionals():
|
|
render_vars = template_vars('MM', '6', other_vars={
|
|
'false': '',
|
|
'true': 'true',
|
|
})
|
|
lines = render_lines(render_vars, 'StringConditional')
|
|
assert lines == [
|
|
"",
|
|
"2015/03/14 MM",
|
|
" Income:Sales -1.00 USD",
|
|
" Income:Sales -2.00 USD",
|
|
" Income:Sales -3.00 USD",
|
|
" Assets:Cash 6.00 USD",
|
|
]
|
|
|
|
def test_self_balanced():
|
|
# The amount needs to be different than what's in the template.
|
|
render_vars = template_vars('NN', '0')
|
|
lines = render_lines(render_vars, 'SelfBalanced')
|
|
assert lines == [
|
|
"",
|
|
"2015/03/14 NN",
|
|
" Income:Sales -5.00 USD",
|
|
" Assets:Cash 5.00 USD",
|
|
]
|
|
|
|
@pytest.mark.parametrize('amount_expr', [
|
|
'',
|
|
'name',
|
|
'-',
|
|
'()',
|
|
'+()',
|
|
'{}',
|
|
'{{}}',
|
|
'{()}',
|
|
'{name',
|
|
'name}',
|
|
'{42}',
|
|
'(5).real',
|
|
'{amount.real}',
|
|
'{amount.is_nan()}',
|
|
'{Decimal}',
|
|
'{FOO}',
|
|
])
|
|
def test_bad_amount_expression(amount_expr):
|
|
with pytest.raises(errors.UserInputError):
|
|
ledger_entry.Template(" Income " + amount_expr)
|
|
|
|
class Config:
|
|
def __init__(self, use_section):
|
|
self.section_name = use_section
|
|
self.stdout = io.StringIO()
|
|
|
|
@contextlib.contextmanager
|
|
def open_output_file(self):
|
|
yield self.stdout
|
|
|
|
def get_section(self, name=None):
|
|
return config[self.section_name]
|
|
|
|
|
|
def run_hook(entry_data, config_section):
|
|
hook_config = Config(config_section)
|
|
hook = ledger_entry.LedgerEntryHook(hook_config)
|
|
assert hook.run(entry_data) is None
|
|
stdout = hook_config.stdout.getvalue()
|
|
return normalize_whitespace(stdout).splitlines()
|
|
|
|
def test_hook_renders_template():
|
|
entry_data = template_vars('BB', '0.99')
|
|
lines = run_hook(entry_data, 'Simplest')
|
|
assert lines == [
|
|
"",
|
|
"2015-03-14 BB",
|
|
" Accrued:Accounts Receivable $0.99",
|
|
" Income:Donations -$0.99",
|
|
]
|
|
|
|
def test_hook_handles_empty_template():
|
|
entry_data = template_vars('CC', 1)
|
|
assert not run_hook(entry_data, 'Empty')
|
|
|
|
def test_hook_handles_template_undefined():
|
|
entry_data = template_vars('DD', 1)
|
|
assert not run_hook(entry_data, 'Nonexistent')
|
|
|
|
def test_string_value_is_user_error():
|
|
entry_data = template_vars('EE', 1)
|
|
with pytest.raises(errors.UserInputConfigurationError):
|
|
run_hook(entry_data, 'NondecimalWord')
|
|
|
|
def test_string_variable_is_user_error():
|
|
entry_data = template_vars('FF', 1)
|
|
with pytest.raises(errors.UserInputConfigurationError):
|
|
run_hook(entry_data, 'NondecimalVariable')
|