rewrite: Support `metakey in value` conditions.

For now, works exactly like ``.account in value``.
This commit is contained in:
Brett Smith 2020-10-24 11:43:11 -04:00
parent ea69d58957
commit 69f597a47c
2 changed files with 49 additions and 2 deletions

View file

@ -82,6 +82,13 @@ Conditions can always use Python's basic comparison operators:
account names. The condition matches when the posting's account names. The condition matches when the posting's
account is any of those named accounts, or any of their account is any of those named accounts, or any of their
respective subaccounts. respective subaccounts.
---------------- -------------------------------------------------------
``metakey in`` Works analogously for metadata values that are
structured as a colon-separated hierarchy like account
names. The operand is parsed as a space-separated list
of parts of the hierarchy. The condition matches when
the posting's ``metakey`` metadata is under any of
those parts of the hierarchy.
================ ======================================================= ================ =======================================================
Action Operators Action Operators
@ -141,9 +148,11 @@ accounting equation, Equity = Assets - Liabilities.
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
import abc import abc
import collections
import datetime import datetime
import decimal import decimal
import enum import enum
import functools
import logging import logging
import operator as opmod import operator as opmod
import re import re
@ -290,9 +299,18 @@ class DateTest(Tester[datetime.date]):
class MetadataTest(Tester[Optional[MetaValue]]): class MetadataTest(Tester[Optional[MetaValue]]):
_GETTERS: Mapping[MetaKey, Callable[[str], data.Account]] = \
collections.defaultdict(lambda: functools.lru_cache()(data.Account))
# We use this class variable to share one cache per metadata key being
# tested.
def __init__(self, key: MetaKey, operator: str, operand: str) -> None: def __init__(self, key: MetaKey, operator: str, operand: str) -> None:
super().__init__(operator, operand)
self.key = key self.key = key
if operator == 'in':
self._get_hierarchy = self._GETTERS[key]
self.under_args = operand.split()
else:
super().__init__(operator, operand)
@staticmethod @staticmethod
def parse_operand(operand: str) -> str: def parse_operand(operand: str) -> str:
@ -301,6 +319,18 @@ class MetadataTest(Tester[Optional[MetaValue]]):
def post_get(self, post: data.Posting) -> Optional[MetaValue]: def post_get(self, post: data.Posting) -> Optional[MetaValue]:
return post.meta.get(self.key) return post.meta.get(self.key)
def __call__(self, post: data.Posting) -> bool:
try:
self.under_args
except AttributeError:
return super().__call__(post)
else:
meta_value = self.post_get(post)
if isinstance(meta_value, str):
return self._get_hierarchy(meta_value).is_under(*self.under_args) is not None
else:
return False
class NumberTest(Tester[Decimal]): class NumberTest(Tester[Decimal]):
@staticmethod @staticmethod

View file

@ -76,6 +76,24 @@ def test_metadata_condition(value, operator):
tester = rewrite.MetadataTest(key, operator, operand) tester = rewrite.MetadataTest(key, operator, operand)
assert tester(post) == eval(f'value {operator} operand') assert tester(post) == eval(f'value {operator} operand')
@pytest.mark.parametrize('value,expected', [
('Root', False),
('Root:Branch', True),
('Root:Branch:Leaf', True),
('Branch', False),
('RootBranch:Leaf', False),
(None, False),
])
def test_metadata_in_condition(value, expected):
key = 'testkey'
meta = {} if value is None else {key: value}
txn = testutil.Transaction(postings=[
('Income:Other', -5, meta),
])
post, = data.Posting.from_txn(txn)
tester = rewrite.MetadataTest(key, 'in', 'Root:Branch')
assert tester(post) == expected
@pytest.mark.parametrize('value', ['4.5', '4.75', '5']) @pytest.mark.parametrize('value', ['4.5', '4.75', '5'])
@pytest.mark.parametrize('operator', CMP_OPS) @pytest.mark.parametrize('operator', CMP_OPS)
def test_number_condition(value, operator): def test_number_condition(value, operator):
@ -118,7 +136,6 @@ def test_parse_good_condition(subject, operator, operand):
'.date in 1990-9-9', # Bad operator '.date in 1990-9-9', # Bad operator
'.number > 0xff', # Bad operand '.number > 0xff', # Bad operand
'.number in 16', # Bad operator '.number in 16', # Bad operator
'testkey in foo', # Bad operator
'units.number == 5', # Bad subject (syntax) 'units.number == 5', # Bad subject (syntax)
'.units == 5', # Bad subject (unknown) '.units == 5', # Bad subject (unknown)
]) ])