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.
This commit is contained in:
		
							parent
							
								
									e79877ee6a
								
							
						
					
					
						commit
						701ccdc192
					
				
					 6 changed files with 79 additions and 65 deletions
				
			
		| 
						 | 
					@ -31,11 +31,13 @@ from beancount.core import convert as bc_convert
 | 
				
			||||||
from typing import (
 | 
					from typing import (
 | 
				
			||||||
    cast,
 | 
					    cast,
 | 
				
			||||||
    Callable,
 | 
					    Callable,
 | 
				
			||||||
 | 
					    Hashable,
 | 
				
			||||||
    Iterable,
 | 
					    Iterable,
 | 
				
			||||||
    Iterator,
 | 
					    Iterator,
 | 
				
			||||||
    MutableMapping,
 | 
					    MutableMapping,
 | 
				
			||||||
    Optional,
 | 
					    Optional,
 | 
				
			||||||
    Sequence,
 | 
					    Sequence,
 | 
				
			||||||
 | 
					    TypeVar,
 | 
				
			||||||
    Union,
 | 
					    Union,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -310,6 +312,19 @@ class Posting(BasePosting):
 | 
				
			||||||
                pass
 | 
					                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,
 | 
					def balance_of(txn: Transaction,
 | 
				
			||||||
               *preds: Callable[[Account], Optional[bool]],
 | 
					               *preds: Callable[[Account], Optional[bool]],
 | 
				
			||||||
) -> Amount:
 | 
					) -> Amount:
 | 
				
			||||||
| 
						 | 
					@ -335,12 +350,23 @@ def balance_of(txn: Transaction,
 | 
				
			||||||
        currency = weights[0].currency
 | 
					        currency = weights[0].currency
 | 
				
			||||||
    return Amount(number, currency)
 | 
					    return Amount(number, currency)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@functools.lru_cache()
 | 
					_opening_balance_cache: MutableMapping[str, bool] = _SizedDict()
 | 
				
			||||||
def is_opening_balance_txn(txn: Transaction) -> bool:
 | 
					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)
 | 
					    opening_equity = balance_of(txn, Account.is_opening_equity)
 | 
				
			||||||
    if not opening_equity.currency:
 | 
					    if not opening_equity.currency:
 | 
				
			||||||
        return False
 | 
					        retval = False
 | 
				
			||||||
    rest = balance_of(txn, lambda acct: not acct.is_opening_equity())
 | 
					    else:
 | 
				
			||||||
    if not rest.currency:
 | 
					        rest = balance_of(txn, lambda acct: not acct.is_opening_equity())
 | 
				
			||||||
        return False
 | 
					        if not rest.currency:
 | 
				
			||||||
    return abs(opening_equity.number + rest.number) < decimal.Decimal('.01')
 | 
					            retval = False
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            retval = abs(opening_equity.number + rest.number) < decimal.Decimal('.01')
 | 
				
			||||||
 | 
					    _opening_balance_cache[key] = retval
 | 
				
			||||||
 | 
					    return retval
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -23,7 +23,7 @@ from . import testutil
 | 
				
			||||||
from conservancy_beancount import data
 | 
					from conservancy_beancount import data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def test_typical_opening():
 | 
					def test_typical_opening():
 | 
				
			||||||
    txn = testutil.Transaction.opening_balance()
 | 
					    txn = testutil.OpeningBalance()
 | 
				
			||||||
    assert data.is_opening_balance_txn(txn)
 | 
					    assert data.is_opening_balance_txn(txn)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def test_multiacct_opening():
 | 
					def test_multiacct_opening():
 | 
				
			||||||
| 
						 | 
					@ -36,7 +36,7 @@ def test_multiacct_opening():
 | 
				
			||||||
    assert data.is_opening_balance_txn(txn)
 | 
					    assert data.is_opening_balance_txn(txn)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def test_opening_with_fx():
 | 
					def test_opening_with_fx():
 | 
				
			||||||
    txn = testutil.Transaction.opening_balance()
 | 
					    txn = testutil.OpeningBalance()
 | 
				
			||||||
    equity_post = txn.postings[-1]
 | 
					    equity_post = txn.postings[-1]
 | 
				
			||||||
    txn.postings[-1] = equity_post._replace(
 | 
					    txn.postings[-1] = equity_post._replace(
 | 
				
			||||||
        units=testutil.Amount(equity_post.units.number * Decimal('.9'), 'EUR'),
 | 
					        units=testutil.Amount(equity_post.units.number * Decimal('.9'), 'EUR'),
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -167,5 +167,5 @@ def test_which_accounts_required_on(hook, account, required):
 | 
				
			||||||
                   for error in errors)
 | 
					                   for error in errors)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def test_not_required_on_opening(hook):
 | 
					def test_not_required_on_opening(hook):
 | 
				
			||||||
    txn = testutil.Transaction.opening_balance()
 | 
					    txn = testutil.OpeningBalance()
 | 
				
			||||||
    assert not list(hook.run(txn))
 | 
					    assert not list(hook.run(txn))
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -142,5 +142,5 @@ def test_missing_invoice(hook, acct1, acct2):
 | 
				
			||||||
    assert actual == {"{} missing {}".format(acct1, TEST_KEY)}
 | 
					    assert actual == {"{} missing {}".format(acct1, TEST_KEY)}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def test_not_required_on_opening(hook):
 | 
					def test_not_required_on_opening(hook):
 | 
				
			||||||
    txn = testutil.Transaction.opening_balance()
 | 
					    txn = testutil.OpeningBalance()
 | 
				
			||||||
    assert not list(hook.run(txn))
 | 
					    assert not list(hook.run(txn))
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -158,11 +158,11 @@ def test_invalid_project_data(repo_path_s, data_path_s):
 | 
				
			||||||
        meta_project.MetaProject(config, Path(data_path_s))
 | 
					        meta_project.MetaProject(config, Path(data_path_s))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def test_not_required_on_opening(hook):
 | 
					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))
 | 
					    assert not list(hook.run(txn))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def test_always_required_on_restricted_funds(hook):
 | 
					def test_always_required_on_restricted_funds(hook):
 | 
				
			||||||
    acct = 'Equity:Funds:Restricted'
 | 
					    acct = 'Equity:Funds:Restricted'
 | 
				
			||||||
    txn = testutil.Transaction.opening_balance(acct)
 | 
					    txn = testutil.OpeningBalance(acct)
 | 
				
			||||||
    actual = {error.message for error in hook.run(txn)}
 | 
					    actual = {error.message for error in hook.run(txn)}
 | 
				
			||||||
    assert actual == {f'{acct} missing project'}
 | 
					    assert actual == {f'{acct} missing project'}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -93,6 +93,37 @@ def Posting(account, number,
 | 
				
			||||||
        meta,
 | 
					        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', '<test>')
 | 
				
			||||||
 | 
					    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 = {
 | 
					LINK_METADATA_STRINGS = {
 | 
				
			||||||
    'Invoices/304321.pdf',
 | 
					    'Invoices/304321.pdf',
 | 
				
			||||||
    'rt:123/456',
 | 
					    'rt:123/456',
 | 
				
			||||||
| 
						 | 
					@ -130,59 +161,16 @@ def balance_map(source=None, **kwargs):
 | 
				
			||||||
        retval.update(balance_map(kwargs.items()))
 | 
					        retval.update(balance_map(kwargs.items()))
 | 
				
			||||||
    return retval
 | 
					    return retval
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Transaction:
 | 
					def OpeningBalance(acct=None, **txn_meta):
 | 
				
			||||||
    def __init__(self,
 | 
					    if acct is None:
 | 
				
			||||||
                 date=FY_MID_DATE, flag='*', payee=None,
 | 
					        acct = next(OPENING_EQUITY_ACCOUNTS)
 | 
				
			||||||
                 narration='', tags=None, links=None, postings=None,
 | 
					    return Transaction(**txn_meta, postings=[
 | 
				
			||||||
                 **meta):
 | 
					        ('Assets:Receivable:Accounts', 100),
 | 
				
			||||||
        if isinstance(date, str):
 | 
					        ('Assets:Receivable:Loans', 200),
 | 
				
			||||||
            date = parse_date(date)
 | 
					        ('Liabilities:Payable:Accounts', -15),
 | 
				
			||||||
        self.date = date
 | 
					        ('Liabilities:Payable:Vacation', -25),
 | 
				
			||||||
        self.flag = flag
 | 
					        (acct, -260),
 | 
				
			||||||
        self.payee = payee
 | 
					    ])
 | 
				
			||||||
        self.narration = narration
 | 
					 | 
				
			||||||
        self.tags = set(tags or '')
 | 
					 | 
				
			||||||
        self.links = set(links or '')
 | 
					 | 
				
			||||||
        self.postings = []
 | 
					 | 
				
			||||||
        self.meta = {
 | 
					 | 
				
			||||||
            'filename': '<test>',
 | 
					 | 
				
			||||||
            '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),
 | 
					 | 
				
			||||||
        ])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
class TestConfig:
 | 
					class TestConfig:
 | 
				
			||||||
    def __init__(self, *,
 | 
					    def __init__(self, *,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		
		Reference in a new issue