From 52adf1f0a5838e53e2f1467e8da752085bdbb401 Mon Sep 17 00:00:00 2001 From: Brett Smith Date: Sat, 29 Aug 2020 10:26:21 -0400 Subject: [PATCH] data: Add PostingMeta.detached() method. --- conservancy_beancount/data.py | 27 ++++++++++++++++++--------- tests/test_data_posting_meta.py | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/conservancy_beancount/data.py b/conservancy_beancount/data.py index b102f3b..24d04b5 100644 --- a/conservancy_beancount/data.py +++ b/conservancy_beancount/data.py @@ -512,10 +512,9 @@ class PostingMeta(Metadata): self.txn = txn self.index = index self.post = post - if post.meta is None: - self.meta = self.txn.meta - else: - self.meta = collections.ChainMap(post.meta, txn.meta) + self.meta: collections.ChainMap = collections.ChainMap(txn.meta) + if post.meta is not None: + self.meta = self.meta.new_child(post.meta) def __getitem__(self, key: MetaKey) -> MetaValue: try: @@ -527,17 +526,16 @@ class PostingMeta(Metadata): raise def __setitem__(self, key: MetaKey, value: MetaValue) -> None: - if self.post.meta is None: + if len(self.meta.maps) == 1: self.post = self.post._replace(meta={key: value}) + assert self.post.meta is not None 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] + self.meta = self.meta.new_child(self.post.meta) else: super().__setitem__(key, value) def __delitem__(self, key: MetaKey) -> None: - if self.post.meta is None: + if len(self.meta.maps) == 1: raise KeyError(key) else: super().__delitem__(key) @@ -550,6 +548,17 @@ class PostingMeta(Metadata): def date(self) -> datetime.date: return self.txn.date + def detached(self) -> 'PostingMeta': + """Create a copy of this PostingMeta detached from the original post + + Changes you make to the detached copy will not propagate to the + underlying data structures. This is mostly useful for reporting code + that may want to "split" and manipulate the metadata multiple times. + """ + retval = type(self)(self.txn, self.index, self.post) + retval.meta = retval.meta.new_child() + return retval + class Posting(BasePosting): """Enhanced Posting objects diff --git a/tests/test_data_posting_meta.py b/tests/test_data_posting_meta.py index 9ab88fd..3ba0d5c 100644 --- a/tests/test_data_posting_meta.py +++ b/tests/test_data_posting_meta.py @@ -126,6 +126,24 @@ def test_date(date): for index, post in enumerate(txn.postings): assert data.PostingMeta(txn, index, post).date == date +def test_mutable_copy(): + txn = testutil.Transaction( + filename='f', lineno=130, txnkey='one', postings=[ + ('Assets:Cash', 18), + ('Income:Donations', -18), + ]) + meta = data.PostingMeta(txn, 1).detached() + meta['layerkey'] = 'two' + assert dict(meta) == { + 'filename': 'f', + 'lineno': 130, + 'txnkey': 'one', + 'layerkey': 'two', + } + assert 'layerkey' not in txn.meta + assert all(post.meta is None for post in txn.postings) + assert meta.date == txn.date + # 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):