Skip to content
Open
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
18 changes: 17 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions requirements-slim.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 1 addition & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 7 additions & 7 deletions roboflow/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import os
from typing import Annotated, Any, Optional

import click
import typer

import roboflow
Expand Down Expand Up @@ -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
Expand Down
11 changes: 5 additions & 6 deletions roboflow/cli/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
Expand All @@ -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 ""),
)
Expand All @@ -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):
Expand Down
3 changes: 1 addition & 2 deletions roboflow/cli/handlers/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.",
Expand Down
3 changes: 1 addition & 2 deletions tests/cli/test_completion_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
from pathlib import Path
from unittest import mock

import click
from typer.testing import CliRunner

from roboflow.cli import app
Expand Down Expand Up @@ -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)
Expand Down
69 changes: 69 additions & 0 deletions tests/cli/test_typer_integration.py
Original file line number Diff line number Diff line change
@@ -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])
Loading