Changeset - 536b50b478d8
[Not reviewed]
0 10 0
Brett Smith - 4 years ago 2020-05-11 13:52:05
brettcsmith@brettcsmith.org
plugin: Don't validate transactions flagged with !. RT#10591.
10 files changed with 77 insertions and 17 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/plugin/core.py
Show inline comments
...
 
@@ -10,247 +10,253 @@
 
# 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 abc
 
import datetime
 
import re
 

	
 
from .. import config as configmod
 
from .. import data
 
from .. import errors as errormod
 

	
 
from typing import (
 
    Any,
 
    Dict,
 
    FrozenSet,
 
    Generic,
 
    Iterable,
 
    Iterator,
 
    Mapping,
 
    Optional,
 
    Sequence,
 
    Type,
 
    TypeVar,
 
)
 
from ..beancount_types import (
 
    Account,
 
    Directive,
 
    MetaKey,
 
    MetaValue,
 
    MetaValueEnum,
 
    Transaction,
 
)
 

	
 
### CONSTANTS
 

	
 
# I expect these will become configurable in the future, which is why I'm
 
# keeping them outside of a class, but for now constants will do.
 
DEFAULT_START_DATE: datetime.date = datetime.date(2020, 3, 1)
 
# The default stop date leaves a little room after so it's easy to test
 
# dates past the far end of the range.
 
DEFAULT_STOP_DATE: datetime.date = datetime.date(datetime.MAXYEAR, 1, 1)
 

	
 
### TYPE DEFINITIONS
 

	
 
HookName = str
 

	
 
Entry = TypeVar('Entry', bound=Directive)
 
class Hook(Generic[Entry], metaclass=abc.ABCMeta):
 
    DIRECTIVE: Type[Directive]
 
    HOOK_GROUPS: FrozenSet[HookName] = frozenset()
 

	
 
    def __init__(self, config: configmod.Config) -> None:
 
        pass
 
        # Subclasses that need configuration should override __init__ to check
 
        # and store it.
 

	
 
    @abc.abstractmethod
 
    def run(self, entry: Entry) -> errormod.Iter: ...
 

	
 

	
 
class TransactionHook(Hook[Transaction]):
 
    DIRECTIVE = Transaction
 

	
 

	
 
### HELPER CLASSES
 

	
 
class LessComparable(metaclass=abc.ABCMeta):
 
    @abc.abstractmethod
 
    def __le__(self, other: Any) -> bool: ...
 

	
 
    @abc.abstractmethod
 
    def __lt__(self, other: Any) -> bool: ...
 

	
 

	
 
CT = TypeVar('CT', bound=LessComparable)
 
class _GenericRange(Generic[CT]):
 
    """Convenience class to check whether a value is within a range.
 

	
 
    `foo in generic_range` is equivalent to `start <= foo < stop`.
 
    Since we have multiple user-configurable ranges, having the check
 
    encapsulated in an object helps implement the check consistently, and
 
    makes it easier for subclasses to override.
 
    """
 

	
 
    def __init__(self, start: CT, stop: CT) -> None:
 
        self.start = start
 
        self.stop = stop
 

	
 
    def __repr__(self) -> str:
 
        return "{clsname}({self.start!r}, {self.stop!r})".format(
 
            clsname=type(self).__name__,
 
            self=self,
 
        )
 

	
 
    def __contains__(self, item: CT) -> bool:
 
        return self.start <= item < self.stop
 

	
 

	
 
class MetadataEnum:
 
    """Map acceptable metadata values to their normalized forms.
 

	
 
    When a piece of metadata uses a set of allowed values, use this class to
 
    define them. You can also specify aliases that hooks will normalize to
 
    the primary values.
 
    """
 

	
 
    def __init__(self,
 
                 key: MetaKey,
 
                 standard_values: Iterable[MetaValueEnum],
 
                 aliases_map: Optional[Mapping[MetaValueEnum, MetaValueEnum]]=None,
 
    ) -> None:
 
        """Specify allowed values and aliases for this metadata.
 

	
 
        Arguments:
 

	
 
        * key: The name of the metadata key that uses this enum.
 
        * standard_values: A sequence of strings that enumerate the standard
 
          values for this metadata.
 
        * aliases_map: A mapping of strings to strings. The keys are
 
          additional allowed metadata values. The values are standard values
 
          that each key will evaluate to. The code asserts that all values are
 
          in standard_values.
 
        """
 
        self.key = key
 
        self._stdvalues = frozenset(standard_values)
 
        self._aliases: Dict[MetaValueEnum, MetaValueEnum] = dict(aliases_map or ())
 
        assert self._stdvalues.issuperset(self._aliases.values())
 
        self._aliases.update((v, v) for v in standard_values)
 

	
 
    def __repr__(self) -> str:
 
        return "{}<{}>".format(type(self).__name__, self.key)
 

	
 
    def __contains__(self, key: MetaValueEnum) -> bool:
 
        """Returns true if `key` is a standard value or alias."""
 
        return key in self._aliases
 

	
 
    def __getitem__(self, key: MetaValueEnum) -> MetaValueEnum:
 
        """Return the standard value for `key`.
 

	
 
        Raises KeyError if `key` is not a known value or alias.
 
        """
 
        return self._aliases[key]
 

	
 
    def __iter__(self) -> Iterator[MetaValueEnum]:
 
        """Iterate over standard values."""
 
        return iter(self._stdvalues)
 

	
 
    def get(self,
 
            key: MetaValueEnum,
 
            default_key: Optional[MetaValueEnum]=None,
 
    ) -> Optional[MetaValueEnum]:
 
        """Return self[key], or a default fallback if that doesn't exist.
 

	
 
        default_key is another key to look up, *not* a default value to return.
 
        This helps ensure you always get a standard value.
 
        """
 
        try:
 
            return self[key]
 
        except KeyError:
 
            if default_key is None:
 
                return None
 
            else:
 
                return self[default_key]
 

	
 

	
 
