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 | | `ddcdf648` from PR #4363 | 4.14.1 | Pass |
| `main` at `de521e65` | 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 Typer 0.26.0 intentionally vendored Click and stopped supporting direct use of
`click.ClickException` exits with code 1 under `typer.testing.CliRunner`, but Click-specific functionality. External `click.ClickException` instances are
both captured output streams are empty. The unformatted `ClickException` therefore distinct from Typer's internal exception classes and bypass Typer's
remains on `result.exception`. Single-command applications behave the same way, concise error formatter.
and direct invocation renders a traceback instead of a concise error.
The CLI therefore writes its concise remote-operation error explicitly to The CLI therefore writes its concise remote-operation error explicitly to
stderr and raises `typer.Exit(1)` instead of relying on standalone Click stderr and raises `typer.Exit(1)` instead of relying on standalone Click
exception formatting. This keeps real CLI and test-runner behavior aligned. exception formatting. This keeps real CLI and test-runner behavior aligned.
Minimal reproducer: The CLI uses Typer's supported public API instead:
```python ```python
import click
import typer import typer
from typer.testing import CliRunner
app = typer.Typer() typer.echo("Error: broken", err=True)
raise typer.Exit(code=1)
@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)
``` ```
This regression is tracked as Typer issue Typer issue [#1867](https://github.com/fastapi/typer/issues/1867) now tracks the
[#1867](https://github.com/fastapi/typer/issues/1867). The repository copy of narrower feature request for a public general-purpose Typer CLI error. The
the verified issue report is repository copy of the request is
[`2026-06-29-typer-click-exception-regression.md`](../superpowers/research/2026-06-29-typer-click-exception-regression.md). [`2026-06-29-typer-public-cli-error-feature.md`](../superpowers/research/2026-06-29-typer-public-cli-error-feature.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.
## Separate Warning Backlog ## 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.
+25 -43
View File
@@ -1,18 +1,19 @@
from __future__ import annotations from __future__ import annotations
import asyncio from pathlib import Path
import pytest
from wf_mcp.models import BrokerConfig from wf_mcp.models import BrokerConfig
from wf_mcp.server import create_server_client from wf_mcp.server import create_server_client
from ..test_support import local_temp_root
from .conftest import assert_safe_tool_maps, server_config 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() config = server_config()
async def run_proxy() -> None:
client = create_server_client(config, search_tools=True) client = create_server_client(config, search_tools=True)
async with client: async with client:
tools = await client.list_tools() tools = await client.list_tools()
@@ -64,13 +65,11 @@ def test_server_search_mode_pins_stable_control_and_workflow_tools() -> None:
assert "wf.admin.call_tool" not in names assert "wf.admin.call_tool" not in names
assert "fixture.personal.echo_tool" not in names assert "fixture.personal.echo_tool" not in names
asyncio.run(run_proxy())
@pytest.mark.asyncio
def test_server_search_mode_can_use_safe_tool_names() -> None: async def test_server_search_mode_can_use_safe_tool_names() -> None:
config = server_config() config = server_config()
async def run_proxy() -> None:
client = create_server_client( client = create_server_client(
config, config,
search_tools=True, search_tools=True,
@@ -94,13 +93,11 @@ def test_server_search_mode_can_use_safe_tool_names() -> None:
source_ids = {source["id"] for source in result["sources"]} source_ids = {source["id"] for source in result["sources"]}
assert "wf.std" in source_ids assert "wf.std" in source_ids
asyncio.run(run_proxy())
@pytest.mark.asyncio
def test_server_safe_tool_names_adapts_dotted_runtime_names() -> None: async def test_server_safe_tool_names_adapts_dotted_runtime_names() -> None:
config = server_config() config = server_config()
async def run_proxy() -> None:
client = create_server_client(config, safe_tool_names=True) client = create_server_client(config, safe_tool_names=True)
async with client: async with client:
artifacts = await assert_safe_tool_maps( artifacts = await assert_safe_tool_maps(
@@ -119,16 +116,14 @@ def test_server_safe_tool_names_adapts_dotted_runtime_names() -> None:
assert artifacts["total"] == 0 assert artifacts["total"] == 0
assert echo["echoed"] == "hello" assert echo["echoed"] == "hello"
asyncio.run(run_proxy())
@pytest.mark.asyncio
def test_workflow_tools_have_human_metadata() -> None: async def test_workflow_tools_have_human_metadata(tmp_path: Path) -> None:
config = BrokerConfig( config = BrokerConfig(
store_root=local_temp_root() / "unified_metadata_store", store_root=tmp_path / "unified_metadata_store",
connections=[], connections=[],
) )
async def run_proxy() -> None:
client = create_server_client(config, admin_tools=False) client = create_server_client(config, admin_tools=False)
async with client: async with client:
tools = await client.list_tools() tools = await client.list_tools()
@@ -145,33 +140,28 @@ def test_workflow_tools_have_human_metadata() -> None:
assert "kind" in list_artifacts.inputSchema["properties"] assert "kind" in list_artifacts.inputSchema["properties"]
assert "cursor" in list_artifacts.inputSchema["properties"] assert "cursor" in list_artifacts.inputSchema["properties"]
assert "limit" in list_artifacts.inputSchema["properties"] assert "limit" in list_artifacts.inputSchema["properties"]
live_check_schema = validate_deployment.inputSchema["properties"][ live_check_schema = validate_deployment.inputSchema["properties"]["live_check"]
"live_check"
]
assert "upstream" in live_check_schema.get("description", "") assert "upstream" in live_check_schema.get("description", "")
assert run_deployment.title == "Run Workflow Deployment" assert run_deployment.title == "Run Workflow Deployment"
assert "deployment_id" in (run_deployment.description or "") assert "deployment_id" in (run_deployment.description or "")
assert "trace_range" in run_deployment.inputSchema["properties"] assert "trace_range" in run_deployment.inputSchema["properties"]
trace_range_schema = run_deployment.inputSchema["properties"]["trace_range"] trace_range_schema = run_deployment.inputSchema["properties"]["trace_range"]
assert "Debug traces" in trace_range_schema.get("description", "") assert "Debug traces" in trace_range_schema.get("description", "")
assert "null" in [ assert "null" in [option.get("type") for option in trace_range_schema["anyOf"]]
option.get("type") for option in trace_range_schema["anyOf"]
]
assert inspect_run.title == "Inspect Workflow Run" assert inspect_run.title == "Inspect Workflow Run"
assert "trace" in (inspect_run.description or "").lower() assert "trace" in (inspect_run.description or "").lower()
read_trace_schema = read_run_trace.inputSchema["properties"]["trace_range"] read_trace_schema = read_run_trace.inputSchema["properties"]["trace_range"]
assert "Debug traces" in read_trace_schema.get("description", "") assert "Debug traces" in read_trace_schema.get("description", "")
asyncio.run(run_proxy())
async def test_create_artifact_from_plan_exposes_plan_as_plain_object(
def test_create_artifact_from_plan_exposes_plan_as_plain_object() -> None: tmp_path: Path,
) -> None:
config = BrokerConfig( config = BrokerConfig(
store_root=local_temp_root() / "unified_create_artifact_schema_store", store_root=tmp_path / "unified_create_artifact_schema_store",
connections=[], connections=[],
) )
async def run_proxy() -> None:
client = create_server_client(config, admin_tools=False) client = create_server_client(config, admin_tools=False)
async with client: async with client:
tools = await client.list_tools() tools = await client.list_tools()
@@ -182,16 +172,15 @@ def test_create_artifact_from_plan_exposes_plan_as_plain_object() -> None:
assert plan_schema["type"] == "object" assert plan_schema["type"] == "object"
assert plan_schema.get("additionalProperties") is True assert plan_schema.get("additionalProperties") is True
asyncio.run(run_proxy())
async def test_draft_tools_expose_plain_object_and_patch_array_schemas(
def test_draft_tools_expose_plain_object_and_patch_array_schemas() -> None: tmp_path: Path,
) -> None:
config = BrokerConfig( config = BrokerConfig(
store_root=local_temp_root() / "unified_draft_schema_store", store_root=tmp_path / "unified_draft_schema_store",
connections=[], connections=[],
) )
async def run_proxy() -> None:
client = create_server_client(config, admin_tools=False) client = create_server_client(config, admin_tools=False)
async with client: async with client:
tools = await client.list_tools() tools = await client.list_tools()
@@ -199,9 +188,7 @@ def test_draft_tools_expose_plain_object_and_patch_array_schemas() -> None:
validate_schema = by_name["wf.workflow.validate_draft"].inputSchema validate_schema = by_name["wf.workflow.validate_draft"].inputSchema
validate_draft_schema = validate_schema["properties"]["draft"] validate_draft_schema = validate_schema["properties"]["draft"]
create_schema = by_name[ create_schema = by_name["wf.workflow.create_artifact_from_draft"].inputSchema
"wf.workflow.create_artifact_from_draft"
].inputSchema
create_draft_schema = create_schema["properties"]["draft"] create_draft_schema = create_schema["properties"]["draft"]
patch_schema = by_name["wf.workflow.patch_draft"].inputSchema patch_schema = by_name["wf.workflow.patch_draft"].inputSchema
patch_draft_schema = patch_schema["properties"]["draft"] patch_draft_schema = patch_schema["properties"]["draft"]
@@ -215,16 +202,13 @@ def test_draft_tools_expose_plain_object_and_patch_array_schemas() -> None:
assert patch_patch_schema["type"] == "array" assert patch_patch_schema["type"] == "array"
assert "$defs" not in patch_patch_schema assert "$defs" not in patch_patch_schema
asyncio.run(run_proxy())
async def test_admin_tools_have_human_metadata(tmp_path: Path) -> None:
def test_admin_tools_have_human_metadata() -> None:
config = BrokerConfig( config = BrokerConfig(
store_root=local_temp_root() / "unified_admin_metadata_store", store_root=tmp_path / "unified_admin_metadata_store",
connections=[], connections=[],
) )
async def run_proxy() -> None:
client = create_server_client(config) client = create_server_client(config)
async with client: async with client:
tools = await client.list_tools() tools = await client.list_tools()
@@ -236,5 +220,3 @@ def test_admin_tools_have_human_metadata() -> None:
assert "configured MCP connections" in (list_connections.description or "") assert "configured MCP connections" in (list_connections.description or "")
assert reload_config.title == "Reload Config" assert reload_config.title == "Reload Config"
assert "remount" in (reload_config.description or "") assert "remount" in (reload_config.description or "")
asyncio.run(run_proxy())