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.25.1", "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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.

## Unreleased

### Fixed

- Support Typer's vendored Click implementation for CLI usage errors, aborts,
completion failures and argument ordering. Remove the Typer upper bound from
both full and slim requirements; test the legacy and latest Typer layouts.

### 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.12.0
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.12.0
7 changes: 3 additions & 4 deletions roboflow/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,10 @@
import os
from typing import Annotated, Any, Optional

import click
import typer

import roboflow
from roboflow.cli._compat import SortedGroup
from roboflow.cli._compat import SortedGroup, UsageError

# ---------------------------------------------------------------------------
# Root application
Expand Down Expand Up @@ -417,15 +416,15 @@ 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:
except UsageError as exc:
# Click/typer validation error — emit JSON on stderr
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."}}
print(_json.dumps(payload), file=sys.stderr)
sys.exit(2)
except click.exceptions.Abort:
except typer.Abort:
sys.exit(1)
except Exception as exc:
import json as _json
Expand Down
20 changes: 14 additions & 6 deletions roboflow/cli/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,21 @@
from __future__ import annotations

import types
from importlib import import_module
from typing import Any

import click
import typer # noqa: TC002 — needed at runtime for Context type

# Typer switched from external Click to its own implementation. Keep this
# compatibility boundary duck-typed so both runtime and mypy use the active one.
try:
_click_exceptions = import_module("typer._click.exceptions")
except ModuleNotFoundError:
_click_exceptions = import_module("click.exceptions")
UsageError = _click_exceptions.UsageError

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 +32,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 +47,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
47 changes: 47 additions & 0 deletions tests/cli/test_typer_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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_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