feat(engine): scaffold combine/derivation/constraint/pipeline module types

This commit is contained in:
DumiFlex
2026-04-27 03:54:13 +03:00
parent bd58aa679a
commit a16c5324e2
15 changed files with 1155 additions and 12 deletions
@@ -0,0 +1 @@
PRAGMA user_version = 2;
+13 -1
View File
@@ -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:
+19
View File
@@ -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",
]
+107
View File
@@ -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}
+146
View File
@@ -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 {}
+191
View File
@@ -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
+14
View File
@@ -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")
+99
View File
@@ -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
+5 -2
View File
@@ -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()
+28
View File
@@ -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={},
)
@@ -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"}
@@ -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
@@ -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"}
@@ -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"}
+12 -9
View File
@@ -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)