Changeset - 9f0c30738db8
[Not reviewed]
0 10 0
Brett Smith - 4 years ago 2020-04-09 19:12:04
brettcsmith@brettcsmith.org
plugin: Most validations skip opening balance transactions. RT#10642.
10 files changed with 78 insertions and 4 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/plugin/core.py
Show inline comments
...
 
@@ -140,97 +140,100 @@ class MetadataEnum:
 
        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):
 
    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:
 
        return txn.date in self.TXN_DATE_RANGE
 
        return (
 
            txn.date in self.TXN_DATE_RANGE
 
            and not data.is_opening_balance_txn(txn)
 
        )
 

	
 
    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.iter_postings(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,
conservancy_beancount/plugin/meta_entity.py
Show inline comments
...
 
@@ -4,72 +4,74 @@
 
# 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/>.
 

	
 
# Type stubs aren't available for regex.
 
# Fortunately, we're using it in a way that's API-compatible with the re
 
# module. We mitigate the lack of type stubs by providing type declarations
 
# for returned objects. This way, the only thing that isn't type checked are
 
# the calls to regex functions.
 
import regex  # type:ignore[import]
 

	
 
from . import core
 
from .. import data
 
from .. import errors as errormod
 
from ..beancount_types import (
 
    Transaction,
 
)
 

	
 
from typing import (
 
    Pattern,
 
)
 

	
 
class MetaEntity(core.TransactionHook):
 
    METADATA_KEY = 'entity'
 
    HOOK_GROUPS = frozenset(['posting', 'metadata', METADATA_KEY])
 

	
 
    # alnum is the set of characters we always accept in entity metadata:
 
    # letters and digits, minus the Latin 1 supplement (i.e., Roman letters
 
    # with diacritics: áÁàÀâÂåÅäÄãà çÇ ðÐ ñÑ øØ ß etc.)
 
    # See the tests for specific cases.
 
    alnum = r'\p{Letter}\p{Digit}--\p{Block=Latin_1_Supplement}'
 
    # A regexp that would be reasonably stricter would be:
 
    #   f'^[{alnum}][.{alnum}]*(?:-[.{alnum}])*$'
 
    # However, current producers fail that regexp in a few different ways.
 
    # See the tests for specific cases.
 
    ENTITY_RE: Pattern[str] = regex.compile(f'^[{alnum}][-.{alnum}]*$', regex.VERSION1)
 
    del alnum
 

	
 
    def run(self, txn: Transaction) -> errormod.Iter:
 
        if data.is_opening_balance_txn(txn):
 
            return
 
        txn_entity = txn.meta.get(self.METADATA_KEY, txn.payee)
 
        if txn_entity is None:
 
            txn_entity_ok = None
 
        elif isinstance(txn_entity, str):
 
            txn_entity_ok = bool(self.ENTITY_RE.match(txn_entity))
 
        else:
 
            txn_entity_ok = False
 
        if txn_entity_ok is False:
 
            yield errormod.InvalidMetadataError(txn, self.METADATA_KEY, txn_entity)
 
        for post in data.iter_postings(txn):
 
            if not post.account.is_under(
 
                    'Assets:Receivable',
 
                    'Expenses',
 
                    'Income',
 
                    'Liabilities:Payable',
 
            ):
 
                continue
 
            entity = post.meta.get(self.METADATA_KEY)
 
            if entity is None:
 
                yield errormod.InvalidMetadataError(txn, self.METADATA_KEY, entity, post)
 
            elif entity is txn_entity:
 
                pass
 
            elif not self.ENTITY_RE.match(entity):
 
                yield errormod.InvalidMetadataError(txn, self.METADATA_KEY, entity, post)
conservancy_beancount/plugin/meta_project.py
Show inline comments
 
"""meta_project - Validate project metadata"""
 
# 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 pathlib import Path
 

	
 
import yaml
 
import yaml.error
 

	
 
from . import core
 
from .. import config as configmod
 
from .. import data
 
from .. import errors as errormod
 
from ..beancount_types import (
 
    MetaValueEnum,
 
    Transaction,
 
)
 

	
 
from typing import (
 
    Any,
 
    Dict,
 
    NoReturn,
 
    Optional,
 
    Set,
 
)
 

	
 
class MetaProject(core._NormalizePostingMetadataHook):
 
    DEFAULT_PROJECT = 'Conservancy'
 
    PROJECT_DATA_PATH = Path('Projects', 'project-data.yml')
 
    VALUES_ENUM = core.MetadataEnum('project', {DEFAULT_PROJECT})
 
    RESTRICTED_FUNDS_ACCT = 'Equity:Funds:Restricted'
 

	
 
    def __init__(self, config: configmod.Config, source_path: Path=PROJECT_DATA_PATH) -> None:
 
        repo_path = config.repository_path()
 
        if repo_path is None:
 
            self._config_error("no repository configured")
 
        project_data_path = repo_path / source_path
 
        source = {'filename': str(project_data_path)}
 
        try:
 
            with project_data_path.open() as yaml_file:
 
                project_data: Dict[str, Dict[str, Any]] = yaml.safe_load(yaml_file)
 
            names: Set[MetaValueEnum] = {self.DEFAULT_PROJECT}
 
            aliases: Dict[MetaValueEnum, MetaValueEnum] = {}
 
            for key, params in project_data.items():
 
                name = params.get('accountName', key)
 
                names.add(name)
 
                human_name = params.get('humanName', name)
 
                if name != human_name:
 
                    aliases[human_name] = name
 
                if name != key:
 
                    aliases[key] = name
 
        except AttributeError:
 
            self._config_error("loaded YAML data not in project-data format", project_data_path)
 
        except OSError as error:
 
            self._config_error(error.strerror, project_data_path)
 
        except yaml.error.YAMLError as error:
 
            self._config_error(error.args[0] or "YAML load error", project_data_path)
 
        else:
 
            self.VALUES_ENUM = core.MetadataEnum(self.METADATA_KEY, names, aliases)
 

	
 
    def _config_error(self, msg: str, filename: Optional[Path]=None) -> NoReturn:
 
        source = {}
 
        if filename is not None:
 
            source['filename'] = str(filename)
 
        raise errormod.ConfigurationError(
 
            "cannot load project data: " + msg,
 
            source=source,
 
        )
 

	
 
    def _run_on_post(self, txn: Transaction, post: data.Posting) -> bool:
 
    def _run_on_opening_post(self, txn: Transaction, post: data.Posting) -> bool:
 
        return post.account.is_under(self.RESTRICTED_FUNDS_ACCT) is not None
 

	
 
    def _run_on_other_post(self, txn: Transaction, post: data.Posting) -> bool:
 
        if post.account.is_under('Liabilities'):
 
            return not post.account.is_credit_card()
 
        else:
 
            return post.account.is_under(
 
                'Assets:Receivable',
 
                'Expenses',
 
                'Income',
 
                self.RESTRICTED_FUNDS_ACCT,
 
            ) is not None
 

	
 
    def _default_value(self, txn: Transaction, post: data.Posting) -> MetaValueEnum:
 
        if post.account.is_under(
 
                'Expenses:Payroll',
 
                'Liabilities:Payable:Vacation',
 
        ):
 
            return self.DEFAULT_PROJECT
 
        else:
 
            raise errormod.InvalidMetadataError(txn, self.METADATA_KEY, None, post)
 

	
 
    def _run_on_txn(self, txn: Transaction) -> bool:
 
        return txn.date in self.TXN_DATE_RANGE
 

	
 
    def run(self, txn: Transaction) -> errormod.Iter:
 
        # mypy says we can't assign over a method.
 
        # I understand why it wants to enforce thas as a blanket rule, but
 
        # we're substituting in another type-compatible method, so it's pretty
 
        # safe.
 
        if data.is_opening_balance_txn(txn):
 
            self._run_on_post = self._run_on_opening_post  # type:ignore[assignment]
 
        else:
 
            self._run_on_post = self._run_on_other_post  # type:ignore[assignment]
 
        return super().run(txn)
tests/test_meta_entity.py
Show inline comments
...
 
@@ -120,48 +120,52 @@ def test_valid_values_on_payee(hook, src_value):
 

	
 
@pytest.mark.parametrize('src_value', INVALID_VALUES)
 
def test_invalid_values_on_payee(hook, src_value):
 
    txn = testutil.Transaction(payee=src_value, postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert 1 <= len(errors) <= 2
 
    assert all(error.message == "transaction has invalid entity: {}".format(src_value)
 
               for error in hook.run(txn))
 

	
 
@pytest.mark.parametrize('payee,src_value', testutil.combine_values(
 
    INVALID_VALUES,
 
    VALID_VALUES,
 
))
 
def test_invalid_payee_but_valid_metadata(hook, payee, src_value):
 
    txn = testutil.Transaction(**{'payee': payee, TEST_KEY: src_value}, postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:Other', 25),
 
    ])
 
    assert not any(hook.run(txn))
 

	
 
@pytest.mark.parametrize('account,required', [
 
    ('Assets:Bank:Checking', False),
 
    ('Assets:Cash', False),
 
    ('Assets:Receivable:Accounts', True),
 
    ('Assets:Receivable:Loans', True),
 
    ('Equity:OpeningBalances', False),
 
    ('Expenses:General', True),
 
    ('Income:Donations', True),
 
    ('Liabilities:CreditCard', False),
 
    ('Liabilities:Payable:Accounts', True),
 
    ('Liabilities:Payable:Vacation', True),
 
    ('Liabilities:UnearnedIncome:Donations', False),
 
])
 
def test_which_accounts_required_on(hook, account, required):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Checking', -25),
 
        (account, 25),
 
    ])
 
    errors = list(hook.run(txn))
 
    if not required:
 
        assert not errors
 
    else:
 
        assert errors
 
        assert any(error.message == "{} missing entity".format(account)
 
                   for error in errors)
 

	
 
