diff --git a/conservancy_beancount/data.py b/conservancy_beancount/data.py index 179202ce090f1353fda700b3f7ecf7f9f30138c4..f7b438b6da38f8d99b8b4f21af865e16094796df 100644 --- a/conservancy_beancount/data.py +++ b/conservancy_beancount/data.py @@ -19,7 +19,7 @@ throughout Conservancy tools. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import collections.abc +import collections from beancount.core import account as bc_account @@ -28,6 +28,7 @@ from typing import ( Iterator, MutableMapping, Optional, + Sequence, ) from .beancount_types import ( @@ -98,7 +99,45 @@ class Account(str): return None -class PostingMeta(collections.abc.MutableMapping): +class Metadata(MutableMapping[MetaKey, MetaValue]): + """Transaction or posting metadata + + This class wraps a Beancount metadata dictionary with additional methods + for common parsing and query tasks. + """ + + def __init__(self, source: MutableMapping[MetaKey, MetaValue]) -> None: + self.meta = source + + def __iter__(self) -> Iterator[MetaKey]: + return iter(self.meta) + + def __len__(self) -> int: + return len(self.meta) + + def __getitem__(self, key: MetaKey) -> MetaValue: + return self.meta[key] + + def __setitem__(self, key: MetaKey, value: MetaValue) -> None: + self.meta[key] = value + + def __delitem__(self, key: MetaKey) -> None: + del self.meta[key] + + def get_links(self, key: MetaKey) -> Sequence[str]: + try: + value = self.meta[key] + except KeyError: + return () + if isinstance(value, str): + return value.split() + else: + raise TypeError("{} metadata is a {}, not str".format( + key, type(value).__name__, + )) + + +class PostingMeta(Metadata): """Combined access to posting metadata with its parent transaction metadata This lets you access posting metadata through a single dict-like object. @@ -127,38 +166,26 @@ class PostingMeta(collections.abc.MutableMapping): self.txn = txn self.index = index self.post = post - - def __iter__(self) -> Iterator[MetaKey]: - keys: Iterable[MetaKey] - if self.post.meta is None: - keys = self.txn.meta.keys() + if post.meta is None: + self.meta = self.txn.meta else: - keys = frozenset(self.post.meta.keys()).union(self.txn.meta.keys()) - return iter(keys) - - def __len__(self) -> int: - return sum(1 for _ in self) - - def __getitem__(self, key: MetaKey) -> MetaValue: - if self.post.meta: - try: - return self.post.meta[key] - except KeyError: - pass - return self.txn.meta[key] + self.meta = collections.ChainMap(post.meta, txn.meta) def __setitem__(self, key: MetaKey, value: MetaValue) -> None: if self.post.meta is None: self.post = self.post._replace(meta={key: value}) self.txn.postings[self.index] = self.post + # mypy complains that self.post.meta could be None, but we know + # from two lines up that it's not. + self.meta = collections.ChainMap(self.post.meta, self.txn.meta) # type:ignore[arg-type] else: - self.post.meta[key] = value + super().__setitem__(key, value) def __delitem__(self, key: MetaKey) -> None: if self.post.meta is None: raise KeyError(key) else: - del self.post.meta[key] + super().__delitem__(key) class Posting(BasePosting): @@ -178,7 +205,7 @@ class Posting(BasePosting): # declaration should also use MutableMapping, because it would be very # unusual for code to specifically require a Dict over that. # If it did, this declaration would pass without issue. - meta: MutableMapping[MetaKey, MetaValue] # type:ignore[assignment] + meta: Metadata # type:ignore[assignment] def iter_postings(txn: Transaction) -> Iterator[Posting]: diff --git a/tests/test_data_metadata.py b/tests/test_data_metadata.py new file mode 100644 index 0000000000000000000000000000000000000000..acc5e6dba1c643ebfffbe8bce3f3a642ac5891a2 --- /dev/null +++ b/tests/test_data_metadata.py @@ -0,0 +1,57 @@ +"""Test Metadata class""" +# Copyright © 2020 Brett Smith +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pytest + +from . import testutil + +from conservancy_beancount import data + +@pytest.fixture +def simple_txn(index=None, key=None): + return testutil.Transaction(note='txn note', postings=[ + ('Assets:Cash', 5), + ('Income:Donations', -5, {'note': 'donation love', 'extra': 'Extra'}), + ]) +SIMPLE_TXN_METAKEYS = frozenset(['filename', 'lineno', 'note']) + +def test_metadata_transforms_source(): + source = {'1': 'one'} + meta = data.Metadata(source) + meta['2'] = 'two' + assert source['2'] == 'two' + del meta['1'] + assert set(source) == {'2'} + +@pytest.mark.parametrize('value', [ + '', + 'link', + 'link1 link2', + ' link1 link2 link3 ', +]) +def test_get_links(value): + meta = data.Metadata({'key': value}) + assert list(meta.get_links('key')) == value.split() + +def test_get_links_missing(): + meta = data.Metadata({}) + assert not meta.get_links('key') + +@pytest.mark.parametrize('value', testutil.NON_STRING_METADATA_VALUES) +def test_get_links_bad_type(value): + meta = data.Metadata({'key': value}) + with pytest.raises(TypeError): + meta.get_links('key') diff --git a/tests/test_data_posting_meta.py b/tests/test_data_posting_meta.py index 7f091afd2385fdc1b3e13d6639f24b1845ef5f65..1edfc66203e7723438096009b450bc8db4e1a777 100644 --- a/tests/test_data_posting_meta.py +++ b/tests/test_data_posting_meta.py @@ -80,6 +80,14 @@ def test_iter_with_empty_post_meta(simple_txn): def test_iter_with_post_meta_over_txn(simple_txn): assert set(data.PostingMeta(simple_txn, 1)) == SIMPLE_TXN_METAKEYS.union(['extra']) +def test_get_links_from_txn(simple_txn): + meta = data.PostingMeta(simple_txn, 0) + assert list(meta.get_links('note')) == ['txn', 'note'] + +def test_get_links_from_post_override(simple_txn): + meta = data.PostingMeta(simple_txn, 1) + assert list(meta.get_links('note')) == ['donation', 'love'] + # The .get() tests are arguably testing the stdlib, but they're short and # they confirm that we're using the stdlib as we intend. def test_get_with_meta_value(simple_txn): diff --git a/tests/testutil.py b/tests/testutil.py index 956a4788adfbc4016a5fa30c4f3de8a6a00290c8..648e5ec17845e471509970105d8d81902f30cc5f 100644 --- a/tests/testutil.py +++ b/tests/testutil.py @@ -54,6 +54,9 @@ def test_path(s): s = TESTS_DIR / s return s +def Amount(number, currency='USD'): + return bc_amount.Amount(Decimal(number), currency) + def Posting(account, number, currency='USD', cost=None, price=None, flag=None, **meta): @@ -68,6 +71,13 @@ def Posting(account, number, meta, ) +NON_STRING_METADATA_VALUES = [ + Decimal(5), + FY_MID_DATE, + Amount(50), + Amount(500, None), +] + class Transaction: def __init__(self, date=FY_MID_DATE, flag='*', payee=None,