feat: expose foreach context paths

This commit is contained in:
lda
2026-09-05 00:16:25 +07:00 Verified
parent 0c0dcbce49
commit f6e5190696
4 changed files with 129 additions and 0 deletions
+18
View File
@@ -274,6 +274,24 @@ class ForeachNode(BaseModel):
raise ValueError("concurrent policy is only valid when mode='concurrent'") raise ValueError("concurrent policy is only valid when mode='concurrent'")
return self return self
@property
def item(self) -> GraphSourcePath:
"""Return the structured item path for this foreach controller.
The constructor uses literal tuple segments so a foreach id
containing dots stays one TOML-quoted segment instead of being
reparsed as nested path separators.
"""
return GraphSourcePath("context", ("foreach", self.id, "item"))
@property
def index(self) -> GraphSourcePath:
"""Return the structured index path for this foreach controller.
Like :attr:`item`, the foreach id is one literal segment.
"""
return GraphSourcePath("context", ("foreach", self.id, "index"))
class JoinNode(BaseModel): class JoinNode(BaseModel):
"""Control-flow step that marks a branch or frame as joined.""" """Control-flow step that marks a branch or frame as joined."""
+69
View File
@@ -773,3 +773,72 @@ class _StructuralKeyMap:
def test_input_map_rejects_structural_dict_keys_with_clear_message() -> None: def test_input_map_rejects_structural_dict_keys_with_clear_message() -> None:
with pytest.raises(TypeError, match="structural path dicts cannot be map keys"): with pytest.raises(TypeError, match="structural path dicts cannot be map keys"):
normalize_input_mapping(cast(Mapping[object, object], _StructuralKeyMap())) normalize_input_mapping(cast(Mapping[object, object], _StructuralKeyMap()))
def test_foreach_reference_exposes_item_and_index_paths() -> None:
from wf_core.paths import GraphSourcePath
builder = WorkflowBuilder(
name="foreach_ref",
input_schema={"type": "object"},
state_schema={"type": "object"},
output_schema={"type": "object"},
)
each = builder.foreach(id="orders", over=state_path("orders"), as_="order")
assert each.item == GraphSourcePath("context", ("foreach", "orders", "item"))
assert each.index == GraphSourcePath("context", ("foreach", "orders", "index"))
assert str(each.item) == "context.foreach.orders.item"
assert str(each.index) == "context.foreach.orders.index"
def test_foreach_reference_treats_dotted_id_as_one_literal_segment() -> None:
from wf_core.paths import GraphSourcePath
builder = WorkflowBuilder(
name="foreach_dotted",
input_schema={"type": "object"},
state_schema={"type": "object"},
output_schema={"type": "object"},
)
each = builder.foreach(
id="orders.v2", over=state_path("orders"), as_="order"
)
assert each.item == GraphSourcePath(
"context", ("foreach", "orders.v2", "item")
)
assert str(each.item) == 'context.foreach."orders.v2".item'
assert str(each.index) == 'context.foreach."orders.v2".index'
def test_foreach_computed_paths_are_not_serialized_fields() -> None:
builder = WorkflowBuilder(
name="foreach_serialized",
input_schema={"type": "object"},
state_schema={"type": "object"},
output_schema={"type": "object"},
)
each = builder.foreach(id="orders", over=state_path("orders"), as_="order")
dumped = each.model_dump(mode="json")
assert "item" not in dumped
assert "index" not in dumped
def test_foreach_ref_works_in_node_input_binding() -> None:
builder = WorkflowBuilder(
name="foreach_node_binding",
input_schema={"type": "object"},
state_schema={"type": "object"},
output_schema={"type": "object"},
)
each = builder.foreach(id="orders", over=state_path("orders"), as_="order")
work = builder.use(
auto_bind_node,
input=[input_from(each.item, "order")],
)
binding = work.input[0]
assert isinstance(binding, object)
assert str(binding.path) == "context.foreach.orders.item"
+19
View File
@@ -273,3 +273,22 @@ def test_workflow_builder_subgraph_adds_native_subgraph_node() -> None:
assert workflow.nodes[0].id == "run_child" assert workflow.nodes[0].id == "run_child"
assert workflow.nodes[0].outcomes == child.outcomes assert workflow.nodes[0].outcomes == child.outcomes
assert workflow.validate_structure().errors == [] assert workflow.validate_structure().errors == []
def test_foreach_ref_works_in_subgraph_input_binding() -> None:
from wf_authoring import state_path
child = build_demo_workflow()
parent = WorkflowBuilder(
name="foreach_subgraph_parent",
input_schema={"type": "object"},
state_schema={"type": "object"},
output_schema={"type": "object"},
)
each = parent.foreach(id="orders", over=state_path("orders"), as_="order")
step = parent.subgraph(
workflow=child,
id="run_child",
input=[input_from(each.item, "order")],
)
assert str(step.input[0].path) == "context.foreach.orders.item"
@@ -448,3 +448,26 @@ def test_foreach_node_serializes_over_path_as_canonical_string():
assert node.over == GraphSourcePath.state("items") assert node.over == GraphSourcePath.state("items")
assert node.model_dump(mode="json")["over"] == "state.items" assert node.model_dump(mode="json")["over"] == "state.items"
def test_foreach_ref_item_index_are_literal_structured_paths() -> None:
node = ForeachNode.model_validate(
{
"id": "orders.v2",
"type": "foreach",
"over": "state.orders",
"as": "order",
}
)
assert node.item == GraphSourcePath("context", ("foreach", "orders.v2", "item"))
assert node.index == GraphSourcePath("context", ("foreach", "orders.v2", "index"))
assert str(node.item) == 'context.foreach."orders.v2".item'
assert "item" not in node.model_dump(mode="json")
assert "index" not in node.model_dump(mode="json")
# GraphSourcePath still rejects an output root.
try:
GraphSourcePath.parse("output.result")
except Exception:
pass
else:
raise AssertionError("expected output root to be rejected")