diff --git a/conservancy_beancount/data.py b/conservancy_beancount/data.py
index 374e757..179202c 100644
--- a/conservancy_beancount/data.py
+++ b/conservancy_beancount/data.py
@@ -37,6 +37,16 @@ from .beancount_types import (
Transaction,
)
+LINK_METADATA = frozenset([
+ 'approval',
+ 'check',
+ 'contract',
+ 'invoice',
+ 'purchase-order',
+ 'receipt',
+ 'statement',
+])
+
class Account(str):
"""Account name string
diff --git a/conservancy_beancount/errors.py b/conservancy_beancount/errors.py
index 074fc1d..7f331a4 100644
--- a/conservancy_beancount/errors.py
+++ b/conservancy_beancount/errors.py
@@ -38,6 +38,14 @@ class Error(Exception):
Iter = Iterable[Error]
+class BrokenLinkError(Error):
+ def __init__(self, txn, key, link, source=None):
+ super().__init__(
+ "{} not found in repository: {}".format(key, link),
+ txn,
+ source,
+ )
+
class ConfigurationError(Error):
def __init__(self, message, entry=None, source=None):
if source is None:
diff --git a/conservancy_beancount/plugin/meta_repo_links.py b/conservancy_beancount/plugin/meta_repo_links.py
new file mode 100644
index 0000000..4c1bdf0
--- /dev/null
+++ b/conservancy_beancount/plugin/meta_repo_links.py
@@ -0,0 +1,59 @@
+"""meta_repo_links - Check that repository links are valid"""
+# 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 .
+
+import re
+
+from . import core
+from .. import config as configmod
+from .. import data
+from .. import errors as errormod
+from ..beancount_types import (
+ MetaKey,
+ MetaValue,
+ Transaction,
+)
+
+from typing import (
+ Mapping,
+)
+
+class MetaRepoLinks(core.TransactionHook):
+ HOOK_GROUPS = frozenset(['linkcheck'])
+ PATH_PUNCT_RE = re.compile(r'[:/]')
+
+ def __init__(self, config: configmod.Config) -> None:
+ repo_path = config.repository_path()
+ if repo_path is None:
+ raise errormod.ConfigurationError("no repository configured")
+ self.repo_path = repo_path
+
+ def _check_links(self,
+ txn: Transaction,
+ meta: Mapping[MetaKey, MetaValue],
+ ) -> errormod.Iter:
+ for key in data.LINK_METADATA.intersection(meta):
+ for link in str(meta[key]).split():
+ match = self.PATH_PUNCT_RE.search(link)
+ if match and match.group(0) == ':':
+ pass
+ elif not (self.repo_path / link).exists():
+ yield errormod.BrokenLinkError(txn, key, link)
+
+ def run(self, txn: Transaction) -> errormod.Iter:
+ yield from self._check_links(txn, txn.meta)
+ for post in txn.postings:
+ if post.meta is not None:
+ yield from self._check_links(txn, post.meta)
diff --git a/tests/test_meta_repo_links.py b/tests/test_meta_repo_links.py
new file mode 100644
index 0000000..b174c54
--- /dev/null
+++ b/tests/test_meta_repo_links.py
@@ -0,0 +1,135 @@
+"""Test link checker for repository documents"""
+# 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 .
+
+import itertools
+
+from pathlib import Path
+
+import pytest
+
+from . import testutil
+
+from conservancy_beancount import errors as errormod
+from conservancy_beancount.plugin import meta_repo_links
+
+METADATA_KEYS = [
+ 'approval',
+ 'check',
+ 'contract',
+ 'invoice',
+ 'purchase-order',
+ 'receipt',
+ 'statement',
+]
+
+GOOD_LINKS = [Path(s) for s in [
+ 'Projects/project-data.yml',
+ 'Projects/project-list.yml',
+]]
+
+BAD_LINKS = [Path(s) for s in [
+ 'NonexistentDirectory/NonexistentFile1.txt',
+ 'NonexistentDirectory/NonexistentFile2.txt',
+]]
+
+NOT_FOUND_MSG = '{} not found in repository: {}'.format
+
+def build_meta(keys=None, *sources):
+ if keys is None:
+ keys = iter(METADATA_KEYS)
+ sources = (itertools.cycle(src) for src in sources)
+ return {key: ' '.join(str(x) for x in rest)
+ for key, *rest in zip(keys, *sources)}
+
+@pytest.fixture(scope='module')
+def hook():
+ config = testutil.TestConfig(repo_path='repository')
+ return meta_repo_links.MetaRepoLinks(config)
+
+def test_error_with_no_repository():
+ config = testutil.TestConfig(repo_path=None)
+ with pytest.raises(errormod.ConfigurationError):
+ meta_repo_links.MetaRepoLinks(config)
+
+def test_good_txn_links(hook):
+ meta = build_meta(None, GOOD_LINKS)
+ txn = testutil.Transaction(**meta, postings=[
+ ('Income:Donations', -5),
+ ('Assets:Cash', 5),
+ ])
+ assert not list(hook.run(txn))
+
+def test_good_post_links(hook):
+ meta = build_meta(None, GOOD_LINKS)
+ txn = testutil.Transaction(postings=[
+ ('Income:Donations', -5, meta),
+ ('Assets:Cash', 5),
+ ])
+ assert not list(hook.run(txn))
+
+def test_bad_txn_links(hook):
+ meta = build_meta(None, BAD_LINKS)
+ txn = testutil.Transaction(**meta, postings=[
+ ('Income:Donations', -5),
+ ('Assets:Cash', 5),
+ ])
+ expected = {NOT_FOUND_MSG(key, value) for key, value in meta.items()}
+ actual = {error.message for error in hook.run(txn)}
+ assert expected == actual
+
+def test_bad_post_links(hook):
+ meta = build_meta(None, BAD_LINKS)
+ txn = testutil.Transaction(postings=[
+ ('Income:Donations', -5, meta.copy()),
+ ('Assets:Cash', 5),
+ ])
+ expected = {NOT_FOUND_MSG(key, value) for key, value in meta.items()}
+ actual = {error.message for error in hook.run(txn)}
+ assert expected == actual
+
+@pytest.mark.parametrize('ext_doc', [
+ 'rt:123',
+ 'rt:456/789',
+ 'rt://ticket/23',
+ 'rt://ticket/34/attachments/567890',
+])
+def test_docs_outside_repository_not_checked(hook, ext_doc):
+ txn = testutil.Transaction(
+ receipt='{} {} {}'.format(GOOD_LINKS[0], ext_doc, BAD_LINKS[1]),
+ postings=[
+ ('Income:Donations', -5),
+ ('Assets:Cash', 5),
+ ])
+ expected = {NOT_FOUND_MSG('receipt', BAD_LINKS[1])}
+ actual = {error.message for error in hook.run(txn)}
+ assert expected == actual
+
+def test_mixed_results(hook):
+ txn = testutil.Transaction(
+ approval='{} {}'.format(*GOOD_LINKS),
+ contract='{} {}'.format(BAD_LINKS[0], GOOD_LINKS[1]),
+ postings=[
+ ('Income:Donations', -5, {'invoice': '{} {}'.format(*BAD_LINKS)}),
+ ('Assets:Cash', 5, {'statement': '{} {}'.format(GOOD_LINKS[0], BAD_LINKS[1])}),
+ ])
+ expected = {
+ NOT_FOUND_MSG('contract', BAD_LINKS[0]),
+ NOT_FOUND_MSG('invoice', BAD_LINKS[0]),
+ NOT_FOUND_MSG('invoice', BAD_LINKS[1]),
+ NOT_FOUND_MSG('statement', BAD_LINKS[1]),
+ }
+ actual = {error.message for error in hook.run(txn)}
+ assert expected == actual