### HOOK SUBCLASSES
 

	
 
class _PostingHook(TransactionHook, metaclass=abc.ABCMeta):
 
class TransactionHook(Hook[Transaction]):
 
    DIRECTIVE = Transaction
 
    TXN_DATE_RANGE: _GenericRange = _GenericRange(DEFAULT_START_DATE, DEFAULT_STOP_DATE)
 

	
 
    def __init_subclass__(cls) -> None:
 
        cls.HOOK_GROUPS = cls.HOOK_GROUPS.union(['posting'])
 

	
 
    def _run_on_txn(self, txn: Transaction) -> bool:
 
        """Check whether we should run on a given transaction
 

	
 
        This method implements our usual checks for whether or not a hook
 
        should run on a given transaction. It's here for subclasses to use in
 
        their own implementations. See _PostingHook below for an example.
 
        """
 
        return (
 
            txn.date in self.TXN_DATE_RANGE
 
            txn.flag != '!'
 
            and txn.date in self.TXN_DATE_RANGE
 
            and not data.is_opening_balance_txn(txn)
 
        )
 

	
 

	
 
class _PostingHook(TransactionHook, metaclass=abc.ABCMeta):
 
    def __init_subclass__(cls) -> None:
 
        cls.HOOK_GROUPS = cls.HOOK_GROUPS.union(['posting'])
 

	
 
    def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool:
 
        return True
 

	
 
    def run(self, txn: Transaction) -> errormod.Iter:
 
        if self._run_on_txn(txn):
 
            for post in data.Posting.from_txn(txn):
 
                if self._run_on_post(txn, post):
 
                    yield from self.post_run(txn, post)
 

	
 
    @abc.abstractmethod
 
    def post_run(self, txn: Transaction, post: data.Posting) -> errormod.Iter: ...
 

	
 

	
 
class _NormalizePostingMetadataHook(_PostingHook):
 
    """Base class to normalize posting metadata from an enum."""
 
    # This class provides basic functionality to filter postings, normalize
 
    # metadata values, and set default values.
 
    METADATA_KEY: MetaKey
 
    VALUES_ENUM: MetadataEnum
 

	
 
    def __init_subclass__(cls) -> None:
 
        super().__init_subclass__()
 
        cls.METADATA_KEY = cls.VALUES_ENUM.key
 
        cls.HOOK_GROUPS = cls.HOOK_GROUPS.union(['metadata', cls.METADATA_KEY])
 

	
 
    # If the posting does not specify METADATA_KEY, the hook calls
 
    # _default_value to get a default. This method should either return
 
    # a value string from METADATA_ENUM, or else raise InvalidMetadataError.
 
    # This base implementation does the latter.
 
    def _default_value(self, txn: Transaction, post: data.Posting) -> MetaValueEnum:
 
        raise errormod.InvalidMetadataError(txn, self.METADATA_KEY, None, post)
 

	
 
    def post_run(self, txn: Transaction, post: data.Posting) -> errormod.Iter:
 
        source_value = post.meta.get(self.METADATA_KEY)
 
        set_value = source_value
 
        error: Optional[errormod.Error] = None
 
        if source_value is None:
 
            try:
 
                set_value = self._default_value(txn, post)
 
            except errormod.Error as error_:
 
                error = error_
 
        else:
 
            try:
 
                set_value = self.VALUES_ENUM[source_value]
 
            except KeyError:
 
                error = errormod.InvalidMetadataError(
 
                    txn, self.METADATA_KEY, source_value, post,
 
                )
 
        if error is None:
 
            post.meta[self.METADATA_KEY] = set_value
 
        else:
 
            yield error
 

	
 

	
 
