generated from coulomb/repo-seed
Implemented foundation of task-flow-engine
This commit is contained in:
22
task_flow_engine/__init__.py
Normal file
22
task_flow_engine/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from task_flow_engine.engine import FlowEngine
|
||||
from task_flow_engine.evaluator import AssertionEvaluator, resolve_target
|
||||
from task_flow_engine.models import (
|
||||
AssertionDef,
|
||||
AssertionResult,
|
||||
FlowDef,
|
||||
FlowResult,
|
||||
UnreachableWorkstation,
|
||||
WorkstationDef,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AssertionDef",
|
||||
"AssertionEvaluator",
|
||||
"AssertionResult",
|
||||
"FlowDef",
|
||||
"FlowEngine",
|
||||
"FlowResult",
|
||||
"UnreachableWorkstation",
|
||||
"WorkstationDef",
|
||||
"resolve_target",
|
||||
]
|
||||
37
task_flow_engine/builtins.py
Normal file
37
task_flow_engine/builtins.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
|
||||
EMPTY_VALUES = (None, "", [], {}, ())
|
||||
|
||||
|
||||
def all_eq(values: list[Any], expected: Any) -> bool:
|
||||
return all(_matches(value, expected) for value in values)
|
||||
|
||||
|
||||
def any_eq(values: list[Any], expected: Any) -> bool:
|
||||
return any(_matches(value, expected) for value in values)
|
||||
|
||||
|
||||
def none_eq(values: list[Any], expected: Any) -> bool:
|
||||
return all(not _matches(value, expected) for value in values)
|
||||
|
||||
|
||||
def exists(values: list[Any], expected: Any = None) -> bool:
|
||||
return any(value not in EMPTY_VALUES for value in values)
|
||||
|
||||
|
||||
def count_gte(values: list[Any], expected: Any) -> bool:
|
||||
try:
|
||||
threshold = int(expected)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return len(values) >= threshold
|
||||
|
||||
|
||||
def _matches(value: Any, expected: Any) -> bool:
|
||||
if isinstance(expected, Sequence) and not isinstance(expected, (str, bytes, bytearray)):
|
||||
return value in expected
|
||||
return value == expected
|
||||
82
task_flow_engine/engine.py
Normal file
82
task_flow_engine/engine.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from task_flow_engine.evaluator import AssertionEvaluator, CustomOp
|
||||
from task_flow_engine.models import (
|
||||
AssertionResult,
|
||||
FlowDef,
|
||||
FlowResult,
|
||||
UnreachableWorkstation,
|
||||
WorkstationDef,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlowEngine:
|
||||
custom_ops: dict[str, CustomOp] | None = None
|
||||
|
||||
def evaluate(self, obj: dict[str, Any], flow: FlowDef) -> FlowResult:
|
||||
evaluator = AssertionEvaluator(custom_ops=self.custom_ops)
|
||||
current_name = str(obj.get("workstation") or obj.get("status") or "")
|
||||
current = flow.workstation(current_name)
|
||||
blocking_assertions = (
|
||||
self._failed_assertions(current.exit_assertions, obj, evaluator)
|
||||
if current is not None
|
||||
else []
|
||||
)
|
||||
|
||||
reachable: list[str] = []
|
||||
unreachable: list[UnreachableWorkstation] = []
|
||||
for workstation in flow.workstations:
|
||||
failed = self._failed_assertions(workstation.entry_assertions, obj, evaluator)
|
||||
if failed:
|
||||
unreachable.append(
|
||||
UnreachableWorkstation(
|
||||
workstation=workstation.name,
|
||||
blocking=failed[0],
|
||||
)
|
||||
)
|
||||
else:
|
||||
reachable.append(workstation.name)
|
||||
|
||||
return FlowResult(
|
||||
current_workstation=current_name,
|
||||
exit_blocked=bool(blocking_assertions),
|
||||
blocking_assertions=blocking_assertions,
|
||||
reachable=reachable,
|
||||
unreachable=unreachable,
|
||||
)
|
||||
|
||||
def can_reach(
|
||||
self,
|
||||
obj: dict[str, Any],
|
||||
flow: FlowDef,
|
||||
target_workstation: str,
|
||||
) -> tuple[bool, list[AssertionResult]]:
|
||||
workstation = flow.workstation(target_workstation)
|
||||
if workstation is None:
|
||||
return False, [
|
||||
AssertionResult(
|
||||
id="flow.unknown_workstation",
|
||||
passed=False,
|
||||
target="workstation",
|
||||
op="exists",
|
||||
expected=target_workstation,
|
||||
actual=[item.name for item in flow.workstations],
|
||||
reason=f"Flow '{flow.id}' has no workstation '{target_workstation}'.",
|
||||
)
|
||||
]
|
||||
evaluator = AssertionEvaluator(custom_ops=self.custom_ops)
|
||||
failed = self._failed_assertions(workstation.entry_assertions, obj, evaluator)
|
||||
return not failed, failed
|
||||
|
||||
@staticmethod
|
||||
def _failed_assertions(
|
||||
assertions: list,
|
||||
obj: dict[str, Any],
|
||||
evaluator: AssertionEvaluator,
|
||||
) -> list[AssertionResult]:
|
||||
results = [evaluator.evaluate(assertion, obj) for assertion in assertions]
|
||||
return [result for result in results if not result.passed]
|
||||
140
task_flow_engine/evaluator.py
Normal file
140
task_flow_engine/evaluator.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from task_flow_engine import builtins
|
||||
from task_flow_engine.models import AssertionDef, AssertionResult
|
||||
|
||||
|
||||
CustomOp = Callable[[AssertionDef, dict[str, Any], list[Any]], bool | tuple[bool, str]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssertionEvaluator:
|
||||
custom_ops: dict[str, CustomOp] | None = None
|
||||
max_nodes: int = 1_000
|
||||
|
||||
def evaluate(self, assertion: AssertionDef, obj: dict[str, Any]) -> AssertionResult:
|
||||
values = resolve_target(obj, assertion.target, max_nodes=self.max_nodes)
|
||||
passed, reason = self._evaluate(assertion, obj, values)
|
||||
if not reason:
|
||||
reason = _default_reason(assertion, values, passed)
|
||||
return AssertionResult(
|
||||
id=assertion.id,
|
||||
passed=passed,
|
||||
target=assertion.target,
|
||||
op=assertion.op,
|
||||
expected=assertion.value,
|
||||
actual=values,
|
||||
description=assertion.description,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
def _evaluate(
|
||||
self,
|
||||
assertion: AssertionDef,
|
||||
obj: dict[str, Any],
|
||||
values: list[Any],
|
||||
) -> tuple[bool, str]:
|
||||
if assertion.op == "all_eq":
|
||||
return builtins.all_eq(values, assertion.value), ""
|
||||
if assertion.op == "any_eq":
|
||||
return builtins.any_eq(values, assertion.value), ""
|
||||
if assertion.op == "none_eq":
|
||||
return builtins.none_eq(values, assertion.value), ""
|
||||
if assertion.op == "exists":
|
||||
return builtins.exists(values, assertion.value), ""
|
||||
if assertion.op == "count_gte":
|
||||
return builtins.count_gte(values, assertion.value), ""
|
||||
if assertion.op == "custom":
|
||||
return self._evaluate_custom(assertion, obj, values)
|
||||
return False, f"Unknown assertion op '{assertion.op}'."
|
||||
|
||||
def _evaluate_custom(
|
||||
self,
|
||||
assertion: AssertionDef,
|
||||
obj: dict[str, Any],
|
||||
values: list[Any],
|
||||
) -> tuple[bool, str]:
|
||||
if not self.custom_ops or assertion.id not in self.custom_ops:
|
||||
return False, f"No custom op registered for assertion '{assertion.id}'."
|
||||
result = self.custom_ops[assertion.id](assertion, obj, values)
|
||||
if isinstance(result, tuple):
|
||||
passed, reason = result
|
||||
return bool(passed), reason
|
||||
return bool(result), ""
|
||||
|
||||
|
||||
def resolve_target(obj: Any, target: str, max_nodes: int = 1_000) -> list[Any]:
|
||||
if not target:
|
||||
return [obj]
|
||||
parts = target.split(".")
|
||||
seen: set[int] = set()
|
||||
values = _resolve(obj, parts, seen, max_nodes)
|
||||
return [_scalarize(value) for value in values]
|
||||
|
||||
|
||||
def _resolve(current: Any, parts: list[str], seen: set[int], max_nodes: int) -> list[Any]:
|
||||
if len(seen) > max_nodes:
|
||||
return []
|
||||
current_id = id(current)
|
||||
if isinstance(current, (dict, list, tuple, set)) or hasattr(current, "__dict__"):
|
||||
if current_id in seen:
|
||||
return []
|
||||
seen.add(current_id)
|
||||
|
||||
if not parts:
|
||||
return [current]
|
||||
|
||||
part, rest = parts[0], parts[1:]
|
||||
if part == "*":
|
||||
values: list[Any] = []
|
||||
for item in _iter_items(current):
|
||||
values.extend(_resolve(item, rest, seen.copy(), max_nodes))
|
||||
return values
|
||||
|
||||
next_value = _get_child(current, part)
|
||||
if next_value is _MISSING:
|
||||
return []
|
||||
return _resolve(next_value, rest, seen, max_nodes)
|
||||
|
||||
|
||||
def _iter_items(current: Any) -> list[Any]:
|
||||
if isinstance(current, dict):
|
||||
return list(current.values())
|
||||
if isinstance(current, (list, tuple, set)):
|
||||
return list(current)
|
||||
return []
|
||||
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def _get_child(current: Any, part: str) -> Any:
|
||||
if isinstance(current, dict):
|
||||
return current.get(part, _MISSING)
|
||||
if isinstance(current, (list, tuple)) and part.isdigit():
|
||||
index = int(part)
|
||||
return current[index] if index < len(current) else _MISSING
|
||||
return getattr(current, part, _MISSING)
|
||||
|
||||
|
||||
def _scalarize(value: Any) -> Any:
|
||||
if hasattr(value, "value") and not isinstance(value, (str, bytes, bytearray)):
|
||||
return value.value
|
||||
return value
|
||||
|
||||
|
||||
def _default_reason(assertion: AssertionDef, values: list[Any], passed: bool) -> str:
|
||||
if passed:
|
||||
return f"Assertion '{assertion.id}' passed."
|
||||
if assertion.op == "exists":
|
||||
return f"Expected at least one non-empty value at {assertion.target}; got {values}."
|
||||
if assertion.op == "count_gte":
|
||||
return f"Expected at least {assertion.value} values at {assertion.target}; got {len(values)}."
|
||||
return (
|
||||
f"Expected {assertion.op} at {assertion.target} with {assertion.value!r}; "
|
||||
f"got {values!r}."
|
||||
)
|
||||
94
task_flow_engine/models.py
Normal file
94
task_flow_engine/models.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssertionDef:
|
||||
id: str
|
||||
target: str
|
||||
op: str
|
||||
value: Any = None
|
||||
description: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "AssertionDef":
|
||||
return cls(
|
||||
id=str(data["id"]),
|
||||
target=str(data.get("target", "")),
|
||||
op=str(data["op"]),
|
||||
value=data.get("value"),
|
||||
description=str(data.get("description", "")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkstationDef:
|
||||
name: str
|
||||
entry_assertions: list[AssertionDef] = field(default_factory=list)
|
||||
exit_assertions: list[AssertionDef] = field(default_factory=list)
|
||||
description: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "WorkstationDef":
|
||||
return cls(
|
||||
name=str(data["name"]),
|
||||
description=str(data.get("description", "")),
|
||||
entry_assertions=[
|
||||
AssertionDef.from_dict(item)
|
||||
for item in data.get("entry_assertions", []) or []
|
||||
],
|
||||
exit_assertions=[
|
||||
AssertionDef.from_dict(item)
|
||||
for item in data.get("exit_assertions", []) or []
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlowDef:
|
||||
id: str
|
||||
entity_type: str
|
||||
workstations: list[WorkstationDef]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "FlowDef":
|
||||
return cls(
|
||||
id=str(data["id"]),
|
||||
entity_type=str(data["entity_type"]),
|
||||
workstations=[
|
||||
WorkstationDef.from_dict(item)
|
||||
for item in data.get("workstations", []) or []
|
||||
],
|
||||
)
|
||||
|
||||
def workstation(self, name: str) -> WorkstationDef | None:
|
||||
return next((item for item in self.workstations if item.name == name), None)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssertionResult:
|
||||
id: str
|
||||
passed: bool
|
||||
target: str
|
||||
op: str
|
||||
expected: Any = None
|
||||
actual: list[Any] = field(default_factory=list)
|
||||
description: str = ""
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UnreachableWorkstation:
|
||||
workstation: str
|
||||
blocking: AssertionResult
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlowResult:
|
||||
current_workstation: str
|
||||
exit_blocked: bool
|
||||
blocking_assertions: list[AssertionResult]
|
||||
reachable: list[str]
|
||||
unreachable: list[UnreachableWorkstation]
|
||||
Reference in New Issue
Block a user