p2: old style deprecated support + wf_authoring to use new stuff

holy moly file changes
This commit is contained in:
lda
2026-05-20 17:47:37 +07:00 Verified
parent 3683c23937
commit 1a2349e017
28 changed files with 702 additions and 137 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ def resolve_operand(
if isinstance(operand, LiteralOperand):
return operand.value
return safe_resolve_path(
operand.path,
str(operand.path),
state=state,
workflow_input=workflow_input,
context={"prior_outcome": context_data},
+3 -1
View File
@@ -49,7 +49,9 @@ def set_local_value(payload: dict[str, Any], path: str | LocalPath, value: Any)
for part in parts[:-1]:
next_value = current.setdefault(part, {})
if not isinstance(next_value, dict):
raise LocalPathError(f"local path {str(parsed)!r} overlaps an existing value")
raise LocalPathError(
f"local path {str(parsed)!r} overlaps an existing value"
)
current = next_value
current[parts[-1]] = value
+4 -2
View File
@@ -4,11 +4,13 @@ from typing import Annotated, Any, Literal
from pydantic import BaseModel, Field
from wf_core.paths import GraphSourcePath
class PathOperand(BaseModel):
"""Condition operand resolved from a workflow graph path."""
path: str
path: GraphSourcePath
class LiteralOperand(BaseModel):
@@ -25,7 +27,7 @@ class ExistsCondition(BaseModel):
"""Condition that is true when a graph path resolves to a present value."""
op: Literal["exists"]
path: str
path: GraphSourcePath
class NotCondition(BaseModel):
+154 -5
View File
@@ -1,22 +1,60 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
from jsonschema import Draft202012Validator, SchemaError, validators
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_serializer,
field_validator,
model_serializer,
model_validator,
)
from wf_core.models.reducers import ReducerRef
from wf_core.paths import StatePath
class SchemaRef(BaseModel):
"""JSON-schema-like shape used at workflow boundaries."""
"""JSON Schema object used at workflow and node boundaries."""
model_config = ConfigDict(extra="allow")
title: str | None = None
type: str | None = None
type: str | list[str] | None = None
properties: dict[str, Any] = Field(default_factory=dict)
required: list[str] = Field(default_factory=list)
@model_serializer(mode="wrap")
def _serialize_without_none_fields(self, handler: Any) -> dict[str, Any]:
"""Persist JSON Schema objects without null-valued optional keywords."""
data = handler(self)
return {key: value for key, value in data.items() if value is not None}
@model_validator(mode="before")
@classmethod
def _validate_json_schema_definition(cls, value: object) -> object:
if isinstance(value, SchemaRef):
schema = value.model_dump(mode="json", exclude_none=True)
elif isinstance(value, Mapping):
schema = dict(value)
else:
return value
validator_cls = (
validators.validator_for(schema)
if "$schema" in schema
else Draft202012Validator
)
try:
validator_cls.check_schema(schema)
except SchemaError as exc:
raise ValueError(f"invalid JSON Schema: {exc.message}") from exc
return value
class StateField(BaseModel):
"""Declared state path plus its runtime merge behavior."""
@@ -36,12 +74,123 @@ class StateField(BaseModel):
return value
class StateFieldDecl(BaseModel):
"""One declared state path plus validation and reducer metadata."""
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
path: StatePath
validation_schema: SchemaRef = Field(
default_factory=lambda: SchemaRef(type="object"),
alias="schema",
serialization_alias="schema",
)
reducer: ReducerRef = Field(
default_factory=lambda: ReducerRef(name="wf.std.replace")
)
trace: bool = True
default: Any = None
@property
def type(self) -> str | None:
"""Compatibility accessor for callers migrating from StateField.type."""
schema_type = self.validation_schema.type
return schema_type if isinstance(schema_type, str) else None
@field_serializer("path")
def _serialize_path(self, path: StatePath) -> str:
return str(path)
@model_validator(mode="before")
@classmethod
def _coerce_legacy_type(cls, value: object) -> object:
if not isinstance(value, Mapping):
return value
data = dict(value)
if "schema" not in data and "type" in data:
data["schema"] = {"type": data.pop("type")}
return data
@field_validator("reducer", mode="before")
@classmethod
def _coerce_reducer(cls, value: object) -> object:
if isinstance(value, str):
return {"name": value}
return value
class StateSchema(BaseModel):
"""Workflow state schema keyed by declared exact state path."""
"""Workflow state schema with canonical list fields.
Deprecated dict-shaped input is still accepted at parse time and normalized
so runtime and serialization only deal with list-of-struct declarations.
"""
model_config = ConfigDict(extra="allow")
fields: dict[str, StateField] = Field(default_factory=dict)
fields: list[StateFieldDecl] = Field(default_factory=list)
@classmethod
def from_field_map(cls, fields: Mapping[str, StateField]) -> StateSchema:
"""Build from the deprecated dict shape at typed Python call sites."""
return cls.model_validate({"fields": fields})
def field_map(self) -> dict[str, StateFieldDecl]:
"""Return declarations keyed by rootless dotted path."""
return {".".join(field.path.parts): field for field in self.fields}
def root_fields(self) -> set[str]:
"""Return declared top-level state field names."""
return {field.path.parts[0] for field in self.fields}
@model_validator(mode="before")
@classmethod
def _coerce_deprecated_field_map(cls, value: object) -> object:
if not isinstance(value, Mapping):
return value
data = dict(value)
fields = data.get("fields")
if not isinstance(fields, Mapping):
return data
normalized_fields: list[object] = []
for raw_path, raw_field in fields.items():
path = str(raw_path)
if not path.startswith("state."):
path = f"state.{path}"
if isinstance(raw_field, BaseModel):
field_data = raw_field.model_dump(mode="python")
elif isinstance(raw_field, Mapping):
field_data = dict(raw_field)
if "schema" in field_data or "type" not in field_data:
raise ValueError(
"legacy state field map entries must include 'type'; "
"use canonical list form for entries with 'schema'"
)
else:
raise ValueError(
"legacy state field map entries must include 'type'; "
"use canonical list form for non-legacy declarations"
)
field_data["path"] = path
normalized_fields.append(field_data)
data["fields"] = normalized_fields
return data
@model_validator(mode="after")
def _reject_duplicate_field_paths(self) -> StateSchema:
seen: set[str] = set()
for field in self.fields:
key = ".".join(field.path.parts)
if key in seen:
raise ValueError(f"duplicate state field path {key!r}")
seen.add(key)
return self
class NodeDef(BaseModel):
+4 -3
View File
@@ -78,14 +78,15 @@ class NodeUse(BaseModel):
input_values = cls._deprecated_mapping(
normalized.pop("input_values", {}), field_name="input_values"
)
in_map = cls._deprecated_mapping(normalized.pop("in_map", {}), field_name="in_map")
in_map = cls._deprecated_mapping(
normalized.pop("in_map", {}), field_name="in_map"
)
out_map = cls._deprecated_mapping(
normalized.pop("out_map", {}), field_name="out_map"
)
input_bindings.extend(
{"target": target, "value": value}
for target, value in input_values.items()
{"target": target, "value": value} for target, value in input_values.items()
)
input_bindings.extend(
{"target": target, "path": path} for path, target in in_map.items()
+8 -9
View File
@@ -90,7 +90,7 @@ class LocalPath:
return core_schema.no_info_plain_validator_function(
validate,
serialization=core_schema.plain_serializer_function_ser_schema(
str, when_used="json"
str,
),
)
@@ -112,9 +112,7 @@ class GraphSourcePath:
parts: tuple[str, ...] = ()
_ROOTS: ClassVar[set[str]] = {"input", "state", "context"}
_JSON_PATTERN: ClassVar[str] = (
r"^(input|state|context)(\.[A-Za-z_][A-Za-z0-9_]*)*$"
)
_JSON_PATTERN: ClassVar[str] = r"^(input|state|context)(\.[A-Za-z_][A-Za-z0-9_]*)*$"
def __post_init__(self) -> None:
if self.root not in self._ROOTS:
@@ -123,8 +121,7 @@ class GraphSourcePath:
self,
"parts",
tuple(
_validate_segment(part, path_kind="graph source")
for part in self.parts
_validate_segment(part, path_kind="graph source") for part in self.parts
),
)
@@ -167,7 +164,7 @@ class GraphSourcePath:
return core_schema.no_info_plain_validator_function(
validate,
serialization=core_schema.plain_serializer_function_ser_schema(
str, when_used="json"
str,
),
)
@@ -187,7 +184,9 @@ class StatePath:
parts: tuple[str, ...]
_JSON_PATTERN: ClassVar[str] = r"^state\.[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$"
_JSON_PATTERN: ClassVar[str] = (
r"^state\.[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$"
)
def __post_init__(self) -> None:
parts = tuple(_validate_segment(part, path_kind="state") for part in self.parts)
@@ -226,7 +225,7 @@ class StatePath:
return core_schema.no_info_plain_validator_function(
validate,
serialization=core_schema.plain_serializer_function_ser_schema(
str, when_used="json"
str,
),
)
+5 -5
View File
@@ -3,15 +3,15 @@ from __future__ import annotations
from copy import deepcopy
from wf_core.models.workflow import Workflow
from wf_core.paths import set_nested_value
from wf_core.run_state import ExecutionFrame, FrameStatus, RunState, RunStatus
def create_run_state(workflow: Workflow, workflow_input: dict[str, object]) -> RunState:
state = {
name: deepcopy(field.default)
for name, field in workflow.state_schema.fields.items()
if field.default is not None
}
state: dict[str, object] = {}
for field in workflow.state_schema.fields:
if field.default is not None:
set_nested_value(state, list(field.path.parts), deepcopy(field.default))
state.update(dict(workflow_input))
run = RunState(
workflow_name=workflow.name,
+8 -1
View File
@@ -7,6 +7,7 @@ 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.schemas import StateFieldDecl
from wf_core.models.steps import NodeUse, OutputBinding
from wf_core.models.workflow import Workflow
from wf_core.paths import (
@@ -59,6 +60,7 @@ def apply_output_bindings(
"mapped state patch has overlapping destination paths"
)
state_fields = workflow.state_schema.field_map()
resolved_patch: dict[StatePath, Any] = {}
for binding in bindings:
try:
@@ -77,6 +79,7 @@ def apply_output_bindings(
destination_path,
value,
reducers=reducers,
state_fields=state_fields,
)
prepared_patch[destination_path] = (key_path, merged_value)
@@ -137,6 +140,7 @@ def prepare_state_value(
value: Any,
*,
reducers: Mapping[str, ReducerDefinition] | None = None,
state_fields: Mapping[str, StateFieldDecl] | None = None,
) -> tuple[list[str], Any]:
"""Resolve reducer output for a state write without mutating state."""
try:
@@ -150,7 +154,10 @@ def prepare_state_value(
)
declared_path = ".".join(parts)
declared_field = workflow.state_schema.fields.get(declared_path)
fields = (
state_fields if state_fields is not None else workflow.state_schema.field_map()
)
declared_field = fields.get(declared_path)
reducer = (
declared_field.reducer if declared_field else ReducerRef(name="wf.std.replace")
)
+1 -1
View File
@@ -54,7 +54,7 @@ def _validate_nodes(
report: ValidationReport,
) -> dict[str, Step]:
nodes_by_id: dict[str, Step] = {}
state_root_fields = set(workflow.state_schema.fields)
state_root_fields = workflow.state_schema.root_fields()
input_root_fields = set(workflow.input_schema.properties)
for index, node in enumerate(workflow.nodes):
+1 -2
View File
@@ -47,8 +47,7 @@ def validate_node_use(
input_fields = set(node_def.input_schema.properties)
output_fields = set(node_def.output_schema.properties)
state_fields = set(workflow.state_schema.fields)
state_root_fields = {field.split(".", maxsplit=1)[0] for field in state_fields}
state_root_fields = workflow.state_schema.root_fields()
input_root_fields = set(workflow.input_schema.properties)
input_targets = []