From ee8bd86858d979dff4500fe04517d273b820dfb5 Mon Sep 17 00:00:00 2001 From: JoyboyBrian Date: Wed, 1 Jul 2026 16:31:44 -0700 Subject: [PATCH 1/3] [cli] refactor: migrate to typer 0.26 (vendored click) Typer 0.26 vendors Click, so exceptions raised by Typer are no longer instances of the external click package's classes. Instead of pinning typer <0.26 forever, migrate the CLI off direct `import click` entirely: - All vendored symbols without a public typer re-export yet (fastapi/typer#1868) go through one module: cli/_click_compat.py. Everything else uses public typer API (typer.Exit, typer.Abort). - main(): replace the empty-message UsageError hack for no_args_is_help with an explicit `except NoArgsIsHelpError`. - get_output_context(): the ContextVar set by install_output_context() is now the source of truth; no more reaching into Click's threadlocal. - OsmosisGroup stays: tests pin its single-candidate suggestions, hidden command filtering, and the 'help' nudge. Native suggest_commands is disabled so it cannot double-append suggestions. - Tests construct vendored objects (typer.core.TyperCommand) and use typer.testing.CliRunner instead of external click equivalents. CLI code no longer depends on the external click package at all, which also ends click version conflicts with other click-dependent deps in user environments. --- osmosis_ai/cli/_click_compat.py | 22 +++++++++++++ osmosis_ai/cli/commands/auth.py | 4 +-- osmosis_ai/cli/main.py | 45 ++++++++++++++++---------- osmosis_ai/cli/output/context.py | 19 ++++------- osmosis_ai/cli/output/error.py | 9 +++--- osmosis_ai/cli/output/renderer.py | 8 ++--- pyproject.toml | 5 ++- tests/unit/cli/output/test_context.py | 46 +++++++++++++++++++-------- tests/unit/cli/output/test_error.py | 16 +++++----- tests/unit/cli/output/test_safety.py | 4 +-- tests/unit/cli/test_template_cmd.py | 7 ++-- uv.lock | 10 +++--- 12 files changed, 119 insertions(+), 76 deletions(-) create mode 100644 osmosis_ai/cli/_click_compat.py diff --git a/osmosis_ai/cli/_click_compat.py b/osmosis_ai/cli/_click_compat.py new file mode 100644 index 00000000..c00f2b80 --- /dev/null +++ b/osmosis_ai/cli/_click_compat.py @@ -0,0 +1,22 @@ +"""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. +""" + +from typer._click.core import Command, Context +from typer._click.exceptions import ClickException, NoArgsIsHelpError, UsageError +from typer._click.globals import get_current_context + +__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..22d4822a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,9 +39,8 @@ 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", "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..99d36955 100644 --- a/uv.lock +++ b/uv.lock @@ -2041,7 +2041,7 @@ 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 = "types-requests", marker = "extra == 'dev'", specifier = ">=2.0" }, { name = "uvicorn", marker = "extra == 'server'", specifier = ">=0.23.0,<1.0.0" }, ] @@ -3284,17 +3284,17 @@ 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]] From 98d854af22fdd739309904e8fe939fc78b022951 Mon Sep 17 00:00:00 2001 From: JoyboyBrian Date: Wed, 1 Jul 2026 16:40:15 -0700 Subject: [PATCH 2/3] [cli] fix: upgrade typer-slim past its code-shipping versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit typer-slim <0.22 ships its own copy of the typer/ package; the lock had 0.21.1 (via huggingface-hub), which overlay-corrupted the typer 0.26.8 install nondeterministically (install-order dependent) — green locally, mixed 0.21/0.26 module tree on CI. typer-slim 0.22+ is an empty shim that just requires typer, so pinning >=0.22 removes the file collision for our CI and for downstream installs alike. --- pyproject.toml | 3 +++ uv.lock | 11 ++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 22d4822a..53caa2be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,9 @@ dependencies = [ "questionary>=2.1.0,<3.0.0", # 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/uv.lock b/uv.lock index 99d36955..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] @@ -2042,6 +2043,7 @@ requires-dist = [ { name = "strands-agents", extras = ["litellm"], specifier = ">=1.29.0" }, { name = "tqdm", specifier = ">=4.0.0,<5.0.0" }, { 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" }, ] @@ -3299,15 +3301,14 @@ wheels = [ [[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]] From 1a018163d37821751733b317bf0694bdbfcede60 Mon Sep 17 00:00:00 2001 From: JoyboyBrian Date: Wed, 1 Jul 2026 16:49:46 -0700 Subject: [PATCH 3/3] [cli] feat: fail loudly on a corrupted typer install The typer-slim>=0.22 floor protects new resolutions, but an environment that was already overlay-corrupted (stale typer-slim files over typer's) degrades silently: exceptions stop matching, exit codes drift, and suggestions vanish. Assert the vendored-class lineage at import time and raise an actionable ImportError instead. --- osmosis_ai/cli/_click_compat.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/osmosis_ai/cli/_click_compat.py b/osmosis_ai/cli/_click_compat.py index c00f2b80..908465c0 100644 --- a/osmosis_ai/cli/_click_compat.py +++ b/osmosis_ai/cli/_click_compat.py @@ -8,10 +8,22 @@ 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",