Changeset - 9c335175835c
[Not reviewed]
0 3 0
Brett Smith - 4 years ago 2020-06-11 14:44:05
brettcsmith@brettcsmith.org
data: Add Metadata.first_link() method.
3 files changed with 45 insertions and 5 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/data.py
Show inline comments
...
 
@@ -199,96 +199,108 @@ class Amount(bc_amount.Amount):
 
    # beancount.core._Amount is the plain namedtuple.
 
    # beancore.core.Amount adds instance methods to it.
 
    # b.c.Amount.__New__ calls `b.c._Amount.__new__`, which confuses type
 
    # checking. See <https://github.com/python/mypy/issues/1279>.
 
    # It works fine if you use super(), which is better practice anyway.
 
    # So we override __new__ just to call _Amount.__new__ this way.
 
    def __new__(cls, number: decimal.Decimal, currency: str) -> 'Amount':
 
        return super(bc_amount.Amount, Amount).__new__(cls, number, currency)
 

	
 

	
 
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.
 
    """
 
    __slots__ = ('meta',)
 

	
 
    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__,
 
            ))
 

	
 
    @overload
 
    def first_link(self, key: MetaKey, default: None=None) -> Optional[str]: ...
 

	
 
    @overload
 
    def first_link(self, key: MetaKey, default: str) -> str: ...
 

	
 
    def first_link(self, key: MetaKey, default: Optional[str]=None) -> Optional[str]:
 
        try:
 
            return self.get_links(key)[0]
 
        except (IndexError, TypeError):
 
            return default
 

	
 

	
 
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.
 
    If you try to look up metadata that doesn't exist on the posting, it will
 
    look for the value in the parent transaction metadata instead.
 

	
 
    You can set and delete metadata as well. Changes only affect the metadata
 
    of the posting, never the transaction. Changes are propagated to the
 
    underlying Beancount data structures.
 

	
 
    Functionally, you can think of this as identical to:
 

	
 
      collections.ChainMap(post.meta, txn.meta)
 

	
 
    Under the hood, this class does a little extra work to avoid creating
 
    posting metadata if it doesn't have to.
 
    """
 
    __slots__ = ('txn', 'index', 'post')
 

	
 
    def __init__(self,
 
                 txn: Transaction,
 
                 index: int,
 
                 post: Optional[BasePosting]=None,
 
    ) -> None:
 
        if post is None:
 
            post = txn.postings[index]
 
        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)
 

	
 
    def __getitem__(self, key: MetaKey) -> MetaValue:
 
        try:
 
            return super().__getitem__(key)
 
        except KeyError:
 
            if key == 'entity' and self.txn.payee is not None:
 
                return self.txn.payee
 
            else:
 
                raise
 

	
 
    def __setitem__(self, key: MetaKey, value: MetaValue) -> None:
 
        if self.post.meta is None:
 
            self.post = self.post._replace(meta={key: value})
conservancy_beancount/reports/accrual.py
Show inline comments
...
 