class _RequireLinksPostingMetadataHook(_PostingHook):
 
    """Base class to require that posting metadata include links"""
 
    # This base class confirms that a posting's metadata has one or more links
 
    # under one of the metadata keys listed in CHECKED_METADATA.
 
    # Most subclasses only need to define CHECKED_METADATA and _run_on_post.
 
    CHECKED_METADATA: Sequence[MetaKey]
 

	
 
    def __init_subclass__(cls) -> None:
 
        super().__init_subclass__()
 
        cls.HOOK_GROUPS = cls.HOOK_GROUPS.union(cls.CHECKED_METADATA).union('metadata')
conservancy_beancount/plugin/meta_repo_links.py
Show inline comments
...
 
@@ -2,68 +2,69 @@
 
# 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 re
 

	
 
from . import core
 
from .. import config as configmod
 
from .. import data
 
from .. import errors as errormod
 
from ..beancount_types import (
 
    MetaKey,
 
    MetaValue,
 
    Posting,
 
    Transaction,
 
)
 

	
 
from typing import (
 
    MutableMapping,
 
    Optional,
 
)
 

	
 
class MetaRepoLinks(core.TransactionHook):
 
    HOOK_GROUPS = frozenset(['linkcheck'])
 
    LINK_METADATA = data.LINK_METADATA.difference('rt-id')
 
    PATH_PUNCT_RE = re.compile(r'[:/]')
 

	
 
    def __init__(self, config: configmod.Config) -> None:
 
        repo_path = config.repository_path()
 
        if repo_path is None:
 
            raise errormod.ConfigurationError("no repository configured")
 
        self.repo_path = repo_path
 

	
 
    def _check_links(self,
 
                     meta: MutableMapping[MetaKey, MetaValue],
 
                     txn: Transaction,
 
                     post: Optional[Posting]=None,
 
    ) -> errormod.Iter:
 
        metadata = data.Metadata(meta)
 
        for key in self.LINK_METADATA:
 
            try:
 
                links = metadata.get_links(key)
 
            except TypeError:
 
                yield errormod.InvalidMetadataError(txn, key, meta[key], post)
 
            else:
 
                for link in links:
 
                    match = self.PATH_PUNCT_RE.search(link)
 
                    if match and match.group(0) == ':':
 
                        pass
 
                    elif not (self.repo_path / link).exists():
 
                        yield errormod.BrokenLinkError(txn, key, link)
 

	
 
    def run(self, txn: Transaction) -> errormod.Iter:
 
        yield from self._check_links(txn.meta, txn)
 
        for post in txn.postings:
 
            if post.meta is not None:
 
                yield from self._check_links(post.meta, txn, post)
 
        if self._run_on_txn(txn):
 
            yield from self._check_links(txn.meta, txn)
 
            for post in txn.postings:
 
                if post.meta is not None:
 
                    yield from self._check_links(post.meta, txn, post)
conservancy_beancount/plugin/meta_rt_links.py
Show inline comments
 
"""meta_rt_links - Check that RT links are valid"""
 
# 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/>.
 

	
 
from . import core
 
from .. import config as configmod
 
from .. import data
 
from .. import errors as errormod
 
from ..beancount_types import (
 
    MetaKey,
 
    MetaValue,
 
    Posting,
 
    Transaction,
 
)
 

	
 
from typing import (
 
    MutableMapping,
 
    Optional,
 
)
 

	
 
class MetaRTLinks(core.TransactionHook):
 
    HOOK_GROUPS = frozenset(['linkcheck', 'network', 'rt'])
 

	
 
    def __init__(self, config: configmod.Config) -> None:
 
        rt_wrapper = config.rt_wrapper()
 
        if rt_wrapper is None:
 
            raise errormod.ConfigurationError("can't log in to RT")
 
        self.rt = rt_wrapper
 

	
 
    def _check_links(self,
 
                     meta: MutableMapping[MetaKey, MetaValue],
 
                     txn: Transaction,
 
                     post: Optional[Posting]=None,
 
    ) -> errormod.Iter:
 
        metadata = data.Metadata(meta)
 
        for key in data.LINK_METADATA:
 
            try:
 
                links = metadata.get_links(key)
 
            except TypeError:
 
                yield errormod.InvalidMetadataError(txn, key, meta[key], post)
 
            else:
 
                for link in links:
 
                    if not link.startswith('rt:'):
 
                        continue
 
                    parsed = self.rt.parse(link)
 
                    if parsed is None or not self.rt.exists(*parsed):
 
                        yield errormod.BrokenRTLinkError(txn, key, link, parsed)
 

	
 
    def run(self, txn: Transaction) -> errormod.Iter:
 
        yield from self._check_links(txn.meta, txn)
 
        for post in txn.postings:
 
            if post.meta is not None:
 
                yield from self._check_links(post.meta, txn, post)
 
        if self._run_on_txn(txn):
 
            yield from self._check_links(txn.meta, txn)
 
            for post in txn.postings:
 
                if post.meta is not None:
 
                    yield from self._check_links(post.meta, txn, post)
