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,