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
+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)