chore: finish compatibility cleanup

This commit is contained in:
lda
2026-06-29 17:15:06 +07:00 Verified
parent 99f733c5ea
commit 664beec837
4 changed files with 256 additions and 313 deletions
+12 -28
View File
@@ -46,46 +46,30 @@ passes with AnyIO 4.14.1 or newer, and the full suite remains clean.
| `ddcdf648` from PR #4363 | 4.14.1 | Pass |
| `main` at `de521e65` | 4.14.1 | Pass |
## Typer Command Callback Errors
## Typer Vendored Click Error Boundary
With Typer 0.26.8 and Click 8.4.2, a nested Typer command that raises
`click.ClickException` exits with code 1 under `typer.testing.CliRunner`, but
both captured output streams are empty. The unformatted `ClickException`
remains on `result.exception`. Single-command applications behave the same way,
and direct invocation renders a traceback instead of a concise error.
Typer 0.26.0 intentionally vendored Click and stopped supporting direct use of
Click-specific functionality. External `click.ClickException` instances are
therefore distinct from Typer's internal exception classes and bypass Typer's
concise error formatter.
The CLI therefore writes its concise remote-operation error explicitly to
stderr and raises `typer.Exit(1)` instead of relying on standalone Click
exception formatting. This keeps real CLI and test-runner behavior aligned.
Minimal reproducer:
The CLI uses Typer's supported public API instead:
```python
import click
import typer
from typer.testing import CliRunner
app = typer.Typer()
@app.command()
def fail() -> None:
raise click.ClickException("broken")
result = CliRunner().invoke(app)
assert result.exit_code == 1
assert result.stderr == "" # Expected to contain "Error: broken".
assert isinstance(result.exception, click.ClickException)
typer.echo("Error: broken", err=True)
raise typer.Exit(code=1)
```
This regression is tracked as Typer issue
[#1867](https://github.com/fastapi/typer/issues/1867). The repository copy of
the verified issue report is
[`2026-06-29-typer-click-exception-regression.md`](../superpowers/research/2026-06-29-typer-click-exception-regression.md).
Typer 0.24.2 and 0.25.0 format the exception normally. Vendoring Click in Typer
0.26.0 introduced the exception-class identity mismatch; Typer 0.26.8 and
current `master` at `b210c0e2` reproduce the failure.
Typer issue [#1867](https://github.com/fastapi/typer/issues/1867) now tracks the
narrower feature request for a public general-purpose Typer CLI error. The
repository copy of the request is
[`2026-06-29-typer-public-cli-error-feature.md`](../superpowers/research/2026-06-29-typer-public-cli-error-feature.md).
## Separate Warning Backlog
@@ -1,97 +0,0 @@
### Description
After Typer vendored Click in 0.26.0, exceptions imported from the external
`click` package are no longer handled as normal Typer CLI errors.
A real invocation renders a Rich traceback ending in
`ClickException: broken`. Under `typer.testing.CliRunner`, both stdout and
stderr are empty and the raw `click.ClickException` remains in
`result.exception`.
Typer 0.25.0 renders the expected concise error panel and returns `SystemExit`.
### Root Cause
Typer commit `1829d73` vendored Click as `typer._click`. Typer's `_main()`
catches `typer._click.exceptions.ClickException`, but an application that
raises `click.exceptions.ClickException` raises a different class:
```python
import click
import typer._click
assert click.ClickException is not typer._click.ClickException
```
The exception therefore bypasses Typer's Click-exception handler. This affects
the external Click variants of `ClickException`, `UsageError`, `BadParameter`,
and other subclasses.
Typer publicly re-exports some vendored exception types, including
`typer.BadParameter` and `typer.Exit`, but it does not currently expose a public
`typer.ClickException` or `typer.UsageError` equivalent.
### Minimal Reproduction
```python
import click
import typer
from typer.testing import CliRunner
app = typer.Typer()
@app.command()
def fail() -> None:
raise click.ClickException("broken")
result = CliRunner().invoke(app)
print("exit:", result.exit_code)
print("stdout:", repr(result.stdout))
print("stderr:", repr(result.stderr))
print("exception:", type(result.exception).__name__)
```
The same behavior occurs in nested Typer command groups.
### Version Matrix
Tested with Click 8.4.2 unless noted otherwise:
| Typer | Result |
| --- | --- |
| 0.24.2 | Concise error output; `SystemExit` |
| 0.25.0 | Concise error output; `SystemExit` |
| 0.26.0 | Empty runner output; raw `ClickException` |
| 0.26.8 with Click 8.1.7 | Empty runner output; raw `ClickException` |
| 0.26.8 with Click 8.4.2 | Empty runner output; raw `ClickException` |
| `master` at `b210c0e2` | Empty runner output; raw `ClickException` |
Test environment:
- Python 3.14.3
- Windows 11
- Click 8.1.7 and 8.4.2
### Expected Behavior
Typer should provide a public way to raise its concise general-purpose CLI
exception after vendoring Click. Possible resolutions include:
- re-exporting the vendored `ClickException` and `UsageError` classes from the
`typer` namespace;
- preserving compatibility with exceptions imported from external Click; or
- documenting that external Click exceptions are no longer compatible and
identifying their supported Typer replacements.
### Workaround
Applications can write the message explicitly and raise `typer.Exit(1)`:
```python
typer.echo("Error: something went wrong", err=True)
raise typer.Exit(code=1)
```
Importing from `typer._click` also works, but relies on a private API.
@@ -0,0 +1,74 @@
### Feature Request
Expose a public, general-purpose Typer exception for concise user-facing CLI
errors after Click vendoring.
Typer 0.26.0 intentionally vendored Click and no longer supports using Click
directly. This is documented in the 0.26.0 breaking changes and the Vendored
Click guide. This request is not asking to restore compatibility with exception
classes imported from the external `click` package.
### Current API Gap
Typer publicly re-exports several vendored exception types, including
`typer.BadParameter`, `typer.Abort`, and `typer.Exit`, but it does not expose a
general-purpose `typer.ClickException` or `typer.UsageError` equivalent.
Before 0.26.0, an application could raise `click.ClickException("broken")` to
produce Typer's concise error panel and exit with code 1. After vendoring, the
supported public workaround is to write and terminate separately:
```python
typer.echo("Error: broken", err=True)
raise typer.Exit(code=1)
```
This works, but each application must reproduce the error prefix, output
stream, formatting policy, and exit behavior instead of expressing one typed
CLI error.
`typer.Abort` is not an equivalent because it represents user cancellation and
adds `Aborted!` output.
### Proposed API
Expose the vendored general-purpose exception through Typer's public namespace,
or provide a Typer-native equivalent:
```python
import typer
app = typer.Typer()
@app.command()
def fail() -> None:
raise typer.ClickException("broken")
```
Expected output should use Typer's standard concise error formatting and be
captured by `typer.testing.CliRunner` on stderr.
The exact public name is not important. A Typer-native `UsageError` or another
documented general-purpose CLI error would satisfy the same need.
### Why This Is Distinct From Existing Exports
- `typer.BadParameter` describes parameter validation and requires parameter
context for its best output.
- `typer.Abort` describes cancellation and prints `Aborted!`.
- `typer.Exit` controls termination but carries no error message or formatting.
A general-purpose error is useful when adapting failures from HTTP clients,
RPC calls, configuration loading, or other application services at the CLI
boundary.
### Version Context
- Typer 0.25.0 with external Click: `click.ClickException` produced concise
output.
- Typer 0.26.0 and newer: direct Click use is intentionally unsupported.
- Typer 0.26.8 and `master` at `b210c0e2`: no public general-purpose Typer
exception is exported.
Test environment: Python 3.14.3 on Windows 11.
+170 -188
View File
@@ -1,240 +1,222 @@
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
from wf_mcp.models import BrokerConfig
from wf_mcp.server import create_server_client
from ..test_support import local_temp_root
from .conftest import assert_safe_tool_maps, server_config
def test_server_search_mode_pins_stable_control_and_workflow_tools() -> None:
@pytest.mark.asyncio
async def test_server_search_mode_pins_stable_control_and_workflow_tools() -> None:
config = server_config()
async def run_proxy() -> None:
client = create_server_client(config, search_tools=True)
async with client:
tools = await client.list_tools()
names = [tool.name for tool in tools]
client = create_server_client(config, search_tools=True)
async with client:
tools = await client.list_tools()
names = [tool.name for tool in tools]
assert "search_tools" in names
assert "call_tool" in names
assert "wf.admin.list_sources" in names
assert "wf.admin.list_connections" in names
assert "wf.admin.get_connection_statuses" in names
assert "wf.admin.reload_config" in names
assert "wf.admin.list_proxy_tools" in names
assert "wf.admin.get_proxy_tool" in names
assert "wf.workflow.list_artifacts" in names
assert "wf.workflow.list_capabilities" in names
assert "wf.workflow.inspect_capability" in names
assert "wf.workflow.call_capability" in names
assert "wf.workflow.list_draft_workspaces" in names
assert "wf.workflow.create_draft_workspace" in names
assert "wf.workflow.get_draft_workspace" in names
assert "wf.workflow.delete_draft_workspace" in names
assert "wf.workflow.patch_draft_workspace" in names
assert "wf.workflow.validate_draft_workspace" in names
assert "wf.workflow.set_draft_name" in names
assert "wf.workflow.set_draft_route" in names
assert "wf.workflow.set_step_input_map" in names
assert "wf.workflow.set_step_output_map" in names
assert "wf.workflow.set_workflow_output_map" in names
assert "wf.workflow.bind" in names
assert "wf.workflow.remove_draft_route" in names
assert "wf.workflow.remove_draft_step" in names
assert "wf.workflow.remove_draft_binding" in names
assert "wf.workflow.create_minimal_draft_workspace" in names
assert "wf.workflow.create_artifact_from_workspace" in names
assert "wf.workflow.create_wrapper_from_workspace" in names
assert "wf.workflow.inspect_artifact" in names
assert "wf.workflow.list_deployments" in names
assert "wf.workflow.inspect_deployment" in names
assert "wf.workflow.save_deployment" in names
assert "wf.workflow.delete_deployment" in names
assert "wf.workflow.validate_deployment" in names
assert "wf.workflow.run_deployment" in names
assert "search_tools" in names
assert "call_tool" in names
assert "wf.admin.list_sources" in names
assert "wf.admin.list_connections" in names
assert "wf.admin.get_connection_statuses" in names
assert "wf.admin.reload_config" in names
assert "wf.admin.list_proxy_tools" in names
assert "wf.admin.get_proxy_tool" in names
assert "wf.workflow.list_artifacts" in names
assert "wf.workflow.list_capabilities" in names
assert "wf.workflow.inspect_capability" in names
assert "wf.workflow.call_capability" in names
assert "wf.workflow.list_draft_workspaces" in names
assert "wf.workflow.create_draft_workspace" in names
assert "wf.workflow.get_draft_workspace" in names
assert "wf.workflow.delete_draft_workspace" in names
assert "wf.workflow.patch_draft_workspace" in names
assert "wf.workflow.validate_draft_workspace" in names
assert "wf.workflow.set_draft_name" in names
assert "wf.workflow.set_draft_route" in names
assert "wf.workflow.set_step_input_map" in names
assert "wf.workflow.set_step_output_map" in names
assert "wf.workflow.set_workflow_output_map" in names
assert "wf.workflow.bind" in names
assert "wf.workflow.remove_draft_route" in names
assert "wf.workflow.remove_draft_step" in names
assert "wf.workflow.remove_draft_binding" in names
assert "wf.workflow.create_minimal_draft_workspace" in names
assert "wf.workflow.create_artifact_from_workspace" in names
assert "wf.workflow.create_wrapper_from_workspace" in names
assert "wf.workflow.inspect_artifact" in names
assert "wf.workflow.list_deployments" in names
assert "wf.workflow.inspect_deployment" in names
assert "wf.workflow.save_deployment" in names
assert "wf.workflow.delete_deployment" in names
assert "wf.workflow.validate_deployment" in names
assert "wf.workflow.run_deployment" in names
assert "wf.workflow.validate_draft" not in names
assert "wf.workflow.compile_draft" not in names
assert "wf.workflow.create_artifact_from_plan" not in names
assert "wf.workflow.create_artifact_from_draft" not in names
assert "wf.workflow.patch_draft" not in names
assert "wf.admin.call_tool" not in names
assert "fixture.personal.echo_tool" not in names
asyncio.run(run_proxy())
assert "wf.workflow.validate_draft" not in names
assert "wf.workflow.compile_draft" not in names
assert "wf.workflow.create_artifact_from_plan" not in names
assert "wf.workflow.create_artifact_from_draft" not in names
assert "wf.workflow.patch_draft" not in names
assert "wf.admin.call_tool" not in names
assert "fixture.personal.echo_tool" not in names
def test_server_search_mode_can_use_safe_tool_names() -> None:
@pytest.mark.asyncio
async def test_server_search_mode_can_use_safe_tool_names() -> None:
config = server_config()
async def run_proxy() -> None:
client = create_server_client(
config,
search_tools=True,
safe_tool_names=True,
client = create_server_client(
config,
search_tools=True,
safe_tool_names=True,
)
async with client:
tools = await client.list_tools()
names = [tool.name for tool in tools]
assert "search_tools" in names
assert "call_tool" in names
assert "wf_admin_list_sources" in names
assert "wf_workflow_call_capability" in names
assert "wf.admin.list_sources" not in names
result = await assert_safe_tool_maps(
client,
original_name="wf.admin.list_sources",
safe_name="wf_admin_list_sources",
)
async with client:
tools = await client.list_tools()
names = [tool.name for tool in tools]
assert "search_tools" in names
assert "call_tool" in names
assert "wf_admin_list_sources" in names
assert "wf_workflow_call_capability" in names
assert "wf.admin.list_sources" not in names
result = await assert_safe_tool_maps(
client,
original_name="wf.admin.list_sources",
safe_name="wf_admin_list_sources",
)
source_ids = {source["id"] for source in result["sources"]}
assert "wf.std" in source_ids
asyncio.run(run_proxy())
source_ids = {source["id"] for source in result["sources"]}
assert "wf.std" in source_ids
def test_server_safe_tool_names_adapts_dotted_runtime_names() -> None:
@pytest.mark.asyncio
async def test_server_safe_tool_names_adapts_dotted_runtime_names() -> None:
config = server_config()
async def run_proxy() -> None:
client = create_server_client(config, safe_tool_names=True)
async with client:
artifacts = await assert_safe_tool_maps(
client,
original_name="wf.workflow.list_artifacts",
safe_name="wf_workflow_list_artifacts",
)
echo = await assert_safe_tool_maps(
client,
original_name="fixture.personal.echo_tool",
safe_name="fixture_personal_echo_tool",
arguments={"text": "hello"},
)
client = create_server_client(config, safe_tool_names=True)
async with client:
artifacts = await assert_safe_tool_maps(
client,
original_name="wf.workflow.list_artifacts",
safe_name="wf_workflow_list_artifacts",
)
echo = await assert_safe_tool_maps(
client,
original_name="fixture.personal.echo_tool",
safe_name="fixture_personal_echo_tool",
arguments={"text": "hello"},
)
assert artifacts["nodes"] == []
assert artifacts["total"] == 0
assert echo["echoed"] == "hello"
asyncio.run(run_proxy())
assert artifacts["nodes"] == []
assert artifacts["total"] == 0
assert echo["echoed"] == "hello"
def test_workflow_tools_have_human_metadata() -> None:
@pytest.mark.asyncio
async def test_workflow_tools_have_human_metadata(tmp_path: Path) -> None:
config = BrokerConfig(
store_root=local_temp_root() / "unified_metadata_store",
store_root=tmp_path / "unified_metadata_store",
connections=[],
)
async def run_proxy() -> None:
client = create_server_client(config, admin_tools=False)
async with client:
tools = await client.list_tools()
by_name = {tool.name: tool for tool in tools}
list_artifacts = by_name["wf.workflow.list_artifacts"]
validate_deployment = by_name["wf.workflow.validate_deployment"]
run_deployment = by_name["wf.workflow.run_deployment"]
inspect_run = by_name["wf.workflow.inspect_run"]
read_run_trace = by_name["wf.workflow.read_run_trace"]
client = create_server_client(config, admin_tools=False)
async with client:
tools = await client.list_tools()
by_name = {tool.name: tool for tool in tools}
list_artifacts = by_name["wf.workflow.list_artifacts"]
validate_deployment = by_name["wf.workflow.validate_deployment"]
run_deployment = by_name["wf.workflow.run_deployment"]
inspect_run = by_name["wf.workflow.inspect_run"]
read_run_trace = by_name["wf.workflow.read_run_trace"]
assert list_artifacts.title == "List Workflow Artifacts"
assert "saved workflow artifacts" in (list_artifacts.description or "")
assert "query" in list_artifacts.inputSchema["properties"]
assert "kind" in list_artifacts.inputSchema["properties"]
assert "cursor" in list_artifacts.inputSchema["properties"]
assert "limit" in list_artifacts.inputSchema["properties"]
live_check_schema = validate_deployment.inputSchema["properties"][
"live_check"
]
assert "upstream" in live_check_schema.get("description", "")
assert run_deployment.title == "Run Workflow Deployment"
assert "deployment_id" in (run_deployment.description or "")
assert "trace_range" in run_deployment.inputSchema["properties"]
trace_range_schema = run_deployment.inputSchema["properties"]["trace_range"]
assert "Debug traces" in trace_range_schema.get("description", "")
assert "null" in [
option.get("type") for option in trace_range_schema["anyOf"]
]
assert inspect_run.title == "Inspect Workflow Run"
assert "trace" in (inspect_run.description or "").lower()
read_trace_schema = read_run_trace.inputSchema["properties"]["trace_range"]
assert "Debug traces" in read_trace_schema.get("description", "")
asyncio.run(run_proxy())
assert list_artifacts.title == "List Workflow Artifacts"
assert "saved workflow artifacts" in (list_artifacts.description or "")
assert "query" in list_artifacts.inputSchema["properties"]
assert "kind" in list_artifacts.inputSchema["properties"]
assert "cursor" in list_artifacts.inputSchema["properties"]
assert "limit" in list_artifacts.inputSchema["properties"]
live_check_schema = validate_deployment.inputSchema["properties"]["live_check"]
assert "upstream" in live_check_schema.get("description", "")
assert run_deployment.title == "Run Workflow Deployment"
assert "deployment_id" in (run_deployment.description or "")
assert "trace_range" in run_deployment.inputSchema["properties"]
trace_range_schema = run_deployment.inputSchema["properties"]["trace_range"]
assert "Debug traces" in trace_range_schema.get("description", "")
assert "null" in [option.get("type") for option in trace_range_schema["anyOf"]]
assert inspect_run.title == "Inspect Workflow Run"
assert "trace" in (inspect_run.description or "").lower()
read_trace_schema = read_run_trace.inputSchema["properties"]["trace_range"]
assert "Debug traces" in read_trace_schema.get("description", "")
def test_create_artifact_from_plan_exposes_plan_as_plain_object() -> None:
async def test_create_artifact_from_plan_exposes_plan_as_plain_object(
tmp_path: Path,
) -> None:
config = BrokerConfig(
store_root=local_temp_root() / "unified_create_artifact_schema_store",
store_root=tmp_path / "unified_create_artifact_schema_store",
connections=[],
)
async def run_proxy() -> None:
client = create_server_client(config, admin_tools=False)
async with client:
tools = await client.list_tools()
by_name = {tool.name: tool for tool in tools}
schema = by_name["wf.workflow.create_artifact_from_plan"].inputSchema
plan_schema = schema["properties"]["plan"]
client = create_server_client(config, admin_tools=False)
async with client:
tools = await client.list_tools()
by_name = {tool.name: tool for tool in tools}
schema = by_name["wf.workflow.create_artifact_from_plan"].inputSchema
plan_schema = schema["properties"]["plan"]
assert plan_schema["type"] == "object"
assert plan_schema.get("additionalProperties") is True
asyncio.run(run_proxy())
assert plan_schema["type"] == "object"
assert plan_schema.get("additionalProperties") is True
def test_draft_tools_expose_plain_object_and_patch_array_schemas() -> None:
async def test_draft_tools_expose_plain_object_and_patch_array_schemas(
tmp_path: Path,
) -> None:
config = BrokerConfig(
store_root=local_temp_root() / "unified_draft_schema_store",
store_root=tmp_path / "unified_draft_schema_store",
connections=[],
)
async def run_proxy() -> None:
client = create_server_client(config, admin_tools=False)
async with client:
tools = await client.list_tools()
by_name = {tool.name: tool for tool in tools}
client = create_server_client(config, admin_tools=False)
async with client:
tools = await client.list_tools()
by_name = {tool.name: tool for tool in tools}
validate_schema = by_name["wf.workflow.validate_draft"].inputSchema
validate_draft_schema = validate_schema["properties"]["draft"]
create_schema = by_name[
"wf.workflow.create_artifact_from_draft"
].inputSchema
create_draft_schema = create_schema["properties"]["draft"]
patch_schema = by_name["wf.workflow.patch_draft"].inputSchema
patch_draft_schema = patch_schema["properties"]["draft"]
patch_patch_schema = patch_schema["properties"]["patch"]
validate_schema = by_name["wf.workflow.validate_draft"].inputSchema
validate_draft_schema = validate_schema["properties"]["draft"]
create_schema = by_name["wf.workflow.create_artifact_from_draft"].inputSchema
create_draft_schema = create_schema["properties"]["draft"]
patch_schema = by_name["wf.workflow.patch_draft"].inputSchema
patch_draft_schema = patch_schema["properties"]["draft"]
patch_patch_schema = patch_schema["properties"]["patch"]
assert validate_draft_schema["type"] == "object"
assert validate_draft_schema.get("additionalProperties") is True
assert create_draft_schema["type"] == "object"
assert create_draft_schema.get("additionalProperties") is True
assert patch_draft_schema["type"] == "object"
assert patch_patch_schema["type"] == "array"
assert "$defs" not in patch_patch_schema
asyncio.run(run_proxy())
assert validate_draft_schema["type"] == "object"
assert validate_draft_schema.get("additionalProperties") is True
assert create_draft_schema["type"] == "object"
assert create_draft_schema.get("additionalProperties") is True
assert patch_draft_schema["type"] == "object"
assert patch_patch_schema["type"] == "array"
assert "$defs" not in patch_patch_schema
def test_admin_tools_have_human_metadata() -> None:
async def test_admin_tools_have_human_metadata(tmp_path: Path) -> None:
config = BrokerConfig(
store_root=local_temp_root() / "unified_admin_metadata_store",
store_root=tmp_path / "unified_admin_metadata_store",
connections=[],
)
async def run_proxy() -> None:
client = create_server_client(config)
async with client:
tools = await client.list_tools()
by_name = {tool.name: tool for tool in tools}
list_connections = by_name["wf.admin.list_connections"]
reload_config = by_name["wf.admin.reload_config"]
client = create_server_client(config)
async with client:
tools = await client.list_tools()
by_name = {tool.name: tool for tool in tools}
list_connections = by_name["wf.admin.list_connections"]
reload_config = by_name["wf.admin.reload_config"]
assert list_connections.title == "List Connections"
assert "configured MCP connections" in (list_connections.description or "")
assert reload_config.title == "Reload Config"
assert "remount" in (reload_config.description or "")
asyncio.run(run_proxy())
assert list_connections.title == "List Connections"
assert "configured MCP connections" in (list_connections.description or "")
assert reload_config.title == "Reload Config"
assert "remount" in (reload_config.description or "")