diff --git a/osmosis_ai/cli/_click_compat.py b/osmosis_ai/cli/_click_compat.py new file mode 100644 index 00000000..908465c0 --- /dev/null +++ b/osmosis_ai/cli/_click_compat.py @@ -0,0 +1,34 @@ +"""Single import point for Typer's vendored Click internals. + +Typer 0.26+ vendors Click but does not re-export these symbols publicly yet +(fastapi/typer#1868); the <0.27 pin in pyproject.toml keeps the private paths +stable. Delete this module once upstream exports them. + +Never import the external ``click`` package in CLI code: Typer raises the +vendored classes, so external ``click`` types silently never match. +""" + +import typer.core +from typer._click.core import Command, Context +from typer._click.exceptions import ClickException, NoArgsIsHelpError, UsageError +from typer._click.globals import get_current_context + +# In a coherent install, Typer's own classes derive from the vendored Click +# classes imported above. If typer/ files were overwritten by a stale +# typer-slim (<0.22 ships its own copy), the CLI would degrade silently — +# fail loudly with a fix instead. +if not issubclass(typer.core.TyperGroup, Command): + raise ImportError( + "Corrupted typer install: typer's files were overwritten by another " + "distribution (typically typer-slim<0.22). " + "Fix: pip install --force-reinstall --no-deps typer" + ) + +__all__ = [ + "ClickException", + "Command", + "Context", + "NoArgsIsHelpError", + "UsageError", + "get_current_context", +] diff --git a/osmosis_ai/cli/commands/auth.py b/osmosis_ai/cli/commands/auth.py index 53628f7d..06572553 100644 --- a/osmosis_ai/cli/commands/auth.py +++ b/osmosis_ai/cli/commands/auth.py @@ -652,9 +652,9 @@ def whoami() -> Any: # result callback. Preserve that rich rendering path without duplicating # output during normal CLI invocations. if output.format is OutputFormat.rich: - import click + from osmosis_ai.cli._click_compat import get_current_context - if click.get_current_context(silent=True) is None: + if get_current_context(silent=True) is None: console.table([(field.label, field.value) for field in fields]) return result diff --git a/osmosis_ai/cli/main.py b/osmosis_ai/cli/main.py index 8f2e5801..235a43a4 100644 --- a/osmosis_ai/cli/main.py +++ b/osmosis_ai/cli/main.py @@ -4,11 +4,17 @@ import sys import warnings -import click import typer import typer.core from dotenv import find_dotenv, load_dotenv +from osmosis_ai.cli._click_compat import ( + ClickException, + Command, + Context, + NoArgsIsHelpError, + UsageError, +) from osmosis_ai.cli.output.context import ( OutputContext, OutputFormat, @@ -32,15 +38,15 @@ class OsmosisGroup(typer.core.TyperGroup): """Typer group with fuzzy command suggestion.""" def resolve_command( - self, ctx: click.Context, args: list[str] - ) -> tuple[str | None, click.Command | None, list[str]]: + self, ctx: Context, args: list[str] + ) -> tuple[str | None, Command | None, list[str]]: try: return super().resolve_command(ctx, args) - except click.UsageError: + except UsageError: if args: cmd_name = args[0] if cmd_name == "help": - raise click.UsageError( + raise UsageError( "No such command 'help'. Use 'osmosis --help', " "or 'osmosis --help' for a specific command." ) from None @@ -54,7 +60,7 @@ def resolve_command( cmd_name, candidates, n=1, cutoff=0.5 ) if matches: - raise click.UsageError( + raise UsageError( f"No such command '{cmd_name}'. Did you mean '{matches[0]}'?" ) from None raise @@ -67,6 +73,10 @@ def resolve_command( add_completion=False, context_settings={"help_option_names": ["-h", "--help"]}, result_callback=render_command_result, + # OsmosisGroup owns suggestions: single candidate, hidden commands + # filtered, and the 'help' nudge. Typer's built-in suggester would + # append its own (unfiltered, multi-candidate) line on top. + suggest_commands=False, ) @@ -127,7 +137,7 @@ def _output_context_for_error( argv: list[str] | None, ) -> OutputContext: ctx = getattr(exc, "ctx", None) - if isinstance(exc, click.ClickException) and isinstance(ctx, click.Context): + if isinstance(exc, ClickException) and isinstance(ctx, Context): root_obj = ctx.find_root().obj if isinstance(root_obj, OutputContext): return root_obj @@ -151,7 +161,7 @@ def _handle_cli_error( output = _output_context_for_error(exc, argv) if output.format is OutputFormat.json: raw_ctx = getattr(exc, "ctx", None) - ctx = raw_ctx if isinstance(raw_ctx, click.Context) else None + ctx = raw_ctx if isinstance(raw_ctx, Context) else None command_argv = argv if argv is not None else sys.argv[1:] emit_structured_error_to_stderr( classify_error(exc), @@ -217,22 +227,23 @@ def main(argv: list[str] | None = None) -> int: argv = hoist_format_selectors(argv if argv is not None else sys.argv[1:]) try: result = app(argv, standalone_mode=False) - # standalone_mode=False returns None on normal completion; - # typer.Exit() still raises SystemExit, caught by the except block below. + # standalone_mode=False returns the command result on success, or the + # exit code when the run ended via typer.Exit (Typer also converts + # Ctrl-C into Exit(130) internally). if isinstance(result, int) and result != 0: return result return 0 - except click.exceptions.Exit as e: + except NoArgsIsHelpError: + # no_args_is_help=True: instantiating this exception already rendered + # rich help to stdout (via ctx.get_help), so just exit cleanly. + return 0 + except typer.Exit as e: return e.exit_code except SystemExit as e: return int(e.code) if e.code is not None else 0 - except click.UsageError as exc: - # NoArgsIsHelpError (from no_args_is_help=True) has an empty message - # after help is already printed — just exit cleanly. - if not str(exc): - return 0 + except UsageError as exc: return _handle_cli_error(exc, argv=argv, exit_code=exc.exit_code) - except (KeyboardInterrupt, click.Abort): + except (KeyboardInterrupt, typer.Abort): return 130 except Exception as exc: # CLIError, PlatformAPIError, AuthenticationExpiredError and anything diff --git a/osmosis_ai/cli/output/context.py b/osmosis_ai/cli/output/context.py index 3be73bbe..32e6a5e2 100644 --- a/osmosis_ai/cli/output/context.py +++ b/osmosis_ai/cli/output/context.py @@ -9,7 +9,7 @@ from dataclasses import dataclass from enum import StrEnum -import click +from osmosis_ai.cli._click_compat import Context class OutputFormat(StrEnum): @@ -108,17 +108,12 @@ def hoist_format_selectors(argv: list[str]) -> list[str]: def get_output_context() -> OutputContext: - """Resolve the active OutputContext through the fallback stack.""" - try: - ctx = click.get_current_context(silent=True) - except RuntimeError: - ctx = None - - if ctx is not None: - root_obj = ctx.find_root().obj - if isinstance(root_obj, OutputContext): - return root_obj + """Resolve the active OutputContext through the fallback stack. + The ContextVar is the source of truth: install_output_context() sets it + for the lifetime of the root Click context (reset via call_on_close), so + it is populated exactly while a CLI invocation is in flight. + """ stored = _output_context_var.get() if stored is not None: return stored @@ -130,7 +125,7 @@ def get_output_context() -> OutputContext: return default_output_context() -def install_output_context(ctx: click.Context, output: OutputContext) -> None: +def install_output_context(ctx: Context, output: OutputContext) -> None: """Mirror `output` to Click.Context.obj and the ContextVar.""" ctx.obj = output token: Token[OutputContext | None] = _output_context_var.set(output) diff --git a/osmosis_ai/cli/output/error.py b/osmosis_ai/cli/output/error.py index 4a70d8b4..90ec1c7e 100644 --- a/osmosis_ai/cli/output/error.py +++ b/osmosis_ai/cli/output/error.py @@ -6,8 +6,7 @@ import sys from typing import Any -import click - +from osmosis_ai.cli._click_compat import Context, UsageError, get_current_context from osmosis_ai.cli.errors import CLIError from osmosis_ai.consts import PACKAGE_VERSION @@ -95,7 +94,7 @@ def classify_error(exc: BaseException) -> CLIError: details=details, ) - if isinstance(exc, click.UsageError): + if isinstance(exc, UsageError): return CLIError(str(exc) or "Invalid usage.", code="VALIDATION") return CLIError( @@ -139,7 +138,7 @@ def _argv_command_path(argv: list[str]) -> str: def command_path_for_error( - ctx: click.Context | None, + ctx: Context | None, *, argv: list[str] | None = None, ) -> str: @@ -164,7 +163,7 @@ def emit_structured_error_to_stderr( """Write the JSON-mode error envelope to stderr.""" if command is None: try: - ctx = click.get_current_context(silent=True) + ctx = get_current_context(silent=True) except RuntimeError: ctx = None command = command_path_for_error(ctx) diff --git a/osmosis_ai/cli/output/renderer.py b/osmosis_ai/cli/output/renderer.py index a26ca65b..fdf3c4d5 100644 --- a/osmosis_ai/cli/output/renderer.py +++ b/osmosis_ai/cli/output/renderer.py @@ -397,9 +397,9 @@ def render_command_result(result: Any, **_: Any) -> None: render(result, get_output_context()) exit_code = getattr(result, "exit_code", 0) if exit_code: - import click + import typer - raise click.exceptions.Exit(exit_code) + raise typer.Exit(exit_code) return output = get_output_context() @@ -434,7 +434,7 @@ def verify_output_emitted() -> None: if sys.exc_info()[0] is not None: return - import click + import typer from osmosis_ai.cli.errors import CLIError @@ -444,4 +444,4 @@ def verify_output_emitted() -> None: code="INTERNAL", ) ) - raise click.exceptions.Exit(1) + raise typer.Exit(1) diff --git a/pyproject.toml b/pyproject.toml index 7c1449fe..53caa2be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,9 +39,11 @@ dependencies = [ "keyring>=25.0.0", # Interactive CLI prompts "questionary>=2.1.0,<3.0.0", - # CLI framework. <0.26: Typer 0.26+ vendors its own Click, so the - # exceptions it raises bypass our top-level `click` error handling. - "typer>=0.16.0,<0.26", + # CLI framework. <0.27: cli/_click_compat.py imports typer._click internals. + "typer>=0.26,<0.27", + # Guard: typer-slim <0.22 ships its own typer/ files (pulled in via + # huggingface-hub) and overlay-corrupts the real typer install. + "typer-slim>=0.22", "rich>=14.2.0", "harbor[daytona]>=0.15.0,<0.16.0", # Security floors for load-bearing transitive deps (CVE remediation): diff --git a/tests/unit/cli/output/test_context.py b/tests/unit/cli/output/test_context.py index 594cd7f2..af26818d 100644 --- a/tests/unit/cli/output/test_context.py +++ b/tests/unit/cli/output/test_context.py @@ -4,9 +4,10 @@ from io import StringIO -import click import pytest +import typer.core +from osmosis_ai.cli._click_compat import Context, UsageError from osmosis_ai.cli.errors import CLIError from osmosis_ai.cli.output.context import ( OutputContext, @@ -37,15 +38,31 @@ def test_default_output_context_is_rich_and_uses_stdin_isatty(monkeypatch) -> No assert output.output_emitted is False -def test_get_output_context_layer_1_uses_active_click_context() -> None: +def test_get_output_context_reads_contextvar_installed_via_context() -> None: + """install_output_context() + ContextVar is the supported resolution path. + + A bare Click context obj that was never installed is not consulted: + the ContextVar is the source of truth. + """ output = OutputContext(format=OutputFormat.plain, interactive=False) - cmd = click.Command("dummy", callback=lambda: None) - with click.Context(cmd) as ctx: - ctx.obj = output + cmd = typer.core.TyperCommand(name="dummy", callback=lambda: None) + with Context(cmd) as ctx: + install_output_context(ctx, output) assert get_output_context() is output -def test_get_output_context_layer_3_falls_back_to_contextvar() -> None: +def test_get_output_context_ignores_uninstalled_click_context_obj( + monkeypatch, +) -> None: + monkeypatch.setattr("sys.argv", ["osmosis"]) + output = OutputContext(format=OutputFormat.plain, interactive=False) + cmd = typer.core.TyperCommand(name="dummy", callback=lambda: None) + with Context(cmd) as ctx: + ctx.obj = output + assert get_output_context().format is OutputFormat.rich + + +def test_get_output_context_falls_back_to_contextvar() -> None: forced = OutputContext(format=OutputFormat.json, interactive=False) token = _output_context_var.set(forced) try: @@ -54,14 +71,14 @@ def test_get_output_context_layer_3_falls_back_to_contextvar() -> None: _output_context_var.reset(token) -def test_get_output_context_layer_4_uses_argv_prescan(monkeypatch) -> None: +def test_get_output_context_falls_back_to_argv_prescan(monkeypatch) -> None: monkeypatch.setattr("sys.argv", ["osmosis", "--json", "dataset", "list"]) output = get_output_context() assert output.format is OutputFormat.json assert output.interactive is False -def test_get_output_context_layer_5_default_when_unset(monkeypatch) -> None: +def test_get_output_context_default_when_unset(monkeypatch) -> None: monkeypatch.setattr("sys.argv", ["osmosis"]) output = get_output_context() assert output.format is OutputFormat.rich @@ -69,8 +86,8 @@ def test_get_output_context_layer_5_default_when_unset(monkeypatch) -> None: def test_install_output_context_sets_obj_and_contextvar() -> None: output = OutputContext(format=OutputFormat.plain, interactive=False) - cmd = click.Command("dummy", callback=lambda: None) - with click.Context(cmd) as ctx: + cmd = typer.core.TyperCommand(name="dummy", callback=lambda: None) + with Context(cmd) as ctx: install_output_context(ctx, output) assert ctx.obj is output assert _output_context_var.get() is output @@ -143,12 +160,13 @@ def test_hoist_format_selectors_hoists_conflicting_flags_together() -> None: ] -def test_get_output_context_layer_2_uses_usage_error_ctx() -> None: +def test_usage_error_ctx_carries_output_context() -> None: + """main._output_context_for_error reads exc.ctx.find_root().obj.""" output = OutputContext(format=OutputFormat.json, interactive=False) - root_cmd = click.Command("root", callback=lambda: None) - with click.Context(root_cmd) as ctx: + root_cmd = typer.core.TyperCommand(name="root", callback=lambda: None) + with Context(root_cmd) as ctx: ctx.obj = output - exc = click.UsageError("Bad subcommand", ctx=ctx) + exc = UsageError("Bad subcommand", ctx=ctx) assert exc.ctx is not None assert exc.ctx.find_root().obj is output diff --git a/tests/unit/cli/output/test_error.py b/tests/unit/cli/output/test_error.py index b2e70df7..b6a937b4 100644 --- a/tests/unit/cli/output/test_error.py +++ b/tests/unit/cli/output/test_error.py @@ -8,10 +8,12 @@ from pathlib import Path from typing import Any -import click import pytest +import typer +import typer.core import osmosis_ai.cli.main as cli_main +from osmosis_ai.cli._click_compat import Context, UsageError from osmosis_ai.cli.errors import CLIError from osmosis_ai.cli.main import _handle_cli_error, main from osmosis_ai.cli.output.error import ( @@ -39,7 +41,7 @@ def _capture_json_usage_error_for_argv( capsys: pytest.CaptureFixture[str], ) -> dict[str, Any]: rc = _handle_cli_error( - click.UsageError("No such command."), + UsageError("No such command."), argv=argv, exit_code=2, ) @@ -132,13 +134,11 @@ def test_command_path_fallback_excludes_top_level_argument(monkeypatch) -> None: def test_command_path_uses_click_context_when_available() -> None: - import click - - parent = click.Context(click.Command("osmosis")) + parent = Context(typer.core.TyperCommand(name="osmosis")) parent.info_name = "osmosis" - middle = click.Context(click.Command("dataset"), parent=parent) + middle = Context(typer.core.TyperCommand(name="dataset"), parent=parent) middle.info_name = "dataset" - nested = click.Context(click.Command("list"), parent=middle) + nested = Context(typer.core.TyperCommand(name="list"), parent=middle) nested.info_name = "list" assert command_path_for_error(nested) == "dataset list" @@ -230,7 +230,7 @@ def test_main_maps_click_abort_to_interrupt_exit_code( monkeypatch: pytest.MonkeyPatch, ) -> None: def raise_abort(*_: Any, **__: Any) -> None: - raise click.Abort() + raise typer.Abort() monkeypatch.setattr(cli_main, "_register_commands", lambda: None) monkeypatch.setattr(cli_main, "app", raise_abort) diff --git a/tests/unit/cli/output/test_safety.py b/tests/unit/cli/output/test_safety.py index 941229f1..bb37380d 100644 --- a/tests/unit/cli/output/test_safety.py +++ b/tests/unit/cli/output/test_safety.py @@ -6,8 +6,8 @@ import json from contextlib import redirect_stderr, redirect_stdout -import click import pytest +import typer from osmosis_ai.cli.errors import CLIError from osmosis_ai.cli.output.context import OutputFormat, override_output_context @@ -63,7 +63,7 @@ def test_verify_output_emitted_emits_internal_error_when_silent_in_json() -> Non with override_output_context(format=OutputFormat.json) as ctx: assert ctx.output_emitted is False err = io.StringIO() - with pytest.raises(click.exceptions.Exit) as exit_info: + with pytest.raises(typer.Exit) as exit_info: with redirect_stderr(err): verify_output_emitted() assert exit_info.value.exit_code == 1 diff --git a/tests/unit/cli/test_template_cmd.py b/tests/unit/cli/test_template_cmd.py index 7c3392e1..ada02266 100644 --- a/tests/unit/cli/test_template_cmd.py +++ b/tests/unit/cli/test_template_cmd.py @@ -7,8 +7,7 @@ from pathlib import Path import pytest -from click.testing import CliRunner -from typer.main import get_command +from typer.testing import CliRunner from osmosis_ai.cli import main as cli @@ -127,7 +126,7 @@ def test_zsh_completion_for_template_commands_does_not_require_comp_words( ) -> None: cli._register_commands() result = CliRunner().invoke( - get_command(cli.app), + cli.app, [], prog_name="osmosis", env={ @@ -266,7 +265,7 @@ def test_template_apply_unknown_template_returns_not_found_in_json( def test_zsh_completion_for_template_apply_names(workspace_template) -> None: cli._register_commands() result = CliRunner().invoke( - get_command(cli.app), + cli.app, [], prog_name="osmosis", env={ diff --git a/uv.lock b/uv.lock index 56dd7490..5f9b76e4 100644 --- a/uv.lock +++ b/uv.lock @@ -1985,6 +1985,7 @@ dependencies = [ { name = "strands-agents", extra = ["litellm"] }, { name = "tqdm" }, { name = "typer" }, + { name = "typer-slim" }, ] [package.optional-dependencies] @@ -2041,7 +2042,8 @@ requires-dist = [ { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.20" }, { name = "strands-agents", extras = ["litellm"], specifier = ">=1.29.0" }, { name = "tqdm", specifier = ">=4.0.0,<5.0.0" }, - { name = "typer", specifier = ">=0.16.0,<0.26" }, + { name = "typer", specifier = ">=0.26,<0.27" }, + { name = "typer-slim", specifier = ">=0.22" }, { name = "types-requests", marker = "extra == 'dev'", specifier = ">=2.0" }, { name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.23.0,<1.0.0" }, ] @@ -3284,30 +3286,29 @@ wheels = [ [[package]] name = "typer" -version = "0.23.1" +version = "0.26.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, - { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/07/b822e1b307d40e263e8253d2384cf98c51aa2368cc7ba9a07e523a1d964b/typer-0.23.1.tar.gz", hash = "sha256:2070374e4d31c83e7b61362fd859aa683576432fd5b026b060ad6b4cd3b86134", size = 120047, upload-time = "2026-02-13T10:04:30.984Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/91/9b286ab899c008c2cb05e8be99814807e7fbbd33f0c0c960470826e5ac82/typer-0.23.1-py3-none-any.whl", hash = "sha256:3291ad0d3c701cbf522012faccfbb29352ff16ad262db2139e6b01f15781f14e", size = 56813, upload-time = "2026-02-13T10:04:32.008Z" }, + { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, ] [[package]] name = "typer-slim" -version = "0.21.1" +version = "0.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "click" }, - { name = "typing-extensions" }, + { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/d4/064570dec6358aa9049d4708e4a10407d74c99258f8b2136bb8702303f1a/typer_slim-0.21.1.tar.gz", hash = "sha256:73495dd08c2d0940d611c5a8c04e91c2a0a98600cbd4ee19192255a233b6dbfd", size = 110478, upload-time = "2026-01-06T11:21:11.176Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/a7/e6aecc4b4eb59598829a3b5076a93aff291b4fdaa2ded25efc4e1f4d219c/typer_slim-0.24.0.tar.gz", hash = "sha256:f0ed36127183f52ae6ced2ecb2521789995992c521a46083bfcdbb652d22ad34", size = 4776, upload-time = "2026-02-16T22:08:51.2Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/0a/4aca634faf693e33004796b6cee0ae2e1dba375a800c16ab8d3eff4bb800/typer_slim-0.21.1-py3-none-any.whl", hash = "sha256:6e6c31047f171ac93cc5a973c9e617dbc5ab2bddc4d0a3135dc161b4e2020e0d", size = 47444, upload-time = "2026-01-06T11:21:12.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/5480c20380dfd18cf33d14784096dca45a24eae6102e91d49a718d3b6855/typer_slim-0.24.0-py3-none-any.whl", hash = "sha256:d5d7ee1ee2834d5020c7c616ed5e0d0f29b9a4b1dd283bdebae198ec09778d0e", size = 3394, upload-time = "2026-02-16T22:08:49.92Z" }, ] [[package]]