feat: replace draft input bindings from cli

This commit is contained in:
lda
2026-07-22 22:55:25 +07:00 Verified
parent 448895e831
commit 88e51901a7
5 changed files with 526 additions and 34 deletions
+8 -12
View File
@@ -42,6 +42,7 @@ from .draft_options import (
_parse_step_input_map_flags,
parse_json_file,
route_source,
validation_error_as_bad_parameter,
)
app = typer.Typer(
@@ -83,11 +84,6 @@ def _submit_step(
)
def _as_bad_parameter(exc: ValidationError) -> typer.BadParameter:
"""Keep model validation failures on Click's concise input-error surface."""
return typer.BadParameter(str(exc))
@app.command("capability")
def add_step_from_capability(
ctx: typer.Context,
@@ -270,7 +266,7 @@ def add_interrupt_step(
)
)
except ValidationError as exc:
raise _as_bad_parameter(exc) from exc
raise validation_error_as_bad_parameter(exc) from exc
_submit_step(
ctx,
@@ -365,7 +361,7 @@ def add_foreach_step(
)
)
except ValidationError as exc:
raise _as_bad_parameter(exc) from exc
raise validation_error_as_bad_parameter(exc) from exc
_submit_step(
ctx,
@@ -443,7 +439,7 @@ def add_end_step(
try:
step = DraftEndStep(end=DraftEndPayload(outcome=outcome))
except ValidationError as exc:
raise _as_bad_parameter(exc) from exc
raise validation_error_as_bad_parameter(exc) from exc
_submit_step(
ctx,
workspace_id=workspace_id,
@@ -494,7 +490,7 @@ def add_when_step(
)
)
except ValidationError as exc:
raise _as_bad_parameter(exc) from exc
raise validation_error_as_bad_parameter(exc) from exc
_submit_step(
ctx,
workspace_id=workspace_id,
@@ -543,7 +539,7 @@ def add_choose_step(
choose=DraftChoosePayload(clauses=clauses, default=default)
)
except ValidationError as exc:
raise _as_bad_parameter(exc) from exc
raise validation_error_as_bad_parameter(exc) from exc
_submit_step(
ctx,
workspace_id=workspace_id,
@@ -593,7 +589,7 @@ def add_match_step(
match=DraftMatchPayload(value=value, cases=cases, default=default)
)
except ValidationError as exc:
raise _as_bad_parameter(exc) from exc
raise validation_error_as_bad_parameter(exc) from exc
_submit_step(
ctx,
workspace_id=workspace_id,
@@ -724,7 +720,7 @@ def add_subgraph_step(
)
)
except ValidationError as exc:
raise _as_bad_parameter(exc) from exc
raise validation_error_as_bad_parameter(exc) from exc
_submit_step(
ctx,
workspace_id=workspace_id,
+84 -1
View File
@@ -5,9 +5,13 @@ from pathlib import Path
from typing import Any
import typer
from pydantic import TypeAdapter, ValidationError
from wf_api.surface import RouteSource
from wf_core.paths import LocalPath, PathResolutionError, StatePath
from wf_core.models.steps import InputBinding, InputPathBinding, InputValueBinding
from wf_core.paths import GraphSourcePath, LocalPath, PathResolutionError, StatePath
_INPUT_BINDINGS_ADAPTER = TypeAdapter(list[InputBinding])
def _parse_assignment_flags(
@@ -87,6 +91,85 @@ def _parse_step_input_map_flags(
return parsed
def validation_error_as_bad_parameter(
exc: ValidationError,
) -> typer.BadParameter:
"""Keep Pydantic failures on Click's concise input-error surface."""
return typer.BadParameter(str(exc))
def parse_step_input_binding_flags(
values: list[str] | None,
) -> list[InputPathBinding]:
"""Parse ordered canonical path bindings without collapsing source fan-out."""
bindings: list[InputPathBinding] = []
for item in values or []:
source, separator, target = item.partition("=")
if separator != "=" or not source or not target:
raise typer.BadParameter("--map must use GRAPH_SOURCE=LOCAL_TARGET")
if target.startswith("local."):
bare_target = target.removeprefix("local.")
raise typer.BadParameter(
"--map target must be a rootless node-local path; "
f"use {source}={bare_target}, not {source}={target}"
)
try:
source_path = GraphSourcePath.parse(source)
except PathResolutionError as exc:
raise typer.BadParameter(
f"--map source must be a graph source path: {exc}"
) from exc
try:
target_path = LocalPath.parse(target)
except PathResolutionError as exc:
raise typer.BadParameter(
f"--map target must be a rootless node-local path: {exc}"
) from exc
bindings.append(InputPathBinding(path=source_path, target=target_path))
return bindings
def parse_step_input_value_flags(
values: list[str] | None,
) -> list[InputValueBinding]:
"""Parse ordered canonical literal bindings from LOCAL_TARGET=JSON flags."""
bindings: list[InputValueBinding] = []
for item in values or []:
target, separator, raw_value = item.partition("=")
if separator != "=" or not target:
raise typer.BadParameter("--value must use LOCAL_TARGET=JSON")
if target.startswith("local."):
raise typer.BadParameter(
"--value target must be a rootless node-local path"
)
try:
value = json.loads(raw_value)
bindings.append(
InputValueBinding(target=LocalPath.parse(target), value=value)
)
except json.JSONDecodeError as exc:
raise typer.BadParameter(
f"--value for {target!r} is invalid JSON: {exc.msg}"
) from exc
except ValidationError as exc:
raise validation_error_as_bad_parameter(exc) from exc
except PathResolutionError as exc:
raise typer.BadParameter(
f"--value target must be a valid rootless local path: {exc}"
) from exc
return bindings
def parse_step_input_bindings_file(path: Path) -> list[InputBinding]:
"""Read and validate an ordered canonical input-binding list."""
try:
return _INPUT_BINDINGS_ADAPTER.validate_python(
parse_json_file(path, option_name="--bindings-file")
)
except ValidationError as exc:
raise validation_error_as_bad_parameter(exc) from exc
def _parse_route_flags(values: list[str] | None) -> dict[str, str]:
return _parse_assignment_flags(
values,
+78 -14
View File
@@ -12,6 +12,9 @@ from wf_cli.commands.draft_options import (
_parse_route_flags,
_parse_step_input_map_flags,
parse_json_object_file,
parse_step_input_binding_flags,
parse_step_input_bindings_file,
parse_step_input_value_flags,
)
from wf_cli.context import load_cli_context_from_typer as load_cli_context
from wf_cli.formats import ListOutputFormat, emit_list_payload
@@ -371,7 +374,7 @@ def set_draft_route(
@app.command("set-input")
def set_step_input_map(
def set_step_input(
ctx: typer.Context,
workspace_id: Annotated[str, typer.Argument(help="Draft workspace id.")],
revision: Annotated[
@@ -385,19 +388,40 @@ def set_step_input_map(
help="One input binding SOURCE=LOCAL_TARGET. Repeat in one command.",
),
] = None,
literal_values: Annotated[
list[str] | None,
typer.Option(
"--value",
help="Literal input binding LOCAL_TARGET=JSON. Repeat in one command.",
),
] = None,
bindings_file: Annotated[
Path | None,
typer.Option(
"--bindings-file",
help="JSON file containing the complete ordered canonical binding list.",
),
] = None,
clear: Annotated[
bool,
typer.Option("--clear", help="Replace the step's input bindings with []."),
] = False,
merge: Annotated[
bool,
typer.Option(
"--merge",
help="Preserve existing input bindings and add/update the passed --map entries.",
help=(
"Compatibility map-only mode: preserve existing bindings and "
"add/update --map entries."
),
),
] = False,
) -> None:
"""Set one step's input map without writing JSON Patch manually.
"""Replace one step's canonical inputs, or use compatibility map merge.
Default behavior replaces the full input map for this step. Pass all desired
--map entries in one command for a complete replacement. Use --merge only
when adding or updating entries across a later revision.
By default, repeated --map and --value flags replace the complete ordered
binding list. --bindings-file replaces from canonical JSON, while --clear
sends an empty list. Use --merge only with map-only compatibility edits.
Targets are rootless node-local paths. For example, use
`--map input.title=report.title`, not
@@ -408,18 +432,58 @@ def set_step_input_map(
Run `wf draft validate <workspace_id>` after map edits; validation reports
unresolved paths and conflicting writes.
"""
input_map = _parse_step_input_map_flags(mapping)
has_flags = bool(mapping or literal_values)
has_file = bindings_file is not None
selected_modes = sum((has_flags, has_file, clear))
if selected_modes == 0:
raise typer.BadParameter("provide --map/--value, --bindings-file, or --clear")
if selected_modes > 1:
raise typer.BadParameter(
"--bindings-file and --clear cannot be combined with --map or --value"
)
if merge and (literal_values or has_file or clear):
raise typer.BadParameter(
"--merge is supported only for compatibility map-only edits"
)
if merge:
input_map = _parse_step_input_map_flags(mapping)
bindings = None
else:
input_map = None
bindings = (
parse_step_input_bindings_file(bindings_file)
if bindings_file is not None
else []
if clear
else [
*parse_step_input_binding_flags(mapping),
*parse_step_input_value_flags(literal_values),
]
)
context = load_cli_context(ctx)
if merge:
assert input_map is not None
operation = context.handlers.set_step_input_map(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
input_map=input_map,
merge=True,
)
else:
assert bindings is not None
operation = context.handlers.set_step_input_bindings(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
bindings=bindings,
)
emit_json(
run_cli_operation(
context,
context.handlers.set_step_input_map(
workspace_id=workspace_id,
revision=revision,
step_id=step_id,
input_map=input_map,
merge=merge,
),
operation,
)
)
+271 -4
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import json
from types import SimpleNamespace
from typing import Any
@@ -8,7 +9,13 @@ import typer
from typer.testing import CliRunner
from wf_cli.app import app
from wf_cli.commands.draft_options import parse_json_file, route_source
from wf_cli.commands.draft_options import (
parse_json_file,
parse_step_input_binding_flags,
parse_step_input_bindings_file,
parse_step_input_value_flags,
route_source,
)
runner = CliRunner()
@@ -31,6 +38,59 @@ def test_draft_options_route_source_requires_an_incoming_step() -> None:
assert incoming.outcome == "ok"
def test_draft_options_parse_step_input_bindings_preserves_source_fan_out() -> None:
bindings = parse_step_input_binding_flags(
["state.title=request.title", "state.title=audit.title"]
)
assert [str(binding.path) for binding in bindings] == [
"state.title",
"state.title",
]
assert [str(binding.target) for binding in bindings] == [
"request.title",
"audit.title",
]
def test_draft_options_parse_step_input_values_preserves_null_and_equals() -> None:
bindings = parse_step_input_value_flags(
['request.format="markdown=compact"', "request.optional=null"]
)
assert bindings[0].value == "markdown=compact"
assert bindings[1].value is None
def test_draft_options_parse_step_input_bindings_file_validates_union(
tmp_path,
) -> None:
path = tmp_path / "bindings.json"
path.write_text(
json.dumps(
[
{"path": "input.items", "target": "items"},
{"value": ",", "target": "separator"},
]
),
encoding="utf-8",
)
bindings = parse_step_input_bindings_file(path)
assert [binding.model_dump(mode="json") for binding in bindings] == [
{"target": "items", "path": "input.items"},
{"target": "separator", "value": ","},
]
path.write_text(
json.dumps([{"path": "input.items", "value": [], "target": "items"}]),
encoding="utf-8",
)
with pytest.raises(typer.BadParameter, match="validation errors"):
parse_step_input_bindings_file(path)
def test_wf_help_lists_lifecycle_groups() -> None:
result = runner.invoke(app, ["--help"])
@@ -510,7 +570,7 @@ def test_wf_draft_map_help_explains_replace_merge_and_validate() -> None:
input_help = " ".join(input_result.output.split())
output_help = " ".join(output_result.output.split())
workflow_output_help = " ".join(workflow_output_result.output.split())
assert "replaces the full input map" in input_help
assert "replace the complete ordered binding list" in input_help
assert "Use --merge only" in input_help
assert "draft validate" in input_help
assert "input.text=text" in input_help
@@ -1498,7 +1558,7 @@ def test_wf_draft_set_input_accepts_nested_rootless_target(monkeypatch) -> None:
calls: list[dict[str, Any]] = []
class FakeHandlers:
async def set_step_input_map(self, **kwargs: Any) -> dict[str, Any]:
async def set_step_input_bindings(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"revision": 2, "status": "valid"}
@@ -1521,7 +1581,214 @@ def test_wf_draft_set_input_accepts_nested_rootless_target(monkeypatch) -> None:
)
assert result.exit_code == 0, result.output
assert calls[0]["input_map"] == {"input.title": "report.title"}
assert [binding.model_dump(mode="json") for binding in calls[0]["bindings"]] == [
{"target": "report.title", "path": "input.title"}
]
def test_wf_draft_set_input_combines_path_and_literal_replacement(monkeypatch) -> None:
calls: list[dict[str, Any]] = []
class FakeHandlers:
async def set_step_input_bindings(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"revision": 2, "status": "valid"}
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
monkeypatch.setattr("wf_cli.commands.drafts.load_cli_context", lambda _ctx: context)
result = runner.invoke(
app,
[
"draft",
"set-input",
"report_ws",
"--revision",
"1",
"--step",
"render",
"--map",
"state.title=request.title",
"--map",
"state.title=audit.title",
"--value",
'request.format="markdown"',
],
)
assert result.exit_code == 0, result.output
assert len(calls) == 1
assert [binding.model_dump(mode="json") for binding in calls[0]["bindings"]] == [
{"target": "request.title", "path": "state.title"},
{"target": "audit.title", "path": "state.title"},
{"target": "request.format", "value": "markdown"},
]
def test_wf_draft_set_input_replaces_from_bindings_file_in_order(
monkeypatch, tmp_path
) -> None:
calls: list[dict[str, Any]] = []
class FakeHandlers:
async def set_step_input_bindings(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"revision": 2, "status": "valid"}
bindings_path = tmp_path / "bindings.json"
bindings_path.write_text(
json.dumps(
[
{"value": "markdown", "target": "request.format"},
{"path": "state.title", "target": "request.title"},
]
),
encoding="utf-8",
)
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
monkeypatch.setattr("wf_cli.commands.drafts.load_cli_context", lambda _ctx: context)
result = runner.invoke(
app,
[
"draft",
"set-input",
"report_ws",
"--revision",
"1",
"--step",
"render",
"--bindings-file",
str(bindings_path),
],
)
assert result.exit_code == 0, result.output
assert [binding.model_dump(mode="json") for binding in calls[0]["bindings"]] == [
{"target": "request.format", "value": "markdown"},
{"target": "request.title", "path": "state.title"},
]
def test_wf_draft_set_input_clear_sends_empty_binding_list(monkeypatch) -> None:
calls: list[dict[str, Any]] = []
class FakeHandlers:
async def set_step_input_bindings(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"revision": 2, "status": "valid"}
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
monkeypatch.setattr("wf_cli.commands.drafts.load_cli_context", lambda _ctx: context)
result = runner.invoke(
app,
[
"draft",
"set-input",
"report_ws",
"--revision",
"1",
"--step",
"render",
"--clear",
],
)
assert result.exit_code == 0, result.output
assert calls[0]["bindings"] == []
@pytest.mark.parametrize(
("extra_args", "expected_error"),
[
([], "provide --map/--value, --bindings-file, or --clear"),
(
["--bindings-file", "bindings.json", "--map", "input.x=x"],
"cannot be combined with --map or",
),
(
["--clear", "--value", "x=null"],
"cannot be combined with --map or",
),
(
["--merge", "--value", "x=null"],
"--merge is supported only for compatibility map-only edits",
),
(
["--merge", "--bindings-file", "bindings.json"],
"--merge is supported only for compatibility map-only edits",
),
(
["--merge", "--clear"],
"--merge is supported only for compatibility map-only edits",
),
],
)
def test_wf_draft_set_input_rejects_invalid_mode_combinations(
monkeypatch, extra_args: list[str], expected_error: str
) -> None:
monkeypatch.setattr(
"wf_cli.commands.drafts.load_cli_context",
lambda _ctx: (_ for _ in ()).throw(AssertionError("context loaded")),
)
result = runner.invoke(
app,
[
"draft",
"set-input",
"report_ws",
"--revision",
"1",
"--step",
"render",
*extra_args,
],
)
assert result.exit_code == 2
assert "context loaded" not in result.output
assert expected_error in " ".join(result.output.split())
def test_wf_draft_set_input_merge_keeps_compatibility_map_handler(monkeypatch) -> None:
calls: list[dict[str, Any]] = []
class FakeHandlers:
async def set_step_input_map(self, **kwargs: Any) -> dict[str, Any]:
calls.append(kwargs)
return {"revision": 2, "status": "valid"}
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
monkeypatch.setattr("wf_cli.commands.drafts.load_cli_context", lambda _ctx: context)
result = runner.invoke(
app,
[
"draft",
"set-input",
"report_ws",
"--revision",
"1",
"--step",
"render",
"--map",
"input.title=title",
"--merge",
],
)
assert result.exit_code == 0, result.output
assert calls == [
{
"workspace_id": "report_ws",
"revision": 1,
"step_id": "render",
"input_map": {"input.title": "title"},
"merge": True,
}
]
def test_wf_draft_set_input_rejects_malformed_local_path(monkeypatch) -> None:
+85 -3
View File
@@ -1158,8 +1158,8 @@ def test_wf_draft_focused_edit_commands_use_rpc_target(monkeypatch, tmp_path) ->
"3",
"--step",
"call",
"--map",
"input.value=value",
"--value",
'value="seed"',
],
)
output_mapped = runner.invoke(
@@ -1228,7 +1228,7 @@ def test_wf_draft_focused_edit_commands_use_rpc_target(monkeypatch, tmp_path) ->
assert draft["steps"]["call"]["input"] == [
{
"target": "value",
"path": "input.value",
"value": "seed",
},
{
"target": "extra",
@@ -1433,6 +1433,7 @@ def test_wf_draft_set_input_preserves_nested_target_over_rpc(
"call",
"--map",
"input.value=payload.value",
"--merge",
],
)
inspected = runner.invoke(
@@ -1448,6 +1449,87 @@ def test_wf_draft_set_input_preserves_nested_target_over_rpc(
]
def test_wf_draft_set_input_replaces_canonical_bindings_over_rpc(
monkeypatch, tmp_path
) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
_patch_rpc_client_to_server(monkeypatch, server)
rpc_methods: list[str] = []
original_call = RpcClientTransport._call
async def recording_call(
self: RpcClientTransport, method: str, params: dict[str, Any]
) -> dict[str, Any]:
rpc_methods.append(method)
return await original_call(self, method, params)
monkeypatch.setattr(RpcClientTransport, "_call", recording_call)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
base_args = ["--config", str(config_path), "--url", "http://test/rpc"]
created = runner.invoke(
app,
[
*base_args,
"draft",
"create",
"filter_ws",
"--capability",
"wf.std.filter_items",
"--name",
"filter_items",
],
)
initial = runner.invoke(
app,
[*base_args, "draft", "inspect", "filter_ws", "--include-draft"],
)
assert created.exit_code == 0, created.output
assert initial.exit_code == 0, initial.output
bindings_path = tmp_path / "input-bindings.json"
initial_bindings = json.loads(initial.output)["draft"]["steps"]["call"]["input"]
bindings_path.write_text(json.dumps(initial_bindings), encoding="utf-8")
assert json.loads(bindings_path.read_text(encoding="utf-8")) == initial_bindings
replacement = [
{"path": "input.key", "target": "key"},
{"path": "input.key", "target": "value"},
{"value": [], "target": "items"},
]
bindings_path.write_text(json.dumps(replacement), encoding="utf-8")
replaced = runner.invoke(
app,
[
*base_args,
"draft",
"set-input",
"filter_ws",
"--revision",
"1",
"--step",
"call",
"--bindings-file",
str(bindings_path),
],
)
inspected = runner.invoke(
app,
[*base_args, "draft", "inspect", "filter_ws", "--include-draft"],
)
assert replaced.exit_code == 0, replaced.output
assert json.loads(replaced.output)["revision"] == 2
assert inspected.exit_code == 0, inspected.output
assert json.loads(inspected.output)["draft"]["steps"]["call"]["input"] == [
{"target": "key", "path": "input.key"},
{"target": "value", "path": "input.key"},
{"target": "items", "value": []},
]
assert rpc_methods.count("workflow.draft_workspaces.set_step_input_bindings") == 1
def test_wf_draft_add_capability_uses_rpc_target(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
_patch_rpc_client_to_server(monkeypatch, server)