Changeset - 51eee8ec8fd8
[Not reviewed]
0 3 0
Brett Smith - 4 years ago 2020-11-04 18:43:54
brettcsmith@brettcsmith.org
meta_entity: Don't set transaction metadata when payee is None. RT#12913
3 files changed with 12 insertions and 2 deletions:
0 comments (0 inline, 0 general)
conservancy_beancount/plugin/meta_entity.py
Show inline comments
 
"""meta_entity - Validate entity 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/>.
 

	
 
# 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 (
 
    MetaKey,
 
    MetaValue,
 
    Transaction,
 
)
 

	
 
from typing import (
 
    MutableMapping,
 
    Optional,
 
    Pattern,
 
    Tuple,
 
)
 

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

	
 
    # chars is the set of characters we always accept in entity metadata:
 
    # letters, digits, and ASCII punctuation, except `-` and the Latin 1 supplement
 
    # (i.e., Roman letters with diacritics: áÁàÀâÂåÅäÄãà çÇ ðÐ ñÑ øØ ß etc.)
 
    # See the tests for specific cases.
 
    chars = r'\u0021-\u002c\u002e-\u007e\p{Letter}\p{Digit}--\p{Block=Latin_1_Supplement}'
 
    ENTITY_RE: Pattern[str] = regex.compile(f'^[{chars}][-{chars}]*$', regex.VERSION1)
 
    ANONYMOUS_RE: Pattern[str] = regex.compile(r'^[-_.?!\s]*$', regex.VERSION1)
 
    del chars
 

	
 
    def _check_entity(self,
 
                      meta: MutableMapping[MetaKey, MetaValue],
 
                      default: Optional[str]=None,
 
    ) -> Tuple[Optional[str], Optional[bool]]:
 
        entity = meta.get(self.METADATA_KEY, default)
 
        if entity is None:
 
            return None, None
 
        elif not isinstance(entity, str):
 
            return None, False
 
        elif self.ANONYMOUS_RE.match(entity):
 
            entity = 'Anonymous'
 
            meta[self.METADATA_KEY] = entity
 
            return entity, True
 
        else:
 
            return entity, self.ENTITY_RE.match(entity) is not None
 

	
 
    def run(self, txn: Transaction) -> errormod.Iter:
 
        if not self._run_on_txn(txn):
 
            return
 
        txn_entity, txn_entity_ok = self._check_entity(txn.meta, txn.payee)
 
        if txn_entity_ok is False:
 
            yield errormod.InvalidMetadataError(txn, self.METADATA_KEY, txn_entity)
 
        if txn_entity is txn.payee:
 
        if txn_entity is txn.payee and txn_entity is not None:
 
            txn.meta[self.METADATA_KEY] = txn.payee
 
        for post in data.Posting.from_txn(txn):
 
            if not post.account.is_under(
 
                    'Assets:Receivable',
 
                    'Expenses',
 
                    'Income',
 
                    'Liabilities:Payable',
 
            ):
 
                continue
 
            entity, entity_ok = self._check_entity(post.meta, txn_entity)
 
            if entity is txn_entity and entity is not None:
 
                pass
 
            elif not entity_ok:
 
                yield errormod.InvalidMetadataError(txn, self.METADATA_KEY, entity, post)
setup.py
Show inline comments
 
#!/usr/bin/env python3
 

	
 
from setuptools import setup
 

	
 
setup(
 
    name='conservancy_beancount',
 
    description="Plugin, library, and reports for reading Conservancy's books",
 
    version='1.13.0',
 
    version='1.13.1',
 
    author='Software Freedom Conservancy',
 
    author_email='info@sfconservancy.org',
 
    license='GNU AGPLv3+',
 

	
 
    install_requires=[
 
        'babel>=2.6',  # Debian:python3-babel
 
        'beancount>=2.2',  # Debian:beancount
 
        'GitPython>=2.0',  # Debian:python3-git
 
        # 1.4.1 crashes when trying to save some documents.
 
        'odfpy>=1.4.0,!=1.4.1',  # Debian:python3-odf
 
        'PyYAML>=3.0',  # Debian:python3-yaml
 
        'regex',  # Debian:python3-regex
 
        'rt>=2.0',
 
    ],
 
    setup_requires=[
 
        'pytest-mypy',
 
        'pytest-runner',  # Debian:python3-pytest-runner
 
    ],
 
    tests_require=[
 
        'mypy>=0.770',  # Debian:python3-mypy
 
        'pytest',  # Debian:python3-pytest
 
    ],
 

	
 
    packages=[
 
        'conservancy_beancount',
 
        'conservancy_beancount.plugin',
 
        'conservancy_beancount.reports',
 
        'conservancy_beancount.tools',
 
    ],
 
    entry_points={
 
        'console_scripts': [
 
            'accrual-report = conservancy_beancount.reports.accrual:entry_point',
 
            'assemble-audit-reports = conservancy_beancount.tools.audit_report:entry_point',
 
            'balance-sheet-report = conservancy_beancount.reports.balance_sheet:entry_point',
 
            'budget-report = conservancy_beancount.reports.budget:entry_point',
 
            'bean-sort = conservancy_beancount.tools.sort_entries:entry_point',
 
            'extract-odf-links = conservancy_beancount.tools.extract_odf_links:entry_point',
 
            'fund-report = conservancy_beancount.reports.fund:entry_point',
 
            'ledger-report = conservancy_beancount.reports.ledger:entry_point',
 
            'opening-balances = conservancy_beancount.tools.opening_balances:entry_point',
 
            'split-ods-links = conservancy_beancount.tools.split_ods_links:entry_point',
 
        ],
 
    },
 
)
tests/test_meta_entity.py
Show inline comments
 
"""Test validation of entity 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_entity
 

	
 