@@ -175,101 +175,97 @@ class AccrualPostings(core.RelatedPostings):
 
    )
 
    INCONSISTENT = Sentinel()
 

	
 
    def __init__(self,
 
                 source: Iterable[data.Posting]=(),
 
                 *,
 
                 _can_own: bool=False,
 
    ) -> None:
 
        super().__init__(source, _can_own=_can_own)
 
        # The following type declarations tell mypy about values set in the for
 
        # loop that are important enough to be referenced directly elsewhere.
 
        self.account = self._single_item(post.account for post in self)
 
        if isinstance(self.account, Sentinel):
 
            self.accrual_type: Optional[AccrualAccount] = None
 
            norm_func: Callable[[T], T] = lambda x: x
 
            entity_pred: Callable[[data.Posting], bool] = bool
 
        else:
 
            self.accrual_type = AccrualAccount.by_account(self.account)
 
            norm_func = self.accrual_type.normalize_amount
 
            entity_pred = lambda post: norm_func(post.units).number > 0
 
        self.entity = self._single_item(self.entities(entity_pred))
 
        self.invoice = self._single_item(self.first_links('invoice'))
 
        self.end_balance = norm_func(self.balance_at_cost())
 

	
 
    def _single_item(self, seq: Iterable[T]) -> Union[T, Sentinel]:
 
        items = iter(seq)
 
        try:
 
            item1 = next(items)
 
        except StopIteration:
 
            all_same = False
 
        else:
 
            all_same = all(item == item1 for item in items)
 
        return item1 if all_same else self.INCONSISTENT
 

	
 
    def entities(self, pred: Callable[[data.Posting], bool]=bool) -> Iterator[MetaValue]:
 
        seen: Set[MetaValue] = set()
 
        for post in self:
 
            if pred(post):
 
                try:
 
                    entity = post.meta['entity']
 
                except KeyError:
 
                    pass
 
                else:
 
                    if entity not in seen:
 
                        yield entity
 
                        seen.add(entity)
 

	
 
    def first_links(self, key: MetaKey, default: Optional[str]=None) -> Iterator[Optional[str]]:
 
        for post in self:
 
            try:
 
                yield post.meta.get_links(key)[0]
 
            except (IndexError, TypeError):
 
                yield default
 
        return (post.meta.first_link(key, default) for post in self)
 

	
 
    def make_consistent(self) -> Iterator[Tuple[MetaValue, 'AccrualPostings']]:
 
        account_ok = isinstance(self.account, str)
 
        entity_ok = isinstance(self.entity, str)
 
        # `'/' in self.invoice` is just our heuristic to ensure that the
 
        # invoice metadata is "unique enough," and not just a placeholder
 
        # value like "FIXME". It can be refined if needed.
 
        invoice_ok = isinstance(self.invoice, str) and '/' in self.invoice
 
        if account_ok and entity_ok and invoice_ok:
 
            yield (self.invoice, self)
 
            return
 
        groups = collections.defaultdict(list)
 
        for post in self:
 
            post_invoice = self.invoice if invoice_ok else (
 
                post.meta.get('invoice') or 'BlankInvoice'
 
            )
 
            post_entity = self.entity if entity_ok else (
 
                post.meta.get('entity') or 'BlankEntity'
 
            )
 
            groups[f'{post.account} {post_invoice} {post_entity}'].append(post)
 
        type_self = type(self)
 
        for group_key, posts in groups.items():
 
            yield group_key, type_self(posts, _can_own=True)
 

	
 
    def is_paid(self, default: Optional[bool]=None) -> Optional[bool]:
 
        if self.accrual_type is None:
 
            return default
 
        else:
 
            return self.end_balance.le_zero()
 

	
 
    def is_zero(self, default: Optional[bool]=None) -> Optional[bool]:
 
        if self.accrual_type is None:
 
            return default
 
        else:
 
            return self.end_balance.is_zero()
 

	
 
    def since_last_nonzero(self) -> 'AccrualPostings':
 
        for index, (post, balance) in enumerate(self.iter_with_balance()):
 
            if balance.is_zero():
 
                start_index = index
 
        try:
 
            empty = start_index == index
 
        except NameError:
 
            empty = True
 
        return self if empty else self[start_index + 1:]
 

	
 

	
 
class BaseReport:
tests/test_data_metadata.py
Show inline comments
...
 
@@ -12,48 +12,80 @@
 
# 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',
 
    '  link',
 
    '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')
 

	
 
def test_first_link_from_txn(simple_txn):
 
    meta = data.PostingMeta(simple_txn, 0)
 
    assert meta.first_link('note') == 'txn'
 

	
 
def test_first_link_from_post_override(simple_txn):
 
    meta = data.PostingMeta(simple_txn, 1)
 
    assert meta.first_link('note') == 'donation'
 

	
 
def test_first_link_is_only_link(simple_txn):
 
    meta = data.PostingMeta(simple_txn, 1)
 
    assert meta.first_link('extra') == 'Extra'
 

	
 
def test_first_link_nonexistent_metadata(simple_txn):
 
    meta = data.PostingMeta(simple_txn, 1)
 
    assert meta.first_link('Nonexistent') is None
 

	
 
def test_first_link_nonexistent_default(simple_txn):
 
    meta = data.PostingMeta(simple_txn, 1)
 
    assert meta.first_link('Nonexistent', 'missing') == 'missing'
 

	
 
@pytest.mark.parametrize('meta_value', testutil.NON_STRING_METADATA_VALUES)
 
def test_first_link_bad_type_metadata(simple_txn, meta_value):
 
    simple_txn.meta['badmeta'] = meta_value
 
    meta = data.PostingMeta(simple_txn, 1)
 
    assert meta.first_link('badmeta') is None
 

	
 
@pytest.mark.parametrize('meta_value', testutil.NON_STRING_METADATA_VALUES)
 
def test_first_link_bad_type_default(simple_txn, meta_value):
 
    simple_txn.meta['badmeta'] = meta_value
 
    meta = data.PostingMeta(simple_txn, 1)
 
    assert meta.first_link('badmeta', '_') == '_'
0 comments (0 inline, 0 general)