diff --git a/conservancy_beancount/plugin/meta_project.py b/conservancy_beancount/plugin/meta_project.py new file mode 100644 index 0000000..5194534 --- /dev/null +++ b/conservancy_beancount/plugin/meta_project.py @@ -0,0 +1,91 @@ +"""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 . + +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, + Optional, + Set, +) + +class MetaProject(core._NormalizePostingMetadataHook): + DEFAULT_PROJECT = 'Conservancy' + PROJECT_DATA_PATH = Path('Projects', 'project-data.yml') + VALUES_ENUM = core.MetadataEnum('project', {DEFAULT_PROJECT}) + + def __init__(self, config: configmod.Config, source_path: Path=PROJECT_DATA_PATH) -> None: + repo_path = config.repository_path() + if repo_path is None: + return 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): + 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: + return post.account.is_under('Assets', 'Liabilities') is None + + def _default_value(self, txn: Transaction, post: data.Posting) -> MetaValueEnum: + if post.account.is_under( + 'Accrued:VacationPayable', + 'Expenses:Payroll', + ): + return self.DEFAULT_PROJECT + else: + raise errormod.InvalidMetadataError(txn, post, self.METADATA_KEY) + diff --git a/tests/repository/Projects/project-data.yml b/tests/repository/Projects/project-data.yml new file mode 100644 index 0000000..d8c8b97 --- /dev/null +++ b/tests/repository/Projects/project-data.yml @@ -0,0 +1,11 @@ +Alpha: + percentage: 0.0 +Bravo: + current: false + percentage: 5.0 +Charles: + percentage: 10.0 + accountName: Charlie + humanName: Chuck +Delta: + percentage: 15.0 diff --git a/tests/repository/Projects/project-list.yml b/tests/repository/Projects/project-list.yml new file mode 100644 index 0000000..36d77cb --- /dev/null +++ b/tests/repository/Projects/project-list.yml @@ -0,0 +1,4 @@ +- Alpha +- Bravo +- Charlie +- Delta diff --git a/tests/test_meta_project.py b/tests/test_meta_project.py new file mode 100644 index 0000000..cbf834f --- /dev/null +++ b/tests/test_meta_project.py @@ -0,0 +1,151 @@ +"""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 . + +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', [ + ('Accrued:AccountsReceivable', True), + ('Assets:Cash', False), + ('Expenses:General', True), + ('Income:Donations', True), + ('Liabilities:CreditCard', False), + ('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', [ + 'Accrued:VacationPayable', + 'Expenses:Payroll:Salary', + 'Expenses:Payroll:Tax', +]) +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), + ('Accrued:VacationPayable', -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)) diff --git a/tests/testutil.py b/tests/testutil.py index c021e21..d0cfc66 100644 --- a/tests/testutil.py +++ b/tests/testutil.py @@ -98,8 +98,14 @@ class Transaction: class TestConfig: + TESTS_DIR = Path(__file__).parent + def __init__(self, repo_path=None): - self.repo_path = None if repo_path is None else Path(repo_path) + if repo_path is not None: + repo_path = Path(repo_path) + if not repo_path.is_absolute(): + repo_path = Path(self.TESTS_DIR, repo_path) + self.repo_path = repo_path def repository_path(self): return self.repo_path