typed path, path handling,
half the plan is done
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from wf_core import (
|
||||
END,
|
||||
Edge,
|
||||
NodeDef,
|
||||
NodeUse,
|
||||
ReducerRef,
|
||||
SchemaRef,
|
||||
StateField,
|
||||
StateSchema,
|
||||
Workflow,
|
||||
WorkflowExecutionError,
|
||||
)
|
||||
from wf_core.models.steps import OutputBinding
|
||||
from wf_core.runtime.engine import resume_workflow
|
||||
from wf_core.runtime.ops.runs import create_run_state
|
||||
from wf_core.runtime.ops.state import apply_output_bindings
|
||||
|
||||
|
||||
def test_output_bindings_commit_patch_atomically_when_source_is_missing() -> None:
|
||||
workflow = _workflow()
|
||||
state = {"person": {"name": "old"}}
|
||||
|
||||
with pytest.raises(WorkflowExecutionError, match="missing"):
|
||||
apply_output_bindings(
|
||||
workflow,
|
||||
[
|
||||
_binding("person.name", "state.person.name"),
|
||||
_binding("missing", "state.person.extra"),
|
||||
],
|
||||
{"person": {"name": "new"}},
|
||||
state,
|
||||
)
|
||||
|
||||
assert state["person"]["name"] == "old"
|
||||
assert "extra" not in state["person"]
|
||||
|
||||
|
||||
def test_output_bindings_reject_overlapping_write_targets_before_mutation() -> None:
|
||||
workflow = _workflow()
|
||||
state = {"person": {"name": "old"}}
|
||||
|
||||
with pytest.raises(WorkflowExecutionError, match="overlapping"):
|
||||
apply_output_bindings(
|
||||
workflow,
|
||||
[
|
||||
_binding("person", "state.person"),
|
||||
_binding("person.name", "state.person.name"),
|
||||
],
|
||||
{"person": {"name": "Ada"}},
|
||||
state,
|
||||
)
|
||||
|
||||
assert state["person"]["name"] == "old"
|
||||
|
||||
|
||||
def test_output_bindings_prepare_reducer_results_before_mutation() -> None:
|
||||
workflow = _workflow(
|
||||
fields={
|
||||
"person.name": StateField(type="string"),
|
||||
"person.tags": StateField(
|
||||
type="array",
|
||||
reducer=ReducerRef(name="wf.std.set_union", config={"bad": True}),
|
||||
),
|
||||
}
|
||||
)
|
||||
state = {"person": {"name": "old", "tags": ["seed"]}}
|
||||
|
||||
with pytest.raises(WorkflowExecutionError, match="reducer config"):
|
||||
apply_output_bindings(
|
||||
workflow,
|
||||
[
|
||||
_binding("person.name", "state.person.name"),
|
||||
_binding("person.tags", "state.person.tags"),
|
||||
],
|
||||
{"person": {"name": "new", "tags": ["next"]}},
|
||||
state,
|
||||
)
|
||||
|
||||
assert state["person"]["name"] == "old"
|
||||
assert state["person"]["tags"][0] == "seed"
|
||||
assert len(state["person"]["tags"]) == 1
|
||||
|
||||
|
||||
def test_output_bindings_commit_to_staged_state_before_mutating_original() -> None:
|
||||
workflow = _workflow(
|
||||
fields={
|
||||
"person.name": StateField(type="string"),
|
||||
"blocked.child": StateField(type="string"),
|
||||
}
|
||||
)
|
||||
state = {"person": {"name": "old"}, "blocked": "not-an-object"}
|
||||
|
||||
with pytest.raises(WorkflowExecutionError, match="cannot descend"):
|
||||
apply_output_bindings(
|
||||
workflow,
|
||||
[
|
||||
_binding("person.name", "state.person.name"),
|
||||
_binding("blocked.child", "state.blocked.child"),
|
||||
],
|
||||
{"person": {"name": "new"}, "blocked": {"child": "value"}},
|
||||
state,
|
||||
)
|
||||
|
||||
assert state["person"]["name"] == "old"
|
||||
assert state["blocked"] == "not-an-object"
|
||||
|
||||
|
||||
def test_full_workflow_execution_writes_canonical_output_bindings() -> None:
|
||||
workflow = _workflow_with_node()
|
||||
run = create_run_state(workflow, {})
|
||||
|
||||
run = resume_workflow(
|
||||
workflow,
|
||||
run,
|
||||
{
|
||||
"rename": lambda _payload, _ctx: {
|
||||
"outcome": "ok",
|
||||
"output": {"person": {"name": "Ada"}},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert run.state["person"]["name"] == "Ada"
|
||||
assert run.trace[0].state_changes["state.person.name"] == "Ada"
|
||||
|
||||
|
||||
def _binding(source: str, target: str) -> OutputBinding:
|
||||
return OutputBinding.model_validate({"source": source, "target": target})
|
||||
|
||||
|
||||
def _workflow(
|
||||
fields: dict[str, StateField] | None = None,
|
||||
) -> Workflow:
|
||||
return Workflow(
|
||||
name="patch",
|
||||
input_schema=SchemaRef(type="object", properties={}),
|
||||
state_schema=StateSchema(
|
||||
fields=fields
|
||||
or {
|
||||
"person": StateField(type="object"),
|
||||
"person.name": StateField(type="string"),
|
||||
"person.extra": StateField(type="string"),
|
||||
}
|
||||
),
|
||||
output_schema=SchemaRef(type="object", properties={}),
|
||||
start="n",
|
||||
nodes=[],
|
||||
edges=[],
|
||||
)
|
||||
|
||||
|
||||
def _workflow_with_node() -> Workflow:
|
||||
return Workflow(
|
||||
name="canonical_output",
|
||||
input_schema=SchemaRef(type="object", properties={}),
|
||||
state_schema=StateSchema(fields={"person.name": StateField(type="string")}),
|
||||
output_schema=SchemaRef(type="object", properties={"person": {"type": "object"}}),
|
||||
node_defs=[
|
||||
NodeDef(
|
||||
name="rename",
|
||||
input_schema=SchemaRef(type="object", properties={}),
|
||||
output_schema=SchemaRef(
|
||||
type="object",
|
||||
properties={"person": {"type": "object"}},
|
||||
),
|
||||
outcomes=["ok"],
|
||||
)
|
||||
],
|
||||
start="rename",
|
||||
nodes=[
|
||||
NodeUse.model_validate(
|
||||
{
|
||||
"id": "rename",
|
||||
"type": "node",
|
||||
"node": "rename",
|
||||
"output": [{"source": "person.name", "target": "state.person.name"}],
|
||||
}
|
||||
)
|
||||
],
|
||||
edges=[Edge.model_validate({"from": "rename", "outcome": "ok", "to": END})],
|
||||
)
|
||||
@@ -0,0 +1,166 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from wf_core.models.steps import InputPathBinding, InputValueBinding, NodeUse
|
||||
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
|
||||
|
||||
|
||||
def test_node_use_accepts_canonical_input_and_output_bindings():
|
||||
node = NodeUse.model_validate(
|
||||
{
|
||||
"id": "echo",
|
||||
"type": "node",
|
||||
"node": "echo",
|
||||
"input": [
|
||||
{"target": "message", "path": "input.message"},
|
||||
{"target": "mode", "value": None},
|
||||
],
|
||||
"output": [{"source": "echoed", "target": "state.echoed"}],
|
||||
}
|
||||
)
|
||||
|
||||
path_binding = node.input[0]
|
||||
assert isinstance(path_binding, InputPathBinding)
|
||||
assert path_binding.target == LocalPath.of("message")
|
||||
assert path_binding.path == GraphSourcePath.input("message")
|
||||
|
||||
value_binding = node.input[1]
|
||||
assert isinstance(value_binding, InputValueBinding)
|
||||
assert value_binding.target == LocalPath.of("mode")
|
||||
assert value_binding.value is None
|
||||
|
||||
assert node.output[0].source == LocalPath.of("echoed")
|
||||
assert node.output[0].target == StatePath.of("echoed")
|
||||
|
||||
|
||||
def test_node_use_converts_old_maps_to_canonical_bindings():
|
||||
node = NodeUse.model_validate(
|
||||
{
|
||||
"id": "echo",
|
||||
"type": "node",
|
||||
"node": "echo",
|
||||
"in_map": {"input.message": "message"},
|
||||
"input_values": {"mode": "fast"},
|
||||
"out_map": {"echoed": "state.echoed"},
|
||||
}
|
||||
)
|
||||
|
||||
dumped = node.model_dump(mode="json")
|
||||
assert "in_map" not in dumped
|
||||
assert "input_values" not in dumped
|
||||
assert "out_map" not in dumped
|
||||
assert dumped["input"][0]["value"] == "fast"
|
||||
assert dumped["input"][0]["target"] == "mode"
|
||||
assert dumped["input"][1]["path"] == "input.message"
|
||||
assert dumped["input"][1]["target"] == "message"
|
||||
assert dumped["output"][0]["source"] == "echoed"
|
||||
assert dumped["output"][0]["target"] == "state.echoed"
|
||||
|
||||
|
||||
def test_node_use_rejects_mixed_old_and_new_binding_styles():
|
||||
with pytest.raises(ValidationError):
|
||||
NodeUse.model_validate(
|
||||
{
|
||||
"id": "echo",
|
||||
"type": "node",
|
||||
"node": "echo",
|
||||
"input": [{"target": "message", "path": "input.message"}],
|
||||
"in_map": {"input.other": "other"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_input_binding_rejects_path_and_value_together():
|
||||
with pytest.raises(ValidationError):
|
||||
NodeUse.model_validate(
|
||||
{
|
||||
"id": "bad",
|
||||
"type": "node",
|
||||
"node": "bad",
|
||||
"input": [
|
||||
{"target": "message", "path": "input.message", "value": "x"}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_input_binding_rejects_neither_path_nor_value():
|
||||
with pytest.raises(ValidationError):
|
||||
NodeUse.model_validate(
|
||||
{
|
||||
"id": "bad",
|
||||
"type": "node",
|
||||
"node": "bad",
|
||||
"input": [{"target": "message"}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,binding",
|
||||
[
|
||||
("input", {"target": "message", "path": "input.message", "extra": True}),
|
||||
("output", {"source": "echoed", "target": "state.echoed", "extra": True}),
|
||||
],
|
||||
)
|
||||
def test_bindings_reject_extra_fields(field: str, binding: dict[str, object]):
|
||||
with pytest.raises(ValidationError):
|
||||
NodeUse.model_validate(
|
||||
{"id": "bad", "type": "node", "node": "bad", field: [binding]}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[
|
||||
("in_map", None),
|
||||
("input_values", []),
|
||||
("out_map", "bad"),
|
||||
],
|
||||
)
|
||||
def test_deprecated_maps_reject_non_mapping_values(field: str, value: object):
|
||||
with pytest.raises(ValidationError):
|
||||
NodeUse.model_validate(
|
||||
{"id": "bad", "type": "node", "node": "bad", field: value}
|
||||
)
|
||||
|
||||
|
||||
def test_deprecated_conversion_preserves_input_value_then_in_map_order():
|
||||
node = NodeUse.model_validate(
|
||||
{
|
||||
"id": "ordered",
|
||||
"type": "node",
|
||||
"node": "ordered",
|
||||
"input_values": {"first": 1, "second": 2},
|
||||
"in_map": {"input.third": "third", "state.fourth": "fourth"},
|
||||
}
|
||||
)
|
||||
|
||||
dumped_input = node.model_dump(mode="json")["input"]
|
||||
assert dumped_input[0]["target"] == "first"
|
||||
assert dumped_input[0]["value"] == 1
|
||||
assert dumped_input[1]["target"] == "second"
|
||||
assert dumped_input[1]["value"] == 2
|
||||
assert dumped_input[2]["target"] == "third"
|
||||
assert dumped_input[2]["path"] == "input.third"
|
||||
assert dumped_input[3]["target"] == "fourth"
|
||||
assert dumped_input[3]["path"] == "state.fourth"
|
||||
|
||||
|
||||
def test_deprecated_input_value_preserves_explicit_null():
|
||||
node = NodeUse.model_validate(
|
||||
{
|
||||
"id": "null",
|
||||
"type": "node",
|
||||
"node": "null",
|
||||
"input_values": {"maybe": None},
|
||||
}
|
||||
)
|
||||
|
||||
value_binding = node.input[0]
|
||||
assert isinstance(value_binding, InputValueBinding)
|
||||
assert value_binding.value is None
|
||||
|
||||
dumped_input = node.model_dump(mode="json")["input"]
|
||||
assert dumped_input[0]["target"] == "maybe"
|
||||
assert dumped_input[0]["value"] is None
|
||||
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
from wf_core import Edge, NodeDef, NodeUse, SchemaRef, StateField, StateSchema, Workflow
|
||||
from wf_core.validation.issues import ValidationIssueCode
|
||||
|
||||
|
||||
def test_validation_allows_nested_node_local_paths() -> None:
|
||||
@@ -22,7 +25,10 @@ def test_validation_rejects_overlapping_node_input_destinations() -> None:
|
||||
).validate_structure()
|
||||
|
||||
assert any(
|
||||
"overlapping node-local input paths" in issue.message for issue in report.errors
|
||||
issue.code == ValidationIssueCode.INVALID_NODE_INPUT_FIELD
|
||||
and issue.path == "nodes[0].input"
|
||||
and "overlapping node-local input paths" in issue.message
|
||||
for issue in report.errors
|
||||
)
|
||||
|
||||
|
||||
@@ -36,18 +42,146 @@ def test_validation_rejects_overlapping_state_write_destinations() -> None:
|
||||
).validate_structure()
|
||||
|
||||
assert any(
|
||||
"overlapping state destination paths" in issue.message
|
||||
issue.code == ValidationIssueCode.INVALID_DESTINATION_PATH
|
||||
and issue.path == "nodes[0].output"
|
||||
and "overlapping state destination paths" in issue.message
|
||||
for issue in report.errors
|
||||
)
|
||||
|
||||
|
||||
def _workflow(*, in_map: dict[str, str], out_map: dict[str, str]) -> Workflow:
|
||||
def test_validation_rejects_invalid_canonical_input_source_path() -> None:
|
||||
report = _workflow(
|
||||
input=[{"target": "user.name", "path": "state.unknown.name"}],
|
||||
output=[],
|
||||
).validate_structure()
|
||||
|
||||
assert any(
|
||||
issue.code == ValidationIssueCode.INVALID_SOURCE_PATH
|
||||
and issue.path == "nodes[0].input[0].path"
|
||||
for issue in report.errors
|
||||
)
|
||||
|
||||
|
||||
def test_validation_allows_canonical_input_source_under_declared_state_field_root() -> None:
|
||||
report = _workflow(
|
||||
input=[{"target": "user.name", "path": "state.person.name"}],
|
||||
output=[],
|
||||
state_fields={"person.name": StateField(type="string")},
|
||||
).validate_structure()
|
||||
|
||||
assert not any(
|
||||
issue.code == ValidationIssueCode.INVALID_SOURCE_PATH for issue in report.errors
|
||||
)
|
||||
|
||||
|
||||
def test_validation_rejects_invalid_canonical_output_destination() -> None:
|
||||
workflow = _workflow(
|
||||
input=[],
|
||||
output=[{"source": "user.name", "target": "state.person.name"}],
|
||||
)
|
||||
# StatePath parsing rejects bad roots before workflow validation; mutate here so
|
||||
# validate_node_use still guards malformed canonical destinations.
|
||||
cast(Any, workflow.nodes[0]).output[0].target = "output.person.name"
|
||||
report = workflow.validate_structure()
|
||||
|
||||
assert any(
|
||||
issue.code == ValidationIssueCode.INVALID_DESTINATION_PATH
|
||||
and issue.path == "nodes[0].output[0].target"
|
||||
for issue in report.errors
|
||||
)
|
||||
|
||||
|
||||
def test_validation_rejects_undeclared_canonical_output_destination_root() -> None:
|
||||
report = _workflow(
|
||||
input=[],
|
||||
output=[{"source": "user.name", "target": "state.unknown.foo"}],
|
||||
).validate_structure()
|
||||
|
||||
assert any(
|
||||
issue.code == ValidationIssueCode.INVALID_DESTINATION_PATH
|
||||
and issue.path == "nodes[0].output[0].target"
|
||||
for issue in report.errors
|
||||
)
|
||||
|
||||
|
||||
def test_validation_rejects_overlapping_canonical_input_targets() -> None:
|
||||
report = _workflow(
|
||||
input=[
|
||||
{"target": "user", "value": {"name": "Ada"}},
|
||||
{"target": "user.name", "path": "input.person.name"},
|
||||
],
|
||||
output=[],
|
||||
).validate_structure()
|
||||
|
||||
assert any(
|
||||
issue.code == ValidationIssueCode.INVALID_NODE_INPUT_FIELD
|
||||
and issue.path == "nodes[0].input"
|
||||
for issue in report.errors
|
||||
)
|
||||
|
||||
|
||||
def test_validation_rejects_overlapping_canonical_output_targets() -> None:
|
||||
report = _workflow(
|
||||
input=[],
|
||||
output=[
|
||||
{"source": "user", "target": "state.person"},
|
||||
{"source": "user.name", "target": "state.person.name"},
|
||||
],
|
||||
).validate_structure()
|
||||
|
||||
assert any(
|
||||
issue.code == ValidationIssueCode.INVALID_DESTINATION_PATH
|
||||
and issue.path == "nodes[0].output"
|
||||
for issue in report.errors
|
||||
)
|
||||
|
||||
|
||||
def test_validation_allows_valid_canonical_mapping() -> None:
|
||||
report = _workflow(
|
||||
input=[
|
||||
{"target": "user.name", "path": "input.person.name"},
|
||||
{"target": "user.nickname", "value": "Ada"},
|
||||
],
|
||||
output=[{"source": "user.age", "target": "state.person.age"}],
|
||||
).validate_structure()
|
||||
|
||||
mapping_issue_codes = {
|
||||
ValidationIssueCode.INVALID_NODE_INPUT_FIELD,
|
||||
ValidationIssueCode.INVALID_NODE_OUTPUT_FIELD,
|
||||
ValidationIssueCode.INVALID_SOURCE_PATH,
|
||||
ValidationIssueCode.INVALID_DESTINATION_PATH,
|
||||
}
|
||||
assert not any(issue.code in mapping_issue_codes for issue in report.errors)
|
||||
|
||||
|
||||
def _workflow(
|
||||
*,
|
||||
in_map: dict[str, str] | None = None,
|
||||
out_map: dict[str, str] | None = None,
|
||||
input: list[dict[str, object]] | None = None,
|
||||
output: list[dict[str, str]] | None = None,
|
||||
state_fields: dict[str, StateField] | None = None,
|
||||
) -> Workflow:
|
||||
node_data: dict[str, object] = {
|
||||
"id": "tool",
|
||||
"type": "node",
|
||||
"node": "tool",
|
||||
}
|
||||
if input is not None or output is not None:
|
||||
node_data["input"] = input or []
|
||||
node_data["output"] = output or []
|
||||
else:
|
||||
node_data["in_map"] = in_map or {}
|
||||
node_data["out_map"] = out_map or {}
|
||||
|
||||
return Workflow(
|
||||
name="mapping_validation",
|
||||
input_schema=SchemaRef.model_validate(
|
||||
{"type": "object", "properties": {"person": {"type": "object"}}}
|
||||
),
|
||||
state_schema=StateSchema(fields={"person": StateField(type="object")}),
|
||||
state_schema=StateSchema(
|
||||
fields=state_fields or {"person": StateField(type="object")}
|
||||
),
|
||||
output_schema=SchemaRef(type="object", properties={}),
|
||||
node_defs=[
|
||||
NodeDef(
|
||||
@@ -62,14 +196,6 @@ def _workflow(*, in_map: dict[str, str], out_map: dict[str, str]) -> Workflow:
|
||||
)
|
||||
],
|
||||
start="tool",
|
||||
nodes=[
|
||||
NodeUse(
|
||||
id="tool",
|
||||
type="node",
|
||||
node="tool",
|
||||
in_map=in_map,
|
||||
out_map=out_map,
|
||||
)
|
||||
],
|
||||
nodes=[NodeUse.model_validate(node_data)],
|
||||
edges=[Edge.model_validate({"from": "tool", "outcome": "ok", "to": "__end__"})],
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from wf_core import (
|
||||
@@ -16,6 +18,72 @@ from wf_core import (
|
||||
)
|
||||
|
||||
|
||||
def test_canonical_bindings_resolve_input_values_paths_and_explicit_null() -> None:
|
||||
workflow = Workflow.model_validate(
|
||||
{
|
||||
"name": "canonical",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"message": {"type": "string"}},
|
||||
},
|
||||
"state_schema": {"fields": {"echoed": {"type": "string"}}},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {"echoed": {"type": "string"}},
|
||||
},
|
||||
"start": "echo",
|
||||
"node_defs": [
|
||||
{
|
||||
"name": "echo",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {"type": "string"},
|
||||
"mode": {"type": "string"},
|
||||
"maybe": {"type": "null"},
|
||||
},
|
||||
"required": ["message", "mode", "maybe"],
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {"echoed": {"type": "string"}},
|
||||
},
|
||||
"outcomes": ["ok"],
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"id": "echo",
|
||||
"type": "node",
|
||||
"node": "echo",
|
||||
"input": [
|
||||
{"target": "message", "path": "input.message"},
|
||||
{"target": "mode", "value": "fast"},
|
||||
{"target": "maybe", "value": None},
|
||||
],
|
||||
"output": [{"source": "echoed", "target": "state.echoed"}],
|
||||
}
|
||||
],
|
||||
"edges": [{"from": "echo", "outcome": "ok", "to": END}],
|
||||
}
|
||||
)
|
||||
run = execute_workflow(
|
||||
workflow,
|
||||
{"message": "hi"},
|
||||
registry={
|
||||
"echo": lambda payload, _ctx: {
|
||||
"outcome": "ok",
|
||||
"output": {"echoed": payload["message"]},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert run.trace[0].resolved_input["message"] == "hi"
|
||||
assert run.trace[0].resolved_input["mode"] == "fast"
|
||||
assert run.trace[0].resolved_input["maybe"] is None
|
||||
assert run.state["echoed"] == "hi"
|
||||
|
||||
|
||||
def test_nested_node_local_paths_build_input_and_read_output() -> None:
|
||||
workflow = _nested_mapping_workflow()
|
||||
|
||||
@@ -33,9 +101,8 @@ def test_nested_node_local_paths_build_input_and_read_output() -> None:
|
||||
},
|
||||
)
|
||||
|
||||
assert run.trace[0].resolved_input == {
|
||||
"user": {"name": "Ada", "email": "[email protected]"}
|
||||
}
|
||||
assert run.trace[0].resolved_input["user"]["name"] == "Ada"
|
||||
assert run.trace[0].resolved_input["user"]["email"] == "[email protected]"
|
||||
assert run.state["person"]["age"] == 36
|
||||
assert run.state["person"]["gender"] == "x"
|
||||
assert run.state["experience"]["years"] == 12
|
||||
@@ -46,7 +113,7 @@ def test_missing_nested_node_output_path_fails() -> None:
|
||||
|
||||
with pytest.raises(
|
||||
WorkflowExecutionError,
|
||||
match="did not return required mapped field 'user.gender'",
|
||||
match="node output did not include required field 'user.gender'",
|
||||
):
|
||||
execute_workflow(
|
||||
workflow,
|
||||
@@ -87,12 +154,17 @@ def test_root_node_local_paths_map_whole_input_and_output_payloads() -> None:
|
||||
],
|
||||
start="force",
|
||||
nodes=[
|
||||
NodeUse(
|
||||
id="force",
|
||||
type="node",
|
||||
node="force_rates",
|
||||
in_map={"input.rates": "."},
|
||||
out_map={".": "state.rates"},
|
||||
cast(
|
||||
Any,
|
||||
NodeUse.model_validate(
|
||||
{
|
||||
"id": "force",
|
||||
"type": "node",
|
||||
"node": "force_rates",
|
||||
"in_map": {"input.rates": "."},
|
||||
"out_map": {".": "state.rates"},
|
||||
}
|
||||
),
|
||||
)
|
||||
],
|
||||
edges=[Edge.model_validate({"from": "force", "outcome": "ok", "to": END})],
|
||||
@@ -109,8 +181,10 @@ def test_root_node_local_paths_map_whole_input_and_output_payloads() -> None:
|
||||
},
|
||||
)
|
||||
|
||||
assert run.trace[0].resolved_input == {"r_1": 0.9, "r_10": 0.1}
|
||||
assert run.state["rates"] == {"r_1": 0.0, "r_10": 0.1}
|
||||
assert run.trace[0].resolved_input["r_1"] == 0.9
|
||||
assert run.trace[0].resolved_input["r_10"] == 0.1
|
||||
assert run.state["rates"]["r_1"] == 0.0
|
||||
assert run.state["rates"]["r_10"] == 0.1
|
||||
|
||||
|
||||
def test_static_input_values_are_merged_into_node_local_input() -> None:
|
||||
@@ -141,12 +215,17 @@ def test_static_input_values_are_merged_into_node_local_input() -> None:
|
||||
],
|
||||
start="constant",
|
||||
nodes=[
|
||||
NodeUse(
|
||||
id="constant",
|
||||
type="node",
|
||||
node="constant",
|
||||
input_values={"value": "CLICKED"},
|
||||
out_map={"value": "state.message"},
|
||||
cast(
|
||||
Any,
|
||||
NodeUse.model_validate(
|
||||
{
|
||||
"id": "constant",
|
||||
"type": "node",
|
||||
"node": "constant",
|
||||
"input_values": {"value": "CLICKED"},
|
||||
"out_map": {"value": "state.message"},
|
||||
}
|
||||
),
|
||||
)
|
||||
],
|
||||
edges=[Edge.model_validate({"from": "constant", "outcome": "ok", "to": END})],
|
||||
@@ -204,19 +283,24 @@ def _nested_mapping_workflow() -> Workflow:
|
||||
],
|
||||
start="big",
|
||||
nodes=[
|
||||
NodeUse(
|
||||
id="big",
|
||||
type="node",
|
||||
node="big_tool",
|
||||
in_map={
|
||||
"input.person.name": "user.name",
|
||||
"input.digital.email": "user.email",
|
||||
},
|
||||
out_map={
|
||||
"user.age": "state.person.age",
|
||||
"user.gender": "state.person.gender",
|
||||
"job.years": "state.experience.years",
|
||||
},
|
||||
cast(
|
||||
Any,
|
||||
NodeUse.model_validate(
|
||||
{
|
||||
"id": "big",
|
||||
"type": "node",
|
||||
"node": "big_tool",
|
||||
"in_map": {
|
||||
"input.person.name": "user.name",
|
||||
"input.digital.email": "user.email",
|
||||
},
|
||||
"out_map": {
|
||||
"user.age": "state.person.age",
|
||||
"user.gender": "state.person.gender",
|
||||
"job.years": "state.experience.years",
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
],
|
||||
edges=[Edge.model_validate({"from": "big", "outcome": "ok", "to": END})],
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from wf_core.paths import (
|
||||
GraphSourcePath,
|
||||
LocalPath,
|
||||
PathResolutionError,
|
||||
StatePath,
|
||||
is_valid_destination_path,
|
||||
is_valid_source_path,
|
||||
)
|
||||
|
||||
|
||||
def test_graph_source_path_accepts_root_and_nested_paths() -> None:
|
||||
assert str(GraphSourcePath.parse("state")) == "state"
|
||||
assert str(GraphSourcePath.parse("input")) == "input"
|
||||
assert str(GraphSourcePath.parse("context")) == "context"
|
||||
assert str(GraphSourcePath.parse("input.user")) == "input.user"
|
||||
assert str(GraphSourcePath.parse("state.person.name")) == "state.person.name"
|
||||
assert str(GraphSourcePath.context("loop_item")) == "context.loop_item"
|
||||
|
||||
|
||||
def test_state_path_serializes_with_state_prefix() -> None:
|
||||
assert str(StatePath.of("person.name")) == "state.person.name"
|
||||
assert str(StatePath.parse("state.person.name")) == "state.person.name"
|
||||
|
||||
|
||||
def test_state_path_rejects_bare_state_write_target() -> None:
|
||||
with pytest.raises(PathResolutionError, match="state path"):
|
||||
StatePath.parse("state")
|
||||
|
||||
|
||||
def test_local_path_supports_root_marker_and_fragments() -> None:
|
||||
assert str(LocalPath.root()) == "."
|
||||
assert str(LocalPath.of("user.name")) == "user.name"
|
||||
assert str(LocalPath.of("user", "name")) == "user.name"
|
||||
assert LocalPath.parse(".") == LocalPath.root()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"",
|
||||
".",
|
||||
"state.",
|
||||
"state..name",
|
||||
"state.items.0",
|
||||
"state.user-name",
|
||||
"state.items[0]",
|
||||
"output.foo",
|
||||
],
|
||||
)
|
||||
def test_graph_source_paths_reject_invalid_segments(raw: str) -> None:
|
||||
with pytest.raises(PathResolutionError):
|
||||
GraphSourcePath.parse(raw)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"factory",
|
||||
[
|
||||
LocalPath.parse,
|
||||
StatePath.parse,
|
||||
GraphSourcePath.parse,
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"raw",
|
||||
[
|
||||
"state.",
|
||||
"state..name",
|
||||
"state.items.0",
|
||||
"state.user-name",
|
||||
"state.items[0]",
|
||||
],
|
||||
)
|
||||
def test_all_path_types_reject_invalid_segments(factory, raw: str) -> None:
|
||||
with pytest.raises(PathResolutionError):
|
||||
factory(raw)
|
||||
|
||||
|
||||
def test_path_objects_are_immutable_and_hashable() -> None:
|
||||
paths = {StatePath.of("person.name"), StatePath.of("person.name")}
|
||||
assert len(paths) == 1
|
||||
|
||||
with pytest.raises(Exception):
|
||||
StatePath.of("person.name").parts = ("other",) # type: ignore[misc]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("factory", "args"),
|
||||
[
|
||||
(GraphSourcePath, ("output", ("user-name",))),
|
||||
(StatePath, (("0",),)),
|
||||
(LocalPath, (("items[0]",),)),
|
||||
],
|
||||
)
|
||||
def test_direct_constructors_enforce_path_invariants(factory, args: tuple[object, ...]) -> None:
|
||||
with pytest.raises(PathResolutionError):
|
||||
factory(*args)
|
||||
|
||||
|
||||
def test_pydantic_revalidates_existing_path_objects() -> None:
|
||||
class Payload(BaseModel):
|
||||
source: GraphSourcePath
|
||||
target: StatePath
|
||||
local: LocalPath
|
||||
|
||||
# Bypass constructors to simulate stale or malicious objects that predate
|
||||
# constructor validation. Pydantic must not blindly trust existing instances.
|
||||
source = object.__new__(GraphSourcePath)
|
||||
object.__setattr__(source, "root", "output")
|
||||
object.__setattr__(source, "parts", ("user-name",))
|
||||
target = object.__new__(StatePath)
|
||||
object.__setattr__(target, "parts", ("0",))
|
||||
local = object.__new__(LocalPath)
|
||||
object.__setattr__(local, "parts", ("items[0]",))
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Payload.model_validate({"source": source, "target": StatePath.of("person"), "local": LocalPath.root()})
|
||||
with pytest.raises(ValidationError):
|
||||
Payload.model_validate({"source": GraphSourcePath.input("user"), "target": target, "local": LocalPath.root()})
|
||||
with pytest.raises(ValidationError):
|
||||
Payload.model_validate({"source": GraphSourcePath.input("user"), "target": StatePath.of("person"), "local": local})
|
||||
|
||||
|
||||
def test_pydantic_accepts_path_strings_and_serializes_strings() -> None:
|
||||
class Payload(BaseModel):
|
||||
source: GraphSourcePath
|
||||
target: StatePath
|
||||
local: LocalPath
|
||||
|
||||
payload = Payload.model_validate(
|
||||
{"source": "input.user", "target": "state.person", "local": "user"}
|
||||
)
|
||||
|
||||
assert payload.source == GraphSourcePath.input("user")
|
||||
assert payload.target == StatePath.of("person")
|
||||
assert payload.local == LocalPath.of("user")
|
||||
|
||||
dumped = payload.model_dump(mode="json")
|
||||
assert dumped["source"] == "input.user"
|
||||
assert dumped["target"] == "state.person"
|
||||
assert dumped["local"] == "user"
|
||||
|
||||
|
||||
def test_pydantic_accepts_existing_path_objects() -> None:
|
||||
class Payload(BaseModel):
|
||||
source: GraphSourcePath
|
||||
target: StatePath
|
||||
local: LocalPath
|
||||
|
||||
payload = Payload.model_validate(
|
||||
{
|
||||
"source": GraphSourcePath.state("person"),
|
||||
"target": StatePath.of("person.name"),
|
||||
"local": LocalPath.root(),
|
||||
}
|
||||
)
|
||||
|
||||
assert str(payload.source) == "state.person"
|
||||
assert str(payload.target) == "state.person.name"
|
||||
assert str(payload.local) == "."
|
||||
|
||||
|
||||
def test_pydantic_rejects_bad_path_string() -> None:
|
||||
class Payload(BaseModel):
|
||||
source: GraphSourcePath
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Payload.model_validate({"source": "output.foo"})
|
||||
|
||||
|
||||
def test_existing_source_and_destination_validation_helpers_use_new_parsers() -> None:
|
||||
assert is_valid_source_path("state", set(), set()) is True
|
||||
assert is_valid_source_path("input", set(), set()) is True
|
||||
assert is_valid_source_path("context", set(), set(), allow_context=True) is True
|
||||
assert is_valid_source_path("state.person", {"person"}, set()) is True
|
||||
assert is_valid_source_path("input.person", set(), {"person"}) is True
|
||||
assert is_valid_source_path("state.person-name", {"person-name"}, set()) is False
|
||||
|
||||
assert is_valid_destination_path("state") is False
|
||||
assert is_valid_destination_path("state.person") is True
|
||||
assert is_valid_destination_path("input.person") is False
|
||||
Reference in New Issue
Block a user