tests/test_meta_approval.py
Show inline comments
...
 
@@ -119,64 +119,71 @@ def test_invalid_values_on_transaction(hook, acct1, acct2, value):
 
@pytest.mark.parametrize('acct1,acct2,value', testutil.combine_values(
 
    REQUIRED_ACCOUNTS,
 
    NON_REQUIRED_ACCOUNTS,
 
    testutil.NON_STRING_METADATA_VALUES,
 
))
 
def test_bad_type_values_on_transaction(hook, acct1, acct2, value):
 
    txn = testutil.Transaction(**{TEST_KEY: value}, postings=[
 
        (acct2, 25),
 
        (acct1, -25),
 
    ])
 
    expected_msg = "{} has wrong type of {}: expected str but is a {}".format(
 
        acct1,
 
        TEST_KEY,
 
        type(value).__name__,
 
    )
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected_msg in actual
 

	
 
@pytest.mark.parametrize('acct1,acct2', testutil.combine_values(
 
    REQUIRED_ACCOUNTS,
 
    NON_REQUIRED_ACCOUNTS,
 
))
 
def test_approval_not_required_on_credits(hook, acct1, acct2):
 
    txn = testutil.Transaction(postings=[
 
        (acct2, -25),
 
        (acct1, 25),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
def test_approval_not_required_to_charge_credit_card(hook):
 
    txn = testutil.Transaction(postings=[
 
        ('Expenses:Other', 25),
 
        (CREDITCARD_ACCOUNT, 25),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
def test_approval_not_required_to_pay_credit_card(hook):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Checking', -25),
 
        (CREDITCARD_ACCOUNT, 25),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
@pytest.mark.parametrize('tax_implication,other_acct', [
 
    ('Bank-Transfer', 'Assets:Savings'),
 
    ('Chargeback', 'Income:Donations'),
 
])
 
def test_approval_not_required_for_asset_transfers(hook, tax_implication, other_acct):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Checking', -250, {'tax-implication': tax_implication}),
 
        (other_acct, 250),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
def test_approval_required_for_partial_transfer(hook):
 
    # I'm not sure this ever comes up in reality, but just being thorough
 
    # out of an abundance of precaution.
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Checking', -250, {'tax-implication': 'Bank-Transfer'}),
 
        ('Assets:Savings', 225),
 
        ('Expenses:BankingFees', 25),
 
    ])
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == {"Assets:Checking missing {}".format(TEST_KEY)}
 

	
 
def test_not_required_on_flagged(hook):
 
    txn = testutil.Transaction(flag='!', postings=[
 
        ('Assets:Checking', -25),
 
        ('Liabilities:Payable:Accounts', 25),
 
    ])
 
    assert not list(hook.run(txn))
tests/test_meta_invoice.py
Show inline comments
...
 
@@ -83,64 +83,75 @@ def test_bad_type_values_on_postings(hook, acct1, acct2, value):
 
    expected = {
 
        MISSING_MSG(acct1),
 
        WRONG_TYPE_MSG(acct1, type(value).__name__),
 
    }
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == expected
 

	
 
@pytest.mark.parametrize('acct1,acct2,value', testutil.combine_values(
 
    REQUIRED_ACCOUNTS,
 
    NON_REQUIRED_ACCOUNTS,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_valid_values_on_transaction(hook, acct1, acct2, value):
 
    txn = testutil.Transaction(**{TEST_KEY: value}, postings=[
 
        (acct2, -25),
 
        (acct1, 25),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
@pytest.mark.parametrize('acct1,acct2,value', testutil.combine_values(
 
    REQUIRED_ACCOUNTS,
 
    NON_REQUIRED_ACCOUNTS,
 
    testutil.NON_LINK_METADATA_STRINGS,
 
))
 
def test_invalid_values_on_transaction(hook, acct1, acct2, value):
 
    txn = testutil.Transaction(**{TEST_KEY: value}, postings=[
 
        (acct2, -25),
 
        (acct1, 25),
 
    ])
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == {"{} missing {}".format(acct1, TEST_KEY)}
 

	
 
@pytest.mark.parametrize('acct1,acct2,value', testutil.combine_values(
 
    REQUIRED_ACCOUNTS,
 
    NON_REQUIRED_ACCOUNTS,
 
    testutil.NON_STRING_METADATA_VALUES,
 
))
 
def test_bad_type_values_on_transaction(hook, acct1, acct2, value):
 
    txn = testutil.Transaction(**{TEST_KEY: value}, postings=[
 
        (acct2, -25),
 
        (acct1, 25),
 
    ])
 
    expected = {
 
        MISSING_MSG(acct1),
 
        WRONG_TYPE_MSG(acct1, type(value).__name__),
 
    }
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == expected
 

	
 
@pytest.mark.parametrize('acct1,acct2', testutil.combine_values(
 
    REQUIRED_ACCOUNTS,
 
    NON_REQUIRED_ACCOUNTS,
 
))
 
def test_missing_invoice(hook, acct1, acct2):
 
    txn = testutil.Transaction(postings=[
 
        (acct2, -25),
 
        (acct1, 25),
 
    ])
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == {"{} missing {}".format(acct1, TEST_KEY)}
 

	
 
def test_not_required_on_opening(hook):
 
    txn = testutil.OpeningBalance()
 
    assert not list(hook.run(txn))
 

	
 
@pytest.mark.parametrize('acct1,acct2', testutil.combine_values(
 
    REQUIRED_ACCOUNTS,
 
    NON_REQUIRED_ACCOUNTS,
 
))
 
def test_not_required_on_flagged(acct1, acct2, hook):
 
    txn = testutil.Transaction(flag='!', postings=[
 
        (acct1, 25),
 
        (acct2, -25),
 
    ])
 
    assert not list(hook.run(txn))
tests/test_meta_payable_documentation.py
Show inline comments
...
 
@@ -110,64 +110,67 @@ def test_docs_all_bad_type(hook, meta_type):
 
    expected = {
 
        wrong_type_message(key, value)
 
        for key, value in meta.items()
 
    }
 
    expected.add(MISSING_MSG)
 
    check(hook, expected, **{meta_type: meta})
 

	
 
@pytest.mark.parametrize('support_key,support_value', testutil.combine_values(
 
    SUPPORTING_METADATA,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_type_errors_reported_with_valid_post_docs(hook, support_key, support_value):
 
    meta = seed_meta(testutil.NON_STRING_METADATA_VALUES)
 
    meta[support_key] = support_value
 
    expected = {
 
        wrong_type_message(key, value)
 
        for key, value in meta.items()
 
        if key != support_key
 
    }
 
    check(hook, expected, post_meta=meta)
 

	
 
@pytest.mark.parametrize('support_key,support_value', testutil.combine_values(
 
    SUPPORTING_METADATA,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_type_errors_reported_with_valid_txn_docs(hook, support_key, support_value):
 
    meta = seed_meta(testutil.NON_STRING_METADATA_VALUES)
 
    meta[support_key] = support_value
 
    expected = {
 
        wrong_type_message(key, value)
 
        for key, value in meta.items()
 
        if key != support_key
 
    }
 
    check(hook, expected, txn_meta=meta)
 

	
 
def test_paid_accts_not_checked(hook):
 
    txn = testutil.Transaction(postings=[
 
        (TEST_ACCT, 250),
 
        (OTHER_ACCT, -250),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
@pytest.mark.parametrize('account', [
 
    'Assets:Bank:Checking',
 
    'Assets:Cash',
 
    'Assets:Receivable:Accounts',
 
    'Equity:OpeningBalance',
 
    'Expenses:BankingFees',
 
    'Income:Donations',
 
    'Liabilities:CreditCard',
 
    'Liabilities:Payable:Vacation',
 
    'Liabilities:UnearnedIncome:Donations',
 
])
 
def test_does_not_apply_to_other_accounts(hook, account):
 
    meta = seed_meta()
 
    check(hook, None, account, post_meta=meta)
 

	
 
def test_not_required_on_opening(hook):
 
    txn = testutil.Transaction(postings=[
 
        ('Liabilities:Payable:Accounts', -15),
 
        ('Liabilities:Payable:Vacation', -25),
 
        (next(testutil.OPENING_EQUITY_ACCOUNTS), 40),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
def test_not_required_on_flagged(hook):
 
    check(hook, None, txn_meta={'flag': '!'})
tests/test_meta_receipt.py
Show inline comments
...
 
@@ -288,64 +288,71 @@ def test_bad_type_check_id_on_post(hook, test_acct, other_acct, value):
 
def test_valid_check_id_on_txn(hook, test_acct, other_acct, value):
 
    check(hook, test_acct, other_acct, None,
 
          txn_meta={test_acct.fallback_meta: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS_WITH_CHECK_ID_FALLBACK,
 
    NOT_REQUIRED_ACCOUNTS,
 
    BAD_CHECK_IDS,
 
))
 
def test_invalid_check_id_on_txn(hook, test_acct, other_acct, value):
 
    expected = {
 
        test_acct.missing_message(),
 
        f"{test_acct.name} has invalid {test_acct.fallback_meta}: {value}",
 
    }
 
    check(hook, test_acct, other_acct, expected,
 
          txn_meta={test_acct.fallback_meta: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS_WITH_CHECK_ID_FALLBACK,
 
    NOT_REQUIRED_ACCOUNTS,
 
    testutil.NON_STRING_METADATA_VALUES,
 
))
 
def test_bad_type_check_id_on_txn(hook, test_acct, other_acct, value):
 
    if isinstance(value, decimal.Decimal):
 
        value = ''
 
    expected = {
 
        test_acct.missing_message(),
 
        test_acct.wrong_type_message(value, test_acct.fallback_meta),
 
    }
 
    check(hook, test_acct, other_acct, expected,
 
          txn_meta={test_acct.fallback_meta: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,key,value', testutil.combine_values(
 
    ACCOUNTS_WITHOUT_FALLBACKS,
 
    NOT_REQUIRED_ACCOUNTS,
 
    KNOWN_FALLBACKS,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_fallback_not_accepted_on_other_accounts(hook, test_acct, other_acct, key, value):
 
    check(hook, test_acct, other_acct, {test_acct.missing_message()},
 
          post_meta={key: value})
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS_WITH_LINK_FALLBACK,
 
    NOT_REQUIRED_ACCOUNTS,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_fallback_on_zero_amount_postings(hook, test_acct, other_acct, value):
 
    # Unfortunately it does happen that we get donations that go 100% to
 
    # banking fees, and our importer writes a zero-amount posting to the
 
    # Assets account.
 
    txn = testutil.Transaction(postings=[
 
        ('Income:Donations', '-.1'),
 
        ('Expenses:BankingFees', '.1'),
 
        (test_acct.name, 0, {test_acct.fallback_meta: value}),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
@pytest.mark.parametrize('test_acct,equity_acct', testutil.combine_values(
 
    ACCOUNTS,
 
    testutil.OPENING_EQUITY_ACCOUNTS,
 
))
 
def test_not_required_on_opening(hook, test_acct, equity_acct):
 
    check(hook, test_acct, equity_acct, None)
 

	
 
@pytest.mark.parametrize('test_acct,other_acct', testutil.combine_values(
 
    ACCOUNTS,
 
    NOT_REQUIRED_ACCOUNTS,
 
))
 
def test_not_required_on_flagged(hook, test_acct, other_acct):
 
    check(hook, test_acct, other_acct, None, txn_meta={'flag': '!'})
tests/test_meta_receivable_documentation.py
Show inline comments
...
 
@@ -152,64 +152,68 @@ def test_type_errors_reported_with_valid_post_docs(hook, invoice, support_key, s
 
    expected = {
 
        wrong_type_message(key, value)
 
        for key, value in meta.items()
 
        if key != 'invoice' and  key != support_key
 
    }
 
    check(hook, expected, post_meta=meta)
 

	
 
@pytest.mark.parametrize('invoice,support_key,support_value', testutil.combine_values(
 
    ISSUED_INVOICE_LINKS,
 
    SUPPORTING_METADATA,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_type_errors_reported_with_valid_txn_docs(hook, invoice, support_key, support_value):
 
    meta = seed_meta(invoice, testutil.NON_STRING_METADATA_VALUES)
 
    meta[support_key] = support_value
 
    expected = {
 
        wrong_type_message(key, value)
 
        for key, value in meta.items()
 
        if key != 'invoice' and  key != support_key
 
    }
 
    check(hook, expected, txn_meta=meta)
 

	
 
@pytest.mark.parametrize('invoice,meta_type', testutil.combine_values(
 
    RECEIVED_INVOICE_LINKS,
 
    ['post_meta', 'txn_meta'],
 
))
 
def test_received_invoices_not_checked(hook, invoice, meta_type):
 
    check(hook, None, **{meta_type: {'invoice': invoice}})
 

	
 
@pytest.mark.parametrize('invoice,other_acct', testutil.combine_values(
 
    ISSUED_INVOICE_LINKS,
 
    ['Assets:Checking', 'Assets:Savings'],
 
))
 
def test_paid_invoices_not_checked(hook, invoice, other_acct):
 
    txn = testutil.Transaction(postings=[
 
        (TEST_ACCT, -250, {'invoice': invoice}),
 
        (other_acct, 250),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
@pytest.mark.parametrize('account', [
 
    'Assets:Bank:Checking',
 
    'Assets:Cash',
 
    'Equity:OpeningBalance',
 
    'Expenses:BankingFees',
 
    'Liabilities:CreditCard',
 
    'Liabilities:Payable:Accounts',
 
])
 
def test_does_not_apply_to_other_accounts(hook, account):
 
    meta = seed_meta()
 
    check(hook, None, account, 'Expenses:Other', post_meta=meta)
 

	
 
def test_configuration_error_without_rt():
 
    config = testutil.TestConfig()
 
    with pytest.raises(errormod.ConfigurationError):
 
        meta_receivable_documentation.MetaReceivableDocumentation(config)
 

	
 
def test_not_required_on_opening(hook):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Receivable:Accounts', 100),
 
        ('Assets:Receivable:Loans', 200),
 
        (next(testutil.OPENING_EQUITY_ACCOUNTS), -300),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
def test_not_required_on_flagged(hook):
 
    post_meta = seed_meta()
 
    check(hook, None, txn_meta={'flag': '!'}, post_meta=post_meta)
tests/test_meta_repo_links.py
Show inline comments
...
 
@@ -39,109 +39,119 @@ GOOD_LINKS = [Path(s) for s in [
 
    'Projects/project-data.yml',
 
    'Projects/project-list.yml',
 
]]
 

	
 
BAD_LINKS = [Path(s) for s in [
 
    'NonexistentDirectory/NonexistentFile1.txt',
 
    'NonexistentDirectory/NonexistentFile2.txt',
 
]]
 

	
 
NOT_FOUND_MSG = '{} not found in repository: {}'.format
 

	
 
def build_meta(keys=None, *sources):
 
    if keys is None:
 
        keys = iter(METADATA_KEYS)
 
    sources = (itertools.cycle(src) for src in sources)
 
    return {key: ' '.join(str(x) for x in rest)
 
            for key, *rest in zip(keys, *sources)}
 

	
 
@pytest.fixture(scope='module')
 
def hook():
 
    config = testutil.TestConfig(repo_path='repository')
 
    return meta_repo_links.MetaRepoLinks(config)
 

	
 
def test_error_with_no_repository():
 
    config = testutil.TestConfig(repo_path=None)
 
    with pytest.raises(errormod.ConfigurationError):
 
        meta_repo_links.MetaRepoLinks(config)
 

	
 
def test_good_txn_links(hook):
 
    meta = build_meta(None, GOOD_LINKS)
 
    txn = testutil.Transaction(**meta, postings=[
 
        ('Income:Donations', -5),
 
        ('Assets:Cash', 5),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
def test_good_post_links(hook):
 
    meta = build_meta(None, GOOD_LINKS)
 
    txn = testutil.Transaction(postings=[
 
        ('Income:Donations', -5, meta),
 
        ('Assets:Cash', 5),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
def test_bad_txn_links(hook):
 
    meta = build_meta(None, BAD_LINKS)
 
    txn = testutil.Transaction(**meta, postings=[
 
        ('Income:Donations', -5),
 
        ('Assets:Cash', 5),
 
    ])
 
    expected = {NOT_FOUND_MSG(key, value) for key, value in meta.items()}
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected == actual
 

	
 
def test_bad_post_links(hook):
 
    meta = build_meta(None, BAD_LINKS)
 
    txn = testutil.Transaction(postings=[
 
        ('Income:Donations', -5, meta.copy()),
 
        ('Assets:Cash', 5),
 
    ])
 
    expected = {NOT_FOUND_MSG(key, value) for key, value in meta.items()}
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected == actual
 

	
 
def test_flagged_txn_not_checked(hook):
 
    keys = iter(METADATA_KEYS)
 
    txn_meta = build_meta(keys, BAD_LINKS)
 
    txn_meta['flag'] = '!'
 
    txn = testutil.Transaction(**txn_meta, postings=[
 
        ('Income:Donations', -5, build_meta(keys, BAD_LINKS)),
 
        ('Assets:Checking', 5, build_meta(keys, BAD_LINKS)),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
@pytest.mark.parametrize('value', testutil.NON_STRING_METADATA_VALUES)
 
def test_bad_metadata_type(hook, value):
 
    txn = testutil.Transaction(**{'check': value}, postings=[
 
        ('Income:Donations', -5),
 
        ('Assets:Cash', 5),
 
    ])
 
    expected = {'transaction has wrong type of check: expected str but is a {}'.format(
 
        type(value).__name__,
 
    )}
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected == actual
 

	
 
@pytest.mark.parametrize('ext_doc', [
 
    'rt:123',
 
    'rt:456/789',
 
    'rt://ticket/23',
 
    'rt://ticket/34/attachments/567890',
 
])
 
def test_docs_outside_repository_not_checked(hook, ext_doc):
 
    txn = testutil.Transaction(
 
        receipt='{} {} {}'.format(GOOD_LINKS[0], ext_doc, BAD_LINKS[1]),
 
        postings=[
 
            ('Income:Donations', -5),
 
            ('Assets:Cash', 5),
 
        ])
 
    expected = {NOT_FOUND_MSG('receipt', BAD_LINKS[1])}
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected == actual
 

	
 
def test_mixed_results(hook):
 
    txn = testutil.Transaction(
 
        approval='{} {}'.format(*GOOD_LINKS),
 
        contract='{} {}'.format(BAD_LINKS[0], GOOD_LINKS[1]),
 
        postings=[
 
            ('Income:Donations', -5, {'invoice': '{} {}'.format(*BAD_LINKS)}),
 
            ('Assets:Cash', 5, {'statement': '{} {}'.format(GOOD_LINKS[0], BAD_LINKS[1])}),
 
        ])
 
    expected = {
 
        NOT_FOUND_MSG('contract', BAD_LINKS[0]),
 
        NOT_FOUND_MSG('invoice', BAD_LINKS[0]),
 
        NOT_FOUND_MSG('invoice', BAD_LINKS[1]),
 
        NOT_FOUND_MSG('statement', BAD_LINKS[1]),
 
    }
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected == actual
tests/test_meta_rt_links.py
Show inline comments
...
 
@@ -85,80 +85,90 @@ def test_good_txn_links(hook):
 

	
 
def test_good_post_links(hook):
 
    meta = build_meta(None, GOOD_LINKS)
 
    txn = testutil.Transaction(postings=[
 
        ('Income:Donations', -5, meta),
 
        ('Assets:Cash', 5),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
@pytest.mark.parametrize('link_source,format_error', [
 
    (MALFORMED_LINKS, MALFORMED_MSG),
 
    (NOT_FOUND_LINKS, NOT_FOUND_MSG),
 
])
 
def test_bad_txn_links(hook, link_source, format_error):
 
    meta = build_meta(None, link_source)
 
    txn = testutil.Transaction(**meta, postings=[
 
        ('Income:Donations', -5),
 
        ('Assets:Cash', 5),
 
    ])
 
    expected = {format_error(key, value) for key, value in meta.items()}
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected == actual
 

	
 
@pytest.mark.parametrize('link_source,format_error', [
 
    (MALFORMED_LINKS, MALFORMED_MSG),
 
    (NOT_FOUND_LINKS, NOT_FOUND_MSG),
 
])
 
def test_bad_post_links(hook, link_source, format_error):
 
    meta = build_meta(None, link_source)
 
    txn = testutil.Transaction(postings=[
 
        ('Income:Donations', -5, meta.copy()),
 
        ('Assets:Cash', 5),
 
    ])
 
    expected = {format_error(key, value) for key, value in meta.items()}
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected == actual
 

	
 
@pytest.mark.parametrize('value', testutil.NON_STRING_METADATA_VALUES)
 
def test_bad_metadata_type(hook, value):
 
    txn = testutil.Transaction(**{'rt-id': value}, postings=[
 
        ('Income:Donations', -5),
 
        ('Assets:Cash', 5),
 
    ])
 
    expected = {'transaction has wrong type of rt-id: expected str but is a {}'.format(
 
        type(value).__name__,
 
    )}
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected == actual
 

	
 
@pytest.mark.parametrize('ext_doc', [
 
    'statement.txt',
 
    'https://example.org/',
 
])
 
def test_docs_outside_rt_not_checked(hook, ext_doc):
 
    txn = testutil.Transaction(
 
        receipt='{} {} {}'.format(GOOD_LINKS[0], ext_doc, MALFORMED_LINKS[1]),
 
        postings=[
 
            ('Income:Donations', -5),
 
            ('Assets:Cash', 5),
 
        ])
 
    expected = {MALFORMED_MSG('receipt', MALFORMED_LINKS[1])}
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected == actual
 

	
 
def test_flagged_txn_not_checked(hook):
 
    txn_meta = build_meta(None, MALFORMED_LINKS)
 
    txn_meta['flag'] = '!'
 
    keys = iter(METADATA_KEYS)
 
    txn = testutil.Transaction(**txn_meta, postings=[
 
        ('Income:Donations', -5, build_meta(keys, MALFORMED_LINKS)),
 
        ('Assets:Checking', 5, build_meta(keys, NOT_FOUND_LINKS)),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
def test_mixed_results(hook):
 
    txn = testutil.Transaction(
 
        approval='{} {}'.format(*GOOD_LINKS),
 
        contract='{} {}'.format(MALFORMED_LINKS[0], GOOD_LINKS[1]),
 
        postings=[
 
            ('Income:Donations', -5, {'invoice': '{} {}'.format(*NOT_FOUND_LINKS)}),
 
            ('Assets:Cash', 5, {'statement': '{} {}'.format(GOOD_LINKS[0], MALFORMED_LINKS[1])}),
 
        ])
 
    expected = {
 
        MALFORMED_MSG('contract', MALFORMED_LINKS[0]),
 
        NOT_FOUND_MSG('invoice', NOT_FOUND_LINKS[0]),
 
        NOT_FOUND_MSG('invoice', NOT_FOUND_LINKS[1]),
 
        MALFORMED_MSG('statement', MALFORMED_LINKS[1]),
 
    }
 
    actual = {error.message for error in hook.run(txn)}
 
    assert expected == actual
0 comments (0 inline, 0 general)