Changeset - d7e2ab34b925
[Not reviewed]
0 3 0
Brett Smith - 4 years ago 2020-06-17 08:29:17
brettcsmith@brettcsmith.org
meta_project: Force the default project on Equity accounts.

See rationale in comments.
3 files changed with 56 insertions and 2 deletions:
0 comments (0 inline, 0 general)
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_opening_post(self, txn: Transaction, post: data.Posting) -> bool:
 
        return post.account.is_under(self.RESTRICTED_FUNDS_ACCT) is not None
 
        return post.account.is_under('Equity') 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',
 
                'Equity',
 
                '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 post_run(self, txn: Transaction, post: data.Posting) -> errormod.Iter:
 
        if (post.account.is_under('Equity')
 
            and not post.account.is_under(self.RESTRICTED_FUNDS_ACCT)):
 
            # Force all unrestricted Equity accounts to have the default
 
            # project. This is what our fiscal controls policy says, and
 
            # setting it here simplifies higher-level queries and reporting.
 
            post_value = post.meta.get(self.METADATA_KEY)
 
            txn_value = txn.meta.get(self.METADATA_KEY)
 
            # Only report an error if the posting specifically had a different
 
            # value, not if it just inherited it from the transaction.
 
            if (post_value is not txn_value
 
                and post_value != self.DEFAULT_PROJECT):
 
                yield errormod.InvalidMetadataError(
 
                    txn, self.METADATA_KEY, post_value, post,
 
                )
 
            post.meta[self.METADATA_KEY] = self.DEFAULT_PROJECT
 
        else:
 
            yield from super().post_run(txn, post)
 

	
 
    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)
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.2.4',
 
    version='1.2.5',
 
    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
 
        # 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',
 
    ],
 
    entry_points={
 
        'console_scripts': [
 
            'accrual-report = conservancy_beancount.reports.accrual:entry_point',
 
            'ledger-report = conservancy_beancount.reports.ledger:entry_point',
 
        ],
 
    },
 
)
tests/test_meta_project.py
Show inline comments
 
"""Test handling of 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 pytest
 

	
 
from . import testutil
 

	
 
from conservancy_beancount import errors as errormod
 
from conservancy_beancount.plugin import meta_project
 

	
 
VALID_VALUES = {
 
    'Conservancy': 'Conservancy',
 
    'Alpha': 'Alpha',
 
    'Bravo': 'Bravo',
 
    'Charles': 'Charlie',
 
    'Chuck': 'Charlie',
 
}
 

	
 
INVALID_VALUES = {
 
    'Alhpa',
 
    'Yankee',
 
    '',
 
}
 

	
 
TEST_KEY = 'project'
 
DEFAULT_VALUE = 'Conservancy'
 

	
 
@pytest.fixture(scope='module')
 
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', [
 
    'Equity:Funds:Unrestricted',
 
    'Equity:Realized:CurrencyConversion',
 
    '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('equity,other_acct,value', testutil.combine_values(
 
    ['Equity:Funds:Unrestricted', 'Equity:Realized:CurrencyConversion'],
 
    ['Assets:Checking', 'Liabilities:CreditCard'],
 
    VALID_VALUES,
 
))
 
def test_equity_override_txn_meta(hook, equity, other_acct, value):
 
    if value == DEFAULT_VALUE:
 
        value = f'Not{value}'
 
    txn = testutil.Transaction(**{TEST_KEY: value}, postings=[
 
        (other_acct, 100),
 
        (equity, -100),
 
    ])
 
    errors = list(hook.run(txn))
 
    assert not errors
 
    testutil.check_post_meta(txn, None, {TEST_KEY: DEFAULT_VALUE})
 

	
 
@pytest.mark.parametrize('equity,other_acct,value', testutil.combine_values(
 
    ['Equity:Funds:Unrestricted', 'Equity:Realized:CurrencyConversion'],
 
    ['Assets:Checking', 'Liabilities:CreditCard'],
 
    VALID_VALUES,
 
))
 
def test_equity_override_post_meta(hook, equity, other_acct, value):
 
    if value == DEFAULT_VALUE:
 
        value = f'Not{value}'
 
    txn = testutil.Transaction(postings=[
 
        (other_acct, 100),
 
        (equity, -100, {TEST_KEY: value}),
 
    ])
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == {f"{equity} has invalid {TEST_KEY}: {value}"}
 
    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.OpeningBalance('Equity:Funds:Unrestricted')
 
    assert not list(hook.run(txn))
 

	
 
def test_always_required_on_restricted_funds(hook):
 
    acct = 'Equity:Funds:Restricted'
 
    txn = testutil.OpeningBalance(acct)
 
    actual = {error.message for error in hook.run(txn)}
 
    assert actual == {f'{acct} missing project'}
 

	
 
@pytest.mark.parametrize('src_value', INVALID_VALUES)
 
def test_still_required_on_flagged_txn(hook, src_value):
 
    txn = testutil.Transaction(flag='!', **{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)
0 comments (0 inline, 0 general)