fix: format cli operation errors

This commit is contained in:
lda
2026-06-05 17:18:39 +07:00 Verified
parent e3bd28968e
commit 04491b2550
14 changed files with 288 additions and 66 deletions
+8
View File
@@ -99,6 +99,14 @@ Detail and mutation commands are JSON-only unless documented otherwise.
There is no `table` format in v1. There is no `table` format in v1.
By default, expected operation failures are shown as compact CLI errors without
Python tracebacks. Use the root `--verbose` flag when debugging internal
failures:
```bash
wf --verbose --url http://127.0.0.1:8765/rpc source inspect missing.source
```
## Lifecycle ## Lifecycle
The normal CLI workflow is: The normal CLI workflow is:
+9
View File
@@ -48,13 +48,22 @@ def root(
float | None, float | None,
typer.Option("--timeout", min=0.1, help="Override RPC timeout seconds."), typer.Option("--timeout", min=0.1, help="Override RPC timeout seconds."),
] = None, ] = None,
verbose: Annotated[
bool,
typer.Option(
"--verbose",
help="Show full tracebacks for unexpected internal errors.",
),
] = False,
) -> None: ) -> None:
"""Run workflow platform commands.""" """Run workflow platform commands."""
app.pretty_exceptions_short = not verbose
ctx.obj = CliTyperState( ctx.obj = CliTyperState(
config_path=config, config_path=config,
force_local=local, force_local=local,
rpc_url=url, rpc_url=url,
rpc_timeout_seconds=timeout, rpc_timeout_seconds=timeout,
verbose=verbose,
) )
+4 -5
View File
@@ -1,12 +1,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from typing import Annotated from typing import Annotated
import typer import typer
from wf_cli.context import load_cli_context_from_typer from wf_cli.context import load_cli_context_from_typer
from wf_cli.formats import ListOutputFormat, emit_list_payload from wf_cli.formats import ListOutputFormat, emit_list_payload
from wf_cli.remote_errors import run_cli_operation
from . import source_registry from . import source_registry
@@ -28,7 +27,7 @@ def list_connections(
) -> None: ) -> None:
"""List configured upstream connections known to the target.""" """List configured upstream connections known to the target."""
context = load_cli_context_from_typer(ctx) context = load_cli_context_from_typer(ctx)
payload = asyncio.run(context.admin.list_connections()) payload = run_cli_operation(context, context.admin.list_connections())
emit_list_payload( emit_list_payload(
payload, payload,
collection_key="connections", collection_key="connections",
@@ -47,7 +46,7 @@ def get_connection_statuses(
) -> None: ) -> None:
"""List connection catalog/status summaries.""" """List connection catalog/status summaries."""
context = load_cli_context_from_typer(ctx) context = load_cli_context_from_typer(ctx)
payload = asyncio.run(context.admin.get_connection_statuses()) payload = run_cli_operation(context, context.admin.get_connection_statuses())
emit_list_payload( emit_list_payload(
payload, payload,
collection_key="statuses", collection_key="statuses",
@@ -66,7 +65,7 @@ def list_events(
) -> None: ) -> None:
"""List recorded workflow platform events.""" """List recorded workflow platform events."""
context = load_cli_context_from_typer(ctx) context = load_cli_context_from_typer(ctx)
payload = asyncio.run(context.admin.list_events()) payload = run_cli_operation(context, context.admin.list_events())
emit_list_payload( emit_list_payload(
payload, payload,
collection_key="events", collection_key="events",
+7 -6
View File
@@ -1,6 +1,4 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from typing import Annotated, Literal from typing import Annotated, Literal
import typer import typer
@@ -8,6 +6,7 @@ import typer
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 emit_json from wf_cli.io import emit_json
from wf_cli.remote_errors import run_cli_operation
app = typer.Typer( app = typer.Typer(
name="artifact", name="artifact",
@@ -38,13 +37,14 @@ def list_artifacts(
) -> None: ) -> None:
"""List compact saved artifact summaries.""" """List compact saved artifact summaries."""
context = load_cli_context(ctx) context = load_cli_context(ctx)
payload = asyncio.run( payload = run_cli_operation(
context,
context.handlers.list_artifacts( context.handlers.list_artifacts(
query=query, query=query,
kind=kind, kind=kind,
cursor=cursor, cursor=cursor,
limit=limit, limit=limit,
) ),
) )
emit_list_payload( emit_list_payload(
payload, payload,
@@ -64,7 +64,8 @@ def inspect_artifact(
"""Inspect one saved artifact version.""" """Inspect one saved artifact version."""
context = load_cli_context(ctx) context = load_cli_context(ctx)
emit_json( emit_json(
asyncio.run( run_cli_operation(
context.handlers.inspect_artifact(artifact_id=artifact_id, version=version) context,
context.handlers.inspect_artifact(artifact_id=artifact_id, version=version),
) )
) )
+7 -6
View File
@@ -1,6 +1,4 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from typing import Annotated from typing import Annotated
import typer import typer
@@ -8,6 +6,7 @@ import typer
from wf_cli.context import load_cli_context_from_typer from wf_cli.context import load_cli_context_from_typer
from wf_cli.formats import ListOutputFormat, emit_list_payload from wf_cli.formats import ListOutputFormat, emit_list_payload
from wf_cli.io import emit_json from wf_cli.io import emit_json
from wf_cli.remote_errors import run_cli_operation
app = typer.Typer( app = typer.Typer(
name="cap", name="cap",
@@ -38,13 +37,14 @@ def list_capabilities(
) -> None: ) -> None:
"""List compact planner-visible workflow capabilities.""" """List compact planner-visible workflow capabilities."""
context = load_cli_context_from_typer(ctx) context = load_cli_context_from_typer(ctx)
payload = asyncio.run( payload = run_cli_operation(
context,
context.handlers.list_capabilities( context.handlers.list_capabilities(
query=query, query=query,
source_id=source_id, source_id=source_id,
cursor=cursor, cursor=cursor,
limit=limit, limit=limit,
) ),
) )
emit_list_payload( emit_list_payload(
payload, payload,
@@ -62,7 +62,8 @@ def inspect_capability(
) -> None: ) -> None:
"""Inspect one workflow capability contract.""" """Inspect one workflow capability contract."""
context = load_cli_context_from_typer(ctx) context = load_cli_context_from_typer(ctx)
payload = asyncio.run( payload = run_cli_operation(
context.handlers.inspect_capability(qualified_name=qualified_name) context,
context.handlers.inspect_capability(qualified_name=qualified_name),
) )
emit_json(payload) emit_json(payload)
+14 -8
View File
@@ -1,6 +1,4 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
@@ -9,6 +7,7 @@ import typer
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_input from wf_cli.io import CliInputError, emit_json, parse_bindings, parse_json_input
from wf_cli.remote_errors import run_cli_operation
app = typer.Typer( app = typer.Typer(
name="deploy", name="deploy",
@@ -31,11 +30,12 @@ def validate_deployment(
) -> None: ) -> None:
"""Validate one saved workflow deployment.""" """Validate one saved workflow deployment."""
context = load_cli_context(ctx) context = load_cli_context(ctx)
payload = asyncio.run( payload = run_cli_operation(
context,
context.handlers.validate_deployment( context.handlers.validate_deployment(
deployment_id=deployment_id, deployment_id=deployment_id,
live_check=live, live_check=live,
) ),
) )
emit_json(payload) emit_json(payload)
@@ -49,7 +49,7 @@ def list_deployments(
) -> None: ) -> None:
"""List saved workflow deployments.""" """List saved workflow deployments."""
context = load_cli_context(ctx) context = load_cli_context(ctx)
payload = asyncio.run(context.handlers.list_deployments()) payload = run_cli_operation(context, context.handlers.list_deployments())
emit_list_payload( emit_list_payload(
payload, payload,
collection_key="deployments", collection_key="deployments",
@@ -67,7 +67,10 @@ def inspect_deployment(
"""Inspect one saved deployment.""" """Inspect one saved deployment."""
context = load_cli_context(ctx) context = load_cli_context(ctx)
emit_json( emit_json(
asyncio.run(context.handlers.inspect_deployment(deployment_id=deployment_id)) run_cli_operation(
context,
context.handlers.inspect_deployment(deployment_id=deployment_id),
)
) )
@@ -107,7 +110,7 @@ def save_deployment(
except CliInputError as exc: except CliInputError as exc:
raise typer.BadParameter(str(exc)) from exc raise typer.BadParameter(str(exc)) from exc
context = load_cli_context(ctx) context = load_cli_context(ctx)
emit_json(asyncio.run(context.handlers.save_deployment(payload))) emit_json(run_cli_operation(context, context.handlers.save_deployment(payload)))
@app.command("delete") @app.command("delete")
@@ -118,7 +121,10 @@ def delete_deployment(
"""Delete one saved deployment.""" """Delete one saved deployment."""
context = load_cli_context(ctx) context = load_cli_context(ctx)
emit_json( emit_json(
asyncio.run(context.handlers.delete_deployment(deployment_id=deployment_id)) run_cli_operation(
context,
context.handlers.delete_deployment(deployment_id=deployment_id),
)
) )
+20 -15
View File
@@ -1,6 +1,4 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from pathlib import Path from pathlib import Path
from typing import Annotated, Literal from typing import Annotated, Literal
@@ -9,6 +7,7 @@ import typer
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
from wf_cli.remote_errors import run_cli_operation
app = typer.Typer( app = typer.Typer(
name="draft", name="draft",
@@ -26,7 +25,7 @@ def list_drafts(
) -> None: ) -> None:
"""List stored draft workspaces.""" """List stored draft workspaces."""
context = load_cli_context(ctx) context = load_cli_context(ctx)
payload = asyncio.run(context.handlers.list_draft_workspaces()) payload = run_cli_operation(context, context.handlers.list_draft_workspaces())
emit_list_payload( emit_list_payload(
payload, payload,
collection_key="workspaces", collection_key="workspaces",
@@ -47,11 +46,12 @@ def inspect_draft(
"""Inspect one draft workspace.""" """Inspect one draft workspace."""
context = load_cli_context(ctx) context = load_cli_context(ctx)
emit_json( emit_json(
asyncio.run( run_cli_operation(
context,
context.handlers.get_draft_workspace( context.handlers.get_draft_workspace(
workspace_id=workspace_id, workspace_id=workspace_id,
include_draft=include_draft, include_draft=include_draft,
) ),
) )
) )
@@ -71,13 +71,14 @@ def create_from_capability(
"""Bootstrap a draft workspace from inspect_capability wrapper hints.""" """Bootstrap a draft workspace from inspect_capability wrapper hints."""
context = load_cli_context(ctx) context = load_cli_context(ctx)
emit_json( emit_json(
asyncio.run( run_cli_operation(
context,
context.handlers.create_draft_workspace_from_capability( context.handlers.create_draft_workspace_from_capability(
workspace_id=workspace_id, workspace_id=workspace_id,
capability_name=capability_name, capability_name=capability_name,
name=name, name=name,
title=title, title=title,
) ),
) )
) )
@@ -105,12 +106,13 @@ def patch_draft(
raise typer.BadParameter("draft patch input must be a JSON array") raise typer.BadParameter("draft patch input must be a JSON array")
context = load_cli_context(ctx) context = load_cli_context(ctx)
emit_json( emit_json(
asyncio.run( run_cli_operation(
context,
context.handlers.patch_draft_workspace( context.handlers.patch_draft_workspace(
workspace_id=workspace_id, workspace_id=workspace_id,
revision=revision, revision=revision,
patch=patch, patch=patch,
) ),
) )
) )
@@ -123,8 +125,9 @@ def validate_draft(
"""Validate one stored draft workspace.""" """Validate one stored draft workspace."""
context = load_cli_context(ctx) context = load_cli_context(ctx)
emit_json( emit_json(
asyncio.run( run_cli_operation(
context.handlers.validate_draft_workspace(workspace_id=workspace_id) context,
context.handlers.validate_draft_workspace(workspace_id=workspace_id),
) )
) )
@@ -158,7 +161,8 @@ def save_draft(
raise typer.BadParameter(str(exc)) from exc raise typer.BadParameter(str(exc)) from exc
context = load_cli_context(ctx) context = load_cli_context(ctx)
if kind == "wrapper": if kind == "wrapper":
payload = asyncio.run( payload = run_cli_operation(
context,
context.handlers.create_wrapper_from_workspace( context.handlers.create_wrapper_from_workspace(
workspace_id=workspace_id, workspace_id=workspace_id,
artifact_id=artifact_id, artifact_id=artifact_id,
@@ -167,10 +171,11 @@ def save_draft(
outcomes=tuple(outcome or ["ok"]), outcomes=tuple(outcome or ["ok"]),
description=description, description=description,
source_bindings=source_bindings or None, source_bindings=source_bindings or None,
) ),
) )
else: else:
payload = asyncio.run( payload = run_cli_operation(
context,
context.handlers.create_artifact_from_workspace( context.handlers.create_artifact_from_workspace(
workspace_id=workspace_id, workspace_id=workspace_id,
artifact_id=artifact_id, artifact_id=artifact_id,
@@ -180,6 +185,6 @@ def save_draft(
kind=kind, kind=kind,
description=description, description=description,
source_bindings=source_bindings or None, source_bindings=source_bindings or None,
) ),
) )
emit_json(payload) emit_json(payload)
+11 -9
View File
@@ -1,6 +1,4 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
@@ -8,6 +6,7 @@ import typer
from wf_cli.context import load_cli_context_from_typer from wf_cli.context import load_cli_context_from_typer
from wf_cli.io import CliInputError, emit_json, parse_json_input from wf_cli.io import CliInputError, emit_json, parse_json_input
from wf_cli.remote_errors import run_cli_operation
from wf_api import TraceRange from wf_api import TraceRange
app = typer.Typer( app = typer.Typer(
@@ -47,12 +46,13 @@ def start_run(
raise typer.BadParameter(str(exc)) from exc raise typer.BadParameter(str(exc)) from exc
context = load_cli_context_from_typer(ctx) context = load_cli_context_from_typer(ctx)
trace_range = _optional_trace_range(start=trace_from, limit=trace_limit) trace_range = _optional_trace_range(start=trace_from, limit=trace_limit)
payload = asyncio.run( payload = run_cli_operation(
context,
context.handlers.run_deployment( context.handlers.run_deployment(
deployment_id=deployment_id, deployment_id=deployment_id,
workflow_input=workflow_input, workflow_input=workflow_input,
trace_range=trace_range, trace_range=trace_range,
) ),
) )
emit_json(payload) emit_json(payload)
@@ -64,7 +64,7 @@ def inspect_run(
) -> None: ) -> None:
"""Inspect a durable run without trace entries.""" """Inspect a durable run without trace entries."""
context = load_cli_context_from_typer(ctx) context = load_cli_context_from_typer(ctx)
emit_json(asyncio.run(context.handlers.inspect_run(run_id=run_id))) emit_json(run_cli_operation(context, context.handlers.inspect_run(run_id=run_id)))
@app.command("resume") @app.command("resume")
@@ -108,13 +108,14 @@ def resume_run(
raise typer.BadParameter(str(exc)) from exc raise typer.BadParameter(str(exc)) from exc
context = load_cli_context_from_typer(ctx) context = load_cli_context_from_typer(ctx)
trace_range = _optional_trace_range(start=trace_from, limit=trace_limit) trace_range = _optional_trace_range(start=trace_from, limit=trace_limit)
payload = asyncio.run( payload = run_cli_operation(
context,
context.handlers.resume_run( context.handlers.resume_run(
run_id=run_id, run_id=run_id,
resume_payload=resume_payload, resume_payload=resume_payload,
resume_outcome=outcome, resume_outcome=outcome,
trace_range=trace_range, trace_range=trace_range,
) ),
) )
emit_json(payload) emit_json(payload)
@@ -134,11 +135,12 @@ def trace_run(
) -> None: ) -> None:
"""Read a bounded debug trace slice.""" """Read a bounded debug trace slice."""
context = load_cli_context_from_typer(ctx) context = load_cli_context_from_typer(ctx)
payload = asyncio.run( payload = run_cli_operation(
context,
context.handlers.read_run_trace( context.handlers.read_run_trace(
run_id=run_id, run_id=run_id,
trace_range=TraceRange(start=trace_from, limit=limit), trace_range=TraceRange(start=trace_from, limit=limit),
) ),
) )
emit_json(payload) emit_json(payload)
+27 -10
View File
@@ -1,6 +1,4 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
from pathlib import Path from pathlib import Path
from typing import Annotated, Any from typing import Annotated, Any
@@ -10,6 +8,7 @@ import typer
from wf_cli.context import CliContext, load_cli_context_from_typer from wf_cli.context import CliContext, load_cli_context_from_typer
from wf_cli.formats import ListOutputFormat, emit_list_payload from wf_cli.formats import ListOutputFormat, emit_list_payload
from wf_cli.io import emit_json from wf_cli.io import emit_json
from wf_cli.remote_errors import run_cli_operation
app = typer.Typer( app = typer.Typer(
name="registry", name="registry",
@@ -43,7 +42,10 @@ def list_registry_entries(
"""List desired persisted source registry entries.""" """List desired persisted source registry entries."""
context = load_cli_context_from_typer(ctx) context = load_cli_context_from_typer(ctx)
admin = _require_registry_admin(context) admin = _require_registry_admin(context)
payload = asyncio.run(admin.list_registry_entries(cursor=cursor, limit=limit)) payload = run_cli_operation(
context,
admin.list_registry_entries(cursor=cursor, limit=limit),
)
emit_list_payload( emit_list_payload(
payload, payload,
collection_key="entries", collection_key="entries",
@@ -61,7 +63,10 @@ def inspect_registry_entry(
"""Inspect one desired persisted source registry entry.""" """Inspect one desired persisted source registry entry."""
context = load_cli_context_from_typer(ctx) context = load_cli_context_from_typer(ctx)
admin = _require_registry_admin(context) admin = _require_registry_admin(context)
payload = asyncio.run(admin.inspect_registry_entry(source_id=source_id)) payload = run_cli_operation(
context,
admin.inspect_registry_entry(source_id=source_id),
)
emit_json(payload) emit_json(payload)
@@ -80,7 +85,7 @@ def add_registry_entry(
context = load_cli_context_from_typer(ctx) context = load_cli_context_from_typer(ctx)
admin = _require_registry_admin(context) admin = _require_registry_admin(context)
entry = _read_json_arg(input_json, input_file, "--input/--input-file") entry = _read_json_arg(input_json, input_file, "--input/--input-file")
payload = asyncio.run(admin.add_registry_entry(entry=entry)) payload = run_cli_operation(context, admin.add_registry_entry(entry=entry))
emit_json(payload) emit_json(payload)
@@ -99,7 +104,10 @@ def update_registry_entry(
context = load_cli_context_from_typer(ctx) context = load_cli_context_from_typer(ctx)
admin = _require_registry_admin(context) admin = _require_registry_admin(context)
patch = _read_json_arg(patch_json, patch_file, "--patch/--patch-file") patch = _read_json_arg(patch_json, patch_file, "--patch/--patch-file")
payload = asyncio.run(admin.update_registry_entry(source_id=source_id, patch=patch)) payload = run_cli_operation(
context,
admin.update_registry_entry(source_id=source_id, patch=patch),
)
emit_json(payload) emit_json(payload)
@@ -111,7 +119,10 @@ def enable_registry_entry(
"""Enable a desired source registry entry.""" """Enable a desired source registry entry."""
context = load_cli_context_from_typer(ctx) context = load_cli_context_from_typer(ctx)
admin = _require_registry_admin(context) admin = _require_registry_admin(context)
payload = asyncio.run(admin.enable_registry_entry(source_id=source_id)) payload = run_cli_operation(
context,
admin.enable_registry_entry(source_id=source_id),
)
emit_json(payload) emit_json(payload)
@@ -123,7 +134,10 @@ def disable_registry_entry(
"""Disable a desired source registry entry.""" """Disable a desired source registry entry."""
context = load_cli_context_from_typer(ctx) context = load_cli_context_from_typer(ctx)
admin = _require_registry_admin(context) admin = _require_registry_admin(context)
payload = asyncio.run(admin.disable_registry_entry(source_id=source_id)) payload = run_cli_operation(
context,
admin.disable_registry_entry(source_id=source_id),
)
emit_json(payload) emit_json(payload)
@@ -140,7 +154,10 @@ def remove_registry_entry(
admin = _require_registry_admin(context) admin = _require_registry_admin(context)
if not confirm: if not confirm:
raise typer.BadParameter("removal requires --confirm flag") raise typer.BadParameter("removal requires --confirm flag")
payload = asyncio.run(admin.remove_registry_entry(source_id=source_id)) payload = run_cli_operation(
context,
admin.remove_registry_entry(source_id=source_id),
)
emit_json(payload) emit_json(payload)
@@ -149,7 +166,7 @@ def apply_registry_changes(ctx: typer.Context) -> None:
"""Apply desired registry state to the running server.""" """Apply desired registry state to the running server."""
context = load_cli_context_from_typer(ctx) context = load_cli_context_from_typer(ctx)
admin = _require_registry_admin(context) admin = _require_registry_admin(context)
payload = asyncio.run(admin.apply_registry_changes()) payload = run_cli_operation(context, admin.apply_registry_changes())
emit_json(payload) emit_json(payload)
+9 -4
View File
@@ -1,6 +1,4 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from typing import Annotated from typing import Annotated
import typer import typer
@@ -8,6 +6,7 @@ import typer
from wf_cli.context import load_cli_context_from_typer from wf_cli.context import load_cli_context_from_typer
from wf_cli.formats import ListOutputFormat, emit_list_payload from wf_cli.formats import ListOutputFormat, emit_list_payload
from wf_cli.io import emit_json from wf_cli.io import emit_json
from wf_cli.remote_errors import run_cli_operation
app = typer.Typer( app = typer.Typer(
name="source", name="source",
@@ -31,7 +30,10 @@ def list_sources(
) -> None: ) -> None:
"""List compact workflow source summaries.""" """List compact workflow source summaries."""
context = load_cli_context_from_typer(ctx) context = load_cli_context_from_typer(ctx)
payload = asyncio.run(context.source_admin.list_sources(cursor=cursor, limit=limit)) payload = run_cli_operation(
context,
context.source_admin.list_sources(cursor=cursor, limit=limit),
)
emit_list_payload( emit_list_payload(
payload, payload,
collection_key="sources", collection_key="sources",
@@ -48,5 +50,8 @@ def inspect_source(
) -> None: ) -> None:
"""Inspect one workflow source inventory.""" """Inspect one workflow source inventory."""
context = load_cli_context_from_typer(ctx) context = load_cli_context_from_typer(ctx)
payload = asyncio.run(context.source_admin.inspect_source(source_id=source_id)) payload = run_cli_operation(
context,
context.source_admin.inspect_source(source_id=source_id),
)
emit_json(payload) emit_json(payload)
+9
View File
@@ -40,6 +40,7 @@ class CliContext:
source_admin: WorkflowSourceAdminSurface source_admin: WorkflowSourceAdminSurface
admin: WorkflowAdminSurface admin: WorkflowAdminSurface
source_registry_admin: WorkflowSourceRegistrySurface | None = None source_registry_admin: WorkflowSourceRegistrySurface | None = None
verbose: bool = False
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -63,6 +64,7 @@ class CliTyperState:
force_local: bool = False force_local: bool = False
rpc_url: str | None = None rpc_url: str | None = None
rpc_timeout_seconds: float | None = None rpc_timeout_seconds: float | None = None
verbose: bool = False
@classmethod @classmethod
def from_context(cls, ctx: typer.Context) -> CliTyperState: def from_context(cls, ctx: typer.Context) -> CliTyperState:
@@ -87,6 +89,7 @@ class CliTyperState:
rpc_timeout_seconds=( rpc_timeout_seconds=(
float(timeout) if isinstance(timeout, float | int) else None float(timeout) if isinstance(timeout, float | int) else None
), ),
verbose=bool(obj.get("verbose", cls.verbose)),
) )
@@ -101,6 +104,7 @@ def load_cli_context(
force_local: bool = False, force_local: bool = False,
rpc_url: str | None = None, rpc_url: str | None = None,
rpc_timeout_seconds: float | None = None, rpc_timeout_seconds: float | None = None,
verbose: bool = False,
) -> CliContext: ) -> CliContext:
"""Load config and build workflow-surface handlers for CLI commands.""" """Load config and build workflow-surface handlers for CLI commands."""
resolved_config_path = Path(config_path) resolved_config_path = Path(config_path)
@@ -123,6 +127,7 @@ def load_cli_context(
source_admin=client, source_admin=client,
admin=client, admin=client,
source_registry_admin=client, source_registry_admin=client,
verbose=verbose,
) )
if _is_legacy_mcp_config(resolved_config_path): if _is_legacy_mcp_config(resolved_config_path):
@@ -137,6 +142,7 @@ def load_cli_context(
connections=service.connection_service, connections=service.connection_service,
events=service.events, events=service.events,
), ),
verbose=verbose,
) )
config = load_workflow_config(resolved_config_path) config = load_workflow_config(resolved_config_path)
@@ -152,6 +158,7 @@ def load_cli_context(
handlers=server.api, handlers=server.api,
source_admin=server.source_admin, source_admin=server.source_admin,
admin=server.admin, admin=server.admin,
verbose=verbose,
) )
if isinstance(target, RpcHttpTargetConfig): if isinstance(target, RpcHttpTargetConfig):
client = RpcWorkflowApiClient( client = RpcWorkflowApiClient(
@@ -169,6 +176,7 @@ def load_cli_context(
source_admin=client, source_admin=client,
admin=client, admin=client,
source_registry_admin=client, source_registry_admin=client,
verbose=verbose,
) )
raise ValueError(f"unsupported workflow target {target!r}") raise ValueError(f"unsupported workflow target {target!r}")
@@ -218,6 +226,7 @@ def load_cli_context_from_typer(ctx: typer.Context) -> CliContext:
force_local=force_local_from_context(ctx), force_local=force_local_from_context(ctx),
rpc_url=rpc_url_from_context(ctx), rpc_url=rpc_url_from_context(ctx),
rpc_timeout_seconds=rpc_timeout_from_context(ctx), rpc_timeout_seconds=rpc_timeout_from_context(ctx),
verbose=CliTyperState.from_context(ctx).verbose,
) )
except ValueError as exc: except ValueError as exc:
raise typer.BadParameter(str(exc)) from exc raise typer.BadParameter(str(exc)) from exc
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
import asyncio
from collections.abc import Coroutine
from typing import Any, TypeVar
import click
import httpx
from wf_cli.context import CliContext
T = TypeVar("T")
def run_cli_operation(context: CliContext, operation: Coroutine[Any, Any, T]) -> T:
"""Run a CLI async operation and format non-verbose operation errors.
Non-verbose CLI output should be useful to users, not a Python crash report.
`--verbose` preserves the raw exception path so developers can still debug
internal failures with a traceback.
"""
try:
return asyncio.run(operation)
except (RuntimeError, httpx.HTTPError) as exc:
if context.verbose:
raise
raise click.ClickException(_operation_error_message(exc)) from exc
def _operation_error_message(exc: RuntimeError | httpx.HTTPError) -> str:
"""Return a stable non-empty message for compact CLI error output."""
message = str(exc)
if message:
return message
return exc.__class__.__name__
+125 -1
View File
@@ -2,18 +2,33 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
from pathlib import Path
from typing import Any, cast
import httpx import httpx
from typer.testing import CliRunner from typer.testing import CliRunner
from wf_api.models import RawWorkflowPlan from wf_api.models import RawWorkflowPlan
from wf_cli.app import app from wf_cli.app import app
from wf_cli.context import load_cli_context, load_local_cli_context from wf_cli.context import CliContext, load_cli_context, load_local_cli_context
from wf_core import END from wf_core import END
from wf_server import build_local_static_workflow_server from wf_server import build_local_static_workflow_server
from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app
class BrokenSourceAdmin:
async def list_sources(
self,
*,
cursor: str | None = None,
limit: int = 50,
) -> dict[str, Any]:
return {"sources": [], "next_cursor": None, "total": 0}
async def inspect_source(self, *, source_id: str) -> dict[str, Any]:
raise RuntimeError(f"broken source admin for {source_id}")
def test_load_cli_context_uses_rpc_client_for_rpc_http_target(tmp_path) -> None: def test_load_cli_context_uses_rpc_client_for_rpc_http_target(tmp_path) -> None:
config_path = tmp_path / "wf.json" config_path = tmp_path / "wf.json"
config_path.write_text( config_path.write_text(
@@ -332,6 +347,115 @@ def test_wf_source_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
assert '"id": "wf.std"' in inspected.output assert '"id": "wf.std"' in inspected.output
def test_wf_remote_source_inspect_formats_expected_rpc_error(
monkeypatch,
tmp_path,
) -> None:
server = build_local_static_workflow_server(tmp_path / "store")
original_client = httpx.AsyncClient
monkeypatch.setattr(
"wf_transport_rpc_http.client.httpx.AsyncClient",
lambda *args, **kwargs: original_client(
transport=httpx.ASGITransport(app=create_rpc_app(server)),
base_url="http://test",
),
)
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"]
result = runner.invoke(app, [*base_args, "source", "inspect", "missing.source"])
assert result.exit_code != 0
assert "Error" in result.output
assert "Workflow operation failed" in result.output
assert "missing.source" in result.output
assert "Traceback" not in result.output
assert "RuntimeError" not in result.output
def test_wf_remote_source_list_formats_transport_error(monkeypatch, tmp_path) -> None:
async def connection_failed(*args: Any, **kwargs: Any) -> dict[str, Any]:
raise httpx.ConnectError(
"connection refused",
request=httpx.Request("POST", "http://test/rpc"),
)
monkeypatch.setattr(
"wf_transport_rpc_http.client_sources.RpcSourceAdminClientMixin.list_sources",
connection_failed,
)
config_path = tmp_path / "wf.json"
config_path.write_text('{"version": 1}', encoding="utf-8")
runner = CliRunner()
result = runner.invoke(
app,
[
"--config",
str(config_path),
"--url",
"http://test/rpc",
"source",
"list",
],
)
assert result.exit_code != 0
assert "Error" in result.output
assert "connection refused" in result.output
assert "Traceback" not in result.output
assert "ConnectError" not in result.output
def test_wf_unexpected_error_uses_short_traceback_by_default(
monkeypatch,
tmp_path,
) -> None:
fake_context = CliContext(
config_path=Path("dummy"),
service=cast(Any, object()),
handlers=build_local_static_workflow_server(tmp_path / "store").api,
source_admin=BrokenSourceAdmin(),
admin=cast(Any, object()),
)
monkeypatch.setattr(
"wf_cli.commands.sources.load_cli_context_from_typer",
lambda _ctx: fake_context,
)
result = CliRunner().invoke(app, ["source", "inspect", "wf.std"])
assert result.exit_code != 0
assert "broken source admin for wf.std" in result.output
assert "tests/wf_cli/test_remote_target.py" not in result.output
def test_wf_verbose_shows_full_traceback_for_unexpected_error(
monkeypatch,
tmp_path,
) -> None:
fake_context = CliContext(
config_path=Path("dummy"),
service=cast(Any, object()),
handlers=build_local_static_workflow_server(tmp_path / "store").api,
source_admin=BrokenSourceAdmin(),
admin=cast(Any, object()),
verbose=True,
)
monkeypatch.setattr(
"wf_cli.commands.sources.load_cli_context_from_typer",
lambda _ctx: fake_context,
)
result = CliRunner().invoke(app, ["--verbose", "source", "inspect", "wf.std"])
assert result.exit_code != 0
assert isinstance(result.exception, RuntimeError)
assert str(result.exception) == "broken source admin for wf.std"
def test_wf_admin_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None: def test_wf_admin_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
server = build_local_static_workflow_server(tmp_path / "store") server = build_local_static_workflow_server(tmp_path / "store")
server.events.record_workflow_event( server.events.record_workflow_event(
+2 -2
View File
@@ -47,8 +47,8 @@ def _patch_load_cli_context(
def _patch_asyncio_run(monkeypatch: pytest.MonkeyPatch) -> None: def _patch_asyncio_run(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr( monkeypatch.setattr(
"wf_cli.commands.source_registry.asyncio.run", "wf_cli.commands.source_registry.run_cli_operation",
lambda coro: coro, lambda _context, operation: operation,
) )