def test_not_required_on_opening(hook):
 
    txn = testutil.Transaction.opening_balance()
 
    assert not list(hook.run(txn))
tests/test_meta_invoice.py
Show inline comments
 
"""Test validation of invoice metadata"""
 
# 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.plugin import meta_invoice
 

	
 
REQUIRED_ACCOUNTS = {
 
    'Assets:Receivable:Accounts',
 
    'Assets:Receivable:Loans',
 
    'Liabilities:Payable:Accounts',
 
    'Liabilities:Payable:Vacation',
 
}
 

	
 
NON_REQUIRED_ACCOUNTS = {
 
    'Assets:Cash',
 
    'Equity:OpeningBalance',
 
    'Equity:Retained',
 
    'Expenses:Other',
 
    'Income:Other',
 
    'Liabilities:CreditCard',
 
}
 

	
 
TEST_KEY = 'invoice'
 

	
 
MISSING_MSG = f'{{}} missing {TEST_KEY}'.format
 
WRONG_TYPE_MSG = f'{{}} has wrong type of {TEST_KEY}: expected str but is a {{}}'.format
 

	
 
@pytest.fixture(scope='module')
 
def hook():
 
    config = testutil.TestConfig()
 
    return meta_invoice.MetaInvoice(config)
 

	
 
