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')