VALID_VALUES = {
 
    # Classic entity: LastName-FirstName
 
    'Smith-Alex',
 
    # Various people and companies have one-word names
 
    # Digits are allowed, as part of a name or standalone
 
    'Company19',
 
    'Company-19',
 
    # No case requirements
 
    'boyd-danah',
 
    # No limit on the number of parts of the name
 
    'B-van-der-A',
 
    # Names that have no ASCII are allowed, with or without dash separators
 
    '田中流星',
 
    '田中-流星',
 
    'スミスダコタ',
 
    'スミス-ダコタ',
 
    'Яшин-Данила',
 
    # Governments, using : as a hierarchy separator
 
    'BE',
 
    'US:KY',
 
    'CA:ON',
 
    # The PayPal importer allows ASCII punctuation in entity metadata
 
    'Du-Bois-W.-E.-B.',
 
    "O'Malley-Thomas",
 
    'O`Malley-Thomas',
 
    # import2ledger produces entities that end with -
 
    # That's probably a bug, but allow it for now.
 
    'foo-',
 
}
 

	
 
INVALID_VALUES = {
 
    # Starting with a - is not allowed
 
    '-foo',
 
    # Names that can be reduced to ASCII should be
 
    # Producers should change this to Uberentity or Ueberentity
 
    # I am not wild about this rule and would like to relax it—it's mostly
 
    # based on an expectation that entities are typed in by an American. That's
 
    # true less and less and it seems like we should reduce the amount of
 
    # mangling producers are expected to do. But it's the rule for today.
 
    'Überentity',
 
    # Whitespace is never allowed
 
    'Alex Smith',
 
    '田中\u00A0流星',  # Non-breaking space
 
    # Non-ASCII punctuation is not allowed
 
    'Яшин—Данила',  # em dash
 
    'O’Malley-Thomas',  # Right-angled apostrophe
 
    'Du-Bois-W。-E。-B。',  # Japanese period
 
}
 

	
 
