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