feat: export draft documents
This commit is contained in:
@@ -24,7 +24,13 @@ from wf_cli.commands.draft_options import (
|
|||||||
)
|
)
|
||||||
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
|
||||||
from wf_cli.io import CliInputError, emit_json, parse_bindings, parse_json_value
|
from wf_cli.io import (
|
||||||
|
CliInputError,
|
||||||
|
emit_json,
|
||||||
|
parse_bindings,
|
||||||
|
parse_json_value,
|
||||||
|
write_json_file,
|
||||||
|
)
|
||||||
from wf_cli.remote_errors import run_cli_operation
|
from wf_cli.remote_errors import run_cli_operation
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
@@ -88,6 +94,35 @@ def inspect_draft(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.command("export")
|
||||||
|
def export_draft(
|
||||||
|
ctx: typer.Context,
|
||||||
|
workspace_id: Annotated[str, typer.Argument(help="Draft workspace id.")],
|
||||||
|
output: Annotated[Path, typer.Option("--output", help="Destination JSON file.")],
|
||||||
|
force: Annotated[
|
||||||
|
bool, typer.Option("--force", help="Replace an existing destination file.")
|
||||||
|
] = False,
|
||||||
|
) -> None:
|
||||||
|
"""Export only the exact draft document from an existing workspace."""
|
||||||
|
context = load_cli_context(ctx)
|
||||||
|
payload = run_cli_operation(
|
||||||
|
context,
|
||||||
|
context.handlers.get_draft_workspace(
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
include_draft=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
draft = payload.get("draft")
|
||||||
|
if not isinstance(draft, dict):
|
||||||
|
raise typer.BadParameter(
|
||||||
|
"draft workspace response does not contain a draft object"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
write_json_file(output, draft, force=force)
|
||||||
|
except CliInputError as exc:
|
||||||
|
raise typer.BadParameter(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@app.command("create")
|
@app.command("create")
|
||||||
def create_draft(
|
def create_draft(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
|
|||||||
@@ -47,6 +47,21 @@ def emit_json(payload: Any) -> None:
|
|||||||
print(json.dumps(payload, indent=2, sort_keys=True))
|
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||||
|
|
||||||
|
|
||||||
|
def write_json_file(path: Path, payload: Any, *, force: bool) -> None:
|
||||||
|
"""Write stable formatted JSON while refusing accidental replacement."""
|
||||||
|
mode = "w" if force else "x"
|
||||||
|
try:
|
||||||
|
with path.open(mode, encoding="utf-8", newline="\n") as output:
|
||||||
|
output.write(json.dumps(payload, indent=2, sort_keys=True))
|
||||||
|
output.write("\n")
|
||||||
|
except FileExistsError as exc:
|
||||||
|
raise CliInputError(
|
||||||
|
f"file {path!s} already exists; use --force to replace it"
|
||||||
|
) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise CliInputError(f"could not write file {path!s}: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
def parse_bindings(bindings: list[str]) -> dict[str, str]:
|
def parse_bindings(bindings: list[str]) -> dict[str, str]:
|
||||||
"""Parse repeatable logical=concrete source binding flags."""
|
"""Parse repeatable logical=concrete source binding flags."""
|
||||||
parsed: dict[str, str] = {}
|
parsed: dict[str, str] = {}
|
||||||
|
|||||||
@@ -2874,3 +2874,109 @@ def test_wf_draft_set_workflow_output_merge_keeps_compatibility_map_handler(
|
|||||||
"merge": True,
|
"merge": True,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_draft_export_writes_only_formatted_draft_document(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
calls: list[dict[str, Any]] = []
|
||||||
|
expected_draft = {
|
||||||
|
"name": "report",
|
||||||
|
"steps": {"finish": {"end": {}}},
|
||||||
|
"routes": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeHandlers:
|
||||||
|
async def get_draft_workspace(self, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
calls.append(kwargs)
|
||||||
|
return {
|
||||||
|
"workspace_id": "report",
|
||||||
|
"revision": 4,
|
||||||
|
"draft": expected_draft,
|
||||||
|
}
|
||||||
|
|
||||||
|
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
|
||||||
|
monkeypatch.setattr("wf_cli.commands.drafts.load_cli_context", lambda _ctx: context)
|
||||||
|
output_path = tmp_path / "report-draft.json"
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"draft",
|
||||||
|
"export",
|
||||||
|
"report",
|
||||||
|
"--output",
|
||||||
|
str(output_path),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert calls == [{"workspace_id": "report", "include_draft": True}]
|
||||||
|
assert json.loads(output_path.read_text(encoding="utf-8")) == expected_draft
|
||||||
|
assert output_path.read_text(encoding="utf-8") == (
|
||||||
|
json.dumps(expected_draft, indent=2, sort_keys=True) + "\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_draft_export_requires_force_to_replace_existing_file(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
class FakeHandlers:
|
||||||
|
async def get_draft_workspace(self, **_kwargs: Any) -> dict[str, Any]:
|
||||||
|
return {"draft": {"name": "replacement"}}
|
||||||
|
|
||||||
|
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
|
||||||
|
monkeypatch.setattr("wf_cli.commands.drafts.load_cli_context", lambda _ctx: context)
|
||||||
|
output_path = tmp_path / "report-draft.json"
|
||||||
|
output_path.write_text('{"name": "existing"}\n', encoding="utf-8")
|
||||||
|
|
||||||
|
refused = runner.invoke(
|
||||||
|
app,
|
||||||
|
["draft", "export", "report", "--output", str(output_path)],
|
||||||
|
)
|
||||||
|
replaced = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"draft",
|
||||||
|
"export",
|
||||||
|
"report",
|
||||||
|
"--output",
|
||||||
|
str(output_path),
|
||||||
|
"--force",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert refused.exit_code == 2
|
||||||
|
assert "already exists" in refused.output
|
||||||
|
assert replaced.exit_code == 0, replaced.output
|
||||||
|
assert json.loads(output_path.read_text(encoding="utf-8")) == {
|
||||||
|
"name": "replacement"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_draft_export_reports_missing_parent_directory(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
class FakeHandlers:
|
||||||
|
async def get_draft_workspace(self, **_kwargs: Any) -> dict[str, Any]:
|
||||||
|
return {"draft": {"name": "report"}}
|
||||||
|
|
||||||
|
context = SimpleNamespace(handlers=FakeHandlers(), verbose=False)
|
||||||
|
monkeypatch.setattr("wf_cli.commands.drafts.load_cli_context", lambda _ctx: context)
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"draft",
|
||||||
|
"export",
|
||||||
|
"report",
|
||||||
|
"--output",
|
||||||
|
str(tmp_path / "missing" / "report-draft.json"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 2
|
||||||
|
assert "could not write file" in result.output
|
||||||
|
|||||||
@@ -820,6 +820,58 @@ def test_wf_remote_capability_free_draft_lifecycle(monkeypatch, tmp_path) -> Non
|
|||||||
assert set(payload["draft"]["steps"]) == {"gate", "finish"}
|
assert set(payload["draft"]["steps"]) == {"gate", "finish"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_wf_draft_export_uses_remote_get_and_writes_only_draft(
|
||||||
|
monkeypatch, tmp_path
|
||||||
|
) -> None:
|
||||||
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
|
_patch_rpc_client_to_server(monkeypatch, server)
|
||||||
|
rpc_calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
original_call = RpcClientTransport._call
|
||||||
|
|
||||||
|
async def recording_call(
|
||||||
|
self: RpcClientTransport, method: str, params: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
rpc_calls.append((method, params))
|
||||||
|
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", "export_ws", "--name", "report"],
|
||||||
|
)
|
||||||
|
assert created.exit_code == 0, created.output
|
||||||
|
rpc_calls.clear()
|
||||||
|
output_path = tmp_path / "exported-draft.json"
|
||||||
|
|
||||||
|
exported = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
*base_args,
|
||||||
|
"draft",
|
||||||
|
"export",
|
||||||
|
"export_ws",
|
||||||
|
"--output",
|
||||||
|
str(output_path),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exported.exit_code == 0, exported.output
|
||||||
|
assert rpc_calls == [
|
||||||
|
(
|
||||||
|
"workflow.draft_workspaces.get",
|
||||||
|
{"workspace_id": "export_ws", "include_draft": True},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
payload = json.loads(output_path.read_text(encoding="utf-8"))
|
||||||
|
assert payload["name"] == "report"
|
||||||
|
assert "workspace_id" not in payload
|
||||||
|
assert "revision" not in payload
|
||||||
|
|
||||||
|
|
||||||
def test_wf_remote_run_resume_interrupted_deployment(monkeypatch, tmp_path) -> None:
|
def test_wf_remote_run_resume_interrupted_deployment(monkeypatch, tmp_path) -> None:
|
||||||
server = build_local_static_workflow_server(tmp_path / "store")
|
server = build_local_static_workflow_server(tmp_path / "store")
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
|
|||||||
Reference in New Issue
Block a user