return value cleanup + raise on empty

This commit is contained in:
lda
2026-05-19 02:38:40 +07:00 Verified
parent 2d5762dd26
commit c7e87bc3c4
7 changed files with 177 additions and 73 deletions
+3
View File
@@ -27,3 +27,6 @@ addopts = "-p no:cacheprovider"
[tool.uv] [tool.uv]
package = true package = true
[tool.basedpyright]
typeCheckingMode = "basic"
+9 -2
View File
@@ -1,4 +1,11 @@
from .core import WorkflowBuilder from .core import WorkflowBuilder
from .refs import BranchRef, RouteRef, StepRef from .refs import BranchRef, BranchResult, DecisionResult, HandleResult, StepRef
__all__ = ["BranchRef", "RouteRef", "StepRef", "WorkflowBuilder"] __all__ = [
"BranchRef",
"BranchResult",
"DecisionResult",
"HandleResult",
"StepRef",
"WorkflowBuilder",
]
+92 -56
View File
@@ -22,6 +22,7 @@ from wf_core import (
from wf_core.errors import WorkflowExecutionError from wf_core.errors import WorkflowExecutionError
from wf_core.models.conditions import Condition as CoreCondition from wf_core.models.conditions import Condition as CoreCondition
from wf_core.models.conditions import BinaryCondition, ExistsCondition, PathOperand from wf_core.models.conditions import BinaryCondition, ExistsCondition, PathOperand
from wf_core.models.steps import Step
from wf_core.runtime.ops.merges import ReducerDefinition from wf_core.runtime.ops.merges import ReducerDefinition
from ..dsl import Expr, PathArg, PathExpr, compile_condition from ..dsl import Expr, PathArg, PathExpr, compile_condition
@@ -38,7 +39,14 @@ from .mapping import (
coerce_path, coerce_path,
normalize_mapping, normalize_mapping,
) )
from .refs import BranchRef, RouteRef, StepRef, is_node_spec, step_id from .refs import (
BranchRef,
BranchResult,
DecisionResult,
HandleResult,
StepRef,
step_id,
)
def _condition_base(condition: CoreCondition) -> str: def _condition_base(condition: CoreCondition) -> str:
@@ -61,7 +69,7 @@ class WorkflowBuilder:
start: str | None = None start: str | None = None
reducers: ReducerCatalog | Mapping[str, ReducerDefinition] | None = None reducers: ReducerCatalog | Mapping[str, ReducerDefinition] | None = None
node_specs: dict[str, NodeSpec[Any, Any]] = field(default_factory=dict) node_specs: dict[str, NodeSpec[Any, Any]] = field(default_factory=dict)
nodes: list[Any] = field(default_factory=list) nodes: list[Step] = field(default_factory=list)
edges: list[Edge] = field(default_factory=list) edges: list[Edge] = field(default_factory=list)
def __post_init__(self) -> None: def __post_init__(self) -> None:
@@ -134,7 +142,13 @@ class WorkflowBuilder:
def _next_step_id(self, base: str) -> str: def _next_step_id(self, base: str) -> str:
"""Return a stable unused step id based on the requested base name.""" """Return a stable unused step id based on the requested base name."""
return next_step_id(base, cast(list[StepRef], self.nodes)) return next_step_id(base, self.nodes)
def _resolve_branch_ref(self, ref: BranchRef) -> StepRef:
"""Resolve a possibly callable-backed branch ref into one step ref."""
if isinstance(ref, NodeSpec):
return self.use(ref)
return ref
def set_entry_point(self, step: StepRef) -> None: def set_entry_point(self, step: StepRef) -> None:
"""Set the workflow start node explicitly.""" """Set the workflow start node explicitly."""
@@ -232,46 +246,59 @@ class WorkflowBuilder:
to: BranchRef, to: BranchRef,
) -> tuple[StepRef, StepRef]: ) -> tuple[StepRef, StepRef]:
"""Connect one outcome, auto-using NodeSpec endpoints as fresh node uses.""" """Connect one outcome, auto-using NodeSpec endpoints as fresh node uses."""
source = self.use(from_) if is_node_spec(from_) else from_ source = self._resolve_branch_ref(from_)
target = self.use(to) if is_node_spec(to) else to target = self._resolve_branch_ref(to)
self.edges.append( self.edges.append(
Edge.model_validate( Edge.model_validate(
{ {
"from": step_id(cast(StepRef, source)), "from": step_id(source),
"outcome": outcome, "outcome": outcome,
"to": step_id(cast(StepRef, target)), "to": step_id(target),
} }
) )
) )
return cast(StepRef, source), cast(StepRef, target) return source, target
def branch( def branch(
self, self,
from_: BranchRef, from_: BranchRef,
branches: Mapping[str, BranchRef], branches: Mapping[str, BranchRef],
) -> dict[str, StepRef]: ) -> BranchResult:
"""Connect multiple outcomes from one branch source. """Connect multiple outcomes from one branch source.
Passing a NodeSpec creates a node use with auto-mapping and an auto id. Passing a NodeSpec creates a node use with auto-mapping and an auto id.
Passing an existing step or id only wires edges. Empty branch maps are Passing an existing step or id only wires edges.
allowed but warn because they usually indicate an unfinished router.
The returned mapping is keyed by branch outcome, not generated target id. The returned mapping is keyed by branch outcome, not generated target id.
""" """
if not branches: if not branches:
warnings.warn( raise ValueError("WorkflowBuilder.branch requires at least one branch")
"WorkflowBuilder.branch called with no branches",
UserWarning,
stacklevel=2,
)
return {}
source = self.use(from_) if is_node_spec(from_) else from_ source = self._resolve_branch_ref(from_)
resolved_targets: dict[str, StepRef] = {} resolved_targets: dict[str, StepRef] = {}
for outcome, target in branches.items(): for outcome, target in branches.items():
resolved = self.use(target) if is_node_spec(target) else target resolved = self._resolve_branch_ref(target)
self.connect(cast(StepRef, source), outcome, cast(StepRef, resolved)) self.connect(source, outcome, resolved)
resolved_targets[outcome] = cast(StepRef, resolved) resolved_targets[outcome] = resolved
return resolved_targets return BranchResult(source=source, targets=resolved_targets)
def handle(
self,
*branches: tuple[BranchRef, str],
to: BranchRef,
) -> HandleResult:
"""Connect several source outcomes to one shared target."""
if not branches:
raise ValueError("WorkflowBuilder.handle requires at least one branch")
resolved_branches: list[tuple[StepRef, str]] = []
target = self._resolve_branch_ref(to)
for branch, outcome in branches:
resolved = self._resolve_branch_ref(branch)
self.connect(resolved, outcome, target)
resolved_branches.append((resolved, outcome))
return HandleResult(
target=target,
branches=tuple(resolved_branches),
)
def match( def match(
self, self,
@@ -280,11 +307,20 @@ class WorkflowBuilder:
*, *,
id: str | None = None, id: str | None = None,
default: BranchRef = runtime_error, default: BranchRef = runtime_error,
) -> RouteRef: ) -> DecisionResult:
"""Match one graph value against ordered equality cases.""" """Match one graph value against ordered equality cases.
DecisionResult.targets is keyed by case value, not generated target id.
The default case is always available under the "default" key. Case
values must be hashable and are compared using equality against the
graph value at runtime, so they should be primitives or tuples of
primitives for predictable behavior.
"""
if not cases:
raise ValueError("WorkflowBuilder.match requires at least one case")
resolved_targets: dict[object, StepRef] = {} resolved_targets: dict[object, StepRef] = {}
conditions: list[ConditionNode] = [] conditions: list[ConditionNode] = []
default_target = self.use(default) if is_node_spec(default) else default default_target = self._resolve_branch_ref(default)
previous_condition: ConditionNode | None = None previous_condition: ConditionNode | None = None
condition_base = id or slug_id(value.path) condition_base = id or slug_id(value.path)
for case_value, target in cases.items(): for case_value, target in cases.items():
@@ -293,17 +329,16 @@ class WorkflowBuilder:
check=value.eq(case_value), check=value.eq(case_value),
) )
conditions.append(condition) conditions.append(condition)
resolved = self.use(target) if is_node_spec(target) else target resolved = self._resolve_branch_ref(target)
if previous_condition is not None: if previous_condition is not None:
self.connect(previous_condition, "false", condition) self.connect(previous_condition, "false", condition)
self.connect(condition, "true", cast(StepRef, resolved)) self.connect(condition, "true", resolved)
previous_condition = condition previous_condition = condition
resolved_targets[case_value] = cast(StepRef, resolved) resolved_targets[case_value] = resolved
if previous_condition is None: assert previous_condition is not None # guarded by cases check above
raise ValueError("WorkflowBuilder.match requires at least one case") self.connect(previous_condition, "false", default_target)
self.connect(previous_condition, "false", cast(StepRef, default_target)) resolved_targets["default"] = default_target
resolved_targets["default"] = cast(StepRef, default_target) return DecisionResult(
return RouteRef(
entry=conditions[0], entry=conditions[0],
conditions=tuple(conditions), conditions=tuple(conditions),
targets=resolved_targets, targets=resolved_targets,
@@ -316,21 +351,19 @@ class WorkflowBuilder:
then: BranchRef, then: BranchRef,
otherwise: BranchRef = runtime_error, otherwise: BranchRef = runtime_error,
id: str | None = None, id: str | None = None,
) -> RouteRef: ) -> DecisionResult:
"""Route one boolean condition through true and false targets.""" """Route one boolean condition through true and false targets."""
condition_node = self.condition(id=id, check=condition) condition_node = self.condition(id=id, check=condition)
resolved_then = self.use(then) if is_node_spec(then) else then resolved_then = self._resolve_branch_ref(then)
resolved_otherwise = ( resolved_otherwise = self._resolve_branch_ref(otherwise)
self.use(otherwise) if is_node_spec(otherwise) else otherwise self.connect(condition_node, "true", resolved_then)
) self.connect(condition_node, "false", resolved_otherwise)
self.connect(condition_node, "true", cast(StepRef, resolved_then)) return DecisionResult(
self.connect(condition_node, "false", cast(StepRef, resolved_otherwise))
return RouteRef(
entry=condition_node, entry=condition_node,
conditions=(condition_node,), conditions=(condition_node,),
targets={ targets={
True: cast(StepRef, resolved_then), True: resolved_then,
False: cast(StepRef, resolved_otherwise), False: resolved_otherwise,
}, },
) )
@@ -339,7 +372,7 @@ class WorkflowBuilder:
*clauses: tuple[Expr, BranchRef], *clauses: tuple[Expr, BranchRef],
default: BranchRef = runtime_error, default: BranchRef = runtime_error,
id: str | None = None, id: str | None = None,
) -> RouteRef: ) -> DecisionResult:
"""Route to the first target whose ordered condition is true.""" """Route to the first target whose ordered condition is true."""
if not clauses: if not clauses:
raise ValueError("WorkflowBuilder.choose requires at least one clause") raise ValueError("WorkflowBuilder.choose requires at least one clause")
@@ -354,16 +387,17 @@ class WorkflowBuilder:
check=condition_expr, check=condition_expr,
) )
conditions.append(condition) conditions.append(condition)
resolved = self.use(target) if is_node_spec(target) else target resolved = self._resolve_branch_ref(target)
if previous_condition is not None: if previous_condition is not None:
self.connect(previous_condition, "false", condition) self.connect(previous_condition, "false", condition)
self.connect(condition, "true", cast(StepRef, resolved)) self.connect(condition, "true", resolved)
previous_condition = condition previous_condition = condition
resolved_targets[index] = cast(StepRef, resolved) resolved_targets[index] = resolved
default_target = self.use(default) if is_node_spec(default) else default default_target = self._resolve_branch_ref(default)
self.connect(cast(ConditionNode, previous_condition), "false", cast(StepRef, default_target)) assert previous_condition is not None # guarded by clauses check above
resolved_targets["default"] = cast(StepRef, default_target) self.connect(previous_condition, "false", default_target)
return RouteRef( resolved_targets["default"] = default_target
return DecisionResult(
entry=conditions[0], entry=conditions[0],
conditions=tuple(conditions), conditions=tuple(conditions),
targets=resolved_targets, targets=resolved_targets,
@@ -377,7 +411,7 @@ class WorkflowBuilder:
*, *,
id: str | None = None, id: str | None = None,
default: BranchRef = runtime_error, default: BranchRef = runtime_error,
) -> RouteRef: ) -> DecisionResult:
"""Deprecated compatibility shim for the old overloaded route API.""" """Deprecated compatibility shim for the old overloaded route API."""
warnings.warn( warnings.warn(
"WorkflowBuilder.route is deprecated; use match(...) or when(...)", "WorkflowBuilder.route is deprecated; use match(...) or when(...)",
@@ -385,9 +419,11 @@ class WorkflowBuilder:
stacklevel=2, stacklevel=2,
) )
if isinstance(value, Expr): if isinstance(value, Expr):
invalid_cases = [case for case in cases if not isinstance(case, bool)] invalid_cases = any(not isinstance(case, bool) for case in cases)
if invalid_cases: if invalid_cases:
raise ValueError("condition route cases must be boolean True/False keys") raise ValueError(
"condition route cases must be boolean True/False keys"
)
if not cases: if not cases:
raise ValueError("WorkflowBuilder.route requires at least one case") raise ValueError("WorkflowBuilder.route requires at least one case")
return self.when( return self.when(
@@ -401,8 +437,8 @@ class WorkflowBuilder:
def compile(self) -> Workflow: def compile(self) -> Workflow:
if self.start is None: if self.start is None:
raise WorkflowExecutionError( raise WorkflowExecutionError(
"workflow builder requires an explicit start; call set_entry_point(...) " "workflow builder requires an explicit start; "
"or pass start=..." "call set_entry_point(...) or pass start=..."
) )
node_defs = [spec.to_node_def() for spec in self.node_specs.values()] node_defs = [spec.to_node_def() for spec in self.node_specs.values()]
return Workflow( return Workflow(
+27 -3
View File
@@ -10,19 +10,43 @@ from ..nodes import NodeSpec
StepRef: TypeAlias = ( StepRef: TypeAlias = (
str | NodeUse | ConditionNode | ForeachNode | InterruptNode | JoinNode str | NodeUse | ConditionNode | ForeachNode | InterruptNode | JoinNode
) )
"""A reference to a step, which can be either a string id or a node object
that should be auto-used."""
BranchRef: TypeAlias = StepRef | NodeSpec[Any, Any] BranchRef: TypeAlias = StepRef | NodeSpec[Any, Any]
"""A reference to a branch source or target, which can be either a step ref
or a NodeSpec that should be auto-used."""
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class RouteRef: class BranchResult:
"""Reference bundle returned by control-flow helpers with generated conditions.""" """Resolved branch source plus outcome-indexed targets."""
source: StepRef
targets: dict[str, StepRef]
def __getitem__(self, outcome: str) -> StepRef:
"""Keep branch outcome lookup ergonomic while exposing the source."""
return self.targets[outcome]
@dataclass(frozen=True, slots=True)
class HandleResult:
"""Resolved shared target plus source/outcome pairs that feed it."""
target: StepRef
branches: tuple[tuple[StepRef, str], ...]
@dataclass(frozen=True, slots=True)
class DecisionResult:
"""Generated condition entry plus resolved targets for one decision helper."""
entry: ConditionNode entry: ConditionNode
conditions: tuple[ConditionNode, ...] conditions: tuple[ConditionNode, ...]
targets: dict[object, StepRef] targets: dict[object, StepRef]
def __getitem__(self, key: object) -> StepRef: def __getitem__(self, key: object) -> StepRef:
"""Keep helper result lookup ergonomic while exposing generated conditions.""" """Keep decision result lookup ergonomic while exposing conditions."""
return self.targets[key] return self.targets[key]
+42 -8
View File
@@ -41,9 +41,9 @@ def test_builder_branch_can_use_node_specs_as_targets() -> None:
) )
router = builder.use(branch_router) router = builder.use(branch_router)
targets = builder.branch(router, {"left": auto_bind_node}) result = builder.branch(router, {"left": auto_bind_node})
assert result.source == router
target = targets["left"] target = result["left"]
assert not isinstance(target, str) assert not isinstance(target, str)
assert target.id == "test_auto_bind" assert target.id == "test_auto_bind"
assert builder.edges[0].from_ == "test_branch_router" assert builder.edges[0].from_ == "test_branch_router"
@@ -51,19 +51,53 @@ def test_builder_branch_can_use_node_specs_as_targets() -> None:
assert builder.edges[0].to == "test_auto_bind" assert builder.edges[0].to == "test_auto_bind"
def test_builder_branch_warns_on_empty_branch_map() -> None: def test_builder_branch_rejects_empty_branch_map_without_using_source() -> None:
builder = WorkflowBuilder( builder = WorkflowBuilder(
name="branch_empty_demo", name="branch_empty_demo",
input_schema=AutoBindInput, input_schema=AutoBindInput,
state_schema=AutoBindState, state_schema=AutoBindState,
output_schema=AutoBindOutput, output_schema=AutoBindOutput,
) )
router = builder.use(branch_router)
with pytest.warns(UserWarning, match="no branches"): with pytest.raises(ValueError, match="at least one branch"):
targets = builder.branch(router, {}) builder.branch(branch_router, {})
assert targets == {} assert builder.nodes == []
def test_builder_handle_connects_shared_target_for_duplicate_outcomes() -> None:
builder = WorkflowBuilder(
name="handle_demo",
input_schema=AutoBindInput,
state_schema=AutoBindState,
output_schema=AutoBindOutput,
)
left = builder.use(auto_bind_node, id="left")
right = builder.use(auto_bind_node, id="right")
fallback = builder.use(auto_bind_node, id="fallback")
result = builder.handle((left, "error"), (right, "error"), to=fallback)
assert result is not None
assert result.target is fallback
assert [(edge.from_, edge.outcome, edge.to) for edge in builder.edges] == [
("left", "error", "fallback"),
("right", "error", "fallback"),
]
assert result.branches == ((left, "error"), (right, "error"))
def test_builder_handle_rejects_empty_sources_without_using_target() -> None:
builder = WorkflowBuilder(
name="handle_empty_demo",
input_schema=AutoBindInput,
state_schema=AutoBindState,
output_schema=AutoBindOutput,
)
with pytest.raises(ValueError, match="at least one branch"):
builder.handle(to=auto_bind_node)
assert builder.nodes == []
def test_builder_match_expands_state_value_cases_into_condition_chain() -> None: def test_builder_match_expands_state_value_cases_into_condition_chain() -> None:
+2 -2
View File
@@ -64,7 +64,7 @@ gacha.connect("keep_rolling", "false", END)
gacha.connect("tick", "ok", "counter_up") gacha.connect("tick", "ok", "counter_up")
gacha.connect("counter_up", "ok", "rate_booster") gacha.connect("counter_up", "ok", "rate_booster")
rate_route = gacha.when( # condition + branch in one. its so good. rate_route = gacha.when( # condition + branch in one. its so good.
state("counter.c_80").ge(65), state("counter.c_80").ge(65),
then=rate_up, then=rate_up,
otherwise=rate_same, otherwise=rate_same,
@@ -75,7 +75,7 @@ gacha.use(pre_roll_router, id="router")
gacha.connect("rate_up", "ok", "router") gacha.connect("rate_up", "ok", "router")
gacha.connect("rate_same", "ok", "router") gacha.connect("rate_same", "ok", "router")
preroll_routes = gacha.branch( # `connect`s in one branch. also so good. preroll_routes = gacha.branch( # `connect`s in one branch. also so good.
"router", "router",
{ {
"240": "r_gs", "240": "r_gs",
+2 -2
View File
@@ -221,7 +221,7 @@ def test_workflow_surface_validates_draft_without_saving() -> None:
assert payload["status"] == "valid" assert payload["status"] == "valid"
assert payload["diagnostics"] == [] assert payload["diagnostics"] == []
assert payload["compiled_plan"]["nodes"][0]["type"] == "node" assert payload["compiled_plan"]["nodes"][0]["type"] == "node"
assert artifact_store.list_artifacts() == [] assert not artifact_store.list_artifacts()
def test_workflow_surface_rejects_unknown_draft_route_outcome_when_spec_is_known() -> ( def test_workflow_surface_rejects_unknown_draft_route_outcome_when_spec_is_known() -> (
@@ -340,7 +340,7 @@ def test_workflow_surface_patches_draft_without_saving() -> None:
assert payload["status"] == "valid" assert payload["status"] == "valid"
assert payload["draft"]["steps"]["echo"]["in"]["input.text"] == "message" assert payload["draft"]["steps"]["echo"]["in"]["input.text"] == "message"
assert artifact_store.list_artifacts() == [] assert not artifact_store.list_artifacts()
def test_raw_workflow_plan_uses_core_step_and_edge_models() -> None: def test_raw_workflow_plan_uses_core_step_and_edge_models() -> None: