typed path, path handling,

half the plan is done
This commit is contained in:
lda
2026-05-20 16:05:43 +07:00 Verified
parent a74e016f7b
commit 3683c23937
11 changed files with 1316 additions and 189 deletions
+19 -17
View File
@@ -8,13 +8,13 @@ from wf_core.errors import WorkflowExecutionError
from wf_core.local_paths import LocalPathError, set_local_value
from wf_core.models.results import NodeResult
from wf_core.models.schemas import NodeDef
from wf_core.models.steps import NodeUse
from wf_core.models.steps import InputPathBinding, InputValueBinding, NodeUse
from wf_core.models.workflow import Workflow
from wf_core.run_state import RunState, RuntimeContext, StepExecutionResult
from wf_core.runtime.ops.frames import frame_context_values
from wf_core.runtime.ops.merges import ReducerDefinition
from wf_core.runtime.ops.schemas import validate_payload_against_schema
from wf_core.runtime.ops.state import apply_output_map
from wf_core.runtime.ops.state import apply_output_bindings
NodeHandler = Callable[[dict[str, Any], RuntimeContext], NodeResult | dict[str, Any]]
AsyncNodeHandler = Callable[
@@ -33,20 +33,22 @@ def _resolve_node_execution(
frame = run.current_frame()
context_values = frame_context_values(frame)
resolved_input: dict[str, Any] = {}
for destination_field, value in node.input_values.items():
for binding in node.input:
if isinstance(binding, InputValueBinding):
value = binding.value
elif isinstance(binding, InputPathBinding):
value = safe_resolve_path(
str(binding.path),
state=run.state,
workflow_input=run.workflow_input,
context=context_values,
)
else:
raise WorkflowExecutionError(
f"unsupported input binding for node {node.id!r}"
)
try:
set_local_value(resolved_input, destination_field, value)
except LocalPathError as exc:
raise WorkflowExecutionError(str(exc)) from exc
for source_path, destination_field in node.in_map.items():
value = safe_resolve_path(
source_path,
state=run.state,
workflow_input=run.workflow_input,
context=context_values,
)
try:
set_local_value(resolved_input, destination_field, value)
set_local_value(resolved_input, binding.target, value)
except LocalPathError as exc:
raise WorkflowExecutionError(str(exc)) from exc
validate_payload_against_schema(
@@ -83,9 +85,9 @@ def _finalize_node_execution(
validate_payload_against_schema(
node_def.output_schema, result.output, f"node output for {node.id}"
)
state_changes = apply_output_map(
state_changes = apply_output_bindings(
workflow,
node,
node.output,
result.output,
run.state,
reducers=reducers,
+98 -36
View File
@@ -1,15 +1,17 @@
from __future__ import annotations
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from copy import deepcopy
from typing import Any
from wf_core.errors import WorkflowExecutionError
from wf_core.local_paths import LocalPathError, get_local_value, has_overlapping_paths
from wf_core.models.reducers import ReducerRef
from wf_core.models.steps import NodeUse
from wf_core.models.steps import NodeUse, OutputBinding
from wf_core.models.workflow import Workflow
from wf_core.paths import (
PathResolutionError,
StatePath,
get_nested_value,
set_nested_value,
split_graph_path,
@@ -24,14 +26,67 @@ def apply_output_map(
state: dict[str, Any],
reducers: Mapping[str, ReducerDefinition] | None = None,
) -> dict[str, Any]:
return apply_mapped_state(
workflow,
node_output,
node.out_map,
state,
reducers=reducers,
missing_field_message=f"node {node.id!r} did not return required mapped field {{field}}",
)
"""Compatibility wrapper for callers that still invoke the old helper."""
try:
return apply_output_bindings(
workflow,
node.output,
node_output,
state,
reducers=reducers,
missing_field_message=(
f"node {node.id!r} did not return required mapped field {{field}}"
),
)
except AttributeError as exc:
raise WorkflowExecutionError(
"apply_output_map requires NodeUse.output canonical bindings"
) from exc
def apply_output_bindings(
workflow: Workflow,
bindings: Sequence[OutputBinding],
node_output: dict[str, Any],
state: dict[str, Any],
*,
reducers: Mapping[str, ReducerDefinition] | None = None,
missing_field_message: str = "node output did not include required field {field}",
) -> dict[str, Any]:
"""Prepare and commit one atomic state patch from canonical output bindings."""
if has_overlapping_paths(str(binding.target) for binding in bindings):
raise WorkflowExecutionError(
"mapped state patch has overlapping destination paths"
)
resolved_patch: dict[StatePath, Any] = {}
for binding in bindings:
try:
value = get_local_value(node_output, binding.source)
except LocalPathError:
raise WorkflowExecutionError(
missing_field_message.format(field=repr(str(binding.source)))
) from None
resolved_patch[binding.target] = value
prepared_patch: dict[StatePath, tuple[list[str], Any]] = {}
for destination_path, value in resolved_patch.items():
key_path, merged_value = prepare_state_value(
workflow,
state,
destination_path,
value,
reducers=reducers,
)
prepared_patch[destination_path] = (key_path, merged_value)
# Stage writes on a copy so commit-time path errors cannot partially mutate state.
staged_state = deepcopy(state)
for _destination_path, (key_path, merged_value) in prepared_patch.items():
safe_set_nested_value(staged_state, key_path, merged_value)
state.clear()
state.update(staged_state)
return {str(path): value for path, value in resolved_patch.items()}
def apply_mapped_state(
@@ -43,30 +98,18 @@ def apply_mapped_state(
reducers: Mapping[str, ReducerDefinition] | None = None,
missing_field_message: str,
) -> dict[str, Any]:
if has_overlapping_paths(mapping.values()):
raise WorkflowExecutionError(
"mapped state patch has overlapping destination paths"
)
patch: dict[str, Any] = {}
for source_field, destination_path in mapping.items():
try:
value = get_local_value(source_data, source_field)
except LocalPathError:
raise WorkflowExecutionError(
missing_field_message.format(field=repr(source_field))
) from None
patch[destination_path] = value
for destination_path, value in patch.items():
write_state_value(
workflow,
state,
destination_path,
value,
reducers=reducers,
)
return dict(patch)
bindings = [
OutputBinding.model_validate({"source": source, "target": target})
for source, target in mapping.items()
]
return apply_output_bindings(
workflow,
bindings,
source_data,
state,
reducers=reducers,
missing_field_message=missing_field_message,
)
def write_state_value(
@@ -77,6 +120,25 @@ def write_state_value(
*,
reducers: Mapping[str, ReducerDefinition] | None = None,
) -> None:
key_path, merged_value = prepare_state_value(
workflow,
state,
destination_path,
value,
reducers=reducers,
)
safe_set_nested_value(state, key_path, merged_value)
def prepare_state_value(
workflow: Workflow,
state: dict[str, Any],
destination_path: str | StatePath,
value: Any,
*,
reducers: Mapping[str, ReducerDefinition] | None = None,
) -> tuple[list[str], Any]:
"""Resolve reducer output for a state write without mutating state."""
try:
root, parts = split_graph_path(destination_path)
except PathResolutionError as exc:
@@ -98,10 +160,10 @@ def write_state_value(
reducer=reducer,
current_value=current_value,
incoming_value=value,
destination_path=destination_path,
destination_path=str(destination_path),
reducers=reducers,
)
safe_set_nested_value(state, key_path, merged_value)
return key_path, merged_value
def project_output(workflow: Workflow, state: dict[str, Any]) -> dict[str, Any]: