feat(engine): scaffold combine/derivation/constraint/pipeline module types
This commit is contained in:
@@ -0,0 +1 @@
|
||||
PRAGMA user_version = 2;
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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}
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user