nested path support for in/out maps, merge strat refactor
validation + runtime for write targets
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import Any
|
||||
|
||||
|
||||
class LocalPathError(ValueError):
|
||||
"""Raised when a node-local dotted path cannot be parsed or resolved."""
|
||||
|
||||
|
||||
def split_local_path(path: str) -> list[str]:
|
||||
"""Split one dotted node-local path, rejecting empty segments."""
|
||||
parts = path.split(".")
|
||||
if not path or any(not part for part in parts):
|
||||
raise LocalPathError(f"invalid local path {path!r}")
|
||||
return parts
|
||||
|
||||
|
||||
def get_local_value(payload: Mapping[str, Any], path: str) -> Any:
|
||||
"""Resolve one node-local path from a nested mapping payload."""
|
||||
current: Any = payload
|
||||
for part in split_local_path(path):
|
||||
if not isinstance(current, Mapping) or part not in current:
|
||||
raise LocalPathError(f"local path {path!r} could not be resolved")
|
||||
current = current[part]
|
||||
return current
|
||||
|
||||
|
||||
def set_local_value(payload: dict[str, Any], path: str, value: Any) -> None:
|
||||
"""Write one value into a nested node-local mapping payload."""
|
||||
parts = split_local_path(path)
|
||||
current = payload
|
||||
for part in parts[:-1]:
|
||||
next_value = current.setdefault(part, {})
|
||||
if not isinstance(next_value, dict):
|
||||
raise LocalPathError(f"local path {path!r} overlaps an existing value")
|
||||
current = next_value
|
||||
current[parts[-1]] = value
|
||||
|
||||
|
||||
def paths_overlap(left: str, right: str) -> bool:
|
||||
"""Return whether two dotted paths overlap by equality or ancestry."""
|
||||
left_parts = split_local_path(left)
|
||||
right_parts = split_local_path(right)
|
||||
shortest = min(len(left_parts), len(right_parts))
|
||||
return left_parts[:shortest] == right_parts[:shortest]
|
||||
|
||||
|
||||
def has_overlapping_paths(paths: Iterable[str]) -> bool:
|
||||
"""Return whether any pair of dotted paths overlaps."""
|
||||
seen: list[str] = []
|
||||
for path in paths:
|
||||
if any(paths_overlap(path, prior) for prior in seen):
|
||||
return True
|
||||
seen.append(path)
|
||||
return False
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from wf_core.errors import WorkflowExecutionError
|
||||
|
||||
|
||||
def apply_builtin_merge(
|
||||
*,
|
||||
strategy: str,
|
||||
current_value: Any,
|
||||
incoming_value: Any,
|
||||
destination_path: str,
|
||||
) -> Any:
|
||||
"""Apply one built-in merge rule.
|
||||
|
||||
This is the future seam for source-owned reducer libraries. The current core
|
||||
still supports only built-in rules and keeps them pure over current and
|
||||
incoming values.
|
||||
"""
|
||||
if strategy == "replace":
|
||||
return incoming_value
|
||||
|
||||
if strategy == "append":
|
||||
if current_value is None:
|
||||
return [incoming_value] if not isinstance(incoming_value, list) else incoming_value
|
||||
if not isinstance(current_value, list):
|
||||
raise WorkflowExecutionError(
|
||||
f"cannot append into non-list state path {destination_path!r}"
|
||||
)
|
||||
return [
|
||||
*current_value,
|
||||
*incoming_value,
|
||||
] if isinstance(incoming_value, list) else [*current_value, incoming_value]
|
||||
|
||||
if strategy == "merge_object":
|
||||
if current_value is None:
|
||||
if not isinstance(incoming_value, dict):
|
||||
raise WorkflowExecutionError(
|
||||
f"cannot merge non-object value into {destination_path!r}"
|
||||
)
|
||||
return dict(incoming_value)
|
||||
if not isinstance(current_value, dict) or not isinstance(incoming_value, dict):
|
||||
raise WorkflowExecutionError(
|
||||
f"merge_object requires dict values at {destination_path!r}"
|
||||
)
|
||||
return current_value | incoming_value
|
||||
|
||||
raise WorkflowExecutionError(f"unknown merge strategy {strategy!r}")
|
||||
@@ -5,6 +5,7 @@ from typing import Any, cast
|
||||
|
||||
from wf_core.conditions import safe_resolve_path
|
||||
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
|
||||
@@ -30,15 +31,18 @@ def _resolve_node_execution(
|
||||
) -> tuple[dict[str, Any], RuntimeContext]:
|
||||
frame = run.current_frame()
|
||||
context_values = frame_context_values(frame)
|
||||
resolved_input = {
|
||||
destination_field: safe_resolve_path(
|
||||
resolved_input: dict[str, Any] = {}
|
||||
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,
|
||||
)
|
||||
for source_path, destination_field in node.in_map.items()
|
||||
}
|
||||
try:
|
||||
set_local_value(resolved_input, destination_field, value)
|
||||
except LocalPathError as exc:
|
||||
raise WorkflowExecutionError(str(exc)) from exc
|
||||
validate_payload_against_schema(
|
||||
node_def.input_schema, resolved_input, f"node input for {node.id}"
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
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.steps import NodeUse
|
||||
from wf_core.models.workflow import Workflow
|
||||
from wf_core.paths import (
|
||||
@@ -11,6 +12,7 @@ from wf_core.paths import (
|
||||
set_nested_value,
|
||||
split_graph_path,
|
||||
)
|
||||
from wf_core.runtime.ops.merges import apply_builtin_merge
|
||||
|
||||
|
||||
def apply_output_map(
|
||||
@@ -36,16 +38,22 @@ def apply_mapped_state(
|
||||
*,
|
||||
missing_field_message: str,
|
||||
) -> dict[str, Any]:
|
||||
state_changes: 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():
|
||||
if source_field not in source_data:
|
||||
try:
|
||||
value = get_local_value(source_data, source_field)
|
||||
except LocalPathError:
|
||||
raise WorkflowExecutionError(
|
||||
missing_field_message.format(field=repr(source_field))
|
||||
)
|
||||
value = source_data[source_field]
|
||||
) from None
|
||||
patch[destination_path] = value
|
||||
|
||||
for destination_path, value in patch.items():
|
||||
write_state_value(workflow, state, destination_path, value)
|
||||
state_changes[destination_path] = value
|
||||
return state_changes
|
||||
return dict(patch)
|
||||
|
||||
|
||||
def write_state_value(
|
||||
@@ -65,44 +73,14 @@ def write_state_value(
|
||||
declared_field = workflow.state_schema.fields.get(field_name)
|
||||
merge_strategy = declared_field.merge_strategy if declared_field else "replace"
|
||||
key_path = parts
|
||||
|
||||
if merge_strategy == "replace":
|
||||
safe_set_nested_value(state, key_path, value)
|
||||
return
|
||||
|
||||
current_value = get_nested_value(state, key_path)
|
||||
if merge_strategy == "append":
|
||||
if current_value is None:
|
||||
safe_set_nested_value(
|
||||
state, key_path, [value] if not isinstance(value, list) else value
|
||||
)
|
||||
return
|
||||
if not isinstance(current_value, list):
|
||||
raise WorkflowExecutionError(
|
||||
f"cannot append into non-list state path {destination_path!r}"
|
||||
)
|
||||
if isinstance(value, list):
|
||||
current_value.extend(value)
|
||||
else:
|
||||
current_value.append(value)
|
||||
return
|
||||
|
||||
if merge_strategy == "merge_object":
|
||||
if current_value is None:
|
||||
if not isinstance(value, dict):
|
||||
raise WorkflowExecutionError(
|
||||
f"cannot merge non-object value into {destination_path!r}"
|
||||
)
|
||||
safe_set_nested_value(state, key_path, dict(value))
|
||||
return
|
||||
if not isinstance(current_value, dict) or not isinstance(value, dict):
|
||||
raise WorkflowExecutionError(
|
||||
f"merge_object requires dict values at {destination_path!r}"
|
||||
)
|
||||
current_value.update(value)
|
||||
return
|
||||
|
||||
raise WorkflowExecutionError(f"unknown merge strategy {merge_strategy!r}")
|
||||
merged_value = apply_builtin_merge(
|
||||
strategy=merge_strategy,
|
||||
current_value=current_value,
|
||||
incoming_value=value,
|
||||
destination_path=destination_path,
|
||||
)
|
||||
safe_set_nested_value(state, key_path, merged_value)
|
||||
|
||||
|
||||
def project_output(workflow: Workflow, state: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
@@ -9,6 +9,7 @@ from wf_core.models.conditions import (
|
||||
PathOperand,
|
||||
VariadicCondition,
|
||||
)
|
||||
from wf_core.local_paths import LocalPathError, has_overlapping_paths, split_local_path
|
||||
from wf_core.models.schemas import NodeDef
|
||||
from wf_core.models.steps import ConditionNode, ForeachNode, InterruptNode, NodeUse
|
||||
from wf_core.models.workflow import Workflow
|
||||
@@ -38,7 +39,11 @@ def validate_node_use(
|
||||
input_root_fields = set(workflow.input_schema.properties)
|
||||
|
||||
for source_path, destination_field in node.in_map.items():
|
||||
if destination_field not in input_fields:
|
||||
try:
|
||||
destination_root = split_local_path(destination_field)[0]
|
||||
except LocalPathError:
|
||||
destination_root = ""
|
||||
if destination_root not in input_fields:
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_NODE_INPUT_FIELD,
|
||||
f"nodes[{index}].in_map[{source_path!r}]",
|
||||
@@ -53,8 +58,19 @@ def validate_node_use(
|
||||
"source path must start with input., state., or context. and reference a declared root field when applicable",
|
||||
)
|
||||
|
||||
if has_overlapping_paths(node.in_map.values()):
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_NODE_INPUT_FIELD,
|
||||
f"nodes[{index}].in_map",
|
||||
"in_map has overlapping node-local input paths",
|
||||
)
|
||||
|
||||
for source_field, destination_path in node.out_map.items():
|
||||
if source_field not in output_fields:
|
||||
try:
|
||||
source_root = split_local_path(source_field)[0]
|
||||
except LocalPathError:
|
||||
source_root = ""
|
||||
if source_root not in output_fields:
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_NODE_OUTPUT_FIELD,
|
||||
f"nodes[{index}].out_map[{source_field!r}]",
|
||||
@@ -66,6 +82,12 @@ def validate_node_use(
|
||||
f"nodes[{index}].out_map[{source_field!r}]",
|
||||
"destination path must start with state.",
|
||||
)
|
||||
if has_overlapping_paths(node.out_map.values()):
|
||||
report.add(
|
||||
ValidationIssueCode.INVALID_DESTINATION_PATH,
|
||||
f"nodes[{index}].out_map",
|
||||
"out_map has overlapping state destination paths",
|
||||
)
|
||||
|
||||
|
||||
def validate_condition_node(
|
||||
|
||||
Reference in New Issue
Block a user