@pytest.mark.parametrize('acct1,acct2,value', testutil.combine_values(
 
    REQUIRED_ACCOUNTS,
 
    NON_REQUIRED_ACCOUNTS,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_valid_values_on_postings(hook, acct1, acct2, value):
 
    txn = testutil.Transaction(postings=[
 
        (acct2, -25),
 
        (acct1, 25, {TEST_KEY: value}),
 
    ])
 
    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_postings(hook, acct1, acct2, value):
 
    txn = testutil.Transaction(postings=[
 
        (acct2, -25),
 
        (acct1, 25, {TEST_KEY: value}),
 
    ])
 
    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_postings(hook, acct1, acct2, value):
 
    txn = testutil.Transaction(postings=[
 
        (acct2, -25),
...
 
@@ -95,48 +95,52 @@ def test_bad_type_values_on_postings(hook, acct1, acct2, value):
 
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.Transaction.opening_balance()
 
    assert not list(hook.run(txn))
tests/test_meta_payable_documentation.py
Show inline comments
...
 
@@ -118,48 +118,56 @@ def test_docs_all_bad_type(hook, meta_type):
 
    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))
tests/test_meta_paypal_id.py
Show inline comments
...
 
@@ -142,48 +142,55 @@ def test_invalid_values_on_transactions(hook, src_value):
 
def test_bad_type_values_on_transactions(hook, src_value, acct):
 
    txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
 
        ('Income:Donations', -25),
 
        (acct, 25),
 
    ])
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == {BAD_TYPE_MSG(acct, type(src_value).__name__)}
 

	
 
