aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--deckit/module.py45
-rw-r--r--tests/test_module.py33
2 files changed, 78 insertions, 0 deletions
diff --git a/deckit/module.py b/deckit/module.py
new file mode 100644
index 0000000..b30fbf5
--- /dev/null
+++ b/deckit/module.py
@@ -0,0 +1,45 @@
+"""The module contract and the registry that dispatches to modules.
+
+A module is a data source plus an allow-listed set of actions. The client sends
+a module name and an action id; it never sends a command. The registry is the
+single place that maps ids to code, so adding a module never touches the server.
+"""
+
+from dataclasses import dataclass, field
+from typing import Callable
+
+
+class ModuleError(Exception):
+ """A bad module/action id, or a module that cannot serve the request."""
+
+
+@dataclass
+class Module:
+ name: str
+ title: str
+ state: Callable[[], dict]
+ actions: dict[str, Callable[[dict], dict | None]] = field(default_factory=dict)
+ start: Callable[[], None] | None = None
+ stop: Callable[[], None] | None = None
+
+
+class Registry:
+ def __init__(self, modules):
+ self._modules = {m.name: m for m in modules}
+
+ def names(self) -> list[dict]:
+ return [{"name": m.name, "title": m.title} for m in self._modules.values()]
+
+ def _get(self, name: str) -> Module:
+ if name not in self._modules:
+ raise ModuleError(f"unknown module: {name}")
+ return self._modules[name]
+
+ def state(self, name: str) -> dict:
+ return self._get(name).state()
+
+ def run_action(self, name: str, action: str, body: dict) -> dict | None:
+ module = self._get(name)
+ if action not in module.actions:
+ raise ModuleError(f"unknown action: {name}/{action}")
+ return module.actions[action](body)
diff --git a/tests/test_module.py b/tests/test_module.py
new file mode 100644
index 0000000..ccfdd74
--- /dev/null
+++ b/tests/test_module.py
@@ -0,0 +1,33 @@
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+import unittest
+
+import deckit.module as mod
+
+
+def make():
+ return mod.Module(name="demo", title="Demo",
+ state=lambda: {"ok": True},
+ actions={"ping": lambda body: {"got": body.get("n")}})
+
+
+class RegistryTest(unittest.TestCase):
+ def test_lists_and_dispatches(self):
+ r = mod.Registry([make()])
+ self.assertEqual(r.names(), [{"name": "demo", "title": "Demo"}])
+ self.assertEqual(r.state("demo"), {"ok": True})
+ self.assertEqual(r.run_action("demo", "ping", {"n": 3}), {"got": 3})
+
+ def test_unknown_module_and_action_raise(self):
+ r = mod.Registry([make()])
+ with self.assertRaises(mod.ModuleError):
+ r.state("nope")
+ with self.assertRaises(mod.ModuleError):
+ r.run_action("demo", "nope", {})
+
+
+if __name__ == "__main__":
+ unittest.main()