ANONYMOUS_VALUES = {
 
    # Values produced by various importers that should be translated to
 
    # Anonymous.
 
    '',
 
    ' ',
 
    '-',
 
    '--',
 
    '-----',
 
    '_',
 
    ' _ ',
 
    '.',
 
}
 

	
 
TEST_KEY = 'entity'
 

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

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

	
 
@pytest.mark.parametrize('src_value', ANONYMOUS_VALUES)
 
def test_anonymous_values_on_postings(hook, src_value):
 
    txn = testutil.Transaction(postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25, {TEST_KEY: src_value}),
 
    ])
 
    assert not any(hook.run(txn))
 
    assert txn.postings[-1].meta[TEST_KEY] == 'Anonymous'
 

	
 
@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 len(errors) == 1
 
    assert errors[0].message == "Expenses:General has invalid entity: {}".format(src_value)
 

	
 
@pytest.mark.parametrize('src_value', VALID_VALUES)
 
def test_valid_values_on_transactions(hook, src_value):
 
    txn = testutil.Transaction(payee='Payee', **{TEST_KEY: src_value}, postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25),
 
    ])
 
    assert not any(hook.run(txn))
 
    # Make sure payee doesn't overwrite metadata. See payee test below.
 
    assert txn.meta[TEST_KEY] == src_value
 

	
 
@pytest.mark.parametrize('src_value', ANONYMOUS_VALUES)
 
def test_anonymous_values_on_transactions(hook, src_value):
 
    txn = testutil.Transaction(**{TEST_KEY: src_value}, postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25),
 
    ])
 
    assert not any(hook.run(txn))
 
    assert txn.meta[TEST_KEY] == 'Anonymous'
 

	
 
@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 1 <= len(errors) <= 2
 
    assert all(error.message == "transaction has invalid entity: {}".format(src_value)
 
               for error in hook.run(txn))
 

	
 
@pytest.mark.parametrize('src_value', VALID_VALUES)
 
def test_valid_values_on_payee(hook, src_value):
 
    txn = testutil.Transaction(payee=src_value, postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25),
 
    ])
 
    assert not any(hook.run(txn))
 
    # In this case, we want the hook to set metadata to make it easier to
 
    # write bean-queries.
 
    assert txn.meta[TEST_KEY] == src_value
 

	
 
@pytest.mark.parametrize('src_value', ANONYMOUS_VALUES)
 
def test_anonymous_values_on_payee(hook, src_value):
 
    txn = testutil.Transaction(payee=src_value, postings=[
 
        ('Assets:Cash', -25),
 
        ('Expenses:General', 25),
 
    ])
 
    assert not any(hook.run(txn))
 
    assert txn.meta[TEST_KEY] == 'Anonymous'
 

	
 
@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_dont_set_entity_none(hook):
 
    txn = testutil.Transaction(postings=[
 
        ('Expenses:Other', 5),
 
        ('Assets:Cash', -5),
 
    ])
 
    assert any(hook.run(txn))
 
    assert 'entity' not in txn.meta
 
    for post in txn.postings:
 
        assert post.meta is None or 'entity' not in post.meta
 

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

	
 
@pytest.mark.parametrize('date,need_value', [
 
    (testutil.EXTREME_FUTURE_DATE, False),
 
    (testutil.FUTURE_DATE, True),
 
    (testutil.FY_START_DATE, True),
 
    (testutil.FY_MID_DATE, True),
 
    (testutil.PAST_DATE, False),
 
])
 
def test_required_by_date(hook, date, need_value):
 
    txn = testutil.Transaction(date=date, postings=[
 
        ('Income:Donations', -10),
 
        ('Assets:Checking', 10),
 
    ])
 
    assert any(hook.run(txn)) == need_value
 

	
 
def test_still_required_on_flagged(hook):
 
    txn = testutil.Transaction(flag='!', postings=[
 
        ('Income:Donations', -10),
 
        ('Assets:Checking', 10),
 
    ])
 
    assert list(hook.run(txn))
0 comments (0 inline, 0 general)