@pytest.mark.parametrize('src_value', VALID_INVOICE_IDS)
 
def test_invoice_ids_not_accepted_for_non_accruals(hook, src_value):
 
    acct = 'Assets:PayPal'
 
    txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
 
        ('Income:Donations', -25),
 
        (acct, 25),
 
    ])
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == {INVALID_MSG(acct, src_value)}
 

	
 
@pytest.mark.parametrize('src_value', VALID_TXN_IDS)
 
def test_transaction_ids_not_accepted_for_accruals(hook, src_value):
 
    acct = 'Assets:Receivable:Accounts'
 
    txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
 
        ('Income:Donations', -25),
 
        (acct, 25),
 
    ])
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == {INVALID_MSG(acct, src_value)}
 

	
 
def test_required_for_assets_paypal(hook):
 
    acct = 'Assets:PayPal'
 
    txn = testutil.Transaction(postings=[
 
        ('Income:Donations', -35),
 
        (acct, 35),
 
    ])
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == {f"{acct} missing {TEST_KEY}"}
 

	
 
@pytest.mark.parametrize('txn_id,inv_id', testutil.combine_values(
 
    VALID_TXN_IDS,
 
    VALID_INVOICE_IDS,
 
))
 
def test_invoice_payment_transaction_ok(hook, txn_id, inv_id):
 
    txn = testutil.Transaction(**{TEST_KEY: txn_id}, postings=[
 
        ('Assets:Receivable:Accounts', -100, {TEST_KEY: inv_id}),
 
        ('Assets:PayPal', 97),
 
        ('Expenses:BankingFees', 3),
 
    ])
 
    assert not list(hook.run(txn))
 

	
 
def test_not_required_on_opening(hook):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:PayPal', 1000),
 
        (next(testutil.OPENING_EQUITY_ACCOUNTS), -1000),
 
    ])
 
    assert not list(hook.run(txn))
tests/test_meta_project.py
Show inline comments
...
 
@@ -45,112 +45,124 @@ def hook():
 
    config = testutil.TestConfig(repo_path='repository')
 
    return meta_project.MetaProject(config)
 

	
 
@pytest.mark.parametrize('src_value,set_value', VALID_VALUES.items())
 
def test_valid_values_on_postings(hook, src_value, set_value):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25, {TEST_KEY: src_value}),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: set_value})
 

	
 
@pytest.mark.parametrize('src_value', INVALID_VALUES)
 
def test_invalid_values_on_postings(hook, src_value):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25, {TEST_KEY: src_value}),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: src_value})
 

	
 
@pytest.mark.parametrize('src_value,set_value', VALID_VALUES.items())
 
def test_valid_values_on_transactions(hook, src_value, set_value):
 
    txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: set_value})
 

	
 
@pytest.mark.parametrize('src_value', INVALID_VALUES)
 
def test_invalid_values_on_transactions(hook, src_value):
 
    txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert errors
 
    testutil.check_post_meta(txn, None, None)
 

	
 
@pytest.mark.parametrize('account,required', [
 
    ('Assets:Cash', False),
 
    ('Assets:Receivable:Accounts', True),
 
    ('Assets:Receivable:Loans', True),
 
    ('Equity:OpeningBalance', False),
 
    ('Equity:Funds:Restricted', True),
 
    ('Equity:Funds:Unrestricted', False),
 
    ('Expenses:General', True),
 
    ('Income:Donations', True),
 
    ('Liabilities:CreditCard', False),
 
    ('Liabilities:Payable:Accounts', True),
 
    # We do want a "project" for Lia:Pay:Vacation but it has a default value
 
    ('Liabilities:Payable:Vacation', False),
 
    ('Liabilities:UnearnedIncome:Donations', True),
 
])
 
