From 5332d87511a9336c004af755f007aaa688d9c654 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 9 Nov 2026 18:11:41 +0100 Subject: feat: module contract and registry --- deckit/module.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_module.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 deckit/module.py create mode 100644 tests/test_module.py 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() -- cgit v1.2.3