feat(engine): module dispatcher + modulehandler abc

This commit is contained in:
DumiFlex
2026-04-26 16:17:57 +03:00
parent 9d183d6779
commit f33ac4c980
3 changed files with 187 additions and 0 deletions
+13
View File
@@ -1,4 +1,10 @@
"""Module dataclasses, snapshot helpers, dispatcher, handlers."""
from engine.modules.dispatcher import (
ModuleHandler,
UnknownModuleType,
register_handler,
resolve_module,
)
from engine.modules.snapshot import (
coerce_legacy_module,
freeze_snapshot,
@@ -27,3 +33,10 @@ __all__ += [
"freeze_snapshot",
"payload_hash",
]
__all__ += [
"ModuleHandler",
"UnknownModuleType",
"register_handler",
"resolve_module",
]
+54
View File
@@ -0,0 +1,54 @@
"""Module-type dispatcher.
Each module type registers a ``ModuleHandler`` subclass keyed by ``type_id``.
Graph execution calls ``resolve_module(snapshot, ctx)`` which routes to the
matching handler. Handlers receive only the snapshot's payload + per-instance
overrides + the runtime context — never the library DB.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class UnknownModuleType(KeyError):
"""Raised when a snapshot's ``type`` has no registered handler."""
class ModuleHandler(ABC):
"""Base class for per-type module handlers."""
type_id: str = ""
@classmethod
@abstractmethod
def resolve(
cls,
payload: dict[str, Any],
instance: dict[str, Any],
ctx: Any,
) -> dict[str, str]:
"""Return a mapping of variable_name → resolved string."""
_HANDLERS: dict[str, type[ModuleHandler]] = {}
def register_handler(handler: type[ModuleHandler]) -> None:
"""Register a handler under its ``type_id``. Re-registration replaces."""
if not handler.type_id:
raise ValueError(f"{handler.__name__} missing type_id")
_HANDLERS[handler.type_id] = handler
def resolve_module(snapshot: dict[str, Any], ctx: Any) -> dict[str, str]:
"""Route a snapshot to its registered handler and return the resolution."""
type_id = snapshot.get("type")
handler = _HANDLERS.get(type_id)
if handler is None:
raise UnknownModuleType(type_id)
return handler.resolve(
snapshot.get("payload", {}),
snapshot.get("instance", {}),
ctx,
)
+120
View File
@@ -0,0 +1,120 @@
"""Tests for the module-handler dispatcher."""
import pytest
from engine.modules.dispatcher import (
ModuleHandler,
UnknownModuleType,
register_handler,
resolve_module,
)
class _FakeHandler(ModuleHandler):
type_id = "fake"
@classmethod
def resolve(cls, payload, instance, ctx):
return {"result": payload.get("v", "")}
@pytest.fixture(autouse=True)
def _reset_registry():
"""Snapshot + restore the handler registry around each test."""
from engine.modules import dispatcher as d
saved = dict(d._HANDLERS)
yield
d._HANDLERS.clear()
d._HANDLERS.update(saved)
def test_register_and_resolve_round_trip():
register_handler(_FakeHandler)
snap = {"type": "fake", "payload": {"v": "x"}, "instance": {}}
assert resolve_module(snap, ctx=None) == {"result": "x"}
def test_resolve_unknown_type_raises():
snap = {"type": "ghost", "payload": {}, "instance": {}}
with pytest.raises(UnknownModuleType):
resolve_module(snap, ctx=None)
def test_resolve_passes_instance_to_handler():
captured = {}
class _Capture(ModuleHandler):
type_id = "capture"
@classmethod
def resolve(cls, payload, instance, ctx):
captured["payload"] = payload
captured["instance"] = instance
captured["ctx"] = ctx
return {}
register_handler(_Capture)
resolve_module(
{
"type": "capture",
"payload": {"a": 1},
"instance": {"variable_binding": "$x"},
},
ctx="some-ctx",
)
assert captured == {
"payload": {"a": 1},
"instance": {"variable_binding": "$x"},
"ctx": "some-ctx",
}
def test_resolve_tolerates_missing_instance_key():
"""Legacy or partial snapshots may lack the `instance` key."""
register_handler(_FakeHandler)
snap = {"type": "fake", "payload": {"v": "y"}}
assert resolve_module(snap, ctx=None) == {"result": "y"}
def test_resolve_tolerates_missing_payload_key():
register_handler(_FakeHandler)
snap = {"type": "fake", "instance": {}}
assert resolve_module(snap, ctx=None) == {"result": ""}
def test_register_handler_rejects_empty_type_id():
class _NoId(ModuleHandler):
type_id = ""
@classmethod
def resolve(cls, payload, instance, ctx):
return {}
with pytest.raises(ValueError, match="type_id"):
register_handler(_NoId)
def test_register_handler_overwrites_existing_for_same_type_id():
"""Re-registration replaces the previous handler — useful for tests."""
class _A(ModuleHandler):
type_id = "swap"
@classmethod
def resolve(cls, payload, instance, ctx):
return {"who": "A"}
class _B(ModuleHandler):
type_id = "swap"
@classmethod
def resolve(cls, payload, instance, ctx):
return {"who": "B"}
register_handler(_A)
register_handler(_B)
out = resolve_module({"type": "swap", "payload": {}, "instance": {}}, ctx=None)
assert out == {"who": "B"}
def test_unknown_module_type_inherits_keyerror():
"""UnknownModuleType must be a KeyError subclass for caller convenience."""
assert issubclass(UnknownModuleType, KeyError)