def test_which_accounts_required_on(hook, account, required):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Checking', 25),
 
        (account, 25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert required == any(errors)
 

	
 
@pytest.mark.parametrize('account', [
 
    'Expenses:Payroll:Salary',
 
    'Expenses:Payroll:Tax',
 
    'Liabilities:Payable:Vacation',
 
])
 
def test_default_values(hook, account):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Checking', -25),
 
        (account, 25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: DEFAULT_VALUE})
 

	
 
@pytest.mark.parametrize('date,required', [
 
    (testutil.EXTREME_FUTURE_DATE, False),
 
    (testutil.FUTURE_DATE, True),
 
    (testutil.FY_START_DATE, True),
 
    (testutil.FY_MID_DATE, True),
 
    (testutil.PAST_DATE, None),
 
])
 
def test_default_value_set_in_date_range(hook, date, required):
 
    txn = testutil.Transaction(date=date, postings=[
 
        ('Expenses:Payroll:Benefits', 25),
 
        ('Liabilities:Payable:Vacation', -25),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    expect_meta = {TEST_KEY: DEFAULT_VALUE} if required else None
 
    testutil.check_post_meta(txn, expect_meta, expect_meta)
 

	
 
@pytest.mark.parametrize('repo_path', [
 
    None,
 
    '..',
 
])
 
def test_missing_project_data(repo_path):
 
    config = testutil.TestConfig(repo_path=repo_path)
 
    with pytest.raises(errormod.ConfigurationError):
 
        meta_project.MetaProject(config)
 

	
 
@pytest.mark.parametrize('repo_path_s,data_path_s', [
 
    ('repository', 'Projects/project-list.yml'),
 
    ('..', 'LICENSE.txt'),
 
])
 
def test_invalid_project_data(repo_path_s, data_path_s):
 
    config = testutil.TestConfig(repo_path=repo_path_s)
 
    with pytest.raises(errormod.ConfigurationError):
 
        meta_project.MetaProject(config, Path(data_path_s))
 

	
 
def test_not_required_on_opening(hook):
 
    txn = testutil.Transaction.opening_balance('Equity:Funds:Unrestricted')
 
    assert not list(hook.run(txn))
 

	
 
def test_always_required_on_restricted_funds(hook):
 
    acct = 'Equity:Funds:Restricted'
 
    txn = testutil.Transaction.opening_balance(acct)
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == {f'{acct} missing project'}
tests/test_meta_receipt.py
Show inline comments
...
 
@@ -41,97 +41,97 @@ class AccountForTesting(typing.NamedTuple):
 
    fallback_meta: typing.Optional[str]
 

	
 
    def missing_message(self, include_fallback=True):
 
        if self.fallback_meta is None or not include_fallback:
 
            rest = ""
 
        else:
 
            rest = f"/{self.fallback_meta}"
 
        return f"{self.name} missing {TEST_KEY}{rest}"
 

	
 
    def wrong_type_message(self, wrong_value, key=TEST_KEY):
 
        expect_type = 'Decimal' if key == 'check-id' else 'str'
 
        return  "{} has wrong type of {}: expected {} but is a {}".format(
 
            self.name,
 
            key,
 
            expect_type,
 
            type(wrong_value).__name__,
 
        )
 

	
 

	
 
ACCOUNTS = [AccountForTesting._make(t) for t in [
 
    ('Assets:Bank:CheckCard', PostType.CREDIT, 'check'),
 
    ('Assets:Bank:CheckCard', PostType.DEBIT, 'check-id'),
 
    ('Assets:Cash', PostType.BOTH, None),
 
    ('Assets:Checking', PostType.CREDIT, 'check'),
 
    ('Assets:Checking', PostType.DEBIT, 'check-id'),
 
    ('Assets:Savings', PostType.BOTH, None),
 
    ('Liabilities:CreditCard', PostType.CREDIT, None),
 
    ('Liabilities:CreditCard', PostType.DEBIT, 'invoice'),
 
]]
 

	
 
ACCOUNTS_WITH_LINK_FALLBACK = [acct for acct in ACCOUNTS
 
                               if acct.fallback_meta and acct.fallback_meta != 'check-id']
 
ACCOUNTS_WITH_CHECK_ID_FALLBACK = [acct for acct in ACCOUNTS
 
                                   if acct.fallback_meta == 'check-id']
 
ACCOUNTS_WITHOUT_FALLBACKS = [acct for acct in ACCOUNTS if not acct.fallback_meta]
 
KNOWN_FALLBACKS = {acct.fallback_meta for acct in ACCOUNTS if acct.fallback_meta}
 

	
 
# These are mostly fill-in values.
 
# We don't need to run every test on every value for these, just enough to
 
# convince ourselves the hook never reports errors against these accounts.
 
# Making this a iterator rather than a sequence means testutil.combine_values
 
# doesn't require the decorated test to go over every value, which in turn
 
# trims unnecessary test time.
 
NOT_REQUIRED_ACCOUNTS = itertools.cycle([
 
    # Only paypal-id is required for PayPal transactions
 
    'Assets:PayPal',
 
    'Assets:Prepaid:Expenses',
 
    'Assets:Receivable:Accounts',
 
    'Equity:OpeningBalance',
 
    'Equity:Retained',
 
    'Expenses:Other',
 
    'Income:Other',
 
    'Liabilities:Payable:Accounts',
 
    'Liabilities:UnearnedIncome:Donations',
 
])
 

	
 
CHECK_IDS = (decimal.Decimal(n) for n in itertools.count(1))
 
def BAD_CHECK_IDS():
 
    # Valid check-id values are positive integers
 
    yield decimal.Decimal(0)
 
    yield -next(CHECK_IDS)
 
    yield next(CHECK_IDS) * decimal.Decimal('1.1')
 
BAD_CHECK_IDS = BAD_CHECK_IDS()
 

	
 
def check(hook, test_acct, other_acct, expected, *,
 
          txn_meta={}, post_meta={}, check_type=PostType.BOTH, min_amt=0):
 
    check_type &= test_acct.required_types
 
    assert check_type, "tried to test a non-applicable account"
 
    if check_type == PostType.BOTH:
 
        check(hook, test_acct, other_acct, expected,
 
              txn_meta=txn_meta, post_meta=post_meta, check_type=PostType.CREDIT)
 
        check_type = PostType.DEBIT
 
    amount = decimal.Decimal('{:.02f}'.format(min_amt + random.random() * 100))
 
    if check_type == PostType.DEBIT:
 
        amount = -amount
 
    txn = testutil.Transaction(**txn_meta, postings=[
 
        (test_acct.name, amount, post_meta),
 
        (other_acct, -amount),
 
    ])
 
    actual = {error.message for error in hook.run(txn)}
 
    if expected is None:
 
        assert not actual
 
    elif isinstance(expected, str):
 
        assert expected in actual
 
    else:
 
        assert actual == expected
 

	
 
@pytest.fixture(scope='module')
 
def hook():
 
    config = testutil.TestConfig()
 
    return meta_receipt.MetaReceipt(config)
 

	
 
@pytest.mark.parametrize('test_acct,other_acct,value', testutil.combine_values(
 
    ACCOUNTS,
 
    NOT_REQUIRED_ACCOUNTS,
 
    testutil.LINK_METADATA_STRINGS,
 
))
 
def test_valid_receipt_on_post(hook, test_acct, other_acct, value):
...
 
@@ -297,48 +297,55 @@ def test_valid_check_id_on_txn(hook, test_acct, other_acct, value):
 
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)
tests/test_meta_receivable_documentation.py
Show inline comments
...
 
@@ -160,48 +160,56 @@ def test_type_errors_reported_with_valid_post_docs(hook, invoice, support_key, s
 
    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))
0 comments (0 inline, 0 general)