diff --git a/conservancy_beancount/plugin/__init__.py b/conservancy_beancount/plugin/__init__.py
index e69de29..217212a 100644
--- a/conservancy_beancount/plugin/__init__.py
+++ b/conservancy_beancount/plugin/__init__.py
@@ -0,0 +1,48 @@
+"""Beancount plugin entry point for Conservancy"""
+# 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 beancount.core.data as bc_data
+
+__plugins__ = ['run']
+
+class HookRegistry:
+ DIRECTIVES = frozenset([
+ *(cls.__name__ for cls in bc_data.ALL_DIRECTIVES),
+ 'Posting',
+ ])
+
+ @classmethod
+ def group_by_directive(cls, hooks_seq):
+ hooks_map = {key: [] for key in cls.DIRECTIVES}
+ for hook in hooks_seq:
+ for key in cls.DIRECTIVES & hook.HOOK_GROUPS:
+ hooks_map[key].append(hook)
+ return hooks_map
+
+
+def run(entries, options_map, config):
+ errors = []
+ hooks = HookRegistry.group_by_directive(config)
+ for entry in entries:
+ entry_type = type(entry).__name__
+ for hook in hooks[entry_type]:
+ errors.extend(hook.check(entry))
+ if entry_type == 'Transaction':
+ for post in entry.postings:
+ for hook in hooks['Posting']:
+ errors.extend(hook.check(entry, post))
+ return entries, errors
+
diff --git a/tests/test_plugin_run.py b/tests/test_plugin_run.py
new file mode 100644
index 0000000..731cb68
--- /dev/null
+++ b/tests/test_plugin_run.py
@@ -0,0 +1,77 @@
+"""Test main plugin run loop"""
+# 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 pytest
+
+from . import testutil
+
+from conservancy_beancount import plugin
+
+CONFIG_MAP = {}
+
+class TransactionCounter:
+ HOOK_GROUPS = frozenset(['Transaction'])
+
+ def __init__(self):
+ self.counter = 0
+
+ def check(self, txn):
+ self.counter += 1
+ return ()
+
+
+class PostingCounter(TransactionCounter):
+ HOOK_GROUPS = frozenset(['Posting'])
+
+ def check(self, txn, post):
+ return super().check(txn)
+
+
+def test_with_multiple_hooks():
+ txn_counter = TransactionCounter()
+ post_counter = PostingCounter()
+ in_entries = [
+ testutil.Transaction(postings=[
+ ('Income:Donations', -25),
+ ('Assets:Cash', 25),
+ ]),
+ testutil.Transaction(postings=[
+ ('Expenses:General', 10),
+ ('Liabilites:CreditCard', -10),
+ ]),
+ ]
+ out_entries, errors = plugin.run(in_entries, CONFIG_MAP, [txn_counter, post_counter])
+ assert len(out_entries) == 2
+ assert len(errors) == 0
+ assert txn_counter.counter == 2
+ assert post_counter.counter == 4
+
+def test_with_posting_hooks_only():
+ post_counter = PostingCounter()
+ in_entries = [
+ testutil.Transaction(postings=[
+ ('Income:Donations', -25),
+ ('Assets:Cash', 25),
+ ]),
+ testutil.Transaction(postings=[
+ ('Expenses:General', 10),
+ ('Liabilites:CreditCard', -10),
+ ]),
+ ]
+ out_entries, errors = plugin.run(in_entries, CONFIG_MAP, [post_counter])
+ assert len(out_entries) == 2
+ assert len(errors) == 0
+ assert post_counter.counter == 4