feat(engine): module dispatcher + modulehandler abc
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user