path control flow stuff to use the struct directly!
This commit is contained in:
@@ -87,6 +87,41 @@ New canonical graph path JSON uses root/parts objects:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Authoring Path Inputs
|
||||||
|
|
||||||
|
`wf_authoring` accepts ergonomic path inputs and normalizes them into the core
|
||||||
|
path objects before building canonical node bindings.
|
||||||
|
|
||||||
|
Single string arguments are TOML key expressions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
state("person.name") # state -> person -> name
|
||||||
|
state('"person.name"') # state -> "person.name"
|
||||||
|
state('person."full name"') # state -> person -> "full name"
|
||||||
|
```
|
||||||
|
|
||||||
|
Varargs and iterables are literal path segments:
|
||||||
|
|
||||||
|
```python
|
||||||
|
state("person.name", "email") # state -> "person.name" -> email
|
||||||
|
state(("person.name",)) # state -> "person.name"
|
||||||
|
```
|
||||||
|
|
||||||
|
Builder maps use the path kind implied by position:
|
||||||
|
|
||||||
|
```python
|
||||||
|
g.use(
|
||||||
|
node,
|
||||||
|
in_map={input_path('"email.address"'): ("payload.email",)},
|
||||||
|
out_map={("result.score",): state_path("score")},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
In an input map, the key is a graph source path and the value is a node-local
|
||||||
|
input path. In an output map, the key is a node-local output path and the value
|
||||||
|
is a workflow state destination path. This lets authors keep concise helpers
|
||||||
|
without forcing saved workflow JSON back through dotted display strings.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"root": "state",
|
"root": "state",
|
||||||
|
|||||||
@@ -0,0 +1,866 @@
|
|||||||
|
# Builder Canonical Bindings Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make `WorkflowBuilder.use()` and `WorkflowBuilder.use_ref()` expose canonical `input` / `output` binding lists, while keeping `in_map`, `input_values`, and `out_map` as deprecated Python sugar.
|
||||||
|
|
||||||
|
**Architecture:** `wf_core.NodeUse` already stores canonical binding structs: `InputPathBinding`, `InputValueBinding`, and `OutputBinding`. The builder should accept those same structs/dicts directly, normalize them through core models, and reject mixed canonical/deprecated arguments. Map sugar remains for pleasant Python authoring, but JSON/MCP-facing callers should use binding lists so structural path dicts live inside structs, not as unhashable mapping keys.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.14, Pydantic models from `wf_core.models.steps`, `wf_authoring.WorkflowBuilder`, pytest, basedpyright, ruff.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why the Previous Plan Did Not Finish This
|
||||||
|
|
||||||
|
`2026-05-21-authoring-path-inputs.md` focused on path coercion:
|
||||||
|
|
||||||
|
- single-string TOML path parsing
|
||||||
|
- iterable/vararg literal segments
|
||||||
|
- typed `GraphPath`
|
||||||
|
- map normalization from `dict[str, str]` toward typed paths
|
||||||
|
|
||||||
|
That plan made `in_map`, `input_values`, and `out_map` safer, but it did not change the public builder API shape. So the current state is still incomplete:
|
||||||
|
|
||||||
|
```python
|
||||||
|
g.use(node, in_map=..., input_values=..., out_map=...)
|
||||||
|
```
|
||||||
|
|
||||||
|
exists, but:
|
||||||
|
|
||||||
|
```python
|
||||||
|
g.use(node, input=[...], output=[...])
|
||||||
|
```
|
||||||
|
|
||||||
|
does not.
|
||||||
|
|
||||||
|
That matters because structural path dicts cannot be Python dict keys. The JSON/MCP-friendly shape must be list-of-structs:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"input": [
|
||||||
|
{
|
||||||
|
"target": {"root": "local", "parts": ["payload.email"]},
|
||||||
|
"path": {"root": "input", "parts": ["email.address"]}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"source": {"root": "local", "parts": ["result.score"]},
|
||||||
|
"target": {"root": "state", "parts": ["score"]}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
Core already has the right canonical models in `src/wf_core/models/steps.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class InputPathBinding(BaseModel):
|
||||||
|
target: LocalPath
|
||||||
|
path: GraphSourcePath
|
||||||
|
|
||||||
|
|
||||||
|
class InputValueBinding(BaseModel):
|
||||||
|
target: LocalPath
|
||||||
|
value: object
|
||||||
|
|
||||||
|
|
||||||
|
class OutputBinding(BaseModel):
|
||||||
|
source: LocalPath
|
||||||
|
target: StatePath
|
||||||
|
|
||||||
|
|
||||||
|
class NodeUse(BaseModel):
|
||||||
|
input: list[InputBinding] = Field(default_factory=list)
|
||||||
|
output: list[OutputBinding] = Field(default_factory=list)
|
||||||
|
```
|
||||||
|
|
||||||
|
Builder currently has only deprecated/sugar arguments in `src/wf_authoring/builder/core.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def use(
|
||||||
|
self,
|
||||||
|
spec: NodeSpec[Any, Any],
|
||||||
|
*,
|
||||||
|
id: str | None = None,
|
||||||
|
in_map: MapArg | None = None,
|
||||||
|
input_values: Mapping[Any, Any] | None = None,
|
||||||
|
out_map: MapArg | None = None,
|
||||||
|
desc: str | None = None,
|
||||||
|
) -> NodeUse:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the API gap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Public Semantics
|
||||||
|
|
||||||
|
### Canonical Builder Inputs
|
||||||
|
|
||||||
|
Add `input` and `output` parameters:
|
||||||
|
|
||||||
|
```python
|
||||||
|
g.use(
|
||||||
|
node,
|
||||||
|
input=[
|
||||||
|
{
|
||||||
|
"target": {"root": "local", "parts": ["payload.email"]},
|
||||||
|
"path": {"root": "input", "parts": ["email.address"]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": {"root": "local", "parts": ["static.limit"]},
|
||||||
|
"value": 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
output=[
|
||||||
|
{
|
||||||
|
"source": {"root": "local", "parts": ["result.score"]},
|
||||||
|
"target": {"root": "state", "parts": ["score"]},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepted item shapes:
|
||||||
|
|
||||||
|
- existing `InputPathBinding`
|
||||||
|
- existing `InputValueBinding`
|
||||||
|
- existing `OutputBinding`
|
||||||
|
- dicts that `InputPathBinding` / `InputValueBinding` / `OutputBinding` can validate
|
||||||
|
|
||||||
|
### Deprecated Sugar Inputs
|
||||||
|
|
||||||
|
Keep these for Python authors:
|
||||||
|
|
||||||
|
```python
|
||||||
|
g.use(node, in_map={state_path("text"): "payload.text"})
|
||||||
|
g.use(node, input_values={"limit": 10})
|
||||||
|
g.use(node, out_map={"result.score": state_path("score")})
|
||||||
|
```
|
||||||
|
|
||||||
|
But mark them as deprecated in docstrings and warn when explicitly used.
|
||||||
|
|
||||||
|
Auto-mapping still uses the same internal sugar when both canonical and deprecated args are absent.
|
||||||
|
|
||||||
|
### Mixing Rules
|
||||||
|
|
||||||
|
Reject ambiguous combinations:
|
||||||
|
|
||||||
|
- `input` cannot be mixed with `in_map`
|
||||||
|
- `input` cannot be mixed with `input_values`
|
||||||
|
- `output` cannot be mixed with `out_map`
|
||||||
|
|
||||||
|
Exact error examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cannot mix canonical input with deprecated in_map/input_values
|
||||||
|
cannot mix canonical output with deprecated out_map
|
||||||
|
```
|
||||||
|
|
||||||
|
Use built-in `TypeError` for these authoring API misuse errors. This is similar
|
||||||
|
in spirit to Pydantic's user-error category: the caller supplied an invalid API
|
||||||
|
shape, not invalid workflow data.
|
||||||
|
|
||||||
|
### Structural Dict Key Rule
|
||||||
|
|
||||||
|
Do not support structural dicts as mapping keys. Python `dict` keys must be hashable, and adding `frozendict` support is not worth it.
|
||||||
|
|
||||||
|
If a user needs structural dict paths, they should use canonical binding lists:
|
||||||
|
|
||||||
|
```python
|
||||||
|
input=[{"target": {"root": "local", "parts": ["payload"]}, "path": {...}}]
|
||||||
|
```
|
||||||
|
|
||||||
|
Map sugar is for hashable Python authoring values only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Modify: `src/wf_authoring/builder/core.py`
|
||||||
|
- Add `input` / `output` parameters to `use()` and `use_ref()`.
|
||||||
|
- Add canonical/deprecated mixing checks.
|
||||||
|
- Use canonical binding normalization when provided.
|
||||||
|
- Warn when deprecated map-sugar args are explicitly used.
|
||||||
|
|
||||||
|
- Modify: `src/wf_authoring/builder/mapping.py`
|
||||||
|
- Add `InputBindingArg`, `OutputBindingArg` aliases.
|
||||||
|
- Add `normalize_input_bindings(...)`.
|
||||||
|
- Add `normalize_output_bindings(...)`.
|
||||||
|
- Keep map normalizers as deprecated/sugar internals.
|
||||||
|
|
||||||
|
- Modify: `docs/structural_refs.md`
|
||||||
|
- Document canonical `input` / `output` list usage for structural path dicts.
|
||||||
|
- State that structural dicts are not supported as map keys.
|
||||||
|
|
||||||
|
- Modify: `docs/authoring_sketch.md` or `docs/core_state_mapping_and_merge.md`
|
||||||
|
- Replace older “builder uses in_map/out_map” framing with “builder accepts canonical lists; maps are sugar.”
|
||||||
|
|
||||||
|
- Test:
|
||||||
|
- `tests/authoring/test_builder.py`
|
||||||
|
- Maybe `tests/authoring/test_path_inputs.py` only if structural dict errors belong there.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Add Canonical Binding Normalizers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/wf_authoring/builder/mapping.py`
|
||||||
|
- Test: `tests/authoring/test_builder.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for canonical dict bindings**
|
||||||
|
|
||||||
|
Add to `tests/authoring/test_builder.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_builder_use_accepts_canonical_binding_dicts_with_structural_paths() -> None:
|
||||||
|
builder = WorkflowBuilder(
|
||||||
|
name="canonical_binding_dicts",
|
||||||
|
input_schema=AutoBindInput,
|
||||||
|
state_schema=AutoBindState,
|
||||||
|
output_schema=AutoBindOutput,
|
||||||
|
)
|
||||||
|
|
||||||
|
step = builder.use(
|
||||||
|
auto_bind_node,
|
||||||
|
input=[
|
||||||
|
{
|
||||||
|
"target": {"root": "local", "parts": ["payload.text"]},
|
||||||
|
"path": {"root": "input", "parts": ["text.with.dot"]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": {"root": "local", "parts": ["static.limit"]},
|
||||||
|
"value": 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
output=[
|
||||||
|
{
|
||||||
|
"source": {"root": "local", "parts": ["payload.text"]},
|
||||||
|
"target": {"root": "state", "parts": ["text.with.dot"]},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(step.input[0], InputPathBinding)
|
||||||
|
assert step.input[0].path == GraphSourcePath("input", ("text.with.dot",))
|
||||||
|
assert step.input[0].target == LocalPath(("payload.text",))
|
||||||
|
assert isinstance(step.input[1], InputValueBinding)
|
||||||
|
assert step.input[1].target == LocalPath(("static.limit",))
|
||||||
|
assert step.input[1].value == 3
|
||||||
|
assert step.output[0].source == LocalPath(("payload.text",))
|
||||||
|
assert step.output[0].target == StatePath(("text.with.dot",))
|
||||||
|
```
|
||||||
|
|
||||||
|
Update imports:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from wf_core.models.steps import InputPathBinding, InputValueBinding
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify red**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --with pytest pytest tests/authoring/test_builder.py::test_builder_use_accepts_canonical_binding_dicts_with_structural_paths -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: fails because `WorkflowBuilder.use()` has no `input` / `output` parameters.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add normalizer aliases and functions**
|
||||||
|
|
||||||
|
In `src/wf_authoring/builder/mapping.py`, add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from wf_core.models.steps import InputBinding, InputPathBinding, InputValueBinding, OutputBinding
|
||||||
|
|
||||||
|
InputBindingArg: TypeAlias = InputBinding | Mapping[str, object]
|
||||||
|
OutputBindingArg: TypeAlias = OutputBinding | Mapping[str, object]
|
||||||
|
```
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def normalize_input_bindings(bindings: Sequence[InputBindingArg] | None) -> list[InputBinding]:
|
||||||
|
"""Validate canonical input binding structs for WorkflowBuilder.use()."""
|
||||||
|
if bindings is None:
|
||||||
|
return []
|
||||||
|
normalized: list[InputBinding] = []
|
||||||
|
for binding in bindings:
|
||||||
|
if isinstance(binding, InputPathBinding | InputValueBinding):
|
||||||
|
normalized.append(binding)
|
||||||
|
continue
|
||||||
|
if not isinstance(binding, Mapping):
|
||||||
|
raise TypeError(f"unsupported input binding {binding!r}")
|
||||||
|
if "path" in binding:
|
||||||
|
normalized.append(InputPathBinding.model_validate(binding))
|
||||||
|
elif "value" in binding:
|
||||||
|
normalized.append(InputValueBinding.model_validate(binding))
|
||||||
|
else:
|
||||||
|
raise ValueError("input binding must contain either 'path' or 'value'")
|
||||||
|
return normalized
|
||||||
|
```
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def normalize_output_bindings(bindings: Sequence[OutputBindingArg] | None) -> list[OutputBinding]:
|
||||||
|
"""Validate canonical output binding structs for WorkflowBuilder.use()."""
|
||||||
|
if bindings is None:
|
||||||
|
return []
|
||||||
|
normalized: list[OutputBinding] = []
|
||||||
|
for binding in bindings:
|
||||||
|
if isinstance(binding, OutputBinding):
|
||||||
|
normalized.append(binding)
|
||||||
|
continue
|
||||||
|
if not isinstance(binding, Mapping):
|
||||||
|
raise TypeError(f"unsupported output binding {binding!r}")
|
||||||
|
normalized.append(OutputBinding.model_validate(binding))
|
||||||
|
return normalized
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run focused normalizer-related test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --with pytest pytest tests/authoring/test_builder.py::test_builder_use_accepts_canonical_binding_dicts_with_structural_paths -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: still fails until builder signatures are updated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Add `input` / `output` to `use()`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/wf_authoring/builder/core.py`
|
||||||
|
- Test: `tests/authoring/test_builder.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update imports**
|
||||||
|
|
||||||
|
In `src/wf_authoring/builder/core.py`, import new aliases/functions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from .mapping import (
|
||||||
|
InputBindingArg,
|
||||||
|
OutputBindingArg,
|
||||||
|
normalize_input_bindings,
|
||||||
|
normalize_output_bindings,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `use()` signature**
|
||||||
|
|
||||||
|
Change:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def use(
|
||||||
|
self,
|
||||||
|
spec: NodeSpec[Any, Any],
|
||||||
|
*,
|
||||||
|
id: str | None = None,
|
||||||
|
in_map: MapArg | None = None,
|
||||||
|
input_values: Mapping[Any, Any] | None = None,
|
||||||
|
out_map: MapArg | None = None,
|
||||||
|
desc: str | None = None,
|
||||||
|
) -> NodeUse:
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def use(
|
||||||
|
self,
|
||||||
|
spec: NodeSpec[Any, Any],
|
||||||
|
*,
|
||||||
|
id: str | None = None,
|
||||||
|
input: Sequence[InputBindingArg] | None = None,
|
||||||
|
output: Sequence[OutputBindingArg] | None = None,
|
||||||
|
in_map: MapArg | None = None,
|
||||||
|
input_values: Mapping[Any, Any] | None = None,
|
||||||
|
out_map: MapArg | None = None,
|
||||||
|
desc: str | None = None,
|
||||||
|
) -> NodeUse:
|
||||||
|
```
|
||||||
|
|
||||||
|
Import `Sequence` from `collections.abc`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add mixing guard helper**
|
||||||
|
|
||||||
|
Add near the canonical binding helpers:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _reject_mixed_binding_styles(
|
||||||
|
*,
|
||||||
|
input: object | None,
|
||||||
|
output: object | None,
|
||||||
|
in_map: object | None,
|
||||||
|
input_values: object | None,
|
||||||
|
out_map: object | None,
|
||||||
|
) -> None:
|
||||||
|
"""Keep canonical binding lists and deprecated map sugar from mixing."""
|
||||||
|
if input is not None and (in_map is not None or input_values is not None):
|
||||||
|
raise TypeError("cannot mix canonical input with deprecated in_map/input_values")
|
||||||
|
if output is not None and out_map is not None:
|
||||||
|
raise TypeError("cannot mix canonical output with deprecated out_map")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Use canonical bindings when provided**
|
||||||
|
|
||||||
|
In `use()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_reject_mixed_binding_styles(
|
||||||
|
input=input,
|
||||||
|
output=output,
|
||||||
|
in_map=in_map,
|
||||||
|
input_values=input_values,
|
||||||
|
out_map=out_map,
|
||||||
|
)
|
||||||
|
|
||||||
|
if input is not None:
|
||||||
|
node_input = normalize_input_bindings(input)
|
||||||
|
else:
|
||||||
|
raw_in_map = auto_input_map(...) if in_map is None else in_map
|
||||||
|
node_input = _canonical_input_bindings(
|
||||||
|
normalize_input_mapping(raw_in_map),
|
||||||
|
normalize_input_values(input_values),
|
||||||
|
)
|
||||||
|
|
||||||
|
if output is not None:
|
||||||
|
node_output = normalize_output_bindings(output)
|
||||||
|
else:
|
||||||
|
raw_out_map = auto_output_map(...) if out_map is None else out_map
|
||||||
|
node_output = _canonical_output_bindings(normalize_output_mapping(raw_out_map))
|
||||||
|
```
|
||||||
|
|
||||||
|
Then pass:
|
||||||
|
|
||||||
|
```python
|
||||||
|
input=node_input,
|
||||||
|
output=node_output,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run focused test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --with pytest pytest tests/authoring/test_builder.py::test_builder_use_accepts_canonical_binding_dicts_with_structural_paths -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Add `input` / `output` to `use_ref()`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/wf_authoring/builder/core.py`
|
||||||
|
- Test: `tests/authoring/test_builder.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_builder_use_ref_accepts_canonical_binding_dicts() -> None:
|
||||||
|
builder = WorkflowBuilder(
|
||||||
|
name="external_ref_canonical_bindings",
|
||||||
|
input_schema={},
|
||||||
|
state_schema={"fields": {}},
|
||||||
|
output_schema={},
|
||||||
|
)
|
||||||
|
|
||||||
|
step = builder.use_ref(
|
||||||
|
"demo.echo",
|
||||||
|
id="echo",
|
||||||
|
input=[
|
||||||
|
{
|
||||||
|
"target": {"root": "local", "parts": ["text"]},
|
||||||
|
"path": {"root": "input", "parts": ["text"]},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
output=[
|
||||||
|
{
|
||||||
|
"source": {"root": "local", "parts": ["echoed"]},
|
||||||
|
"target": {"root": "state", "parts": ["echoed"]},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert step.node == "demo.echo"
|
||||||
|
assert isinstance(step.input[0], InputPathBinding)
|
||||||
|
assert step.input[0].path == GraphSourcePath.input("text")
|
||||||
|
assert step.output[0].target == StatePath.of("echoed")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update `use_ref()` signature**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
input: Sequence[InputBindingArg] | None = None,
|
||||||
|
output: Sequence[OutputBindingArg] | None = None,
|
||||||
|
```
|
||||||
|
|
||||||
|
before deprecated map args.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Use same mixing guard and normalization**
|
||||||
|
|
||||||
|
`use_ref()` has no auto-map fallback, so logic is simpler:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_reject_mixed_binding_styles(...)
|
||||||
|
|
||||||
|
node_input = (
|
||||||
|
normalize_input_bindings(input)
|
||||||
|
if input is not None
|
||||||
|
else _canonical_input_bindings(
|
||||||
|
normalize_input_mapping(in_map),
|
||||||
|
normalize_input_values(input_values),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
node_output = (
|
||||||
|
normalize_output_bindings(output)
|
||||||
|
if output is not None
|
||||||
|
else _canonical_output_bindings(normalize_output_mapping(out_map))
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run focused test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --with pytest pytest tests/authoring/test_builder.py::test_builder_use_ref_accepts_canonical_binding_dicts -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Deprecate Map Sugar Explicitly
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/wf_authoring/builder/core.py`
|
||||||
|
- Test: `tests/authoring/test_builder.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add warning helper**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _warn_deprecated_binding_sugar(
|
||||||
|
*,
|
||||||
|
in_map: object | None,
|
||||||
|
input_values: object | None,
|
||||||
|
out_map: object | None,
|
||||||
|
) -> None:
|
||||||
|
"""Warn when callers explicitly use map sugar instead of canonical bindings."""
|
||||||
|
used = [
|
||||||
|
name
|
||||||
|
for name, value in (
|
||||||
|
("in_map", in_map),
|
||||||
|
("input_values", input_values),
|
||||||
|
("out_map", out_map),
|
||||||
|
)
|
||||||
|
if value is not None
|
||||||
|
]
|
||||||
|
if not used:
|
||||||
|
return
|
||||||
|
warnings.warn(
|
||||||
|
f"{', '.join(used)} are deprecated WorkflowBuilder sugar; use canonical "
|
||||||
|
"input/output binding lists instead",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=3,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-mapping when args are omitted must not warn.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add warning tests**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_builder_warns_when_explicit_deprecated_maps_are_used() -> None:
|
||||||
|
builder = WorkflowBuilder(
|
||||||
|
name="deprecated_maps",
|
||||||
|
input_schema=AutoBindInput,
|
||||||
|
state_schema=AutoBindState,
|
||||||
|
output_schema=AutoBindOutput,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.warns(DeprecationWarning, match="canonical input/output"):
|
||||||
|
builder.use(
|
||||||
|
auto_bind_node,
|
||||||
|
in_map={"input.text": "text"},
|
||||||
|
out_map={"text": "state.text"},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_builder_auto_mapping_does_not_warn() -> None:
|
||||||
|
builder = WorkflowBuilder(
|
||||||
|
name="auto_map_no_warning",
|
||||||
|
input_schema=AutoBindInput,
|
||||||
|
state_schema=AutoBindState,
|
||||||
|
output_schema=AutoBindOutput,
|
||||||
|
)
|
||||||
|
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("error", DeprecationWarning)
|
||||||
|
builder.use(auto_bind_node)
|
||||||
|
```
|
||||||
|
|
||||||
|
Import `warnings` in the test file.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Call warning helper**
|
||||||
|
|
||||||
|
In `use()` and `use_ref()`, after the mixing guard:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_warn_deprecated_binding_sugar(
|
||||||
|
in_map=in_map,
|
||||||
|
input_values=input_values,
|
||||||
|
out_map=out_map,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run focused warning tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --with pytest pytest tests/authoring/test_builder.py::test_builder_warns_when_explicit_deprecated_maps_are_used tests/authoring/test_builder.py::test_builder_auto_mapping_does_not_warn -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Reject Mixed Styles and Dict Keys Clearly
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/wf_authoring/builder/core.py`
|
||||||
|
- Modify: `src/wf_authoring/builder/mapping.py`
|
||||||
|
- Test: `tests/authoring/test_builder.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add mixed-style tests**
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_builder_rejects_mixed_canonical_and_deprecated_input_styles() -> None:
|
||||||
|
builder = WorkflowBuilder(
|
||||||
|
name="mixed_input_styles",
|
||||||
|
input_schema=AutoBindInput,
|
||||||
|
state_schema=AutoBindState,
|
||||||
|
output_schema=AutoBindOutput,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(TypeError, match="cannot mix canonical input"):
|
||||||
|
builder.use(
|
||||||
|
auto_bind_node,
|
||||||
|
input=[{"target": "text", "path": "input.text"}],
|
||||||
|
in_map={"input.text": "text"},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_builder_rejects_mixed_canonical_and_deprecated_output_styles() -> None:
|
||||||
|
builder = WorkflowBuilder(
|
||||||
|
name="mixed_output_styles",
|
||||||
|
input_schema=AutoBindInput,
|
||||||
|
state_schema=AutoBindState,
|
||||||
|
output_schema=AutoBindOutput,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(TypeError, match="cannot mix canonical output"):
|
||||||
|
builder.use(
|
||||||
|
auto_bind_node,
|
||||||
|
output=[{"source": "text", "target": "state.text"}],
|
||||||
|
out_map={"text": "state.text"},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add dict-key diagnostic test**
|
||||||
|
|
||||||
|
Python literal dicts cannot contain dict keys, so test the normalizer directly with a custom `Mapping` that yields a structural dict key:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class _StructuralKeyMap:
|
||||||
|
def items(self):
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
{"root": "input", "parts": ["email.address"]},
|
||||||
|
"payload.email",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_input_map_rejects_structural_dict_keys_with_clear_message() -> None:
|
||||||
|
with pytest.raises(TypeError, match="structural path dicts cannot be map keys"):
|
||||||
|
normalize_input_mapping(_StructuralKeyMap())
|
||||||
|
```
|
||||||
|
|
||||||
|
Import `normalize_input_mapping` from `wf_authoring.builder.mapping`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement dict-key guard**
|
||||||
|
|
||||||
|
In `normalize_input_mapping()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _reject_mapping_path_key(value: object, *, field_name: str) -> None:
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
raise TypeError(
|
||||||
|
f"structural path dicts cannot be map keys in {field_name}; "
|
||||||
|
"use canonical input/output binding lists instead"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Call it on source keys for input maps and source keys for output maps before coercion.
|
||||||
|
|
||||||
|
Do not reject structural dict values, because values are allowed:
|
||||||
|
|
||||||
|
```python
|
||||||
|
out_map={"result": {"root": "state", "parts": ["score"]}}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run focused tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --with pytest pytest tests/authoring/test_builder.py::test_builder_rejects_mixed_canonical_and_deprecated_input_styles tests/authoring/test_builder.py::test_builder_rejects_mixed_canonical_and_deprecated_output_styles tests/authoring/test_builder.py::test_input_map_rejects_structural_dict_keys_with_clear_message -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/structural_refs.md`
|
||||||
|
- Modify: `docs/authoring_sketch.md`
|
||||||
|
- Modify: `docs/core_state_mapping_and_merge.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update structural refs authoring example**
|
||||||
|
|
||||||
|
In `docs/structural_refs.md`, replace the current map-sugar-first example with canonical binding list example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
g.use(
|
||||||
|
node,
|
||||||
|
input=[
|
||||||
|
{
|
||||||
|
"target": {"root": "local", "parts": ["payload.email"]},
|
||||||
|
"path": {"root": "input", "parts": ["email.address"]},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
output=[
|
||||||
|
{
|
||||||
|
"source": {"root": "local", "parts": ["result.score"]},
|
||||||
|
"target": {"root": "state", "parts": ["score"]},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then state:
|
||||||
|
|
||||||
|
```text
|
||||||
|
`in_map`, `input_values`, and `out_map` remain deprecated Python sugar.
|
||||||
|
Structural path dicts are not valid map keys; use canonical binding lists when
|
||||||
|
working from JSON/MCP.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update authoring sketch**
|
||||||
|
|
||||||
|
In `docs/authoring_sketch.md`, update the API sketch from:
|
||||||
|
|
||||||
|
```python
|
||||||
|
use(node_spec, id=..., in_map=..., out_map=...)
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
use(node_spec, id=..., input=[...], output=[...])
|
||||||
|
```
|
||||||
|
|
||||||
|
Then mention:
|
||||||
|
|
||||||
|
```text
|
||||||
|
`in_map`, `input_values`, and `out_map` are compatibility sugar for Python
|
||||||
|
authors, not the preferred saved or MCP-facing shape.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update core mapping docs**
|
||||||
|
|
||||||
|
In `docs/core_state_mapping_and_merge.md`, ensure the docs say:
|
||||||
|
|
||||||
|
```text
|
||||||
|
The canonical public shape is list-of-binding structs. Deprecated map fields
|
||||||
|
are parse-only compatibility inputs at core level and Python sugar at builder
|
||||||
|
level.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- All touched files.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run focused authoring builder tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --with pytest pytest tests/authoring/test_builder.py -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run authoring tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --with pytest pytest tests/authoring -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run full tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --with pytest pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: pass.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run lint/type checks**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvx ruff check src/wf_authoring tests/authoring
|
||||||
|
uvx ruff format --check src/wf_authoring tests/authoring docs/structural_refs.md docs/authoring_sketch.md docs/core_state_mapping_and_merge.md
|
||||||
|
uv run basedpyright --level error src/wf_authoring tests/authoring
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- ruff check passes
|
||||||
|
- format check passes or reports only markdown files if ruff does not handle them
|
||||||
|
- basedpyright reports `0 errors`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Checklist
|
||||||
|
|
||||||
|
- `g.use(input=[...], output=[...])` exists.
|
||||||
|
- `g.use_ref(input=[...], output=[...])` exists.
|
||||||
|
- `input_values` still exists, but emits `DeprecationWarning` when explicitly used.
|
||||||
|
- `in_map` and `out_map` still exist, but emit `DeprecationWarning` when explicitly used.
|
||||||
|
- Auto-mapping does not warn.
|
||||||
|
- Canonical list inputs support structural path dicts inside binding structs.
|
||||||
|
- Structural dicts as map keys are rejected with a clear message.
|
||||||
|
- Saved/core `NodeUse` output remains canonical `input` / `output`; deprecated map fields do not reappear in dumps.
|
||||||
@@ -44,7 +44,10 @@ from .mapping import (
|
|||||||
auto_input_map,
|
auto_input_map,
|
||||||
auto_output_map,
|
auto_output_map,
|
||||||
coerce_path,
|
coerce_path,
|
||||||
|
normalize_input_mapping,
|
||||||
|
normalize_input_values,
|
||||||
normalize_mapping,
|
normalize_mapping,
|
||||||
|
normalize_output_mapping,
|
||||||
)
|
)
|
||||||
from .refs import (
|
from .refs import (
|
||||||
BranchRef,
|
BranchRef,
|
||||||
@@ -68,28 +71,30 @@ def _condition_base(condition: CoreCondition) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _canonical_input_bindings(
|
def _canonical_input_bindings(
|
||||||
in_map: Mapping[str, str],
|
in_map: Mapping[GraphSourcePath, LocalPath],
|
||||||
input_values: Mapping[str, Any],
|
input_values: Mapping[LocalPath, Any],
|
||||||
) -> list[InputBinding]:
|
) -> list[InputBinding]:
|
||||||
"""Convert authoring compatibility maps into canonical core input bindings."""
|
"""Convert typed authoring maps into canonical core input bindings."""
|
||||||
value_bindings = [
|
value_bindings = [
|
||||||
InputValueBinding(target=LocalPath.parse(target), value=value)
|
InputValueBinding(target=target, value=value)
|
||||||
for target, value in input_values.items()
|
for target, value in input_values.items()
|
||||||
]
|
]
|
||||||
path_bindings = [
|
path_bindings = [
|
||||||
InputPathBinding(
|
InputPathBinding(
|
||||||
target=LocalPath.parse(target),
|
target=target,
|
||||||
path=GraphSourcePath.parse(path),
|
path=path,
|
||||||
)
|
)
|
||||||
for path, target in in_map.items()
|
for path, target in in_map.items()
|
||||||
]
|
]
|
||||||
return [*value_bindings, *path_bindings]
|
return [*value_bindings, *path_bindings]
|
||||||
|
|
||||||
|
|
||||||
def _canonical_output_bindings(out_map: Mapping[str, str]) -> list[OutputBinding]:
|
def _canonical_output_bindings(
|
||||||
"""Convert authoring compatibility maps into canonical core output bindings."""
|
out_map: Mapping[LocalPath, StatePath],
|
||||||
|
) -> list[OutputBinding]:
|
||||||
|
"""Convert typed authoring maps into canonical core output bindings."""
|
||||||
return [
|
return [
|
||||||
OutputBinding(source=LocalPath.parse(source), target=StatePath.parse(target))
|
OutputBinding(source=source, target=target)
|
||||||
for source, target in out_map.items()
|
for source, target in out_map.items()
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -118,28 +123,30 @@ class WorkflowBuilder:
|
|||||||
*,
|
*,
|
||||||
id: str | None = None,
|
id: str | None = None,
|
||||||
in_map: MapArg | None = None,
|
in_map: MapArg | None = None,
|
||||||
input_values: Mapping[str, Any] | None = None,
|
input_values: Mapping[Any, Any] | None = None,
|
||||||
out_map: MapArg | None = None,
|
out_map: MapArg | None = None,
|
||||||
desc: str | None = None,
|
desc: str | None = None,
|
||||||
) -> NodeUse:
|
) -> NodeUse:
|
||||||
self.node_specs[spec.name] = spec
|
self.node_specs[spec.name] = spec
|
||||||
normalized_input_schema = cast(SchemaRef, self.input_schema)
|
normalized_input_schema = cast(SchemaRef, self.input_schema)
|
||||||
normalized_state_schema = cast(StateSchema, self.state_schema)
|
normalized_state_schema = cast(StateSchema, self.state_schema)
|
||||||
normalized_in_map = (
|
raw_in_map = (
|
||||||
auto_input_map(
|
auto_input_map(
|
||||||
spec,
|
spec,
|
||||||
input_schema=normalized_input_schema,
|
input_schema=normalized_input_schema,
|
||||||
state_schema=normalized_state_schema,
|
state_schema=normalized_state_schema,
|
||||||
)
|
)
|
||||||
if in_map is None
|
if in_map is None
|
||||||
else normalize_mapping(in_map)
|
else in_map
|
||||||
)
|
)
|
||||||
normalized_input_values = dict(input_values or {})
|
normalized_in_map = normalize_input_mapping(raw_in_map)
|
||||||
normalized_out_map = (
|
normalized_input_values = normalize_input_values(input_values)
|
||||||
|
raw_out_map = (
|
||||||
auto_output_map(spec, state_schema=normalized_state_schema)
|
auto_output_map(spec, state_schema=normalized_state_schema)
|
||||||
if out_map is None
|
if out_map is None
|
||||||
else normalize_mapping(out_map)
|
else out_map
|
||||||
)
|
)
|
||||||
|
normalized_out_map = normalize_output_mapping(raw_out_map)
|
||||||
node = NodeUse(
|
node = NodeUse(
|
||||||
id=id or self._next_step_id(slug_id(spec.name)),
|
id=id or self._next_step_id(slug_id(spec.name)),
|
||||||
type="node",
|
type="node",
|
||||||
@@ -160,7 +167,7 @@ class WorkflowBuilder:
|
|||||||
*,
|
*,
|
||||||
id: str | None = None,
|
id: str | None = None,
|
||||||
in_map: MapArg | None = None,
|
in_map: MapArg | None = None,
|
||||||
input_values: Mapping[str, Any] | None = None,
|
input_values: Mapping[Any, Any] | None = None,
|
||||||
out_map: MapArg | None = None,
|
out_map: MapArg | None = None,
|
||||||
desc: str | None = None,
|
desc: str | None = None,
|
||||||
) -> NodeUse:
|
) -> NodeUse:
|
||||||
@@ -171,9 +178,9 @@ class WorkflowBuilder:
|
|||||||
hatch for MCP/saved-workflow capability refs that are resolved later by
|
hatch for MCP/saved-workflow capability refs that are resolved later by
|
||||||
the environment runner into node definitions and registry handlers.
|
the environment runner into node definitions and registry handlers.
|
||||||
"""
|
"""
|
||||||
normalized_in_map = normalize_mapping(in_map)
|
normalized_in_map = normalize_input_mapping(in_map)
|
||||||
normalized_input_values = dict(input_values or {})
|
normalized_input_values = normalize_input_values(input_values)
|
||||||
normalized_out_map = normalize_mapping(out_map)
|
normalized_out_map = normalize_output_mapping(out_map)
|
||||||
node = NodeUse(
|
node = NodeUse(
|
||||||
id=id or self._next_step_id(slug_id(name)),
|
id=id or self._next_step_id(slug_id(name)),
|
||||||
type="node",
|
type="node",
|
||||||
@@ -254,6 +261,8 @@ class WorkflowBuilder:
|
|||||||
mode: Literal["serial", "parallel"] = "serial",
|
mode: Literal["serial", "parallel"] = "serial",
|
||||||
on_item_error: Literal["fail", "collect", "skip"] = "fail",
|
on_item_error: Literal["fail", "collect", "skip"] = "fail",
|
||||||
) -> ForeachNode:
|
) -> ForeachNode:
|
||||||
|
# Core foreach still stores `over` as a string. Keep this compatibility
|
||||||
|
# path isolated until ForeachNode grows a typed GraphSourcePath field.
|
||||||
node = ForeachNode.model_validate({
|
node = ForeachNode.model_validate({
|
||||||
"id": id or self._next_step_id(f"foreach_{slug_id(as_)}"),
|
"id": id or self._next_step_id(f"foreach_{slug_id(as_)}"),
|
||||||
"type": "foreach",
|
"type": "foreach",
|
||||||
|
|||||||
@@ -4,11 +4,20 @@ from collections.abc import Mapping
|
|||||||
from typing import Any, TypeAlias
|
from typing import Any, TypeAlias
|
||||||
|
|
||||||
from wf_core import SchemaRef, StateSchema
|
from wf_core import SchemaRef, StateSchema
|
||||||
|
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
|
||||||
|
|
||||||
from ..dsl import GraphPath
|
from ..dsl import GraphPath
|
||||||
|
from ..dsl.path_inputs import (
|
||||||
|
coerce_graph_path,
|
||||||
|
coerce_local_path,
|
||||||
|
coerce_state_path,
|
||||||
|
)
|
||||||
from ..nodes import NodeSpec
|
from ..nodes import NodeSpec
|
||||||
|
|
||||||
MapArg: TypeAlias = Mapping[Any, Any]
|
MapArg: TypeAlias = Mapping[Any, Any]
|
||||||
|
InputMap: TypeAlias = dict[GraphSourcePath, LocalPath]
|
||||||
|
OutputMap: TypeAlias = dict[LocalPath, StatePath]
|
||||||
|
InputValues: TypeAlias = dict[LocalPath, Any]
|
||||||
|
|
||||||
|
|
||||||
def coerce_path(value: object) -> str:
|
def coerce_path(value: object) -> str:
|
||||||
@@ -17,6 +26,8 @@ def coerce_path(value: object) -> str:
|
|||||||
return value
|
return value
|
||||||
if isinstance(value, GraphPath):
|
if isinstance(value, GraphPath):
|
||||||
return value.value
|
return value.value
|
||||||
|
if isinstance(value, GraphSourcePath | StatePath | LocalPath):
|
||||||
|
return str(value)
|
||||||
raise TypeError(f"unsupported graph path value {value!r}")
|
raise TypeError(f"unsupported graph path value {value!r}")
|
||||||
|
|
||||||
|
|
||||||
@@ -30,6 +41,38 @@ def normalize_mapping(mapping: MapArg | None) -> dict[str, str]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_input_mapping(mapping: MapArg | None) -> InputMap:
|
||||||
|
"""Normalize `in_map`: graph source path -> node-local input path."""
|
||||||
|
if mapping is None:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
coerce_graph_path(source.path if isinstance(source, GraphPath) else source): (
|
||||||
|
coerce_local_path(destination)
|
||||||
|
)
|
||||||
|
for source, destination in mapping.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_input_values(mapping: Mapping[Any, Any] | None) -> InputValues:
|
||||||
|
"""Normalize `input_values`: node-local input path -> literal value."""
|
||||||
|
if mapping is None:
|
||||||
|
return {}
|
||||||
|
return {coerce_local_path(target): value for target, value in mapping.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_output_mapping(mapping: MapArg | None) -> OutputMap:
|
||||||
|
"""Normalize `out_map`: node-local output path -> workflow state path."""
|
||||||
|
if mapping is None:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
coerce_local_path(source): coerce_state_path(
|
||||||
|
target.path if isinstance(target, GraphPath) else target,
|
||||||
|
allow_legacy_root=True,
|
||||||
|
)
|
||||||
|
for source, target in mapping.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def auto_input_map(
|
def auto_input_map(
|
||||||
spec: NodeSpec[Any, Any],
|
spec: NodeSpec[Any, Any],
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -15,22 +15,17 @@ from wf_core.models.conditions import (
|
|||||||
from wf_core.paths import GraphSourcePath
|
from wf_core.paths import GraphSourcePath
|
||||||
|
|
||||||
from .paths import GraphPath, context_path, input_path, state_path
|
from .paths import GraphPath, context_path, input_path, state_path
|
||||||
|
from .path_inputs import PathInput
|
||||||
|
|
||||||
|
|
||||||
def _operand(value: object) -> PathOperand | LiteralOperand:
|
def _operand(value: object) -> PathOperand | LiteralOperand:
|
||||||
if isinstance(value, PathExpr):
|
if isinstance(value, PathExpr):
|
||||||
return PathOperand(path=GraphSourcePath.parse(value.path))
|
return PathOperand(path=value.source)
|
||||||
if isinstance(value, GraphPath):
|
if isinstance(value, GraphPath):
|
||||||
return PathOperand(path=GraphSourcePath.parse(value.value))
|
return PathOperand(path=value.path)
|
||||||
return LiteralOperand(value=value)
|
return LiteralOperand(value=value)
|
||||||
|
|
||||||
|
|
||||||
def _path_str(value: PathExpr | GraphPath) -> str:
|
|
||||||
if isinstance(value, PathExpr):
|
|
||||||
return value.path
|
|
||||||
return value.value
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class Expr:
|
class Expr:
|
||||||
condition: Condition
|
condition: Condition
|
||||||
@@ -54,7 +49,11 @@ class Expr:
|
|||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class PathExpr:
|
class PathExpr:
|
||||||
path: str
|
source: GraphSourcePath
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path(self) -> str:
|
||||||
|
return str(self.source)
|
||||||
|
|
||||||
def _binary(
|
def _binary(
|
||||||
self, op: Literal["eq", "ne", "gt", "ge", "lt", "le"], other: object
|
self, op: Literal["eq", "ne", "gt", "ge", "lt", "le"], other: object
|
||||||
@@ -62,7 +61,7 @@ class PathExpr:
|
|||||||
return Expr(
|
return Expr(
|
||||||
BinaryCondition(
|
BinaryCondition(
|
||||||
op=op,
|
op=op,
|
||||||
left=PathOperand(path=GraphSourcePath.parse(self.path)),
|
left=PathOperand(path=self.source),
|
||||||
right=_operand(other),
|
right=_operand(other),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -111,25 +110,24 @@ class PathExpr:
|
|||||||
def expr(value: PathExpr | GraphPath) -> PathExpr:
|
def expr(value: PathExpr | GraphPath) -> PathExpr:
|
||||||
if isinstance(value, PathExpr):
|
if isinstance(value, PathExpr):
|
||||||
return value
|
return value
|
||||||
return PathExpr(path=value.value)
|
return PathExpr(source=value.path)
|
||||||
|
|
||||||
|
|
||||||
def state(field: str) -> PathExpr:
|
def state(first: PathInput, *parts: object) -> PathExpr:
|
||||||
return expr(state_path(field))
|
return expr(state_path(first, *parts))
|
||||||
|
|
||||||
|
|
||||||
def input(field: str) -> PathExpr:
|
def input(first: PathInput, *parts: object) -> PathExpr:
|
||||||
return expr(input_path(field))
|
return expr(input_path(first, *parts))
|
||||||
|
|
||||||
|
|
||||||
def context(field: str) -> PathExpr:
|
def context(first: PathInput, *parts: object) -> PathExpr:
|
||||||
return expr(context_path(field))
|
return expr(context_path(first, *parts))
|
||||||
|
|
||||||
|
|
||||||
def exists(value: PathExpr | GraphPath) -> Expr:
|
def exists(value: PathExpr | GraphPath) -> Expr:
|
||||||
return Expr(
|
path = value.source if isinstance(value, PathExpr) else value.path
|
||||||
ExistsCondition(op="exists", path=GraphSourcePath.parse(_path_str(value)))
|
return Expr(ExistsCondition(op="exists", path=path))
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def not_(value: Condition | Expr) -> Expr:
|
def not_(value: Condition | Expr) -> Expr:
|
||||||
|
|||||||
@@ -4,25 +4,40 @@ from collections.abc import Mapping
|
|||||||
from typing import TypeAlias
|
from typing import TypeAlias
|
||||||
|
|
||||||
from .paths import GraphPath
|
from .paths import GraphPath
|
||||||
|
from .path_inputs import PathInput, coerce_graph_path, coerce_state_path
|
||||||
|
|
||||||
PathArg: TypeAlias = str | GraphPath
|
PathArg: TypeAlias = PathInput | GraphPath
|
||||||
|
|
||||||
|
|
||||||
def normalize_path(path: PathArg) -> str:
|
def normalize_path(path: PathArg) -> str:
|
||||||
|
"""Return display text for compatibility helpers.
|
||||||
|
|
||||||
|
Builder internals should prefer typed path normalizers. This function exists
|
||||||
|
for older `bind_*` helpers and docs examples that still traffic in maps.
|
||||||
|
"""
|
||||||
if isinstance(path, GraphPath):
|
if isinstance(path, GraphPath):
|
||||||
return path.value
|
return path.value
|
||||||
return path
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
def bind_fields(**mapping: PathArg) -> dict[str, str]:
|
def bind_fields(**mapping: PathArg) -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
normalize_path(source): destination for destination, source in mapping.items()
|
str(coerce_graph_path(source.path if isinstance(source, GraphPath) else source)): (
|
||||||
|
destination
|
||||||
|
)
|
||||||
|
for destination, source in mapping.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def bind_state(**mapping: PathArg) -> dict[str, str]:
|
def bind_state(**mapping: PathArg) -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
destination: normalize_path(target) for destination, target in mapping.items()
|
destination: str(
|
||||||
|
coerce_state_path(
|
||||||
|
target.path if isinstance(target, GraphPath) else target,
|
||||||
|
allow_legacy_root=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for destination, target in mapping.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
import tomllib
|
||||||
|
from typing import TypeAlias, cast
|
||||||
|
|
||||||
|
from wf_core.paths import GraphRoot, GraphSourcePath, LocalPath, StatePath
|
||||||
|
|
||||||
|
PathInput: TypeAlias = (
|
||||||
|
str
|
||||||
|
| Iterable[str]
|
||||||
|
| Mapping[str, object]
|
||||||
|
| GraphSourcePath
|
||||||
|
| StatePath
|
||||||
|
| LocalPath
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_toml_key_expr(expr: str) -> tuple[str, ...]:
|
||||||
|
"""Parse one authoring string as a TOML key expression.
|
||||||
|
|
||||||
|
We intentionally lean on `tomllib` instead of maintaining our own dotted-key
|
||||||
|
parser. Quoted TOML keys are the escape hatch for literal dots and spaces.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parsed = tomllib.loads(f"{expr} = true")
|
||||||
|
except tomllib.TOMLDecodeError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"invalid TOML key expression {expr!r}; use quoted keys, varargs, "
|
||||||
|
"or an iterable for literal path segments"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
parts: list[str] = []
|
||||||
|
current: object = parsed
|
||||||
|
while isinstance(current, dict):
|
||||||
|
if len(current) != 1:
|
||||||
|
raise ValueError(f"invalid TOML key expression {expr!r}")
|
||||||
|
key, current = next(iter(current.items()))
|
||||||
|
if not isinstance(key, str):
|
||||||
|
raise ValueError(f"invalid TOML key expression {expr!r}")
|
||||||
|
parts.append(key)
|
||||||
|
if current is not True or not parts:
|
||||||
|
raise ValueError(f"invalid TOML key expression {expr!r}")
|
||||||
|
return tuple(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _literal_parts(values: tuple[object, ...]) -> tuple[str, ...]:
|
||||||
|
"""Normalize varargs or non-string iterables into literal path segments."""
|
||||||
|
if not values:
|
||||||
|
raise ValueError("expected at least one path segment")
|
||||||
|
if len(values) == 1:
|
||||||
|
value = values[0]
|
||||||
|
if isinstance(value, str):
|
||||||
|
return _parse_toml_key_expr(value)
|
||||||
|
if isinstance(value, Iterable) and not isinstance(value, Mapping):
|
||||||
|
parts = tuple(value)
|
||||||
|
if all(isinstance(part, str) for part in parts):
|
||||||
|
return cast(tuple[str, ...], parts)
|
||||||
|
if all(isinstance(value, str) for value in values):
|
||||||
|
return cast(tuple[str, ...], values)
|
||||||
|
raise TypeError("expected a string, string iterable, or string varargs path")
|
||||||
|
|
||||||
|
|
||||||
|
def _structural_parts(value: Mapping[str, object]) -> tuple[str, ...]:
|
||||||
|
raw_parts = value.get("parts", [])
|
||||||
|
if not isinstance(raw_parts, list) or not all(
|
||||||
|
isinstance(part, str) for part in raw_parts
|
||||||
|
):
|
||||||
|
raise ValueError("expected structural path parts to be strings")
|
||||||
|
return tuple(raw_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_graph_path(
|
||||||
|
first: PathInput,
|
||||||
|
*parts: object,
|
||||||
|
root: GraphRoot | None = None,
|
||||||
|
) -> GraphSourcePath:
|
||||||
|
"""Coerce authoring input into a readable graph source path.
|
||||||
|
|
||||||
|
With an explicit root, strings are TOML key expressions and varargs /
|
||||||
|
iterables are literal segments. Without a root, only existing structural or
|
||||||
|
full graph paths are accepted so we do not infer roots from display text.
|
||||||
|
"""
|
||||||
|
if isinstance(first, GraphSourcePath):
|
||||||
|
if parts:
|
||||||
|
raise TypeError("cannot append path segments to an existing graph path")
|
||||||
|
if root is not None and first.root != root:
|
||||||
|
raise ValueError(f"expected {root!r} graph path, got {first.root!r}")
|
||||||
|
return first
|
||||||
|
|
||||||
|
if isinstance(first, StatePath):
|
||||||
|
if parts:
|
||||||
|
raise TypeError("cannot append path segments to an existing state path")
|
||||||
|
if root not in (None, "state"):
|
||||||
|
raise ValueError(f"expected {root!r} graph path, got 'state'")
|
||||||
|
return GraphSourcePath("state", first.parts)
|
||||||
|
|
||||||
|
if isinstance(first, Mapping):
|
||||||
|
if parts:
|
||||||
|
raise TypeError("cannot append path segments to a structural graph path")
|
||||||
|
graph_root = first.get("root")
|
||||||
|
if graph_root not in GraphSourcePath._ROOTS:
|
||||||
|
raise ValueError("expected structural graph source path")
|
||||||
|
if root is not None and graph_root != root:
|
||||||
|
raise ValueError(f"expected {root!r} graph path, got {graph_root!r}")
|
||||||
|
return GraphSourcePath(cast(GraphRoot, graph_root), _structural_parts(first))
|
||||||
|
|
||||||
|
if root is None:
|
||||||
|
if parts:
|
||||||
|
raise TypeError("graph path varargs require an explicit root")
|
||||||
|
if isinstance(first, str):
|
||||||
|
return GraphSourcePath.parse(first)
|
||||||
|
raise TypeError("expected graph path string or structural object")
|
||||||
|
|
||||||
|
return GraphSourcePath(root, _literal_parts((first, *parts)))
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_state_path(
|
||||||
|
first: PathInput,
|
||||||
|
*parts: object,
|
||||||
|
allow_legacy_root: bool = False,
|
||||||
|
) -> StatePath:
|
||||||
|
"""Coerce authoring input into a writable workflow state path."""
|
||||||
|
if isinstance(first, StatePath):
|
||||||
|
if parts:
|
||||||
|
raise TypeError("cannot append path segments to an existing state path")
|
||||||
|
return first
|
||||||
|
if isinstance(first, GraphSourcePath):
|
||||||
|
if parts:
|
||||||
|
raise TypeError("cannot append path segments to an existing graph path")
|
||||||
|
if first.root != "state" or not first.parts:
|
||||||
|
raise ValueError("expected state graph path")
|
||||||
|
return StatePath(first.parts)
|
||||||
|
if isinstance(first, Mapping):
|
||||||
|
if parts:
|
||||||
|
raise TypeError("cannot append path segments to a structural state path")
|
||||||
|
if first.get("root") != "state":
|
||||||
|
raise ValueError("expected structural state path")
|
||||||
|
return StatePath(_structural_parts(first))
|
||||||
|
if allow_legacy_root and isinstance(first, str) and not parts:
|
||||||
|
try:
|
||||||
|
return StatePath.parse(first)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return StatePath(_literal_parts((first, *parts)))
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_local_path(first: PathInput, *parts: object) -> LocalPath:
|
||||||
|
"""Coerce authoring input into a node-local path."""
|
||||||
|
if isinstance(first, LocalPath):
|
||||||
|
if parts:
|
||||||
|
raise TypeError("cannot append path segments to an existing local path")
|
||||||
|
return first
|
||||||
|
if isinstance(first, Mapping):
|
||||||
|
if parts:
|
||||||
|
raise TypeError("cannot append path segments to a structural local path")
|
||||||
|
if first.get("root") != "local":
|
||||||
|
raise ValueError("expected structural local path")
|
||||||
|
return LocalPath(_structural_parts(first))
|
||||||
|
if isinstance(first, str) and first == "." and not parts:
|
||||||
|
return LocalPath.root()
|
||||||
|
return LocalPath(_literal_parts((first, *parts)))
|
||||||
@@ -4,30 +4,41 @@ from dataclasses import dataclass
|
|||||||
|
|
||||||
from wf_core.paths import GraphSourcePath
|
from wf_core.paths import GraphSourcePath
|
||||||
|
|
||||||
|
from .path_inputs import PathInput, coerce_graph_path
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class GraphPath:
|
class GraphPath:
|
||||||
value: str
|
path: GraphSourcePath
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
@property
|
||||||
"""Validate authoring paths at construction so invalid roots fail early."""
|
def value(self) -> str:
|
||||||
object.__setattr__(self, "value", str(GraphSourcePath.parse(self.value)))
|
"""Display compatibility for older authoring helpers."""
|
||||||
|
return str(self.path)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.value
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
def graph_path(value: str) -> GraphPath:
|
def graph_path(value: PathInput | GraphPath) -> GraphPath:
|
||||||
return GraphPath(value)
|
if isinstance(value, GraphPath):
|
||||||
|
return value
|
||||||
|
return GraphPath(coerce_graph_path(value))
|
||||||
|
|
||||||
|
|
||||||
def input_path(*parts: str) -> GraphPath:
|
def input_path(first: PathInput | None = None, *parts: object) -> GraphPath:
|
||||||
return GraphPath(str(GraphSourcePath.input(*parts)))
|
if first is None:
|
||||||
|
return GraphPath(GraphSourcePath("input"))
|
||||||
|
return GraphPath(coerce_graph_path(first, *parts, root="input"))
|
||||||
|
|
||||||
|
|
||||||
def state_path(*parts: str) -> GraphPath:
|
def state_path(first: PathInput | None = None, *parts: object) -> GraphPath:
|
||||||
return GraphPath(str(GraphSourcePath.state(*parts)))
|
if first is None:
|
||||||
|
return GraphPath(GraphSourcePath("state"))
|
||||||
|
return GraphPath(coerce_graph_path(first, *parts, root="state"))
|
||||||
|
|
||||||
|
|
||||||
def context_path(*parts: str) -> GraphPath:
|
def context_path(first: PathInput | None = None, *parts: object) -> GraphPath:
|
||||||
return GraphPath(str(GraphSourcePath.context(*parts)))
|
if first is None:
|
||||||
|
return GraphPath(GraphSourcePath("context"))
|
||||||
|
return GraphPath(coerce_graph_path(first, *parts, root="context"))
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from wf_authoring import WorkflowBuilder, state
|
from wf_authoring import WorkflowBuilder, input_path, state, state_path
|
||||||
from wf_core import END, RunStatus, WorkflowExecutionError
|
from wf_core import END, RunStatus, WorkflowExecutionError
|
||||||
from wf_core.models.steps import InputPathBinding
|
from wf_core.models.steps import InputPathBinding
|
||||||
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
|
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
|
||||||
@@ -66,6 +66,27 @@ def test_builder_preserves_explicit_nested_node_local_maps() -> None:
|
|||||||
assert step.output[0].target == StatePath.of("text")
|
assert step.output[0].target == StatePath.of("text")
|
||||||
|
|
||||||
|
|
||||||
|
def test_builder_use_accepts_typed_paths_and_literal_iterable_paths() -> None:
|
||||||
|
builder = WorkflowBuilder(
|
||||||
|
name="typed_path_maps",
|
||||||
|
input_schema=AutoBindInput,
|
||||||
|
state_schema=AutoBindState,
|
||||||
|
output_schema=AutoBindOutput,
|
||||||
|
)
|
||||||
|
|
||||||
|
step = builder.use(
|
||||||
|
auto_bind_node,
|
||||||
|
in_map={input_path('"text.with.dot"'): ("payload.text",)},
|
||||||
|
out_map={("payload.text",): state_path(("state field",))},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(step.input[0], InputPathBinding)
|
||||||
|
assert step.input[0].path == GraphSourcePath("input", ("text.with.dot",))
|
||||||
|
assert step.input[0].target == LocalPath(("payload.text",))
|
||||||
|
assert step.output[0].source == LocalPath(("payload.text",))
|
||||||
|
assert step.output[0].target == StatePath(("state field",))
|
||||||
|
|
||||||
|
|
||||||
def test_builder_preserves_explicit_root_node_local_maps() -> None:
|
def test_builder_preserves_explicit_root_node_local_maps() -> None:
|
||||||
builder = WorkflowBuilder(
|
builder = WorkflowBuilder(
|
||||||
name="root_local_maps",
|
name="root_local_maps",
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wf_authoring.dsl.path_inputs import (
|
||||||
|
coerce_graph_path,
|
||||||
|
coerce_local_path,
|
||||||
|
coerce_state_path,
|
||||||
|
)
|
||||||
|
from wf_authoring import state, state_path
|
||||||
|
from wf_core.models.conditions import BinaryCondition, PathOperand
|
||||||
|
from wf_core.paths import GraphSourcePath, LocalPath, StatePath
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_string_path_input_uses_toml_dotted_key_syntax() -> None:
|
||||||
|
assert coerce_state_path("person.name") == StatePath(("person", "name"))
|
||||||
|
assert coerce_state_path('"person.name"') == StatePath(("person.name",))
|
||||||
|
assert coerce_state_path('person."three and four"') == StatePath(
|
||||||
|
("person", "three and four")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vararg_path_input_treats_parts_as_literal_segments() -> None:
|
||||||
|
assert coerce_state_path("person.name", "email address") == StatePath(
|
||||||
|
("person.name", "email address")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_iterable_path_input_treats_items_as_literal_segments() -> None:
|
||||||
|
assert coerce_local_path(("payload.text",)) == LocalPath(("payload.text",))
|
||||||
|
assert coerce_local_path(iter(["payload.text"])) == LocalPath(("payload.text",))
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_path_objects_pass_through() -> None:
|
||||||
|
source = GraphSourcePath("state", ("person.name",))
|
||||||
|
assert coerce_graph_path(source) is source
|
||||||
|
|
||||||
|
|
||||||
|
def test_structural_path_dicts_validate_through_core_models() -> None:
|
||||||
|
assert coerce_graph_path({"root": "state", "parts": ["person.name"]}) == (
|
||||||
|
GraphSourcePath("state", ("person.name",))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_toml_path_expression_has_actionable_message() -> None:
|
||||||
|
with pytest.raises(ValueError, match="TOML key expression"):
|
||||||
|
coerce_state_path("person..name")
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_path_helper_supports_toml_strings_and_literal_varargs() -> None:
|
||||||
|
assert state_path('"person.name"').path == GraphSourcePath(
|
||||||
|
"state", ("person.name",)
|
||||||
|
)
|
||||||
|
assert state_path("person.name", "email address").path == GraphSourcePath(
|
||||||
|
"state", ("person.name", "email address")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_expr_helper_uses_same_path_input_rules() -> None:
|
||||||
|
condition = state('"person.name"').eq("Ada").to_condition()
|
||||||
|
|
||||||
|
assert isinstance(condition, BinaryCondition)
|
||||||
|
assert isinstance(condition.left, PathOperand)
|
||||||
|
assert condition.left.path == GraphSourcePath("state", ("person.name",))
|
||||||
Reference in New Issue
Block a user