Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions osmosis_ai/cli/_click_compat.py
Original file line number Diff line number Diff line change
@@ -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",
]
4 changes: 2 additions & 2 deletions osmosis_ai/cli/commands/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
45 changes: 28 additions & 17 deletions osmosis_ai/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 <command> --help' for a specific command."
) from None
Expand All @@ -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
Expand All @@ -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,
)


Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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
Expand Down
19 changes: 7 additions & 12 deletions osmosis_ai/cli/output/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
9 changes: 4 additions & 5 deletions osmosis_ai/cli/output/error.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions osmosis_ai/cli/output/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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

Expand All @@ -444,4 +444,4 @@ def verify_output_emitted() -> None:
code="INTERNAL",
)
)
raise click.exceptions.Exit(1)
raise typer.Exit(1)
8 changes: 5 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
46 changes: 32 additions & 14 deletions tests/unit/cli/output/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -54,23 +71,23 @@ 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


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
Expand Down Expand Up @@ -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

Expand Down
Loading