condition builder

This commit is contained in:
lda
2026-04-28 18:24:09 +07:00 Verified
parent df4a808a73
commit 3d428c71db
5 changed files with 210 additions and 36 deletions
+41 -22
View File
@@ -16,7 +16,11 @@ from wf_core.run_factory import create_run_state
from wf_authoring import ( from wf_authoring import (
NodeReturn, NodeReturn,
WorkflowBuilder, WorkflowBuilder,
bind_fields,
bind_state,
build_registry, build_registry,
exists,
state,
context_path, context_path,
input_path, input_path,
node, node,
@@ -170,8 +174,8 @@ def build_authoring_demo_workflow():
list_files = builder.use( list_files = builder.use(
drive_list_files_spec, drive_list_files_spec,
id="list_files", id="list_files",
in_map={input_path("folder_id"): "folder_id"}, in_map=bind_fields(folder_id=input_path("folder_id")),
out_map={"documents": state_path("documents")}, out_map=bind_state(documents=state_path("documents")),
desc="List files from a Google Drive folder", desc="List files from a Google Drive folder",
) )
summarize_each = builder.foreach( summarize_each = builder.foreach(
@@ -184,49 +188,45 @@ def build_authoring_demo_workflow():
summarize_one = builder.use( summarize_one = builder.use(
summarize_document_spec, summarize_document_spec,
id="summarize_one", id="summarize_one",
in_map={context_path("document"): "document"}, in_map=bind_fields(document=context_path("document")),
out_map={"item_summary": state_path("item_summaries")}, out_map=bind_state(item_summary=state_path("item_summaries")),
desc="Summarize one document", desc="Summarize one document",
) )
combine_summaries = builder.use( combine_summaries = builder.use(
combine_summaries_spec, combine_summaries_spec,
id="combine_summaries", id="combine_summaries",
in_map={state_path("item_summaries"): "item_summaries"}, in_map=bind_fields(item_summaries=state_path("item_summaries")),
out_map={"summary": state_path("summary")}, out_map=bind_state(summary=state_path("summary")),
desc="Combine item summaries into one final summary", desc="Combine item summaries into one final summary",
) )
should_email = builder.condition( should_email = builder.condition(
id="should_email", id="should_email",
check={ check=state("should_email").eq(True),
"op": "eq",
"left": {"path": "state.should_email"},
"right": {"value": True},
},
) )
send_email = builder.use( send_email = builder.use(
send_email_spec, send_email_spec,
id="send_email", id="send_email",
in_map={state_path("summary"): "summary"}, in_map=bind_fields(summary=state_path("summary")),
out_map={"email_status": state_path("email_status")}, out_map=bind_state(email_status=state_path("email_status")),
desc="Send the summary by email", desc="Send the summary by email",
) )
approve_email = builder.interrupt( approve_email = builder.interrupt(
id="approve_email", id="approve_email",
kind="approval", kind="approval",
request_map={ request_map=bind_fields(
state_path("summary"): "summary", summary=state_path("summary"),
input_path("folder_id"): "folder_id", folder_id=input_path("folder_id"),
}, ),
out_map={ out_map=bind_state(
"approved": state_path("approved"), approved=state_path("approved"),
"comment": state_path("approval_comment"), comment=state_path("approval_comment"),
}, ),
outcomes=["submitted", "cancelled"], outcomes=["submitted", "cancelled"],
) )
skip_email = builder.use( skip_email = builder.use(
mark_email_skipped_spec, mark_email_skipped_spec,
id="skip_email", id="skip_email",
out_map={"email_status": state_path("email_status")}, out_map=bind_state(email_status=state_path("email_status")),
desc="Record that email delivery was skipped", desc="Record that email delivery was skipped",
) )
@@ -484,3 +484,22 @@ def test_node_decorator_infers_nodereturn_output_model() -> None:
def test_node_decorator_detects_async_automatically() -> None: def test_node_decorator_detects_async_automatically() -> None:
assert inferred_async_echo.is_async is True assert inferred_async_echo.is_async is True
def test_condition_dsl_compiles_to_core_condition() -> None:
condition = state("should_email").eq(True) & exists(state("summary"))
assert condition.to_condition().model_dump() == {
"op": "and",
"args": [
{
"op": "eq",
"left": {"path": "state.should_email"},
"right": {"value": True},
},
{
"op": "exists",
"path": "state.summary",
},
],
}
+9
View File
@@ -1,5 +1,7 @@
from .builder import WorkflowBuilder from .builder import WorkflowBuilder
from .catalog import NodeCatalog, NodeCatalogEntry from .catalog import NodeCatalog, NodeCatalogEntry
from .conditions import context, exists, input, state
from .mapping import bind_fields, bind_state, merge_maps
from .paths import GraphPath, context_path, graph_path, input_path, state_path from .paths import GraphPath, context_path, graph_path, input_path, state_path
from .spec import NodeReturn, NodeSpec, build_registry, node from .spec import NodeReturn, NodeSpec, build_registry, node
@@ -10,10 +12,17 @@ __all__ = [
"NodeReturn", "NodeReturn",
"NodeSpec", "NodeSpec",
"WorkflowBuilder", "WorkflowBuilder",
"bind_fields",
"build_registry", "build_registry",
"bind_state",
"merge_maps",
"context",
"context_path", "context_path",
"exists",
"graph_path", "graph_path",
"input",
"input_path", "input_path",
"node", "node",
"state",
"state_path", "state_path",
] ]
+24 -14
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Literal, TypeAlias from typing import Any, Literal, TypeAlias
@@ -13,27 +14,32 @@ from wf_core import (
StateSchema, StateSchema,
Workflow, Workflow,
) )
from wf_core.model import Condition as CoreCondition
from .conditions import Expr, compile_condition
from .mapping import PathArg
from .paths import GraphPath from .paths import GraphPath
from .spec import NodeSpec from .spec import NodeSpec
PathArg: TypeAlias = str | GraphPath
StepRef: TypeAlias = str | NodeUse | ConditionNode | ForeachNode | InterruptNode StepRef: TypeAlias = str | NodeUse | ConditionNode | ForeachNode | InterruptNode
MapArg: TypeAlias = Mapping[Any, Any]
def _normalize_path(path: PathArg) -> str: def _coerce_path(value: object) -> str:
if isinstance(path, GraphPath): if isinstance(value, str):
return path.value return value
return path if isinstance(value, GraphPath):
return value.value
raise TypeError(f"unsupported graph path value {value!r}")
def _normalize_mapping( def _normalize_mapping(
mapping: dict[PathArg, PathArg] | None, mapping: MapArg | None,
) -> dict[str, str]: ) -> dict[str, str]:
if mapping is None: if mapping is None:
return {} return {}
return { return {
_normalize_path(source): _normalize_path(destination) _coerce_path(source): _coerce_path(destination)
for source, destination in mapping.items() for source, destination in mapping.items()
} }
@@ -60,8 +66,8 @@ class WorkflowBuilder:
spec: NodeSpec[Any, Any], spec: NodeSpec[Any, Any],
*, *,
id: str, id: str,
in_map: dict[PathArg, PathArg] | None = None, in_map: MapArg | None = None,
out_map: dict[PathArg, PathArg] | None = None, out_map: MapArg | None = None,
desc: str | None = None, desc: str | None = None,
) -> NodeUse: ) -> NodeUse:
self.node_specs[spec.name] = spec self.node_specs[spec.name] = spec
@@ -76,8 +82,12 @@ class WorkflowBuilder:
self.nodes.append(node) self.nodes.append(node)
return node return node
def condition(self, *, id: str, check: Any) -> ConditionNode: def condition(self, *, id: str, check: CoreCondition | Expr) -> ConditionNode:
node = ConditionNode(id=id, type="condition", check=check) node = ConditionNode(
id=id,
type="condition",
check=compile_condition(check),
)
self.nodes.append(node) self.nodes.append(node)
return node return node
@@ -94,7 +104,7 @@ class WorkflowBuilder:
{ {
"id": id, "id": id,
"type": "foreach", "type": "foreach",
"over": _normalize_path(over), "over": _coerce_path(over),
"as": as_, "as": as_,
"mode": mode, "mode": mode,
"on_item_error": on_item_error, "on_item_error": on_item_error,
@@ -108,8 +118,8 @@ class WorkflowBuilder:
*, *,
id: str, id: str,
kind: str, kind: str,
request_map: dict[PathArg, PathArg] | None = None, request_map: MapArg | None = None,
out_map: dict[PathArg, PathArg] | None = None, out_map: MapArg | None = None,
outcomes: list[str] | None = None, outcomes: list[str] | None = None,
) -> InterruptNode: ) -> InterruptNode:
node = InterruptNode( node = InterruptNode(
+101
View File
@@ -0,0 +1,101 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from wf_core.model import (
BinaryCondition,
Condition,
ExistsCondition,
LiteralOperand,
NotCondition,
PathOperand,
VariadicCondition,
)
def _operand(value: object) -> PathOperand | LiteralOperand:
if isinstance(value, PathExpr):
return PathOperand(path=value.path)
return LiteralOperand(value=value)
@dataclass(frozen=True, slots=True)
class Expr:
condition: Condition
def __and__(self, other: object) -> Expr:
if not isinstance(other, Expr):
return NotImplemented
return Expr(VariadicCondition(op="and", args=[self.condition, other.condition]))
def __or__(self, other: object) -> Expr:
if not isinstance(other, Expr):
return NotImplemented
return Expr(VariadicCondition(op="or", args=[self.condition, other.condition]))
def __invert__(self) -> Expr:
return Expr(NotCondition(op="not", arg=self.condition))
def to_condition(self) -> Condition:
return self.condition
@dataclass(frozen=True, slots=True)
class PathExpr:
path: str
def _binary(self, op: Literal["eq", "ne", "gt", "lt"], other: object) -> Expr:
return Expr(
BinaryCondition(
op=op,
left=PathOperand(path=self.path),
right=_operand(other),
)
)
def eq(self, other: object) -> Expr:
return self._binary("eq", other)
def ne(self, other: object) -> Expr:
return self._binary("ne", other)
def gt(self, other: object) -> Expr:
return self._binary("gt", other)
def lt(self, other: object) -> Expr:
return self._binary("lt", other)
def __eq__(self, other: object) -> Expr: # ty: ignore[invalid-method-override]
return self._binary("eq", other)
def __ne__(self, other: object) -> Expr: # ty: ignore[invalid-method-override]
return self._binary("ne", other)
def __gt__(self, other: object) -> Expr:
return self.gt(other)
def __lt__(self, other: object) -> Expr:
return self.lt(other)
def state(field: str) -> PathExpr:
return PathExpr(path=f"state.{field}")
def input(field: str) -> PathExpr:
return PathExpr(path=f"input.{field}")
def context(field: str) -> PathExpr:
return PathExpr(path=f"context.{field}")
def exists(value: PathExpr) -> Expr:
return Expr(ExistsCondition(op="exists", path=value.path))
def compile_condition(value: Condition | Expr) -> Condition:
if isinstance(value, Expr):
return value.to_condition()
return value
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import TypeAlias
from .paths import GraphPath
PathArg: TypeAlias = str | GraphPath
def normalize_path(path: PathArg) -> str:
if isinstance(path, GraphPath):
return path.value
return path
def bind_fields(**mapping: PathArg) -> dict[str, str]:
return {
normalize_path(source): destination
for destination, source in mapping.items()
}
def bind_state(**mapping: PathArg) -> dict[str, str]:
return {
destination: normalize_path(target)
for destination, target in mapping.items()
}
def merge_maps(*maps: Mapping[str, str]) -> dict[str, str]:
merged: dict[str, str] = {}
for mapping in maps:
merged.update(mapping)
return merged