data: Add Metadata class.

As I start writing more link-checking hooks, I want a common place to
write link-parsing code.  This new class will be that place.
This commit is contained in:
Brett Smith 2020-03-28 13:35:38 -04:00
parent 2cb131423f
commit 9b63d898af
4 changed files with 125 additions and 23 deletions

View file

@ -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 <https://www.gnu.org/licenses/>.
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]:

View file

@ -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 <https://www.gnu.org/licenses/>.
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')

View file

@ -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):

View file

@ -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,