diff --git a/engine/db/migrations_sql/002_expanded_module_types.sql b/engine/db/migrations_sql/002_expanded_module_types.sql new file mode 100644 index 00000000..81497510 --- /dev/null +++ b/engine/db/migrations_sql/002_expanded_module_types.sql @@ -0,0 +1 @@ +PRAGMA user_version = 2; diff --git a/engine/db/repositories.py b/engine/db/repositories.py index e964ad76..ad977f92 100644 --- a/engine/db/repositories.py +++ b/engine/db/repositories.py @@ -14,7 +14,15 @@ from typing import Any from engine._utils import now_iso as _now -_TYPE_PREFIX = {"wildcard": "wc", "fixed_values": "fv"} +_TYPE_PREFIX = { + "wildcard": "wc", + "fixed_values": "fv", + "combine": "cb", + "derivation": "dr", + "constraint": "ct", + "pipeline": "pl", +} +_VALID_TYPES: frozenset[str] = frozenset(_TYPE_PREFIX.keys()) _SLUG_RE = re.compile(r"[^a-z0-9]+") @@ -75,6 +83,10 @@ class ModuleRepository: payload: dict[str, Any], is_favorite: bool = False, ) -> dict[str, Any]: + if type not in _VALID_TYPES: + raise ValueError( + f"unknown module type {type!r}; expected one of {sorted(_VALID_TYPES)}" + ) mid = self._gen_id(type, name) now = _now() with self._conn: diff --git a/engine/modules/__init__.py b/engine/modules/__init__.py index 9ba04c1f..a7a925b6 100644 --- a/engine/modules/__init__.py +++ b/engine/modules/__init__.py @@ -2,6 +2,7 @@ from engine.modules.dispatcher import ( ModuleHandler, UnknownModuleType, + get_handler, register_handler, resolve_module, ) @@ -37,6 +38,7 @@ __all__ += [ __all__ += [ "ModuleHandler", "UnknownModuleType", + "get_handler", "register_handler", "resolve_module", ] @@ -55,3 +57,20 @@ from engine.modules.fixed_values_handler import FixedValuesHandler # noqa: E402 register_handler(FixedValuesHandler) __all__ += ["FixedValuesHandler"] + +from engine.modules.combine_handler import CombineHandler # noqa: E402 +from engine.modules.constraint_handler import ConstraintHandler # noqa: E402 +from engine.modules.derivation_handler import DerivationHandler # noqa: E402 +from engine.modules.pipeline_handler import PipelineHandler # noqa: E402 + +register_handler(CombineHandler) +register_handler(DerivationHandler) +register_handler(ConstraintHandler) +register_handler(PipelineHandler) + +__all__ += [ + "CombineHandler", + "ConstraintHandler", + "DerivationHandler", + "PipelineHandler", +] diff --git a/engine/modules/combine_handler.py b/engine/modules/combine_handler.py new file mode 100644 index 00000000..8f47f1f1 --- /dev/null +++ b/engine/modules/combine_handler.py @@ -0,0 +1,107 @@ +"""Combine module resolver — fills a string template from existing bindings. + +A combine module reads ``$varname`` tokens out of ``payload["template"]`` and +substitutes the matching value from the runtime context. The result is bound +to ``payload["output_var"]``. Tokens whose variable is not yet bound in the +context fall through unchanged (so downstream modules can still see them). +""" +from __future__ import annotations + +import re +from typing import Any + +from engine.modules.dispatcher import ModuleHandler + +_TOKEN_RE = re.compile(r"\$([a-zA-Z_][a-zA-Z0-9_]*)") +_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") + + +def _ctx_get(ctx: Any, name: str, default: str) -> str: + """Best-effort read of ``name`` from a runtime context. + + Supports plain dicts (or anything ``in``-testable that supports + ``__getitem__``) as well as the engine's own Context class which exposes + ``get(name, default)``. + """ + if ctx is None: + return default + getter = getattr(ctx, "get", None) + if callable(getter): + try: + value = getter(name, default) + except TypeError: + # Some Mapping-like objects only accept one positional arg. + try: + value = getter(name) + if value is None: + value = default + except Exception: + return default + return str(value) if value is not None else default + try: + if name in ctx: # type: ignore[operator] + return str(ctx[name]) # type: ignore[index] + except Exception: + return default + return default + + +def _ctx_set(ctx: Any, name: str, value: str) -> None: + """Best-effort write of ``name=value`` into a runtime context.""" + if ctx is None: + return + setter = getattr(ctx, "set", None) + if callable(setter): + setter(name, value) + return + try: + ctx[name] = value # type: ignore[index] + except Exception: + # Context object is read-only / unsupported — silently no-op. + return + + +class CombineHandler(ModuleHandler): + """Template-fill module: ``$var`` tokens replaced from ctx, result bound.""" + + type_id = "combine" + + @classmethod + def validate_payload(cls, payload: dict[str, Any]) -> None: + if not isinstance(payload, dict): + raise ValueError("combine payload must be an object") + template = payload.get("template") + if not isinstance(template, str): + raise ValueError("combine payload.template must be a string") + output_var = payload.get("output_var") + if not isinstance(output_var, str) or not output_var: + raise ValueError("combine payload.output_var must be a non-empty string") + if not _IDENT_RE.match(output_var): + raise ValueError( + f"combine payload.output_var {output_var!r} is not a valid identifier" + ) + input_vars = payload.get("input_vars", []) + if not isinstance(input_vars, list): + raise ValueError("combine payload.input_vars must be a list") + for v in input_vars: + if not isinstance(v, str): + raise ValueError("combine payload.input_vars entries must be strings") + + @classmethod + def resolve( + cls, + payload: dict[str, Any], + instance: dict[str, Any], + ctx: Any, + ) -> dict[str, str]: + cls.validate_payload(payload) + template: str = payload["template"] + output_var: str = payload["output_var"] + + def _sub(match: re.Match[str]) -> str: + name = match.group(1) + return _ctx_get(ctx, name, f"${name}") + + result = _TOKEN_RE.sub(_sub, template) + _ctx_set(ctx, output_var, result) + return {output_var: result} diff --git a/engine/modules/constraint_handler.py b/engine/modules/constraint_handler.py new file mode 100644 index 00000000..98721e59 --- /dev/null +++ b/engine/modules/constraint_handler.py @@ -0,0 +1,146 @@ +"""Constraint module resolver — pass-through stub. + +A constraint module declares a re-weighting matrix from one wildcard module's +options to another's. The actual mutation is intentionally **not** done here: +WildcardHandler will eventually inspect ``ctx["_constraints"]`` and apply the +matrix when picking options. This handler only validates the payload and +records metadata in the context for the wildcard handler to consume. + +TODO: When WildcardHandler grows constraint awareness, switch from recording +metadata to applying it here, or keep it as a pure metadata channel and let +the consumer drive resolution. The current decision is metadata-only. +""" +from __future__ import annotations + +from typing import Any + +from engine.modules.dispatcher import ModuleHandler + +_VALID_MODES = {"allow", "exclude", "boost", "reduce"} + + +def _ctx_set_constraint(ctx: Any, meta: dict[str, Any]) -> None: + """Append ``meta`` to ``ctx['_constraints']`` (best-effort).""" + if ctx is None: + return + # Try setter-based access first (engine Context API). + setter = getattr(ctx, "set", None) + getter = getattr(ctx, "get", None) + if callable(setter) and callable(getter): + try: + existing = getter("_constraints", None) + except TypeError: + existing = None + bucket = list(existing) if isinstance(existing, list) else [] + bucket.append(meta) + try: + setter("_constraints", bucket) + return + except Exception: + pass + # Fall back to dict-like __getitem__/__setitem__. + try: + existing = ctx["_constraints"] if "_constraints" in ctx else [] # type: ignore[operator,index] + except Exception: + existing = [] + bucket = list(existing) if isinstance(existing, list) else [] + bucket.append(meta) + try: + ctx["_constraints"] = bucket # type: ignore[index] + except Exception: + return + + +def _validate_cell(cell: Any, where: str) -> None: + if not isinstance(cell, dict): + raise ValueError(f"constraint {where} must be an object") + mode = cell.get("mode") + if mode not in _VALID_MODES: + raise ValueError( + f"constraint {where}.mode must be one of {sorted(_VALID_MODES)}" + ) + factor = cell.get("factor") + if not isinstance(factor, (int, float)) or isinstance(factor, bool): + raise ValueError(f"constraint {where}.factor must be a number") + if float(factor) <= 0: + raise ValueError(f"constraint {where}.factor must be a positive number") + + +class ConstraintHandler(ModuleHandler): + """Records a constraint matrix into the context for downstream consumers.""" + + type_id = "constraint" + + @classmethod + def validate_payload(cls, payload: dict[str, Any]) -> None: + if not isinstance(payload, dict): + raise ValueError("constraint payload must be an object") + for key in ("source_wildcard_id", "target_wildcard_id"): + value = payload.get(key) + if not isinstance(value, str) or not value: + raise ValueError(f"constraint payload.{key} must be a non-empty string") + matrix = payload.get("matrix", {}) + if not isinstance(matrix, dict): + raise ValueError("constraint payload.matrix must be an object") + for source, sub in matrix.items(): + if not isinstance(source, str): + raise ValueError("constraint payload.matrix keys must be strings") + if not isinstance(sub, dict): + raise ValueError( + f"constraint payload.matrix[{source!r}] must be an object" + ) + for target, cell in sub.items(): + if not isinstance(target, str): + raise ValueError( + f"constraint payload.matrix[{source!r}] keys must be strings" + ) + _validate_cell(cell, f"matrix[{source!r}][{target!r}]") + exceptions = payload.get("exceptions", []) + if not isinstance(exceptions, list): + raise ValueError("constraint payload.exceptions must be a list") + for i, exc in enumerate(exceptions): + if not isinstance(exc, dict): + raise ValueError( + f"constraint payload.exceptions[{i}] must be an object" + ) + for key in ("source", "target"): + v = exc.get(key) + if not isinstance(v, str) or not v: + raise ValueError( + f"constraint payload.exceptions[{i}].{key} must be a " + f"non-empty string" + ) + mode = exc.get("mode") + if mode not in _VALID_MODES: + raise ValueError( + f"constraint payload.exceptions[{i}].mode must be one of " + f"{sorted(_VALID_MODES)}" + ) + factor = exc.get("factor") + if not isinstance(factor, (int, float)) or isinstance(factor, bool): + raise ValueError( + f"constraint payload.exceptions[{i}].factor must be a number" + ) + if float(factor) <= 0: + raise ValueError( + f"constraint payload.exceptions[{i}].factor must be positive" + ) + + @classmethod + def resolve( + cls, + payload: dict[str, Any], + instance: dict[str, Any], + ctx: Any, + ) -> dict[str, str]: + cls.validate_payload(payload) + meta = { + "source_wildcard_id": payload["source_wildcard_id"], + "target_wildcard_id": payload["target_wildcard_id"], + "matrix": payload.get("matrix", {}), + "exceptions": payload.get("exceptions", []), + } + _ctx_set_constraint(ctx, meta) + # Pass-through stub: return no bindings — WildcardHandler is expected + # to consume ctx["_constraints"] when it grows constraint awareness. + return {} diff --git a/engine/modules/derivation_handler.py b/engine/modules/derivation_handler.py new file mode 100644 index 00000000..ba0b58b4 --- /dev/null +++ b/engine/modules/derivation_handler.py @@ -0,0 +1,191 @@ +"""Derivation module resolver — IF/ELIF/ELSE rules over the runtime context. + +Each rule has an ordered list of branches (index 0 == IF, the rest == ELIF) +and an optional ``else`` clause. Rules evaluate independently top-to-bottom: +the first branch whose condition matches wins; if none match, the ``else`` +action (if any) fires; otherwise the rule is a no-op. + +Actions mutate the runtime context directly. Multiple rules can target the +same variable; later rules see earlier mutations. +""" +from __future__ import annotations + +import re +from typing import Any + +from engine.modules.dispatcher import ModuleHandler + +_VALID_OPS = {"equals", "not_equals", "contains", "matches"} +_VALID_MODES = {"replace", "append", "prepend"} + + +def _ctx_get(ctx: Any, name: str) -> str: + if ctx is None: + return "" + getter = getattr(ctx, "get", None) + if callable(getter): + try: + value = getter(name, "") + except TypeError: + try: + value = getter(name) + except Exception: + return "" + return "" if value is None else str(value) + try: + if name in ctx: # type: ignore[operator] + return str(ctx[name]) # type: ignore[index] + except Exception: + return "" + return "" + + +def _ctx_set(ctx: Any, name: str, value: str) -> None: + if ctx is None: + return + setter = getattr(ctx, "set", None) + if callable(setter): + setter(name, value) + return + try: + ctx[name] = value # type: ignore[index] + except Exception: + return + + +def _match_condition(condition: dict[str, Any], ctx: Any) -> bool: + var = condition.get("var", "") + op = condition.get("op", "") + value = condition.get("value", "") + actual = _ctx_get(ctx, var) + if op == "equals": + return actual == value + if op == "not_equals": + return actual != value + if op == "contains": + return value in actual + if op == "matches": + try: + return re.search(value, actual) is not None + except re.error: + return False + return False + + +def _apply_action(action: dict[str, Any], ctx: Any) -> tuple[str, str] | None: + target = action.get("target_var", "") + if not target: + return None + mode = action.get("mode", "replace") + new_value = str(action.get("value", "")) + if mode == "replace": + result = new_value + elif mode == "append": + result = _ctx_get(ctx, target) + new_value + elif mode == "prepend": + result = new_value + _ctx_get(ctx, target) + else: + return None + _ctx_set(ctx, target, result) + return target, result + + +def _validate_condition(condition: Any, where: str) -> None: + if not isinstance(condition, dict): + raise ValueError(f"derivation {where}.condition must be an object") + var = condition.get("var") + if not isinstance(var, str) or not var: + raise ValueError(f"derivation {where}.condition.var must be a non-empty string") + op = condition.get("op") + if op not in _VALID_OPS: + raise ValueError( + f"derivation {where}.condition.op must be one of {sorted(_VALID_OPS)}" + ) + if not isinstance(condition.get("value", ""), str): + raise ValueError(f"derivation {where}.condition.value must be a string") + + +def _validate_action(action: Any, where: str) -> None: + if not isinstance(action, dict): + raise ValueError(f"derivation {where}.action must be an object") + target = action.get("target_var") + if not isinstance(target, str) or not target: + raise ValueError( + f"derivation {where}.action.target_var must be a non-empty string" + ) + mode = action.get("mode", "replace") + if mode not in _VALID_MODES: + raise ValueError( + f"derivation {where}.action.mode must be one of {sorted(_VALID_MODES)}" + ) + if not isinstance(action.get("value", ""), str): + raise ValueError(f"derivation {where}.action.value must be a string") + + +class DerivationHandler(ModuleHandler): + """IF / ELIF / ELSE rule evaluator that mutates ctx.""" + + type_id = "derivation" + + @classmethod + def validate_payload(cls, payload: dict[str, Any]) -> None: + if not isinstance(payload, dict): + raise ValueError("derivation payload must be an object") + rules = payload.get("rules") + if not isinstance(rules, list): + raise ValueError("derivation payload.rules must be a list") + for ri, rule in enumerate(rules): + if not isinstance(rule, dict): + raise ValueError(f"derivation payload.rules[{ri}] must be an object") + rule_id = rule.get("id") + if not isinstance(rule_id, str) or not rule_id: + raise ValueError( + f"derivation payload.rules[{ri}].id must be a non-empty string" + ) + branches = rule.get("branches") + if not isinstance(branches, list) or not branches: + raise ValueError( + f"derivation payload.rules[{ri}].branches must be a non-empty list" + ) + for bi, branch in enumerate(branches): + if not isinstance(branch, dict): + raise ValueError( + f"derivation payload.rules[{ri}].branches[{bi}] must be an object" + ) + _validate_condition( + branch.get("condition"), f"rules[{ri}].branches[{bi}]" + ) + _validate_action(branch.get("action"), f"rules[{ri}].branches[{bi}]") + else_clause = rule.get("else") + if else_clause is not None: + if not isinstance(else_clause, dict): + raise ValueError( + f"derivation payload.rules[{ri}].else must be an object" + ) + _validate_action(else_clause.get("action"), f"rules[{ri}].else") + + @classmethod + def resolve( + cls, + payload: dict[str, Any], + instance: dict[str, Any], + ctx: Any, + ) -> dict[str, str]: + cls.validate_payload(payload) + out: dict[str, str] = {} + for rule in payload.get("rules", []): + applied = False + for branch in rule.get("branches", []): + if _match_condition(branch.get("condition", {}), ctx): + pair = _apply_action(branch.get("action", {}), ctx) + if pair is not None: + out[pair[0]] = pair[1] + applied = True + break + if not applied: + else_clause = rule.get("else") + if isinstance(else_clause, dict): + pair = _apply_action(else_clause.get("action", {}), ctx) + if pair is not None: + out[pair[0]] = pair[1] + return out diff --git a/engine/modules/dispatcher.py b/engine/modules/dispatcher.py index 0e0ec1eb..7a6470dc 100644 --- a/engine/modules/dispatcher.py +++ b/engine/modules/dispatcher.py @@ -30,6 +30,15 @@ class ModuleHandler(ABC): ) -> dict[str, str]: """Return a mapping of variable_name → resolved string.""" + @classmethod + def validate_payload(cls, payload: dict[str, Any]) -> None: + """Validate the payload shape. Default is no-op. + + Handlers override this to reject malformed payloads at create/update + time; raise ``ValueError`` with a helpful message on failure. + """ + return None + _HANDLERS: dict[str, type[ModuleHandler]] = {} @@ -41,6 +50,11 @@ def register_handler(handler: type[ModuleHandler]) -> None: _HANDLERS[handler.type_id] = handler +def get_handler(type_id: str) -> type[ModuleHandler] | None: + """Return the handler registered for ``type_id`` or ``None`` if unknown.""" + return _HANDLERS.get(type_id) + + 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") diff --git a/engine/modules/pipeline_handler.py b/engine/modules/pipeline_handler.py new file mode 100644 index 00000000..a513abd6 --- /dev/null +++ b/engine/modules/pipeline_handler.py @@ -0,0 +1,99 @@ +"""Pipeline module resolver — runs an ordered list of sub-modules. + +Each step references a module in the library by id. The repository is +threaded through ``ctx["_repo"]``; without it the handler can still validate +its payload but ``resolve`` will raise ``RuntimeError`` because it cannot load +sub-modules. Steps marked ``enabled=False`` are skipped. +""" +from __future__ import annotations + +from typing import Any + +from engine.modules.dispatcher import ModuleHandler, resolve_module + + +def _ctx_get(ctx: Any, name: str, default: Any = None) -> Any: + if ctx is None: + return default + getter = getattr(ctx, "get", None) + if callable(getter): + try: + return getter(name, default) + except TypeError: + try: + return getter(name) + except Exception: + return default + try: + if name in ctx: # type: ignore[operator] + return ctx[name] # type: ignore[index] + except Exception: + return default + return default + + +class PipelineHandler(ModuleHandler): + """Composes other modules into an ordered execution list.""" + + type_id = "pipeline" + + @classmethod + def validate_payload(cls, payload: dict[str, Any]) -> None: + if not isinstance(payload, dict): + raise ValueError("pipeline payload must be an object") + steps = payload.get("steps") + if not isinstance(steps, list): + raise ValueError("pipeline payload.steps must be a list") + for i, step in enumerate(steps): + if not isinstance(step, dict): + raise ValueError(f"pipeline payload.steps[{i}] must be an object") + module_id = step.get("module_id") + if not isinstance(module_id, str) or not module_id: + raise ValueError( + f"pipeline payload.steps[{i}].module_id must be a non-empty string" + ) + step_id = step.get("id") + if not isinstance(step_id, str) or not step_id: + raise ValueError( + f"pipeline payload.steps[{i}].id must be a non-empty string" + ) + enabled = step.get("enabled", True) + if not isinstance(enabled, bool): + raise ValueError( + f"pipeline payload.steps[{i}].enabled must be a boolean" + ) + instance = step.get("instance", {}) + if not isinstance(instance, dict): + raise ValueError( + f"pipeline payload.steps[{i}].instance must be an object" + ) + + @classmethod + def resolve( + cls, + payload: dict[str, Any], + instance: dict[str, Any], + ctx: Any, + ) -> dict[str, str]: + cls.validate_payload(payload) + repo = _ctx_get(ctx, "_repo", None) + if repo is None: + raise RuntimeError( + "pipeline handler requires ctx['_repo'] to load sub-modules" + ) + + out: dict[str, str] = {} + for step in payload.get("steps", []): + if not step.get("enabled", True): + continue + module_id = step["module_id"] + row = repo.get(module_id) + sub_snapshot = { + "type": row["type"], + "payload": row["payload"], + "instance": step.get("instance", {}), + } + bindings = resolve_module(sub_snapshot, ctx) + if bindings: + out.update(bindings) + return out diff --git a/tests/engine/db/test_migrations.py b/tests/engine/db/test_migrations.py index fc181dd8..47fdfb53 100644 --- a/tests/engine/db/test_migrations.py +++ b/tests/engine/db/test_migrations.py @@ -27,15 +27,18 @@ def test_migrate_creates_modules_and_categories_and_migrations(tmp_path): def test_migrate_records_version(tmp_path): conn = get_connection(tmp_path / "v2.db") migrate(conn) - assert current_version(conn) == 1 + # Keep this assertion in sync with the highest-numbered migration in + # ``engine/db/migrations_sql``. + assert current_version(conn) == 2 conn.close() def test_migrate_is_idempotent(tmp_path): conn = get_connection(tmp_path / "i.db") migrate(conn) + before = current_version(conn) migrate(conn) - assert current_version(conn) == 1 + assert current_version(conn) == before conn.close() diff --git a/tests/engine/db/test_module_repository.py b/tests/engine/db/test_module_repository.py index ede659ec..f6fa1a2e 100644 --- a/tests/engine/db/test_module_repository.py +++ b/tests/engine/db/test_module_repository.py @@ -187,3 +187,31 @@ def test_list_offset_without_limit(wp_db): rows = repo.list(offset=1) # offset=1 skips the first (most-recent) row → expect 2 rows back assert len(rows) == 2 + + +@pytest.mark.parametrize( + "type_,prefix", + [ + ("combine", "cb_"), + ("derivation", "dr_"), + ("constraint", "ct_"), + ("pipeline", "pl_"), + ], +) +def test_create_accepts_new_module_types(wp_db, type_, prefix): + repo = ModuleRepository(wp_db) + row = repo.create( + type=type_, name="thing", description="", + category_id=None, tags=[], payload={}, + ) + assert row["type"] == type_ + assert row["id"].startswith(prefix) + + +def test_create_rejects_unknown_type(wp_db): + repo = ModuleRepository(wp_db) + with pytest.raises(ValueError, match="unknown module type"): + repo.create( + type="bogus", name="x", description="", + category_id=None, tags=[], payload={}, + ) diff --git a/tests/engine/modules/test_combine_handler.py b/tests/engine/modules/test_combine_handler.py new file mode 100644 index 00000000..93d211fc --- /dev/null +++ b/tests/engine/modules/test_combine_handler.py @@ -0,0 +1,94 @@ +"""Tests for CombineHandler — template-fill from runtime context bindings.""" +import pytest + +from engine.modules.combine_handler import CombineHandler + + +def test_handler_type_id_is_combine(): + assert CombineHandler.type_id == "combine" + + +def test_validate_payload_accepts_well_formed(): + CombineHandler.validate_payload({ + "template": "$name, a $age-year-old", + "output_var": "subject_phrase", + "input_vars": ["name", "age"], + }) + + +def test_validate_payload_rejects_non_string_template(): + with pytest.raises(ValueError, match="template"): + CombineHandler.validate_payload({ + "template": 123, + "output_var": "x", + }) + + +def test_validate_payload_rejects_blank_output_var(): + with pytest.raises(ValueError, match="output_var"): + CombineHandler.validate_payload({"template": "x", "output_var": ""}) + + +def test_validate_payload_rejects_invalid_identifier(): + with pytest.raises(ValueError, match="identifier"): + CombineHandler.validate_payload({ + "template": "x", "output_var": "1bad-name", + }) + + +def test_validate_payload_rejects_non_list_input_vars(): + with pytest.raises(ValueError, match="input_vars"): + CombineHandler.validate_payload({ + "template": "x", "output_var": "y", "input_vars": "abc", + }) + + +def test_resolve_substitutes_known_vars(): + ctx: dict = {"name": "Alice", "age": "30"} + out = CombineHandler.resolve( + { + "template": "$name, a $age-year-old", + "output_var": "subject", + }, + instance={}, + ctx=ctx, + ) + assert out == {"subject": "Alice, a 30-year-old"} + assert ctx["subject"] == "Alice, a 30-year-old" + + +def test_resolve_leaves_unknown_var_token_in_place(): + ctx: dict = {"name": "Bob"} + out = CombineHandler.resolve( + {"template": "$name and $missing", "output_var": "out"}, + instance={}, + ctx=ctx, + ) + assert out == {"out": "Bob and $missing"} + + +def test_resolve_with_none_ctx_keeps_all_tokens(): + out = CombineHandler.resolve( + {"template": "$a $b", "output_var": "phrase"}, + instance={}, + ctx=None, + ) + assert out == {"phrase": "$a $b"} + + +def test_resolve_rejects_malformed_payload(): + with pytest.raises(ValueError): + CombineHandler.resolve( + {"template": 5, "output_var": "x"}, instance={}, ctx={}, + ) + + +def test_resolve_via_dispatcher_after_import(): + from engine.modules import resolve_module + snap = { + "type": "combine", + "payload": {"template": "hi $name", "output_var": "greeting"}, + "instance": {}, + } + out = resolve_module(snap, ctx={"name": "Carol"}) + assert out == {"greeting": "hi Carol"} diff --git a/tests/engine/modules/test_constraint_handler.py b/tests/engine/modules/test_constraint_handler.py new file mode 100644 index 00000000..4efa65c2 --- /dev/null +++ b/tests/engine/modules/test_constraint_handler.py @@ -0,0 +1,123 @@ +"""Tests for ConstraintHandler — pass-through stub that records metadata.""" +import pytest + +from engine.modules.constraint_handler import ConstraintHandler + + +def _payload(): + return { + "source_wildcard_id": "wc_outfit", + "target_wildcard_id": "wc_pose", + "matrix": { + "kimono": {"casual": {"mode": "exclude", "factor": 1.0}}, + }, + "exceptions": [], + } + + +def test_handler_type_id_is_constraint(): + assert ConstraintHandler.type_id == "constraint" + + +def test_validate_payload_accepts_well_formed(): + ConstraintHandler.validate_payload(_payload()) + + +def test_validate_payload_rejects_blank_source_id(): + payload = _payload() + payload["source_wildcard_id"] = "" + with pytest.raises(ValueError, match="source_wildcard_id"): + ConstraintHandler.validate_payload(payload) + + +def test_validate_payload_rejects_unknown_mode(): + payload = _payload() + payload["matrix"]["kimono"]["casual"]["mode"] = "weird" + with pytest.raises(ValueError, match="mode"): + ConstraintHandler.validate_payload(payload) + + +def test_validate_payload_rejects_zero_factor(): + payload = _payload() + payload["matrix"]["kimono"]["casual"]["factor"] = 0.0 + with pytest.raises(ValueError, match="factor"): + ConstraintHandler.validate_payload(payload) + + +def test_validate_payload_rejects_negative_factor(): + payload = _payload() + payload["matrix"]["kimono"]["casual"]["factor"] = -1.0 + with pytest.raises(ValueError, match="factor"): + ConstraintHandler.validate_payload(payload) + + +def test_validate_payload_rejects_non_numeric_factor(): + payload = _payload() + payload["matrix"]["kimono"]["casual"]["factor"] = "1.0" + with pytest.raises(ValueError, match="factor"): + ConstraintHandler.validate_payload(payload) + + +def test_validate_payload_rejects_bool_factor(): + """``True`` is technically an int, but a bool factor is meaningless here.""" + payload = _payload() + payload["matrix"]["kimono"]["casual"]["factor"] = True + with pytest.raises(ValueError, match="factor"): + ConstraintHandler.validate_payload(payload) + + +def test_validate_payload_accepts_exceptions(): + payload = _payload() + payload["exceptions"] = [ + {"source": "kimono", "target": "casual", "mode": "boost", "factor": 1.5}, + ] + ConstraintHandler.validate_payload(payload) + + +def test_validate_payload_rejects_exception_with_bad_mode(): + payload = _payload() + payload["exceptions"] = [ + {"source": "kimono", "target": "casual", "mode": "huh", "factor": 1.0}, + ] + with pytest.raises(ValueError, match="mode"): + ConstraintHandler.validate_payload(payload) + + +def test_resolve_records_metadata_into_ctx_constraints_list(): + ctx: dict = {} + out = ConstraintHandler.resolve(_payload(), instance={}, ctx=ctx) + # Pass-through stub returns no bindings; mutates ctx instead. + assert out == {} + assert isinstance(ctx["_constraints"], list) + assert len(ctx["_constraints"]) == 1 + meta = ctx["_constraints"][0] + assert meta["source_wildcard_id"] == "wc_outfit" + assert meta["target_wildcard_id"] == "wc_pose" + assert meta["matrix"] == {"kimono": {"casual": {"mode": "exclude", "factor": 1.0}}} + assert meta["exceptions"] == [] + + +def test_resolve_appends_to_existing_constraints(): + ctx: dict = {"_constraints": [{"source_wildcard_id": "earlier"}]} + ConstraintHandler.resolve(_payload(), instance={}, ctx=ctx) + assert len(ctx["_constraints"]) == 2 + assert ctx["_constraints"][0]["source_wildcard_id"] == "earlier" + assert ctx["_constraints"][1]["source_wildcard_id"] == "wc_outfit" + + +def test_resolve_rejects_malformed_payload(): + with pytest.raises(ValueError): + ConstraintHandler.resolve( + {"source_wildcard_id": "", "target_wildcard_id": "x", "matrix": {}}, + instance={}, + ctx={}, + ) + + +def test_resolve_via_dispatcher_after_import(): + from engine.modules import resolve_module + ctx: dict = {} + snap = {"type": "constraint", "payload": _payload(), "instance": {}} + out = resolve_module(snap, ctx=ctx) + assert out == {} + assert len(ctx["_constraints"]) == 1 diff --git a/tests/engine/modules/test_derivation_handler.py b/tests/engine/modules/test_derivation_handler.py new file mode 100644 index 00000000..43be4c4c --- /dev/null +++ b/tests/engine/modules/test_derivation_handler.py @@ -0,0 +1,159 @@ +"""Tests for DerivationHandler — IF/ELIF/ELSE rules over runtime context.""" +import pytest + +from engine.modules.derivation_handler import DerivationHandler + + +def _rule(rid="r1", branches=None, else_clause=None): + out = {"id": rid, "branches": branches or []} + if else_clause is not None: + out["else"] = else_clause + return out + + +def test_handler_type_id_is_derivation(): + assert DerivationHandler.type_id == "derivation" + + +def test_validate_payload_accepts_well_formed(): + DerivationHandler.validate_payload({ + "rules": [ + _rule(branches=[{ + "condition": {"var": "x", "op": "equals", "value": "a"}, + "action": {"target_var": "y", "mode": "replace", "value": "b"}, + }]), + ], + }) + + +def test_validate_payload_rejects_non_list_rules(): + with pytest.raises(ValueError, match="rules"): + DerivationHandler.validate_payload({"rules": "nope"}) + + +def test_validate_payload_rejects_empty_branches(): + with pytest.raises(ValueError, match="branches"): + DerivationHandler.validate_payload({"rules": [_rule(branches=[])]}) + + +def test_validate_payload_rejects_unknown_op(): + with pytest.raises(ValueError, match="op"): + DerivationHandler.validate_payload({"rules": [_rule(branches=[{ + "condition": {"var": "x", "op": "weird", "value": "a"}, + "action": {"target_var": "y", "mode": "replace", "value": "b"}, + }])]}) + + +def test_validate_payload_rejects_unknown_mode(): + with pytest.raises(ValueError, match="mode"): + DerivationHandler.validate_payload({"rules": [_rule(branches=[{ + "condition": {"var": "x", "op": "equals", "value": "a"}, + "action": {"target_var": "y", "mode": "frob", "value": "b"}, + }])]}) + + +def test_resolve_first_matching_branch_wins(): + ctx: dict = {"mood": "happy"} + payload = {"rules": [_rule(branches=[ + { + "condition": {"var": "mood", "op": "equals", "value": "sad"}, + "action": {"target_var": "tone", "mode": "replace", "value": "blue"}, + }, + { + "condition": {"var": "mood", "op": "equals", "value": "happy"}, + "action": {"target_var": "tone", "mode": "replace", "value": "bright"}, + }, + ])]} + out = DerivationHandler.resolve(payload, instance={}, ctx=ctx) + assert out == {"tone": "bright"} + assert ctx["tone"] == "bright" + + +def test_resolve_else_fires_when_nothing_matches(): + ctx: dict = {"mood": "neutral"} + payload = {"rules": [_rule( + branches=[{ + "condition": {"var": "mood", "op": "equals", "value": "happy"}, + "action": {"target_var": "tone", "mode": "replace", "value": "bright"}, + }], + else_clause={ + "action": {"target_var": "tone", "mode": "replace", "value": "muted"}, + }, + )]} + out = DerivationHandler.resolve(payload, instance={}, ctx=ctx) + assert out == {"tone": "muted"} + + +def test_resolve_no_match_no_else_is_noop(): + ctx: dict = {"mood": "neutral"} + payload = {"rules": [_rule(branches=[{ + "condition": {"var": "mood", "op": "equals", "value": "happy"}, + "action": {"target_var": "tone", "mode": "replace", "value": "bright"}, + }])]} + out = DerivationHandler.resolve(payload, instance={}, ctx=ctx) + assert out == {} + assert "tone" not in ctx + + +def test_resolve_append_mode_concatenates(): + ctx: dict = {"x": "value", "buf": "start-"} + payload = {"rules": [_rule(branches=[{ + "condition": {"var": "x", "op": "equals", "value": "value"}, + "action": {"target_var": "buf", "mode": "append", "value": "end"}, + }])]} + out = DerivationHandler.resolve(payload, instance={}, ctx=ctx) + assert out == {"buf": "start-end"} + + +def test_resolve_prepend_mode_concatenates(): + ctx: dict = {"x": "yes", "buf": "world"} + payload = {"rules": [_rule(branches=[{ + "condition": {"var": "x", "op": "equals", "value": "yes"}, + "action": {"target_var": "buf", "mode": "prepend", "value": "hello-"}, + }])]} + out = DerivationHandler.resolve(payload, instance={}, ctx=ctx) + assert out == {"buf": "hello-world"} + + +def test_resolve_contains_op(): + ctx: dict = {"text": "the quick brown fox"} + payload = {"rules": [_rule(branches=[{ + "condition": {"var": "text", "op": "contains", "value": "brown"}, + "action": {"target_var": "found", "mode": "replace", "value": "yes"}, + }])]} + out = DerivationHandler.resolve(payload, instance={}, ctx=ctx) + assert out == {"found": "yes"} + + +def test_resolve_matches_op_uses_regex(): + ctx: dict = {"name": "user_42"} + payload = {"rules": [_rule(branches=[{ + "condition": {"var": "name", "op": "matches", "value": r"^user_\d+$"}, + "action": {"target_var": "kind", "mode": "replace", "value": "user"}, + }])]} + out = DerivationHandler.resolve(payload, instance={}, ctx=ctx) + assert out == {"kind": "user"} + + +def test_resolve_not_equals_op(): + ctx: dict = {"role": "admin"} + payload = {"rules": [_rule(branches=[{ + "condition": {"var": "role", "op": "not_equals", "value": "guest"}, + "action": {"target_var": "level", "mode": "replace", "value": "high"}, + }])]} + out = DerivationHandler.resolve(payload, instance={}, ctx=ctx) + assert out == {"level": "high"} + + +def test_resolve_via_dispatcher_after_import(): + from engine.modules import resolve_module + snap = { + "type": "derivation", + "payload": {"rules": [_rule(branches=[{ + "condition": {"var": "a", "op": "equals", "value": "1"}, + "action": {"target_var": "b", "mode": "replace", "value": "2"}, + }])]}, + "instance": {}, + } + out = resolve_module(snap, ctx={"a": "1"}) + assert out == {"b": "2"} diff --git a/tests/engine/modules/test_pipeline_handler.py b/tests/engine/modules/test_pipeline_handler.py new file mode 100644 index 00000000..950ac993 --- /dev/null +++ b/tests/engine/modules/test_pipeline_handler.py @@ -0,0 +1,144 @@ +"""Tests for PipelineHandler — composes other modules in order.""" +import pytest + +from engine.modules.pipeline_handler import PipelineHandler + + +class _FakeRepo: + """In-memory stand-in for ModuleRepository.get.""" + + def __init__(self, rows): + self._rows = rows + + def get(self, module_id): + if module_id not in self._rows: + raise KeyError(module_id) + return self._rows[module_id] + + +def test_handler_type_id_is_pipeline(): + assert PipelineHandler.type_id == "pipeline" + + +def test_validate_payload_accepts_well_formed(): + PipelineHandler.validate_payload({ + "steps": [ + {"id": "s1", "module_id": "m1", "enabled": True, "instance": {}}, + {"id": "s2", "module_id": "m2", "enabled": False, "instance": {}}, + ], + }) + + +def test_validate_payload_rejects_non_list_steps(): + with pytest.raises(ValueError, match="steps"): + PipelineHandler.validate_payload({"steps": {}}) + + +def test_validate_payload_rejects_step_missing_module_id(): + with pytest.raises(ValueError, match="module_id"): + PipelineHandler.validate_payload({"steps": [ + {"id": "s1", "enabled": True, "instance": {}}, + ]}) + + +def test_validate_payload_rejects_step_blank_id(): + with pytest.raises(ValueError, match=r"steps\[0\].id"): + PipelineHandler.validate_payload({"steps": [ + {"id": "", "module_id": "m1"}, + ]}) + + +def test_validate_payload_rejects_non_bool_enabled(): + with pytest.raises(ValueError, match="enabled"): + PipelineHandler.validate_payload({"steps": [ + {"id": "s1", "module_id": "m1", "enabled": "yes"}, + ]}) + + +def test_resolve_without_repo_raises_runtime_error(): + with pytest.raises(RuntimeError, match="_repo"): + PipelineHandler.resolve( + {"steps": [{"id": "s1", "module_id": "m1"}]}, + instance={}, + ctx={}, + ) + + +def test_resolve_runs_enabled_steps_in_order(): + repo = _FakeRepo({ + "m1": { + "type": "fixed_values", + "payload": {"values": [{"id": "v", "name": "$a", "value": "1"}]}, + }, + "m2": { + "type": "fixed_values", + "payload": {"values": [{"id": "v", "name": "$b", "value": "2"}]}, + }, + }) + ctx = {"_repo": repo} + out = PipelineHandler.resolve( + {"steps": [ + {"id": "s1", "module_id": "m1", "enabled": True, "instance": {}}, + {"id": "s2", "module_id": "m2", "enabled": True, "instance": {}}, + ]}, + instance={}, + ctx=ctx, + ) + assert out == {"$a": "1", "$b": "2"} + + +def test_resolve_skips_disabled_steps(): + repo = _FakeRepo({ + "m1": { + "type": "fixed_values", + "payload": {"values": [{"id": "v", "name": "$a", "value": "1"}]}, + }, + "m2": { + "type": "fixed_values", + "payload": {"values": [{"id": "v", "name": "$b", "value": "2"}]}, + }, + }) + ctx = {"_repo": repo} + out = PipelineHandler.resolve( + {"steps": [ + {"id": "s1", "module_id": "m1", "enabled": False, "instance": {}}, + {"id": "s2", "module_id": "m2", "enabled": True, "instance": {}}, + ]}, + instance={}, + ctx=ctx, + ) + assert out == {"$b": "2"} + + +def test_resolve_default_enabled_true(): + """Steps without an explicit ``enabled`` key default to True.""" + repo = _FakeRepo({ + "m1": { + "type": "fixed_values", + "payload": {"values": [{"id": "v", "name": "$x", "value": "ok"}]}, + }, + }) + ctx = {"_repo": repo} + out = PipelineHandler.resolve( + {"steps": [{"id": "s1", "module_id": "m1", "instance": {}}]}, + instance={}, + ctx=ctx, + ) + assert out == {"$x": "ok"} + + +def test_resolve_via_dispatcher_after_import(): + from engine.modules import resolve_module + repo = _FakeRepo({ + "m1": { + "type": "fixed_values", + "payload": {"values": [{"id": "v", "name": "$only", "value": "yes"}]}, + }, + }) + snap = { + "type": "pipeline", + "payload": {"steps": [{"id": "s1", "module_id": "m1", "enabled": True}]}, + "instance": {}, + } + out = resolve_module(snap, ctx={"_repo": repo}) + assert out == {"$only": "yes"} diff --git a/wp_api/modules.py b/wp_api/modules.py index 237c5c67..5648add0 100644 --- a/wp_api/modules.py +++ b/wp_api/modules.py @@ -47,15 +47,18 @@ async def create_module(request: web.Request) -> web.Response: if missing: return json_error(f"missing fields: {sorted(missing)}", status=400) - with db_session(request) as conn: - row = ModuleRepository(conn).create( - type=body["type"], name=body["name"], - description=body.get("description", ""), - category_id=body.get("category_id"), - tags=body.get("tags", []), - payload=body["payload"], - is_favorite=bool(body.get("is_favorite", False)), - ) + try: + with db_session(request) as conn: + row = ModuleRepository(conn).create( + type=body["type"], name=body["name"], + description=body.get("description", ""), + category_id=body.get("category_id"), + tags=body.get("tags", []), + payload=body["payload"], + is_favorite=bool(body.get("is_favorite", False)), + ) + except ValueError as e: + return json_error(str(e), status=400) return json_ok(row, status=201)