From 701ccdc192502676afec38ece681b1dbd259be34 Mon Sep 17 00:00:00 2001 From: Brett Smith Date: Tue, 28 Apr 2020 15:33:30 -0400 Subject: [PATCH] tests: Test where Transactions are real NamedTuples. This makes methods like _replace available in real code, and caught the bug where we can't use @functools.lru_cache with Transaction arguments, because they're unhashable due to their mutable members. --- conservancy_beancount/data.py | 38 +++++++-- tests/test_data_is_opening_balance_txn.py | 4 +- tests/test_meta_entity.py | 2 +- tests/test_meta_invoice.py | 2 +- tests/test_meta_project.py | 4 +- tests/testutil.py | 94 ++++++++++------------- 6 files changed, 79 insertions(+), 65 deletions(-) diff --git a/conservancy_beancount/data.py b/conservancy_beancount/data.py index 515cb99..0ce0282 100644 --- a/conservancy_beancount/data.py +++ b/conservancy_beancount/data.py @@ -31,11 +31,13 @@ from beancount.core import convert as bc_convert from typing import ( cast, Callable, + Hashable, Iterable, Iterator, MutableMapping, Optional, Sequence, + TypeVar, Union, ) @@ -310,6 +312,19 @@ class Posting(BasePosting): pass +_KT = TypeVar('_KT', bound=Hashable) +_VT = TypeVar('_VT') +class _SizedDict(collections.OrderedDict, MutableMapping[_KT, _VT]): + def __init__(self, maxsize: int=128) -> None: + self.maxsize = maxsize + super().__init__() + + def __setitem__(self, key: _KT, value: _VT) -> None: + super().__setitem__(key, value) + for _ in range(self.maxsize, len(self)): + self.popitem(last=False) + + def balance_of(txn: Transaction, *preds: Callable[[Account], Optional[bool]], ) -> Amount: @@ -335,12 +350,23 @@ def balance_of(txn: Transaction, currency = weights[0].currency return Amount(number, currency) -@functools.lru_cache() +_opening_balance_cache: MutableMapping[str, bool] = _SizedDict() def is_opening_balance_txn(txn: Transaction) -> bool: + key = '\0'.join( + f'{post.account}={post.units}' for post in txn.postings + ) + try: + return _opening_balance_cache[key] + except KeyError: + pass opening_equity = balance_of(txn, Account.is_opening_equity) if not opening_equity.currency: - return False - rest = balance_of(txn, lambda acct: not acct.is_opening_equity()) - if not rest.currency: - return False - return abs(opening_equity.number + rest.number) < decimal.Decimal('.01') + retval = False + else: + rest = balance_of(txn, lambda acct: not acct.is_opening_equity()) + if not rest.currency: + retval = False + else: + retval = abs(opening_equity.number + rest.number) < decimal.Decimal('.01') + _opening_balance_cache[key] = retval + return retval diff --git a/tests/test_data_is_opening_balance_txn.py b/tests/test_data_is_opening_balance_txn.py index 8d229d0..5dba3c8 100644 --- a/tests/test_data_is_opening_balance_txn.py +++ b/tests/test_data_is_opening_balance_txn.py @@ -23,7 +23,7 @@ from . import testutil from conservancy_beancount import data def test_typical_opening(): - txn = testutil.Transaction.opening_balance() + txn = testutil.OpeningBalance() assert data.is_opening_balance_txn(txn) def test_multiacct_opening(): @@ -36,7 +36,7 @@ def test_multiacct_opening(): assert data.is_opening_balance_txn(txn) def test_opening_with_fx(): - txn = testutil.Transaction.opening_balance() + txn = testutil.OpeningBalance() equity_post = txn.postings[-1] txn.postings[-1] = equity_post._replace( units=testutil.Amount(equity_post.units.number * Decimal('.9'), 'EUR'), diff --git a/tests/test_meta_entity.py b/tests/test_meta_entity.py index 58646e4..0711453 100644 --- a/tests/test_meta_entity.py +++ b/tests/test_meta_entity.py @@ -167,5 +167,5 @@ def test_which_accounts_required_on(hook, account, required): for error in errors) def test_not_required_on_opening(hook): - txn = testutil.Transaction.opening_balance() + txn = testutil.OpeningBalance() assert not list(hook.run(txn)) diff --git a/tests/test_meta_invoice.py b/tests/test_meta_invoice.py index 4cd70e1..1d23aff 100644 --- a/tests/test_meta_invoice.py +++ b/tests/test_meta_invoice.py @@ -142,5 +142,5 @@ def test_missing_invoice(hook, acct1, acct2): assert actual == {"{} missing {}".format(acct1, TEST_KEY)} def test_not_required_on_opening(hook): - txn = testutil.Transaction.opening_balance() + txn = testutil.OpeningBalance() assert not list(hook.run(txn)) diff --git a/tests/test_meta_project.py b/tests/test_meta_project.py index 5149e35..8c84fcf 100644 --- a/tests/test_meta_project.py +++ b/tests/test_meta_project.py @@ -158,11 +158,11 @@ def test_invalid_project_data(repo_path_s, data_path_s): meta_project.MetaProject(config, Path(data_path_s)) def test_not_required_on_opening(hook): - txn = testutil.Transaction.opening_balance('Equity:Funds:Unrestricted') + txn = testutil.OpeningBalance('Equity:Funds:Unrestricted') assert not list(hook.run(txn)) def test_always_required_on_restricted_funds(hook): acct = 'Equity:Funds:Restricted' - txn = testutil.Transaction.opening_balance(acct) + txn = testutil.OpeningBalance(acct) actual = {error.message for error in hook.run(txn)} assert actual == {f'{acct} missing project'} diff --git a/tests/testutil.py b/tests/testutil.py index 7eaf304..3612091 100644 --- a/tests/testutil.py +++ b/tests/testutil.py @@ -93,6 +93,37 @@ def Posting(account, number, meta, ) +def Transaction(date=FY_MID_DATE, flag='*', payee=None, + narration='', tags=None, links=None, postings=(), + **meta): + if isinstance(date, str): + date = parse_date(date) + meta.setdefault('filename', '') + meta.setdefault('lineno', 0) + real_postings = [] + for post in postings: + try: + post.account + except AttributeError: + if isinstance(post[-1], dict): + args = post[:-1] + kwargs = post[-1] + else: + args = post + kwargs = {} + post = Posting(*args, **kwargs) + real_postings.append(post) + return bc_data.Transaction( + meta, + date, + flag, + payee, + narration, + set(tags or ''), + set(links or ''), + real_postings, + ) + LINK_METADATA_STRINGS = { 'Invoices/304321.pdf', 'rt:123/456', @@ -130,59 +161,16 @@ def balance_map(source=None, **kwargs): retval.update(balance_map(kwargs.items())) return retval -class Transaction: - def __init__(self, - date=FY_MID_DATE, flag='*', payee=None, - narration='', tags=None, links=None, postings=None, - **meta): - if isinstance(date, str): - date = parse_date(date) - self.date = date - self.flag = flag - self.payee = payee - self.narration = narration - self.tags = set(tags or '') - self.links = set(links or '') - self.postings = [] - self.meta = { - 'filename': '', - 'lineno': 0, - } - self.meta.update(meta) - if postings is not None: - for posting in postings: - self.add_posting(*posting) - - def add_posting(self, arg, *args, **kwargs): - """Add a posting to this transaction. Use any of these forms: - - txn.add_posting(account, number, …, kwarg=value, …) - txn.add_posting(account, number, …, posting_kwargs_dict) - txn.add_posting(posting_object) - """ - if kwargs: - posting = Posting(arg, *args, **kwargs) - elif args: - if isinstance(args[-1], dict): - kwargs = args[-1] - args = args[:-1] - posting = Posting(arg, *args, **kwargs) - else: - posting = arg - self.postings.append(posting) - - @classmethod - def opening_balance(cls, acct=None, **txn_meta): - if acct is None: - acct = next(OPENING_EQUITY_ACCOUNTS) - return cls(**txn_meta, postings=[ - ('Assets:Receivable:Accounts', 100), - ('Assets:Receivable:Loans', 200), - ('Liabilities:Payable:Accounts', -15), - ('Liabilities:Payable:Vacation', -25), - (acct, -260), - ]) - +def OpeningBalance(acct=None, **txn_meta): + if acct is None: + acct = next(OPENING_EQUITY_ACCOUNTS) + return Transaction(**txn_meta, postings=[ + ('Assets:Receivable:Accounts', 100), + ('Assets:Receivable:Loans', 200), + ('Liabilities:Payable:Accounts', -15), + ('Liabilities:Payable:Vacation', -25), + (acct, -260), + ]) class TestConfig: def __init__(self, *,