sched: add typed occurrence expression kind for schedule inputs (T04)

This commit is contained in:
lda
2026-09-08 10:09:31 +07:00 Verified
parent 352686128b
commit 0ded9b0d40
3 changed files with 323 additions and 2 deletions
+71 -1
View File
@@ -103,6 +103,46 @@ type InputExpression = Annotated[
]
class OccurrenceExpression(BaseModel):
"""Reference one typed schedule-occurrence field.
Schedule-only leaf: graph expressions must not accept this kind, and
``GraphSourcePath`` roots stay closed to input/state/context.
"""
model_config = ConfigDict(extra="forbid", strict=True)
kind: Literal["occurrence"]
field: Literal["schedule_id", "occurrence_id", "scheduled_at"]
class ScheduleArrayExpression(BaseModel):
"""Resolve ordered child schedule expressions into one JSON array."""
model_config = ConfigDict(extra="forbid")
kind: Literal["array"]
items: list[ScheduleExpression]
class ScheduleObjectExpression(BaseModel):
"""Resolve named child schedule expressions into one JSON object."""
model_config = ConfigDict(extra="forbid")
kind: Literal["object"]
fields: dict[str, ScheduleExpression]
type ScheduleExpression = Annotated[
LiteralExpression
| OccurrenceExpression
| ScheduleArrayExpression
| ScheduleObjectExpression,
Field(discriminator="kind"),
]
def _raise_limit(limit: str, location: str) -> None:
raise ValueError(f"input expression {limit} limit exceeded at {location}")
@@ -206,5 +246,35 @@ StepInputBinding = TypeAliasType(
)
for _model in (ArrayExpression, ObjectExpression, InputExpressionBinding):
class ScheduleInputBinding(BaseModel):
"""Assign one schedule-side expression to a workflow-input target.
Schedule expressions reuse literal/object/array composition and the same
budget validator, but leaves are typed occurrence references only. Graph
paths are invalid here.
"""
model_config = ConfigDict(extra="forbid")
target: LocalPath
expression: ScheduleExpression
@field_validator("expression", mode="before")
@classmethod
def check_limits(cls, value: object) -> object:
raw_value = (
value.model_dump(mode="python") if isinstance(value, BaseModel) else value
)
validate_input_expression_limits(raw_value)
return value
for _model in (
ArrayExpression,
ObjectExpression,
InputExpressionBinding,
ScheduleArrayExpression,
ScheduleObjectExpression,
ScheduleInputBinding,
):
_model.model_rebuild()
+82 -1
View File
@@ -10,16 +10,22 @@ values through a faked graph ``context`` mapping.
from __future__ import annotations
from collections.abc import Iterator, Mapping
from collections.abc import Iterator, Mapping, Sequence
from typing import Any, Protocol
from wf_core.errors import WorkflowExecutionError
from wf_core.local_paths import LocalPathError, has_overlapping_paths, set_local_value
from wf_core.models.input_bindings import (
ArrayExpression,
InputExpression,
LiteralExpression,
ObjectExpression,
OccurrenceExpression,
PathExpression,
ScheduleArrayExpression,
ScheduleExpression,
ScheduleInputBinding,
ScheduleObjectExpression,
)
from wf_core.models.json_values import JsonValue, validate_strict_json_value
from wf_core.paths import GraphSourcePath, resolve_graph_path
@@ -114,6 +120,81 @@ def resolve_composed_expression(
raise WorkflowExecutionError(f"unsupported input expression for {label} {location}")
def resolve_schedule_expression(
expression: ScheduleExpression,
*,
occurrence: Mapping[str, Any],
label: str,
location: str,
) -> JsonValue:
"""Resolve one schedule-side expression against the occurrence environment.
Composition (literal/array/object) is shared with the graph traversal
above; leaves are typed occurrence fields only. Graph paths are invalid
here and never reach ``GraphSourcePath``.
"""
if isinstance(expression, LiteralExpression):
return expression.value
if isinstance(expression, OccurrenceExpression):
try:
if expression.field not in occurrence:
raise WorkflowExecutionError(
f"unknown occurrence field {expression.field!r}"
)
return validate_strict_json_value(occurrence[expression.field])
except (ValueError, WorkflowExecutionError) as exc:
raise WorkflowExecutionError(f"{label} {location}: {exc}") from exc
if isinstance(expression, ScheduleArrayExpression):
return [
resolve_schedule_expression(
item,
occurrence=occurrence,
label=label,
location=f"{location}[{index}]",
)
for index, item in enumerate(expression.items)
]
if isinstance(expression, ScheduleObjectExpression):
return {
field: resolve_schedule_expression(
value,
occurrence=occurrence,
label=label,
location=f"{location}.{field}",
)
for field, value in expression.fields.items()
}
raise WorkflowExecutionError(
f"unsupported schedule expression for {label} {location}"
)
def resolve_schedule_input_bindings(
bindings: Sequence[ScheduleInputBinding],
*,
occurrence: Mapping[str, Any],
label: str,
) -> dict[str, Any]:
"""Build one workflow-input object from schedule-side bindings."""
targets = [str(binding.target) for binding in bindings]
if has_overlapping_paths(targets):
raise WorkflowExecutionError(f"{label}: schedule targets overlap: {targets!r}")
payload: dict[str, Any] = {}
for binding in bindings:
location = str(binding.target)
value = resolve_schedule_expression(
binding.expression,
occurrence=occurrence,
label=label,
location=location,
)
try:
set_local_value(payload, binding.target, value)
except LocalPathError as exc:
raise WorkflowExecutionError(f"{label} {location}: {exc}") from exc
return payload
def walk_expression_paths(
expression: InputExpression,
) -> Iterator[tuple[GraphSourcePath, str]]: