docs: archive completed superpowers plans
This commit is contained in:
@@ -0,0 +1,873 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user