feat: replace draft input bindings from cli
This commit is contained in:
@@ -42,6 +42,7 @@ from .draft_options import (
|
|||||||
_parse_step_input_map_flags,
|
_parse_step_input_map_flags,
|
||||||
parse_json_file,
|
parse_json_file,
|
||||||
route_source,
|
route_source,
|
||||||
|
validation_error_as_bad_parameter,
|
||||||
)
|
)
|
||||||
|
|
||||||
app = typer.Typer(
|
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")
|
@app.command("capability")
|
||||||
def add_step_from_capability(
|
def add_step_from_capability(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
@@ -270,7 +266,7 @@ def add_interrupt_step(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
raise _as_bad_parameter(exc) from exc
|
raise validation_error_as_bad_parameter(exc) from exc
|
||||||
|
|
||||||
_submit_step(
|
_submit_step(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -365,7 +361,7 @@ def add_foreach_step(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
raise _as_bad_parameter(exc) from exc
|
raise validation_error_as_bad_parameter(exc) from exc
|
||||||
|
|
||||||
_submit_step(
|
_submit_step(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -443,7 +439,7 @@ def add_end_step(
|
|||||||
try:
|
try:
|
||||||
step = DraftEndStep(end=DraftEndPayload(outcome=outcome))
|
step = DraftEndStep(end=DraftEndPayload(outcome=outcome))
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
raise _as_bad_parameter(exc) from exc
|
raise validation_error_as_bad_parameter(exc) from exc
|
||||||
_submit_step(
|
_submit_step(
|
||||||
ctx,
|
ctx,
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
@@ -494,7 +490,7 @@ def add_when_step(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
raise _as_bad_parameter(exc) from exc
|
raise validation_error_as_bad_parameter(exc) from exc
|
||||||
_submit_step(
|
_submit_step(
|
||||||
ctx,
|
ctx,
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
@@ -543,7 +539,7 @@ def add_choose_step(
|
|||||||
choose=DraftChoosePayload(clauses=clauses, default=default)
|
choose=DraftChoosePayload(clauses=clauses, default=default)
|
||||||
)
|
)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
raise _as_bad_parameter(exc) from exc
|
raise validation_error_as_bad_parameter(exc) from exc
|
||||||
_submit_step(
|
_submit_step(
|
||||||
ctx,
|
ctx,
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
@@ -593,7 +589,7 @@ def add_match_step(
|
|||||||
match=DraftMatchPayload(value=value, cases=cases, default=default)
|
match=DraftMatchPayload(value=value, cases=cases, default=default)
|
||||||
)
|
)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
raise _as_bad_parameter(exc) from exc
|
raise validation_error_as_bad_parameter(exc) from exc
|
||||||
_submit_step(
|
_submit_step(
|
||||||
ctx,
|
ctx,
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
@@ -724,7 +720,7 @@ def add_subgraph_step(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
raise _as_bad_parameter(exc) from exc
|
raise validation_error_as_bad_parameter(exc) from exc
|
||||||
_submit_step(
|
_submit_step(
|
||||||
ctx,
|
ctx,
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
from pydantic import TypeAdapter, ValidationError
|
||||||
|
|
||||||
from wf_api.surface import RouteSource
|
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(
|
def _parse_assignment_flags(
|
||||||
@@ -87,6 +91,85 @@ def _parse_step_input_map_flags(
|
|||||||
return parsed
|
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]:
|
def _parse_route_flags(values: list[str] | None) -> dict[str, str]:
|
||||||
return _parse_assignment_flags(
|
return _parse_assignment_flags(
|
||||||
values,
|
values,
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ from wf_cli.commands.draft_options import (
|
|||||||
_parse_route_flags,
|
_parse_route_flags,
|
||||||
_parse_step_input_map_flags,
|
_parse_step_input_map_flags,
|
||||||
parse_json_object_file,
|
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.context import load_cli_context_from_typer as load_cli_context
|
||||||
from wf_cli.formats import ListOutputFormat, emit_list_payload
|
from wf_cli.formats import ListOutputFormat, emit_list_payload
|
||||||
@@ -371,7 +374,7 @@ def set_draft_route(
|
|||||||
|
|
||||||
|
|
||||||
@app.command("set-input")
|
@app.command("set-input")
|
||||||
def set_step_input_map(
|
def set_step_input(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
workspace_id: Annotated[str, typer.Argument(help="Draft workspace id.")],
|
workspace_id: Annotated[str, typer.Argument(help="Draft workspace id.")],
|
||||||
revision: Annotated[
|
revision: Annotated[
|
||||||
@@ -385,19 +388,40 @@ def set_step_input_map(
|
|||||||
help="One input binding SOURCE=LOCAL_TARGET. Repeat in one command.",
|
help="One input binding SOURCE=LOCAL_TARGET. Repeat in one command.",
|
||||||
),
|
),
|
||||||
] = None,
|
] = 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[
|
merge: Annotated[
|
||||||
bool,
|
bool,
|
||||||
typer.Option(
|
typer.Option(
|
||||||
"--merge",
|
"--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,
|
] = False,
|
||||||
) -> None:
|
) -> 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
|
By default, repeated --map and --value flags replace the complete ordered
|
||||||
--map entries in one command for a complete replacement. Use --merge only
|
binding list. --bindings-file replaces from canonical JSON, while --clear
|
||||||
when adding or updating entries across a later revision.
|
sends an empty list. Use --merge only with map-only compatibility edits.
|
||||||
|
|
||||||
Targets are rootless node-local paths. For example, use
|
Targets are rootless node-local paths. For example, use
|
||||||
`--map input.title=report.title`, not
|
`--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
|
Run `wf draft validate <workspace_id>` after map edits; validation reports
|
||||||
unresolved paths and conflicting writes.
|
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)
|
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(
|
emit_json(
|
||||||
run_cli_operation(
|
run_cli_operation(
|
||||||
context,
|
context,
|
||||||
context.handlers.set_step_input_map(
|
operation,
|
||||||
workspace_id=workspace_id,
|
|
||||||
revision=revision,
|
|
||||||
step_id=step_id,
|
|
||||||
input_map=input_map,
|
|
||||||
merge=merge,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+271
-4
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -8,7 +9,13 @@ import typer
|
|||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
from wf_cli.app import app
|
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()
|
runner = CliRunner()
|
||||||
|
|
||||||
@@ -31,6 +38,59 @@ def test_draft_options_route_source_requires_an_incoming_step() -> None:
|
|||||||
assert incoming.outcome == "ok"
|
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:
|
def test_wf_help_lists_lifecycle_groups() -> None:
|
||||||
result = runner.invoke(app, ["--help"])
|
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())
|
input_help = " ".join(input_result.output.split())
|
||||||
output_help = " ".join(output_result.output.split())
|
output_help = " ".join(output_result.output.split())
|
||||||
workflow_output_help = " ".join(workflow_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 "Use --merge only" in input_help
|
||||||
assert "draft validate" in input_help
|
assert "draft validate" in input_help
|
||||||
assert "input.text=text" 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]] = []
|
calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
class FakeHandlers:
|
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)
|
calls.append(kwargs)
|
||||||
return {"revision": 2, "status": "valid"}
|
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 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:
|
def test_wf_draft_set_input_rejects_malformed_local_path(monkeypatch) -> None:
|
||||||
|
|||||||
@@ -1158,8 +1158,8 @@ def test_wf_draft_focused_edit_commands_use_rpc_target(monkeypatch, tmp_path) ->
|
|||||||
"3",
|
"3",
|
||||||
"--step",
|
"--step",
|
||||||
"call",
|
"call",
|
||||||
"--map",
|
"--value",
|
||||||
"input.value=value",
|
'value="seed"',
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
output_mapped = runner.invoke(
|
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"] == [
|
assert draft["steps"]["call"]["input"] == [
|
||||||
{
|
{
|
||||||
"target": "value",
|
"target": "value",
|
||||||
"path": "input.value",
|
"value": "seed",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"target": "extra",
|
"target": "extra",
|
||||||
@@ -1433,6 +1433,7 @@ def test_wf_draft_set_input_preserves_nested_target_over_rpc(
|
|||||||
"call",
|
"call",
|
||||||
"--map",
|
"--map",
|
||||||
"input.value=payload.value",
|
"input.value=payload.value",
|
||||||
|
"--merge",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
inspected = runner.invoke(
|
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:
|
def test_wf_draft_add_capability_uses_rpc_target(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
_patch_rpc_client_to_server(monkeypatch, server)
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
|
|||||||
Reference in New Issue
Block a user