diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d8af7f47..787b87b5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,6 +39,20 @@ jobs: - name: 🧪 Run tests run: "python -m unittest" + test-typer-compat: + runs-on: ubuntu-latest + strategy: + matrix: + typer: ["typer==0.27.2", "typer"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - run: pip install --upgrade ".[dev]" "${{ matrix.typer }}" + - run: make check_code_quality + - run: python -m unittest discover -s tests/cli -t . + test-slim: runs-on: ubuntu-latest steps: @@ -58,4 +72,6 @@ jobs: pip install -e . --no-deps pip install responses - name: 🧪 Run slim-compatible tests - run: "python -m unittest tests.test_slim_compat tests.test_vision_events" + run: >- + python -m unittest tests.test_slim_compat tests.test_vision_events + tests.cli.test_typer_integration tests.cli.test_completion_handler diff --git a/CHANGELOG.md b/CHANGELOG.md index 3de8e4fd..35ea8569 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Changed + +- Migrate CLI error handling to Typer's public exception API, preserving JSON + usage errors, aborts, completion failures and argument ordering. Require + Typer >=0.27.2 in both full and slim installs and remove the external Click + dependency; test the minimum and latest Typer releases. This raises the + previous minimum from 0.12.0 to use the supported exception API. + ### Added - `Project.autolabel(..., keypoint_confidence_threshold=0.75)` and diff --git a/requirements-slim.txt b/requirements-slim.txt index 9c4d021e..2c5b03d9 100644 --- a/requirements-slim.txt +++ b/requirements-slim.txt @@ -6,8 +6,7 @@ tqdm>=4.41.0 PyYAML>=5.3.1 requests_toolbelt filetype -typer>=0.12.0,<0.26 # 0.26 vendors click, dropping the external dep the CLI imports -click>=8.0 +typer>=0.27.2 python-dateutil python-dotenv six diff --git a/requirements.txt b/requirements.txt index 2a105758..53c63eb9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,5 +16,4 @@ tqdm>=4.41.0 PyYAML>=5.3.1 requests_toolbelt filetype -typer>=0.12.0,<0.26 # 0.26 vendors click, dropping the external dep the CLI imports -click>=8.0 +typer>=0.27.2 diff --git a/roboflow/cli/__init__.py b/roboflow/cli/__init__.py index bb52cf56..99f56b51 100644 --- a/roboflow/cli/__init__.py +++ b/roboflow/cli/__init__.py @@ -10,7 +10,6 @@ import os from typing import Annotated, Any, Optional -import click import typer import roboflow @@ -417,15 +416,16 @@ def main() -> None: # Exit code 2 = Click usage error (missing arg, bad option) # Other codes = our output_error already printed JSON raise - except click.exceptions.UsageError as exc: - # Click/typer validation error — emit JSON on stderr + except typer.TyperException as exc: + # Use Typer's public exception API, including its usage-error exit code. import json as _json - # format_message() keeps the option name that str() drops for bad values. - payload = {"error": {"message": exc.format_message(), "hint": "Run with --help for usage information."}} + payload = {"error": {"message": exc.format_message()}} + if exc.exit_code == 2: + payload["error"]["hint"] = "Run with --help for usage information." print(_json.dumps(payload), file=sys.stderr) - sys.exit(2) - except click.exceptions.Abort: + sys.exit(exc.exit_code) + except typer.Abort: sys.exit(1) except Exception as exc: import json as _json diff --git a/roboflow/cli/_compat.py b/roboflow/cli/_compat.py index ae6b1804..9ca7868b 100644 --- a/roboflow/cli/_compat.py +++ b/roboflow/cli/_compat.py @@ -11,11 +11,10 @@ import types from typing import Any -import click import typer # noqa: TC002 — needed at runtime for Context type -def _sort_params(params: list[click.Parameter]) -> None: +def _sort_params(params: list[Any]) -> None: """Sort params in-place: required first, then alphabetical by option name.""" params.sort( key=lambda p: ( @@ -24,7 +23,7 @@ def _sort_params(params: list[click.Parameter]) -> None: # Required options first not getattr(p, "required", False), # Arguments before options (positionals first) - not isinstance(p, click.Argument), + not isinstance(p, typer.core.TyperArgument), # Alphabetical by the first long option name (p.opts[0].lstrip("-") if hasattr(p, "opts") and p.opts else p.name or ""), ) @@ -39,15 +38,15 @@ class SortedGroup(typer.core.TyperGroup): required options first). """ - def list_commands(self, ctx: click.Context) -> list[str]: # type: ignore[override] + def list_commands(self, ctx: Any) -> list[str]: return sorted(super().list_commands(ctx)) - def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> None: + def format_help(self, ctx: Any, formatter: Any) -> None: """Sort options alphabetically before rendering help.""" _sort_params(self.params) super().format_help(ctx, formatter) - def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None: # type: ignore[override] + def get_command(self, ctx: Any, cmd_name: str) -> Any: """Wrap returned commands to sort their options too.""" cmd = super().get_command(ctx, cmd_name) if cmd is not None and not isinstance(cmd, SortedGroup): diff --git a/roboflow/cli/handlers/completion.py b/roboflow/cli/handlers/completion.py index 8acebd71..24c8cbe4 100644 --- a/roboflow/cli/handlers/completion.py +++ b/roboflow/cli/handlers/completion.py @@ -10,7 +10,6 @@ import shutil from typing import Annotated, Optional -import click import typer from typer._completion_classes import completion_init from typer._completion_shared import get_completion_script @@ -72,7 +71,7 @@ def install( try: installed_shell, path = typer_install(shell=shell, prog_name="roboflow", complete_var="_ROBOFLOW_COMPLETE") - except click.exceptions.Exit: + except typer.Exit: output_error( args, "Could not detect or install completion.", diff --git a/tests/cli/test_completion_handler.py b/tests/cli/test_completion_handler.py index d701543e..70cc00b0 100644 --- a/tests/cli/test_completion_handler.py +++ b/tests/cli/test_completion_handler.py @@ -12,7 +12,6 @@ from pathlib import Path from unittest import mock -import click from typer.testing import CliRunner from roboflow.cli import app @@ -78,7 +77,7 @@ def test_hidden_commands_filtered_from_completion(self) -> None: from roboflow.cli import app as rf_app click_app = typer.main.get_command(rf_app) - ctx = click.Context(click_app, info_name="roboflow") + ctx = click_app.make_context("roboflow", [], resilient_parsing=True) visible = { name for name in click_app.list_commands(ctx) diff --git a/tests/cli/test_typer_integration.py b/tests/cli/test_typer_integration.py new file mode 100644 index 00000000..fb0d4463 --- /dev/null +++ b/tests/cli/test_typer_integration.py @@ -0,0 +1,69 @@ +import io +import json +import sys +import unittest +from contextlib import redirect_stderr +from unittest.mock import patch + +import typer + +from roboflow.cli import main +from roboflow.cli._compat import _sort_params + + +class TestTyperIntegration(unittest.TestCase): + def test_json_usage_errors(self): + cases = ( + (["project", "get"], "Missing argument"), + (["--bad-option"], "--bad-option"), + (["image", "search", "example", "--limit", "not-an-int"], "--limit"), + ) + for args, message in cases: + with self.subTest(args=args): + stderr = io.StringIO() + with patch.object(sys, "argv", ["roboflow", "--json", *args]): + with redirect_stderr(stderr), self.assertRaises(SystemExit) as error: + main() + self.assertEqual(error.exception.code, 2) + payload = json.loads(stderr.getvalue()) + self.assertIn("hint", payload["error"]) + self.assertIn(message, payload["error"]["message"]) + + def test_json_abort(self): + with patch.object(sys, "argv", ["roboflow", "--json"]): + with patch("roboflow.cli.app", side_effect=typer.Abort): + stderr = io.StringIO() + with redirect_stderr(stderr), self.assertRaises(SystemExit) as error: + main() + self.assertEqual(error.exception.code, 1) + # Preserve the existing silent cancellation behavior. + self.assertEqual(stderr.getvalue(), "") + + def test_json_application_error_keeps_its_exit_code(self): + for exit_code in (1, 7): + with self.subTest(exit_code=exit_code): + exception = typer.TyperException("Request failed") + exception.exit_code = exit_code + with patch.object(sys, "argv", ["roboflow", "--json"]): + with patch("roboflow.cli.app", side_effect=exception): + stderr = io.StringIO() + with redirect_stderr(stderr), self.assertRaises(SystemExit) as error: + main() + self.assertEqual(error.exception.code, exit_code) + self.assertEqual(json.loads(stderr.getvalue()), {"error": {"message": "Request failed"}}) + + def test_json_non_typer_error_remains_generic(self): + with patch.object(sys, "argv", ["roboflow", "--json"]): + with patch("roboflow.cli.app", side_effect=ValueError("Unexpected failure")): + stderr = io.StringIO() + with redirect_stderr(stderr), self.assertRaises(SystemExit) as error: + main() + self.assertEqual(error.exception.code, 1) + self.assertEqual(json.loads(stderr.getvalue()), {"error": {"message": "Unexpected failure"}}) + + def test_arguments_sort_before_required_options(self): + argument = typer.core.TyperArgument(param_decls=["zulu"], required=True) + option = typer.core.TyperOption(param_decls=["--alpha"], required=True) + params = [option, argument] + _sort_params(params) + self.assertEqual(params, [argument, option])