diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6173fc4b..fd99017c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: os: ["ubuntu-latest", "windows-latest"] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] runs-on: ${{ matrix.os }} env: PYTHONUTF8: 1 diff --git a/CLAUDE.md b/CLAUDE.md index 8c5743d9..30b5e522 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,22 +86,25 @@ The Roboflow Python SDK follows a hierarchical object model that mirrors the Rob ### CLI Package (`roboflow/cli/`) -The CLI is a modular package with auto-discovered handler modules. `roboflow/roboflowpy.py` is a backwards-compatibility shim that delegates to `roboflow.cli.main`. +The CLI is built on [typer](https://typer.tiangolo.com/) (which uses Click under the hood). `roboflow/roboflowpy.py` is a backwards-compatibility shim that delegates to `roboflow.cli.main`. **Package structure:** -- `__init__.py` — Root parser with global flags (`--json`, `--workspace`, `--api-key`, `--quiet`), auto-discovery via `pkgutil.iter_modules`, custom `_CleanHelpFormatter`, and `_reorder_argv` for flexible flag positioning +- `__init__.py` — Root `typer.Typer()` app with global `@app.callback()` for `--json`, `--workspace`, `--api-key`, `--quiet`. Explicitly registers all handler apps via `app.add_typer()`. - `_output.py` — `output(args, data, text)` for JSON/text output, `output_error(args, msg, hint, exit_code)` for structured errors, `suppress_sdk_output()` to silence SDK noise, `stub()` for unimplemented commands +- `_compat.py` — `ctx_to_args(ctx, **kwargs)` bridge that converts `typer.Context` to the `SimpleNamespace` that output helpers expect - `_table.py` — `format_table(rows, columns)` for columnar list output - `_resolver.py` — `resolve_resource(shorthand)` for parsing `project`, `ws/project`, `ws/project/3` -- `handlers/` — One file per command group (auto-discovered). `_aliases.py` registers backwards-compat top-level commands (loaded last) +- `handlers/` — One file per command group, each exporting a `typer.Typer()` app. `_aliases.py` registers backwards-compat top-level commands via `register_aliases(app)`. **Adding a new command:** 1. Create `roboflow/cli/handlers/mycommand.py` -2. Export `register(subparsers)` — it will be auto-discovered -3. Use lazy imports for heavy dependencies (inside handler functions, not at module top level) -4. Use `output()` for all output, `output_error()` for all errors -5. Wrap SDK calls in `with suppress_sdk_output():` to prevent "loading..." noise -6. Add tests in `tests/cli/test_mycommand_handler.py` +2. Create a module-level `mycommand_app = typer.Typer(help="...", no_args_is_help=True)` +3. Add commands with `@mycommand_app.command("verb")` decorators +4. Each command takes `ctx: typer.Context` + typed params, calls `ctx_to_args(ctx, **params)` to create args namespace +5. Use `output()` for all output, `output_error()` for all errors +6. Wrap SDK calls in `with suppress_sdk_output():` to prevent "loading..." noise +7. Register in `roboflow/cli/__init__.py`: `app.add_typer(mycommand_app, name="mycommand")` +8. Add tests using `typer.testing.CliRunner` in `tests/cli/test_mycommand_handler.py` **Agent experience requirements for all CLI commands:** - Support `--json` for structured output (stable schema) @@ -119,16 +122,16 @@ The CLI is a modular package with auto-discovered handler modules. `roboflow/rob 3. **Format Flexibility**: Supports multiple dataset formats (YOLO, COCO, Pascal VOC, etc.) 4. **Batch Operations**: Upload and download operations support concurrent processing 5. **CLI Noun-Verb Pattern**: Commands follow `roboflow ` (e.g. `roboflow project list`). Common operations have top-level aliases (`login`, `upload`, `download`) -6. **CLI Auto-Discovery**: Handler modules in `roboflow/cli/handlers/` are loaded automatically — no registration list to maintain +6. **CLI Explicit Registration**: Handler apps are explicitly imported and registered via `app.add_typer()` in `__init__.py` — clear dependency chain, no runtime discovery 7. **Backwards Compatibility**: Legacy command names and flag signatures are preserved as hidden aliases ## Project Configuration -- **Python Version**: 3.8+ -- **Main Dependencies**: See `requirements.txt` +- **Python Version**: 3.10+ +- **Main Dependencies**: See `requirements.txt` (includes `typer>=0.12.0`) - **Entry Point**: `roboflow=roboflow.roboflowpy:main` (shim delegates to `roboflow.cli.main`) - **Code Style**: Enforced by ruff with Google docstring convention -- **Type Checking**: mypy configured for Python 3.8 +- **Type Checking**: mypy configured for Python 3.10 ## Important Notes diff --git a/CLI-COMMANDS.md b/CLI-COMMANDS.md index c1a7b324..fbd155ce 100644 --- a/CLI-COMMANDS.md +++ b/CLI-COMMANDS.md @@ -122,6 +122,19 @@ roboflow video infer -p my-project -v 3 -f video.mp4 --fps 10 roboflow video status ``` +### Shell completion + +```bash +# Zsh +eval "$(roboflow completion zsh)" + +# Bash (requires bash >= 4.4) +eval "$(roboflow completion bash)" + +# Fish +roboflow completion fish | source +``` + ## JSON output for agents Every command supports `--json` for structured output that's safe to pipe: @@ -167,7 +180,7 @@ Version numbers are always numeric — that's how `x/y` is disambiguated between | `universe` | Search Roboflow Universe | | `video` | Video inference | | `batch` | Batch processing jobs *(coming soon)* | -| `completion` | Shell completion scripts *(coming soon)* | +| `completion` | Generate shell completion scripts (bash, zsh, fish) | Run `roboflow --help` for details on any command. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfa08230..9567a84a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,28 +78,29 @@ python -m pip install mkdocs mkdocs-material mkdocstrings mkdocstrings[python] ### CLI Development -The CLI lives in `roboflow/cli/` with auto-discovered handler modules. To add a new command: +The CLI is built on [typer](https://typer.tiangolo.com/). Each command group is a separate `typer.Typer()` app registered in `roboflow/cli/__init__.py`. To add a new command: 1. Create `roboflow/cli/handlers/mycommand.py`: ```python """My command description.""" from __future__ import annotations -from typing import TYPE_CHECKING -if TYPE_CHECKING: - import argparse - -def register(subparsers: argparse._SubParsersAction) -> None: - parser = subparsers.add_parser("mycommand", help="Do something") - sub = parser.add_subparsers(title="mycommand commands") - - p = sub.add_parser("list", help="List things") - p.add_argument("-p", "--project", required=True, help="Project ID") - p.set_defaults(func=_list) - - parser.set_defaults(func=lambda args: parser.print_help()) - -def _list(args: argparse.Namespace) -> None: +from typing import Annotated, Optional +import typer +from roboflow.cli._compat import ctx_to_args + +mycommand_app = typer.Typer(help="Do something", no_args_is_help=True) + +@mycommand_app.command("list") +def list_things( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], +) -> None: + """List things in a project.""" + args = ctx_to_args(ctx, project=project) + _list(args) + +def _list(args) -> None: from roboflow.cli._output import output, output_error, suppress_sdk_output with suppress_sdk_output(): @@ -113,8 +114,14 @@ def _list(args: argparse.Namespace) -> None: output(args, data, text="Found 1 result.") ``` -2. Add tests in `tests/cli/test_mycommand_handler.py` -3. Run `make check_code_quality` and `python -m unittest` +2. Register in `roboflow/cli/__init__.py`: +```python +from roboflow.cli.handlers.mycommand import mycommand_app +app.add_typer(mycommand_app, name="mycommand") +``` + +3. Add tests using `typer.testing.CliRunner` in `tests/cli/test_mycommand_handler.py` +4. Run `make check_code_quality` and `python -m unittest` **Agent experience checklist** (every command must satisfy): - [ ] Supports `--json` via `output()` helper @@ -123,7 +130,7 @@ def _list(args: argparse.Namespace) -> None: - [ ] SDK calls wrapped in `with suppress_sdk_output():` - [ ] Exit codes: 0=success, 1=error, 2=auth, 3=not found -**Documentation policy:** `CLI-COMMANDS.md` in this repo is a quickstart only. The comprehensive command reference lives in [`roboflow-product-docs`](https://github.com/roboflow/roboflow-product-docs) and is published to docs.roboflow.com. When adding a new command, update both: add a quick example to `CLI-COMMANDS.md` and the full reference to the product docs CLI page. +**Documentation policy:** `CLI-COMMANDS.md` in this repo is a quickstart only. The comprehensive command reference lives in [`roboflow-dev-reference`](https://github.com/roboflow/roboflow-dev-reference) and is published to docs.roboflow.com/developer/command-line-interface. When adding a new command, update both: add a quick example to `CLI-COMMANDS.md` and the full reference to the dev-reference CLI page. ### Pre-commit Hooks diff --git a/pyproject.toml b/pyproject.toml index 64816df6..4de8c9ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ target = ["test", "roboflow"] tests = ["B201", "B301"] [tool.ruff] -target-version = "py39" +target-version = "py310" line-length = 120 [tool.ruff.lint] @@ -104,7 +104,7 @@ banned-module-level-imports = [ ] [tool.mypy] -python_version = "3.9" +python_version = "3.10" exclude = ["^build/"] [[tool.mypy.overrides]] diff --git a/requirements.txt b/requirements.txt index 81d180c2..79cd4bb2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,3 +19,4 @@ tqdm>=4.41.0 PyYAML>=5.3.1 requests_toolbelt filetype +typer>=0.12.0 diff --git a/roboflow/__init__.py b/roboflow/__init__.py index d94067b4..61484ad6 100644 --- a/roboflow/__init__.py +++ b/roboflow/__init__.py @@ -15,7 +15,7 @@ from roboflow.models import CLIPModel, GazeModel # noqa: F401 from roboflow.util.general import write_line -__version__ = "1.2.16" +__version__ = "1.3.0" def check_key(api_key, model, notebook, num_retries=0): diff --git a/roboflow/cli/__init__.py b/roboflow/cli/__init__.py index 0b7ff622..3b3d0c42 100644 --- a/roboflow/cli/__init__.py +++ b/roboflow/cli/__init__.py @@ -1,149 +1,330 @@ -# PYTHON_ARGCOMPLETE_OK """Roboflow CLI — computer vision at your fingertips. -This package implements the modular CLI for the Roboflow Python SDK. -Commands are auto-discovered from the ``handlers`` sub-package: any module -that exposes a ``register(subparsers)`` callable is loaded automatically. +Built on typer. Each command group is a separate Typer app in the +``handlers`` sub-package, registered via ``app.add_typer()``. """ from __future__ import annotations -import argparse -import importlib -import pkgutil -import sys -from typing import Any +import json +from typing import Annotated, Any, Optional + +import click +import typer import roboflow -from roboflow.cli import handlers as _handlers_pkg +from roboflow.cli._compat import SortedGroup + +# --------------------------------------------------------------------------- +# Root application +# --------------------------------------------------------------------------- + +_DESCRIPTION = ( + "Build and deploy computer vision models with Roboflow. " + "Manage datasets, train models, run inference, and deploy " + "workflows \u2014 from the command line or via structured JSON for AI agents." +) + +app = typer.Typer( + name="roboflow", + help=_DESCRIPTION, + cls=SortedGroup, + pretty_exceptions_enable=False, + rich_markup_mode="rich", + add_completion=False, + context_settings={"help_option_names": ["-h", "--help"]}, +) + + +def _version_callback(value: bool) -> None: + if value: + import sys + + if "--json" in sys.argv or "-j" in sys.argv: + print(json.dumps({"version": roboflow.__version__})) + else: + print(roboflow.__version__) + raise typer.Exit + + +@app.callback(invoke_without_command=True) +def _root_callback( + ctx: typer.Context, + api_key: Annotated[ + Optional[str], + typer.Option("--api-key", "-k", help="API key override (default: $ROBOFLOW_API_KEY or config file)"), + ] = None, + json_output: Annotated[ + bool, + typer.Option("--json", "-j", help="Output results as JSON (stable schema, for agents and piping)"), + ] = False, + quiet: Annotated[ + bool, + typer.Option("--quiet", "-q", help="Suppress non-essential output (progress bars, status messages)"), + ] = False, + version: Annotated[ + Optional[bool], + typer.Option( + "--version", + "-v", + help="Show package version and exit", + callback=_version_callback, + is_eager=True, + ), + ] = None, + workspace: Annotated[ + Optional[str], + typer.Option("--workspace", "-w", help="Workspace URL or ID override (default: configured default)"), + ] = None, +) -> None: + """Build and deploy computer vision models with Roboflow.""" + ctx.ensure_object(dict) + ctx.obj["json"] = json_output + ctx.obj["api_key"] = api_key + ctx.obj["workspace"] = workspace + ctx.obj["quiet"] = quiet + + if ctx.invoked_subcommand is None: + _print_flattened_help() + raise typer.Exit(code=0) + + +def _print_flattened_help() -> None: + """Print a Rich-formatted help screen with all commands flattened and alphabetized.""" + from rich.console import Console + from rich.panel import Panel + from rich.table import Table + from rich.text import Text + + console = Console() + + click_app = typer.main.get_command(app) + + # Collect all visible commands, flattened + commands: list[tuple[str, str]] = [] + + def _walk(group: Any, prefix: str = "") -> None: + for name in sorted(group.list_commands(None) or []): # type: ignore[arg-type] + cmd = group.get_command(None, name) # type: ignore[arg-type] + if cmd is None or getattr(cmd, "hidden", False): + continue + full = f"{prefix} {name}".strip() if prefix else name + if hasattr(cmd, "list_commands") and cmd.list_commands(None): + _walk(cmd, full) + else: + # Use the full help text: try help attr, then short_help, then docstring + help_text = getattr(cmd, "help", None) or getattr(cmd, "short_help", None) or "" + # Take only the first line/sentence + help_text = help_text.split("\n")[0].strip() + commands.append((full, help_text)) + + _walk(click_app) + commands.sort(key=lambda x: x[0]) + + # Usage line + console.print() + console.print(" Usage: roboflow [OPTIONS] COMMAND [ARGS]...", highlight=False) + console.print() + console.print(f" {_DESCRIPTION}", highlight=False) + console.print() + + # Options panel — match typer's color scheme + options_data = [ + ("--api-key", "-k", "TEXT", "API key override (default: $ROBOFLOW_API_KEY or config file)"), + ("--json", "-j", "", "Output results as JSON (stable schema, for agents and piping)"), + ("--quiet", "-q", "", "Suppress non-essential output (progress bars, status messages)"), + ("--version", "-v", "", "Show package version and exit"), + ("--workspace", "-w", "TEXT", "Workspace URL or ID override (default: configured default)"), + ("--help", "-h", "", "Show this message and exit."), + ] + opt_table = Table(show_header=False, box=None, padding=(0, 1)) + opt_table.add_column(no_wrap=True, style="bold cyan") # long flag + opt_table.add_column(no_wrap=True, style="bold green") # short flag + opt_table.add_column(no_wrap=True, style="bold yellow") # metavar + opt_table.add_column() # description + for long_flag, short_flag, metavar, desc in options_data: + opt_table.add_row(long_flag, short_flag, metavar, desc) + console.print(Panel(opt_table, title="Options", title_align="left", border_style="dim")) + + # Commands panel — group name in dim cyan, verb in bold + cmd_table = Table(show_header=False, box=None, padding=(0, 1)) + cmd_table.add_column(no_wrap=True) # command name + cmd_table.add_column() # description + for cmd_name, help_text in commands: + parts = cmd_name.split(" ", 1) + styled_name = Text() + if len(parts) == 1: + # Top-level command (no group): just bold + styled_name.append(parts[0], style="bold") + else: + # Group + verb: group in dim cyan, verb in bold + styled_name.append(parts[0], style="cyan") + styled_name.append(" ") + styled_name.append(parts[1], style="bold") + cmd_table.add_row(styled_name, help_text) + console.print(Panel(cmd_table, title="Commands", title_align="left", border_style="dim")) + console.print() + + +# --------------------------------------------------------------------------- +# Register command groups (explicit imports — no auto-discovery needed) +# --------------------------------------------------------------------------- + +from roboflow.cli.handlers.annotation import annotation_app # noqa: E402 +from roboflow.cli.handlers.auth import auth_app # noqa: E402 +from roboflow.cli.handlers.batch import batch_app # noqa: E402 +from roboflow.cli.handlers.completion import completion_app # noqa: E402 +from roboflow.cli.handlers.deployment import deployment_app # noqa: E402 +from roboflow.cli.handlers.folder import folder_app # noqa: E402 +from roboflow.cli.handlers.image import image_app # noqa: E402 +from roboflow.cli.handlers.infer import infer_command # noqa: E402 +from roboflow.cli.handlers.model import model_app # noqa: E402 +from roboflow.cli.handlers.project import project_app # noqa: E402 +from roboflow.cli.handlers.search import search_command # noqa: E402 +from roboflow.cli.handlers.train import train_app # noqa: E402 +from roboflow.cli.handlers.universe import universe_app # noqa: E402 +from roboflow.cli.handlers.version import version_app # noqa: E402 +from roboflow.cli.handlers.video import video_app # noqa: E402 +from roboflow.cli.handlers.workflow import workflow_app # noqa: E402 +from roboflow.cli.handlers.workspace import workspace_app # noqa: E402 + +# Register ALL commands in alphabetical order for clean --help output +app.add_typer(annotation_app, name="annotation") +app.add_typer(auth_app, name="auth") +app.add_typer(batch_app, name="batch", hidden=True) # All stubs — hidden until implemented +app.add_typer(completion_app, name="completion") +app.add_typer(deployment_app, name="deployment") +app.add_typer(folder_app, name="folder") +app.add_typer(image_app, name="image") + +# "infer" — top-level command, registered alphabetically +infer_command(app) +app.add_typer(model_app, name="model") +app.add_typer(project_app, name="project") -class _CleanHelpFormatter(argparse.HelpFormatter): - """Custom formatter that hides SUPPRESS-ed subparser choices. +# "search" — top-level command, registered alphabetically +search_command(app) - The default argparse formatter includes *all* subparser names in the - ``{a,b,c,...}`` usage line and shows ``==SUPPRESS==`` in the command - list. This formatter filters both so that hidden legacy aliases are - truly invisible. +app.add_typer(train_app, name="train") +app.add_typer(universe_app, name="universe") +app.add_typer(version_app, name="version") +app.add_typer(video_app, name="video") +app.add_typer(workflow_app, name="workflow") +app.add_typer(workspace_app, name="workspace") + +# Hidden aliases (loaded last — still functional but not in --help) +from roboflow.cli.handlers._aliases import register_hidden_aliases # noqa: E402 + +register_hidden_aliases(app) + + +# "roboflow help" command +@app.command("help", hidden=True) +def help_command(ctx: typer.Context) -> None: # noqa: ARG001 + """Show help information.""" + _print_flattened_help() + + +# --------------------------------------------------------------------------- +# Backwards-compat: build_parser returns None (argparse is gone) +# --------------------------------------------------------------------------- + + +class _LegacyParserShim: + """Argparse-compatible shim wrapping the typer app. + + Supports ``parser.parse_args(argv)`` and ``parser.print_help()``. + This keeps ``from roboflow.roboflowpy import _argparser`` working + for the ~5M monthly installs that may depend on it. """ - def _format_action(self, action: argparse.Action) -> str: - # Hide subparser entries whose help is SUPPRESS - if action.help == argparse.SUPPRESS: - return "" - return super()._format_action(action) - - def _metavar_formatter( - self, - action: argparse.Action, - default_metavar: str, - ) -> Any: - if isinstance(action, argparse._SubParsersAction): - # Filter choices to only those with visible help - visible = [ - name - for name, parser in action.choices.items() - if not any(ca.dest == name and ca.help == argparse.SUPPRESS for ca in action._choices_actions) - and name in [ca.dest for ca in action._choices_actions if ca.help != argparse.SUPPRESS] - ] - if visible: - - def _fmt(tuple_size: int) -> tuple[str, ...]: - result = "{" + ",".join(visible) + "}" - return (result,) * tuple_size if tuple_size > 1 else (result,) - - return _fmt - return super()._metavar_formatter(action, default_metavar) - - -def build_parser() -> argparse.ArgumentParser: - """Build the root argument parser with global flags and auto-discovered handlers.""" - parser = argparse.ArgumentParser( - prog="roboflow", - description="Roboflow CLI: computer vision at your fingertips", - formatter_class=_CleanHelpFormatter, - ) - - # --- global flags --- - parser.add_argument( - "--json", - "-j", - dest="json", - action="store_true", - default=False, - help="Output results as JSON (stable schema, for agents and piping)", - ) - parser.add_argument( - "--api-key", - "-k", - dest="api_key", - default=None, - help="API key override (default: $ROBOFLOW_API_KEY or config file)", - ) - parser.add_argument( - "--workspace", - "-w", - dest="workspace", - default=None, - help="Workspace URL or ID override (default: configured default)", - ) - parser.add_argument( - "--quiet", - "-q", - dest="quiet", - action="store_true", - default=False, - help="Suppress non-essential output (progress bars, status messages)", - ) - parser.add_argument( - "--version", - action="store_true", - default=False, - help="Show package version and exit", - ) - - # --- subcommands --- - subparsers = parser.add_subparsers(title="commands", dest="command") - - # Auto-discover handler modules (skip private modules starting with _) - for _importer, modname, _ispkg in pkgutil.iter_modules(_handlers_pkg.__path__): - if modname.startswith("_"): - continue - try: - mod = importlib.import_module(f"roboflow.cli.handlers.{modname}") - if hasattr(mod, "register"): - mod.register(subparsers) - except Exception as exc: # noqa: BLE001 - # A broken handler must not take down the entire CLI - import logging + def parse_args(self, argv: list[str] | None = None) -> object: # noqa: ANN001 + """Parse *argv* and return an argparse-like namespace with ``func``. - logging.getLogger("roboflow.cli").debug("Failed to load handler %s: %s", modname, exc) + Does NOT execute the command — callers are expected to call + ``args.func(args)`` themselves, matching the old argparse pattern. + """ + import sys + import types - # Load aliases last so they can reference handler functions - from roboflow.cli.handlers import _aliases + if argv is None: + argv = sys.argv[1:] - _aliases.register(subparsers) + argv = _reorder_argv(list(argv)) - parser.set_defaults(func=None) - return parser + # Build a namespace with the parsed values by invoking the CLI + # in a dry-run fashion: we intercept before execution. + ns = types.SimpleNamespace( + json=False, + api_key=None, + workspace=None, + quiet=False, + func=None, + ) + # Extract global flags manually + remaining = [] + i = 0 + while i < len(argv): + if argv[i] in ("--json", "-j"): + ns.json = True + elif argv[i] in ("--quiet", "-q"): + ns.quiet = True + elif argv[i] in ("--api-key", "-k") and i + 1 < len(argv): + i += 1 + ns.api_key = argv[i] + elif argv[i] == "--workspace" and i + 1 < len(argv): + i += 1 + ns.workspace = argv[i] + else: + remaining.append(argv[i]) + i += 1 -def _show_version(args: argparse.Namespace) -> None: - if getattr(args, "json", False): - import json + # Set func to a lambda that invokes the CLI with the original argv + original_argv = list(argv) - print(json.dumps({"version": roboflow.__version__})) - else: - print(roboflow.__version__) + def _run_via_typer(_args: object) -> None: + from typer.testing import CliRunner as _TyperRunner + + runner = _TyperRunner() + result = runner.invoke(app, original_argv, catch_exceptions=False) + if result.output: + print(result.output, end="") # noqa: T201 + if result.exit_code: + sys.exit(result.exit_code) + + ns.func = _run_via_typer + return ns + + def print_help(self) -> None: + """Print the CLI help text.""" + from typer.testing import CliRunner as _TyperRunner + + runner = _TyperRunner() + result = runner.invoke(app, ["--help"]) + if result.output: + print(result.output, end="") # noqa: T201 + + +def build_parser() -> _LegacyParserShim: + """Legacy compat: returns an argparse-like shim wrapping the typer app.""" + return _LegacyParserShim() + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- def _reorder_argv(argv: list[str]) -> list[str]: """Move known global flags that appear after the subcommand to the front. - argparse only recognises global flags when they appear *before* the - subcommand. Many users (and AI agents) naturally write them at the end, - e.g. ``roboflow project list --json``. This helper transparently - re-orders the argv so those flags are consumed by the root parser. + Typer/Click only recognises parent-level options when they appear + *before* the subcommand. Many users (and AI agents) naturally write + them at the end, e.g. ``roboflow project list --json``. This helper + transparently re-orders the argv so those flags are consumed by the + root callback. """ # Note: -w is intentionally excluded — it collides with deployment's # -w/--wait_on_pending (boolean). --workspace (long form) is safe. @@ -158,10 +339,13 @@ def _reorder_argv(argv: list[str]) -> list[str]: if arg in global_flags_bool: reordered.append(arg) elif arg in global_flags_with_value: - reordered.append(arg) if i + 1 < len(argv): + reordered.append(arg) i += 1 reordered.append(argv[i]) + else: + # No value follows — leave in place so typer shows a proper error + rest.append(arg) else: rest.append(arg) i += 1 @@ -169,16 +353,48 @@ def _reorder_argv(argv: list[str]) -> list[str]: def main() -> None: - """CLI entry point.""" - parser = build_parser() - args = parser.parse_args(_reorder_argv(sys.argv[1:])) + """CLI entry point — called by ``roboflow`` console script.""" + import sys + + sys.argv[1:] = _reorder_argv(sys.argv[1:]) + + # Intercept root-level --help/-h: show our flattened help instead of typer's grouped view. + # Only for the ROOT command (not subcommands like 'roboflow project --help'). + if "--help" in sys.argv[1:] or "-h" in sys.argv[1:]: + argv = sys.argv[1:] + help_idx = next((i for i, a in enumerate(argv) if a in ("--help", "-h")), -1) + pre_help = [a for a in argv[:help_idx] if not a.startswith("-")] + if not pre_help: + _print_flattened_help() + sys.exit(0) + + # In --json mode, intercept Click/typer validation errors and emit + # structured JSON on stderr instead of Rich-formatted text. + json_mode = "--json" in sys.argv or "-j" in sys.argv + if json_mode: + try: + app(standalone_mode=False) + except SystemExit as exc: + # Exit code 0 = success (already handled), just re-raise + if exc.code == 0: + raise + # 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 + import json as _json - if args.version: - _show_version(args) - sys.exit(0) + payload = {"error": {"message": str(exc), "hint": "Run with --help for usage information."}} + print(_json.dumps(payload), file=sys.stderr) + sys.exit(2) + except click.exceptions.Abort: + sys.exit(1) + except Exception as exc: + import json as _json - if args.func is not None: - args.func(args) + payload = {"error": {"message": str(exc)}} + print(_json.dumps(payload), file=sys.stderr) + sys.exit(1) else: - parser.print_help() - sys.exit(0) + app() diff --git a/roboflow/cli/_compat.py b/roboflow/cli/_compat.py new file mode 100644 index 00000000..ae6b1804 --- /dev/null +++ b/roboflow/cli/_compat.py @@ -0,0 +1,79 @@ +"""Bridge helpers for the argparse → typer migration. + +Provides ``ctx_to_args()`` which converts a :class:`typer.Context` to a +:class:`types.SimpleNamespace` matching the shape that ``output()``, +``output_error()``, and other CLI helpers expect. This allows existing +handler business logic to remain unchanged during migration. +""" + +from __future__ import annotations + +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: + """Sort params in-place: required first, then alphabetical by option name.""" + params.sort( + key=lambda p: ( + # --help always last + "help" in (p.opts if hasattr(p, "opts") else [p.name or ""]), + # Required options first + not getattr(p, "required", False), + # Arguments before options (positionals first) + not isinstance(p, click.Argument), + # Alphabetical by the first long option name + (p.opts[0].lstrip("-") if hasattr(p, "opts") and p.opts else p.name or ""), + ) + ) + + +class SortedGroup(typer.core.TyperGroup): + """Click Group that alphabetizes commands and options in --help output. + + Use as ``cls=SortedGroup`` when creating Typer apps so that subcommand + help pages show options and commands in alphabetical order (with + required options first). + """ + + def list_commands(self, ctx: click.Context) -> list[str]: # type: ignore[override] + return sorted(super().list_commands(ctx)) + + def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> 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] + """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): + # Sort the command's params for its --help output + _sort_params(cmd.params) + return cmd + + +def ctx_to_args(ctx: typer.Context, **kwargs: Any) -> types.SimpleNamespace: + """Convert a typer Context (with global opts in ``ctx.obj``) to an args namespace. + + Parameters + ---------- + ctx: + The typer Context, whose ``.obj`` dict holds the global options + set by the root callback (``json``, ``api_key``, ``workspace``, + ``quiet``). + **kwargs: + Command-specific parameters to include in the namespace. These + override anything in ``ctx.obj``. + """ + obj = ctx.obj or {} + return types.SimpleNamespace( + json=obj.get("json", False), + api_key=obj.get("api_key"), + workspace=obj.get("workspace"), + quiet=obj.get("quiet", False), + **kwargs, + ) diff --git a/roboflow/cli/_output.py b/roboflow/cli/_output.py index f33380da..ffc29025 100644 --- a/roboflow/cli/_output.py +++ b/roboflow/cli/_output.py @@ -45,6 +45,22 @@ def output(args: Any, data: Any, text: Optional[str] = None) -> None: ("over_quota", "Your workspace has exceeded its quota. Visit https://roboflow.com/pricing to upgrade."), ] +# Patterns to translate raw API hints into CLI-friendly hints +_API_HINT_REPLACEMENTS: list[tuple[str, str]] = [ + ( + "You can see your active workspace by issuing a GET request to / with your api_key", + "Check available resources with 'roboflow project list' or 'roboflow workspace get'.", + ), + ( + "You can find the API docs at https://docs.roboflow.com", + "Run the command with --help for usage information.", + ), + ( + "You can see your available workspaces by issuing a GET request to /workspaces", + "List workspaces with 'roboflow workspace list'.", + ), +] + def _detect_plan_hint(message: str) -> Optional[str]: """Detect plan/billing-related errors and return an appropriate upgrade hint.""" @@ -55,6 +71,21 @@ def _detect_plan_hint(message: str) -> Optional[str]: return None +def _translate_api_hints(message: str) -> str: + """Replace raw API hints with CLI-friendly equivalents.""" + for api_hint, cli_hint in _API_HINT_REPLACEMENTS: + message = message.replace(api_hint, cli_hint) + # Generic fallback: strip any remaining "issuing a GET/POST request" phrasing + import re + + message = re.sub( + r"You can [^.]*(?:GET|POST|PUT|DELETE) request[^.]*\.", + "Run the command with --help for usage information.", + message, + ) + return message + + def _sanitize_credentials(text: str) -> str: """Strip API keys from URLs and other sensitive patterns in error messages.""" import re @@ -70,7 +101,7 @@ def _parse_error_message(raw: str) -> tuple[Optional[dict[str, Any]], str]: otherwise ``None``. The *human_readable_message* drills into nested ``error.message`` structures so the text-mode output is clean. """ - text = _sanitize_credentials(raw.strip()) + text = _translate_api_hints(_sanitize_credentials(raw.strip())) # Strip status-code prefix like "404: {...}" colon_idx = text.find(": {") if 0 < colon_idx < 5: @@ -81,9 +112,12 @@ def _parse_error_message(raw: str) -> tuple[Optional[dict[str, Any]], str]: err = parsed.get("error", parsed) if isinstance(err, dict): human = str(err.get("message") or err.get("hint") or err) + # Translate API hints in the parsed dict too + if "hint" in err and isinstance(err["hint"], str): + err["hint"] = _translate_api_hints(err["hint"]) else: human = str(err) - return parsed, human + return parsed, _translate_api_hints(human) except (json.JSONDecodeError, TypeError, ValueError): pass return None, text # Return sanitized text, not the original raw diff --git a/roboflow/cli/handlers/_aliases.py b/roboflow/cli/handlers/_aliases.py index 1d9e816b..70895d39 100644 --- a/roboflow/cli/handlers/_aliases.py +++ b/roboflow/cli/handlers/_aliases.py @@ -1,129 +1,191 @@ """Top-level backwards-compatibility aliases. -Registers convenience commands at the root level (``roboflow login``, -``roboflow upload``, etc.) that delegate to the canonical noun-verb handlers. +Split into three registration functions called at different points in +``__init__.py`` to control help ordering: -This module is loaded *after* all other handlers by ``build_parser()`` so -that it can import their handler functions. +- ``register_download_alias(app)`` — visible ``download`` command (alphabetical slot) +- ``register_hidden_aliases(app)`` — all hidden aliases (loaded last) """ from __future__ import annotations -import argparse - -# Use SUPPRESS to hide legacy aliases from --help output -_HIDDEN = argparse.SUPPRESS - - -def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - """Register top-level aliases for common commands.""" - - # --- roboflow login (visible alias for auth login) --- - from roboflow.cli.handlers.auth import _login - - login_p = subparsers.add_parser("login", help="Log in to Roboflow (alias for 'auth login')") - login_p.add_argument("--api-key", dest="login_api_key", default=None, help="API key (skip interactive login)") - login_p.add_argument("--force", "-f", action="store_true", help="Force re-login") - login_p.set_defaults(func=_login) - - # --- roboflow whoami (visible alias for auth status) --- - from roboflow.cli.handlers.auth import _status - - whoami_p = subparsers.add_parser("whoami", help="Show current user (alias for 'auth status')") - whoami_p.set_defaults(func=_status) - - # --- roboflow upload (visible alias for image upload) --- - from roboflow.cli.handlers.image import _handle_upload - - upload_p = subparsers.add_parser("upload", help="Upload images to a project (alias for 'image upload')") - upload_p.add_argument("path", help="Path to image file or directory") - upload_p.add_argument("-p", "--project", dest="project", help="Project ID (required)", required=True) - upload_p.add_argument("-a", "--annotation", dest="annotation", help="Path to annotation file") - upload_p.add_argument("-m", "--labelmap", dest="labelmap", help="Path to labelmap file") - upload_p.add_argument("-s", "--split", dest="split", default="train", help="Split (train/valid/test)") - upload_p.add_argument("-r", "--retries", dest="num_retries", type=int, default=0, help="Retry count") - upload_p.add_argument("-b", "--batch", dest="batch", help="Batch name") - upload_p.add_argument("-t", "--tag", dest="tag_names", help="Comma-separated tag names") - upload_p.add_argument("-M", "--metadata", dest="metadata", help="JSON metadata string") - upload_p.add_argument("-c", "--concurrency", dest="concurrency", type=int, default=10, help="Upload concurrency") - upload_p.add_argument("--is-prediction", dest="is_prediction", action="store_true", help="Mark as prediction") - upload_p.set_defaults(func=_handle_upload) - - # --- roboflow import (hidden alias for image upload with directory) --- - from roboflow.cli.handlers.image import _handle_upload as _handle_import - - import_p = subparsers.add_parser("import", help="Import dataset from folder (alias for 'image upload')") - import_p.add_argument("path", metavar="folder", help="Path to dataset folder") - import_p.add_argument("-p", "--project", dest="project", help="Project ID (required)", required=True) - import_p.add_argument("-c", "--concurrency", dest="concurrency", type=int, default=10, help="Upload concurrency") - import_p.add_argument("-n", "--batch-name", dest="batch", help="Batch name") - import_p.add_argument("-r", "--retries", dest="num_retries", type=int, default=0, help="Retry count") - import_p.set_defaults(func=_handle_import) - - # --- roboflow download (visible alias for version download) --- - from roboflow.cli.handlers.version import _download - - download_p = subparsers.add_parser("download", help="Download a dataset version (alias for 'version download')") - download_p.add_argument("url_or_id", metavar="datasetUrl", help="Dataset URL (e.g. workspace/project/version)") - download_p.add_argument("-f", "--format", dest="format", default="voc", help="Export format") - download_p.add_argument("-l", "--location", dest="location", help="Download location") - download_p.set_defaults(func=_download) - - # --- roboflow search-export (hidden alias for search --export) --- - from roboflow.cli.handlers.search import _search as _search_handler - - search_export_p = subparsers.add_parser("search-export", help=_HIDDEN) - search_export_p.add_argument("query", help="Search query (e.g. 'tag:annotate' or '*')") - search_export_p.add_argument("-f", dest="format", default="coco", help="Annotation format") - search_export_p.add_argument("-l", dest="location", help="Local directory for export") - search_export_p.add_argument("-d", dest="dataset", help="Limit to specific dataset") - search_export_p.add_argument("-g", dest="annotation_group", help="Limit to annotation group") - search_export_p.add_argument("-n", dest="name", help="Export name") - search_export_p.add_argument( - "--no-extract", dest="no_extract", action="store_true", help="Keep zip, skip extraction" - ) - search_export_p.set_defaults(func=_search_handler, export=True) # Force --export mode - - # --- roboflow upload_model (hidden alias for model upload) --- - from roboflow.cli.handlers.model import _upload_model - - upload_model_p = subparsers.add_parser("upload_model", help=_HIDDEN) - upload_model_p.add_argument("-a", dest="api_key", help="API key") - upload_model_p.add_argument("-p", dest="project", action="append", help="Project ID") - upload_model_p.add_argument("-v", dest="version_number", type=int, default=None, help="Version number") - upload_model_p.add_argument("-t", dest="model_type", help="Model type") - upload_model_p.add_argument("-m", dest="model_path", help="Model file path") - upload_model_p.add_argument("-f", dest="filename", default="weights/best.pt", help="Model filename") - upload_model_p.add_argument("-n", dest="model_name", help="Model name") - upload_model_p.set_defaults(func=_upload_model) - - # --- roboflow get_workspace_info (hidden alias, preserved) --- - get_ws_info_p = subparsers.add_parser("get_workspace_info", help=_HIDDEN) - get_ws_info_p.add_argument("-a", dest="api_key", help="API key") - get_ws_info_p.add_argument("-p", dest="project", help="Project ID") - get_ws_info_p.add_argument("-v", dest="version_number", type=int, help="Version number") - get_ws_info_p.set_defaults(func=_get_workspace_info_compat) - - # --- roboflow run_video_inference_api (hidden alias for video infer) --- - from roboflow.cli.handlers.video import _video_infer - - video_api_p = subparsers.add_parser("run_video_inference_api", help=_HIDDEN) - video_api_p.add_argument("-a", dest="api_key", help="API key") - video_api_p.add_argument("-p", dest="project", help="Project ID") - video_api_p.add_argument("-v", dest="version_number", type=int, help="Version number") - video_api_p.add_argument("-f", dest="video_file", help="Video file path") - video_api_p.add_argument("-fps", dest="fps", type=int, default=5, help="FPS") - video_api_p.set_defaults(func=_video_infer) - - -def _get_workspace_info_compat(args: argparse.Namespace) -> None: - """Backwards-compat handler for the old get_workspace_info command.""" - import roboflow - - rf = roboflow.Roboflow(args.api_key) - workspace = rf.workspace() - print("workspace", workspace) - project = workspace.project(args.project) - print("project", project) - version = project.version(args.version_number) - print("version", version) +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import ctx_to_args + + +def register_hidden_aliases(app: typer.Typer) -> None: + """Register all hidden backwards-compat aliases (not shown in --help).""" + + @app.command("download", hidden=True) + def download_alias( + ctx: typer.Context, + url_or_id: Annotated[ + str, typer.Argument(metavar="datasetUrl", help="Dataset URL (e.g. workspace/project/version)") + ], + format: Annotated[str, typer.Option("-f", "--format", help="Export format")] = "voc", + location: Annotated[Optional[str], typer.Option("-l", "--location", help="Download location")] = None, + ) -> None: + """Download a dataset version (alias for 'version download').""" + from roboflow.cli.handlers.version import _download + + args = ctx_to_args(ctx, url_or_id=url_or_id, format=format, location=location) + _download(args) + + @app.command("login", hidden=True) + def login_alias( + ctx: typer.Context, + login_api_key: Annotated[ + Optional[str], typer.Option("--api-key", help="API key (skip interactive login)") + ] = None, + force: Annotated[bool, typer.Option("--force", "-f", help="Force re-login")] = False, + ) -> None: + """Log in to Roboflow (alias for 'auth login').""" + from roboflow.cli.handlers.auth import _login + + args = ctx_to_args(ctx, login_api_key=login_api_key, force=force) + _login(args) + + @app.command("whoami", hidden=True) + def whoami_alias(ctx: typer.Context) -> None: + """Show current user (alias for 'auth status').""" + from roboflow.cli.handlers.auth import _status + + args = ctx_to_args(ctx) + _status(args) + + @app.command("upload", hidden=True) + def upload_alias( + ctx: typer.Context, + path: Annotated[str, typer.Argument(help="Path to image file or directory")], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], + annotation: Annotated[Optional[str], typer.Option("-a", "--annotation", help="Annotation file")] = None, + labelmap: Annotated[Optional[str], typer.Option("-m", "--labelmap", help="Labelmap file")] = None, + split: Annotated[str, typer.Option("-s", "--split", help="Split (train/valid/test)")] = "train", + num_retries: Annotated[int, typer.Option("-r", "--retries", help="Retry count")] = 0, + batch: Annotated[Optional[str], typer.Option("-b", "--batch", help="Batch name")] = None, + tag_names: Annotated[Optional[str], typer.Option("-t", "--tag", help="Tag names")] = None, + metadata: Annotated[Optional[str], typer.Option("-M", "--metadata", help="JSON metadata")] = None, + concurrency: Annotated[int, typer.Option("-c", "--concurrency", help="Concurrency")] = 10, + is_prediction: Annotated[bool, typer.Option("--is-prediction", help="Mark as prediction")] = False, + ) -> None: + """Upload images to a project (alias for 'image upload').""" + from roboflow.cli.handlers.image import _handle_upload + + args = ctx_to_args( + ctx, + path=path, + project=project, + annotation=annotation, + labelmap=labelmap, + split=split, + num_retries=num_retries, + batch=batch, + tag_names=tag_names, + metadata=metadata, + concurrency=concurrency, + is_prediction=is_prediction, + ) + _handle_upload(args) + + @app.command("import", hidden=True) + def import_alias( + ctx: typer.Context, + path: Annotated[str, typer.Argument(metavar="folder", help="Path to dataset folder")], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], + concurrency: Annotated[int, typer.Option("-c", "--concurrency", help="Concurrency")] = 10, + batch: Annotated[Optional[str], typer.Option("-n", "--batch-name", help="Batch name")] = None, + num_retries: Annotated[int, typer.Option("-r", "--retries", help="Retry count")] = 0, + ) -> None: + """Import dataset from folder (alias for 'image upload').""" + from roboflow.cli.handlers.image import _handle_upload + + args = ctx_to_args( + ctx, path=path, project=project, concurrency=concurrency, batch=batch, num_retries=num_retries + ) + _handle_upload(args) + + @app.command("search-export", hidden=True) + def search_export_alias( + ctx: typer.Context, + query: Annotated[str, typer.Argument(help="Search query")], + format: Annotated[str, typer.Option("-f", help="Format")] = "coco", + location: Annotated[Optional[str], typer.Option("-l", help="Export location")] = None, + dataset: Annotated[Optional[str], typer.Option("-d", help="Limit to dataset")] = None, + annotation_group: Annotated[Optional[str], typer.Option("-g", help="Annotation group")] = None, + name: Annotated[Optional[str], typer.Option("-n", help="Export name")] = None, + no_extract: Annotated[bool, typer.Option("--no-extract", help="Keep zip")] = False, + ) -> None: + """Export search results as a dataset.""" + from roboflow.cli.handlers.search import _search + + args = ctx_to_args( + ctx, + query=query, + format=format, + location=location, + dataset=dataset, + annotation_group=annotation_group, + name=name, + no_extract=no_extract, + export=True, + ) + _search(args) + + @app.command("upload_model", hidden=True) + def upload_model_alias( + ctx: typer.Context, + project: Annotated[Optional[list[str]], typer.Option("-p", help="Project ID (repeatable)")] = None, + version_number: Annotated[Optional[int], typer.Option("-v", help="Version")] = None, + model_type: Annotated[Optional[str], typer.Option("-t", help="Model type")] = None, + model_path: Annotated[Optional[str], typer.Option("-m", help="Model path")] = None, + filename: Annotated[str, typer.Option("-f", help="Filename")] = "weights/best.pt", + model_name: Annotated[Optional[str], typer.Option("-n", help="Model name")] = None, + ) -> None: + """Upload a model (hidden legacy alias).""" + from roboflow.cli.handlers.model import _upload_model + + args = ctx_to_args( + ctx, + project=project, + version_number=version_number, + model_type=model_type, + model_path=model_path, + filename=filename, + model_name=model_name, + ) + _upload_model(args) + + @app.command("get_workspace_info", hidden=True) + def get_workspace_info_alias( + ctx: typer.Context, + project: Annotated[Optional[str], typer.Option("-p", help="Project ID")] = None, + version_number: Annotated[Optional[int], typer.Option("-v", help="Version")] = None, + ) -> None: + """Get workspace info (hidden legacy alias).""" + import roboflow as rf_mod + + args = ctx_to_args(ctx, project=project, version_number=version_number) + rf_obj = rf_mod.Roboflow(args.api_key) + workspace = rf_obj.workspace() + print("workspace", workspace) # noqa: T201 + proj = workspace.project(args.project) + print("project", proj) # noqa: T201 + ver = proj.version(args.version_number) + print("version", ver) # noqa: T201 + + @app.command("run_video_inference_api", hidden=True) + def run_video_inference_api_alias( + ctx: typer.Context, + project: Annotated[Optional[str], typer.Option("-p", help="Project ID")] = None, + version_number: Annotated[Optional[int], typer.Option("-v", help="Version")] = None, + video_file: Annotated[Optional[str], typer.Option("-f", help="Video file")] = None, + fps: Annotated[int, typer.Option("-fps", help="FPS")] = 5, + ) -> None: + """Run video inference (hidden legacy alias).""" + from roboflow.cli.handlers.video import _video_infer + + args = ctx_to_args(ctx, project=project, version_number=version_number, video_file=video_file, fps=fps) + _video_infer(args) diff --git a/roboflow/cli/handlers/annotation.py b/roboflow/cli/handlers/annotation.py index 231e6780..2259960e 100644 --- a/roboflow/cli/handlers/annotation.py +++ b/roboflow/cli/handlers/annotation.py @@ -2,77 +2,93 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import Annotated -if TYPE_CHECKING: - import argparse +import typer +from roboflow.cli._compat import SortedGroup, ctx_to_args -def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - """Register the ``annotation`` command group.""" - ann_parser = subparsers.add_parser("annotation", help="Annotation management commands") - ann_sub = ann_parser.add_subparsers(title="annotation commands", dest="annotation_command") +annotation_app = typer.Typer(cls=SortedGroup, help="Annotation management commands", no_args_is_help=True) +batch_app = typer.Typer(cls=SortedGroup, help="Annotation batch commands", no_args_is_help=True) +job_app = typer.Typer(cls=SortedGroup, help="Annotation job commands", no_args_is_help=True) - _add_batch(ann_sub) - _add_job(ann_sub) - - ann_parser.set_defaults(func=lambda args: ann_parser.print_help()) +annotation_app.add_typer(batch_app, name="batch") +annotation_app.add_typer(job_app, name="job") # --------------------------------------------------------------------------- -# batch +# batch commands # --------------------------------------------------------------------------- -def _add_batch(sub: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - batch_parser = sub.add_parser("batch", help="Annotation batch commands") - batch_sub = batch_parser.add_subparsers(title="batch commands", dest="batch_command") - - # batch list - p = batch_sub.add_parser("list", help="List annotation batches") - p.add_argument("-p", "--project", required=True, help="Project ID") - p.set_defaults(func=_batch_list) +@batch_app.command("list") +def batch_list( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], +) -> None: + """List annotation batches.""" + args = ctx_to_args(ctx, project=project) + _batch_list(args) - # batch get - p = batch_sub.add_parser("get", help="Get annotation batch details") - p.add_argument("batch_id", help="Batch ID") - p.add_argument("-p", "--project", required=True, help="Project ID") - p.set_defaults(func=_batch_get) - batch_parser.set_defaults(func=lambda args: batch_parser.print_help()) +@batch_app.command("get") +def batch_get( + ctx: typer.Context, + batch_id: Annotated[str, typer.Argument(help="Batch ID")], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], +) -> None: + """Get annotation batch details.""" + args = ctx_to_args(ctx, batch_id=batch_id, project=project) + _batch_get(args) # --------------------------------------------------------------------------- -# job +# job commands # --------------------------------------------------------------------------- -def _add_job(sub: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - job_parser = sub.add_parser("job", help="Annotation job commands") - job_sub = job_parser.add_subparsers(title="job commands", dest="job_command") - - # job list - p = job_sub.add_parser("list", help="List annotation jobs") - p.add_argument("-p", "--project", required=True, help="Project ID") - p.set_defaults(func=_job_list) - - # job get - p = job_sub.add_parser("get", help="Get annotation job details") - p.add_argument("job_id", help="Job ID") - p.add_argument("-p", "--project", required=True, help="Project ID") - p.set_defaults(func=_job_get) - - # job create - p = job_sub.add_parser("create", help="Create an annotation job") - p.add_argument("-p", "--project", required=True, help="Project ID") - p.add_argument("--name", required=True, help="Job name") - p.add_argument("--batch", required=True, help="Batch ID") - p.add_argument("--num-images", required=True, type=int, help="Number of images") - p.add_argument("--labeler", required=True, help="Labeler email") - p.add_argument("--reviewer", required=True, help="Reviewer email") - p.set_defaults(func=_job_create) - - job_parser.set_defaults(func=lambda args: job_parser.print_help()) +@job_app.command("list") +def job_list( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], +) -> None: + """List annotation jobs.""" + args = ctx_to_args(ctx, project=project) + _job_list(args) + + +@job_app.command("get") +def job_get( + ctx: typer.Context, + job_id: Annotated[str, typer.Argument(help="Job ID")], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], +) -> None: + """Get annotation job details.""" + args = ctx_to_args(ctx, job_id=job_id, project=project) + _job_get(args) + + +@job_app.command("create") +def job_create( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], + name: Annotated[str, typer.Option(help="Job name")], + batch: Annotated[str, typer.Option(help="Batch ID")], + num_images: Annotated[int, typer.Option("--num-images", help="Number of images")], + labeler: Annotated[str, typer.Option(help="Labeler email")], + reviewer: Annotated[str, typer.Option(help="Reviewer email")], +) -> None: + """Create an annotation job.""" + args = ctx_to_args( + ctx, + project=project, + name=name, + batch=batch, + num_images=num_images, + labeler=labeler, + reviewer=reviewer, + ) + _job_create(args) # --------------------------------------------------------------------------- @@ -80,7 +96,7 @@ def _add_job(sub: argparse._SubParsersAction) -> None: # type: ignore[type-arg] # --------------------------------------------------------------------------- -def _normalize_timestamps(obj): +def _normalize_timestamps(obj): # noqa: ANN001 """Recursively convert Firestore timestamp dicts ({"_seconds": N, "_nanoseconds": N}) to ISO 8601 strings.""" from datetime import datetime, timezone @@ -93,12 +109,7 @@ def _normalize_timestamps(obj): return obj -# --------------------------------------------------------------------------- -# handlers -# --------------------------------------------------------------------------- - - -def _resolve_project_context(args: argparse.Namespace): # type: ignore[return] +def _resolve_project_context(args): # noqa: ANN001 """Resolve workspace/project from -p flag and return (api_key, ws, proj) or call output_error.""" from roboflow.cli._output import output_error from roboflow.cli._resolver import resolve_resource @@ -118,7 +129,12 @@ def _resolve_project_context(args: argparse.Namespace): # type: ignore[return] return api_key, workspace_url, project_slug -def _batch_list(args: argparse.Namespace) -> None: +# --------------------------------------------------------------------------- +# handler implementations +# --------------------------------------------------------------------------- + + +def _batch_list(args): # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error from roboflow.cli._table import format_table @@ -145,7 +161,7 @@ def _batch_list(args: argparse.Namespace) -> None: output(args, batches, text=table) -def _batch_get(args: argparse.Namespace) -> None: +def _batch_get(args): # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error @@ -172,7 +188,7 @@ def _batch_get(args: argparse.Namespace) -> None: output(args, data, text=text) -def _job_list(args: argparse.Namespace) -> None: +def _job_list(args): # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error from roboflow.cli._table import format_table @@ -199,7 +215,7 @@ def _job_list(args: argparse.Namespace) -> None: output(args, jobs, text=table) -def _job_get(args: argparse.Namespace) -> None: +def _job_get(args): # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error @@ -226,7 +242,7 @@ def _job_get(args: argparse.Namespace) -> None: output(args, data, text=text) -def _job_create(args: argparse.Namespace) -> None: +def _job_create(args): # noqa: ANN001 import roboflow from roboflow.cli._output import output, output_error, suppress_sdk_output diff --git a/roboflow/cli/handlers/auth.py b/roboflow/cli/handlers/auth.py index 0cac073e..e2c57fd9 100644 --- a/roboflow/cli/handlers/auth.py +++ b/roboflow/cli/handlers/auth.py @@ -2,55 +2,56 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import Annotated, Optional -if TYPE_CHECKING: - import argparse +import typer +from roboflow.cli._compat import SortedGroup, ctx_to_args -def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - """Register the ``auth`` command group.""" - auth_parser = subparsers.add_parser("auth", help="Manage authentication and credentials") - auth_sub = auth_parser.add_subparsers(title="auth commands", dest="auth_command") +auth_app = typer.Typer(cls=SortedGroup, help="Manage authentication and credentials", no_args_is_help=True) - # --- auth login --- - login_p = auth_sub.add_parser("login", help="Log in to Roboflow") - login_p.add_argument( - "--api-key", - dest="login_api_key", - default=None, - help="API key (skip interactive prompt)", - ) - login_p.add_argument( - "--workspace", - dest="login_workspace", - default=None, - help="Set default workspace during login", - ) - login_p.add_argument( - "--force", - "-f", - action="store_true", - default=False, - help="Force re-login even if already logged in", - ) - login_p.set_defaults(func=_login) - # --- auth status --- - status_p = auth_sub.add_parser("status", help="Show current auth status") - status_p.set_defaults(func=_status) +@auth_app.command("login") +def login( + ctx: typer.Context, + login_api_key: Annotated[Optional[str], typer.Option("--api-key", help="API key (skip interactive prompt)")] = None, + login_workspace: Annotated[ + Optional[str], typer.Option("--workspace", help="Set default workspace during login") + ] = None, + force: Annotated[bool, typer.Option("--force", "-f", help="Force re-login even if already logged in")] = False, +) -> None: + """Log in to Roboflow.""" + args = ctx_to_args(ctx, login_api_key=login_api_key, login_workspace=login_workspace, force=force) + _login(args) + + +@auth_app.command("status") +def status(ctx: typer.Context) -> None: + """Show current auth status.""" + args = ctx_to_args(ctx) + _status(args) + + +@auth_app.command("set-workspace") +def set_workspace( + ctx: typer.Context, + workspace_id: Annotated[str, typer.Argument(help="Workspace URL or ID to set as default")], +) -> None: + """Set the default workspace.""" + args = ctx_to_args(ctx, workspace_id=workspace_id) + _set_workspace(args) + - # --- auth set-workspace --- - sw_p = auth_sub.add_parser("set-workspace", help="Set the default workspace") - sw_p.add_argument("workspace_id", help="Workspace URL or ID to set as default") - sw_p.set_defaults(func=_set_workspace) +@auth_app.command("logout") +def logout(ctx: typer.Context) -> None: + """Remove stored credentials.""" + args = ctx_to_args(ctx) + _logout(args) - # --- auth logout --- - logout_p = auth_sub.add_parser("logout", help="Remove stored credentials") - logout_p.set_defaults(func=_logout) - # Default: show help when no subcommand given - auth_parser.set_defaults(func=lambda args: auth_parser.print_help()) +# --------------------------------------------------------------------------- +# Business logic (unchanged from argparse version) +# --------------------------------------------------------------------------- def _get_config_path() -> str: @@ -94,7 +95,7 @@ def _mask_key(key: str) -> str: return key[:2] + "*" * (len(key) - 4) + key[-2:] -def _login(args: argparse.Namespace) -> None: +def _login(args): # noqa: ANN001 from roboflow.cli._output import output, output_error api_key = getattr(args, "login_api_key", None) or getattr(args, "api_key", None) @@ -182,7 +183,7 @@ def _login(args: argparse.Namespace) -> None: ) -def _status(args: argparse.Namespace) -> None: +def _status(args): # noqa: ANN001 import os from roboflow.cli._output import output, output_error @@ -250,7 +251,7 @@ def _status(args: argparse.Namespace) -> None: ) -def _set_workspace(args: argparse.Namespace) -> None: +def _set_workspace(args): # noqa: ANN001 from roboflow.cli._output import output workspace_id = args.workspace_id @@ -264,7 +265,7 @@ def _set_workspace(args: argparse.Namespace) -> None: ) -def _logout(args: argparse.Namespace) -> None: +def _logout(args): # noqa: ANN001 import os from roboflow.cli._output import output diff --git a/roboflow/cli/handlers/batch.py b/roboflow/cli/handlers/batch.py index 3ce1b414..31d24647 100644 --- a/roboflow/cli/handlers/batch.py +++ b/roboflow/cli/handlers/batch.py @@ -2,44 +2,62 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import argparse - - -def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - """Register the ``batch`` command group.""" - from roboflow.cli._output import stub - - batch_parser = subparsers.add_parser("batch", help="Batch processing operations") - batch_subs = batch_parser.add_subparsers(title="batch commands", dest="batch_command") - - # --- batch create --- - create_p = batch_subs.add_parser("create", help="Create a batch processing job") - create_p.add_argument("--workflow", dest="workflow", required=True, help="Workflow ID to run") - create_p.add_argument("--input", dest="input", required=True, help="Input path (image directory or video file)") - create_p.add_argument("--model", dest="model", default=None, help="Model ID override (default: workflow model)") - create_p.add_argument("--output", dest="output", default=None, help="Output directory for results") - create_p.set_defaults(func=stub) - - # --- batch status --- - status_p = batch_subs.add_parser("status", help="Check batch job status") - status_p.add_argument("job_id", help="Batch job ID") - status_p.set_defaults(func=stub) - - # --- batch list --- - list_p = batch_subs.add_parser("list", help="List batch jobs") - list_p.add_argument( - "--status", dest="status", default=None, help="Filter by status (pending, running, completed, failed)" - ) - list_p.set_defaults(func=stub) - - # --- batch results --- - results_p = batch_subs.add_parser("results", help="Get batch job results") - results_p.add_argument("job_id", help="Batch job ID") - results_p.add_argument("--format", dest="format", default=None, help="Output format (json, csv)") - results_p.set_defaults(func=stub) - - # Default - batch_parser.set_defaults(func=lambda args: batch_parser.print_help()) +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +batch_app = typer.Typer(cls=SortedGroup, help="Batch processing operations", no_args_is_help=True) + + +def _stub(args) -> None: # noqa: ANN001 + from roboflow.cli._output import output_error + + output_error(args, "This command is not yet implemented.", hint="Coming soon.", exit_code=1) + + +@batch_app.command("create") +def create( + ctx: typer.Context, + workflow: Annotated[str, typer.Option(help="Workflow ID to run")], + input: Annotated[str, typer.Option(help="Input path (image directory or video file)")], + model: Annotated[Optional[str], typer.Option(help="Model ID override (default: workflow model)")] = None, + output_dir: Annotated[Optional[str], typer.Option("--output", help="Output directory for results")] = None, +) -> None: + """Create a batch processing job.""" + args = ctx_to_args(ctx, workflow=workflow, input=input, model=model, output=output_dir) + _stub(args) + + +@batch_app.command("status") +def status( + ctx: typer.Context, + job_id: Annotated[str, typer.Argument(help="Batch job ID")], +) -> None: + """Check batch job status.""" + args = ctx_to_args(ctx, job_id=job_id) + _stub(args) + + +@batch_app.command("list") +def list_jobs( + ctx: typer.Context, + status_filter: Annotated[ + Optional[str], typer.Option("--status", help="Filter by status (pending, running, completed, failed)") + ] = None, +) -> None: + """List batch jobs.""" + args = ctx_to_args(ctx, status=status_filter) + _stub(args) + + +@batch_app.command("results") +def results( + ctx: typer.Context, + job_id: Annotated[str, typer.Argument(help="Batch job ID")], + format: Annotated[Optional[str], typer.Option(help="Output format (json, csv)")] = None, +) -> None: + """Get batch job results.""" + args = ctx_to_args(ctx, job_id=job_id, format=format) + _stub(args) diff --git a/roboflow/cli/handlers/completion.py b/roboflow/cli/handlers/completion.py index 8f2acf85..f08d4e9f 100644 --- a/roboflow/cli/handlers/completion.py +++ b/roboflow/cli/handlers/completion.py @@ -1,31 +1,65 @@ -"""Shell completion commands.""" +"""Shell completion commands. + +Generates completion scripts for bash, zsh, and fish shells. +Uses Click's built-in completion generation via the ``_ROBOFLOW_COMPLETE`` +environment variable. +""" from __future__ import annotations -from typing import TYPE_CHECKING +import sys + +import click +import typer + +from roboflow.cli._compat import SortedGroup + +completion_app = typer.Typer(cls=SortedGroup, help="Generate shell completions", no_args_is_help=True) + + +def _generate_completion(shell: str) -> None: + """Generate completion script for the given shell using Click's completion system.""" + from click.shell_completion import get_completion_class + + comp_cls = get_completion_class(shell) + if comp_cls is None: + print(f"Shell '{shell}' is not supported for completion.", file=sys.stderr) + raise typer.Exit(code=1) + + from roboflow.cli import app + + # Access the underlying Click command + click_app = typer.main.get_command(app) + ctx = click.Context(click_app, info_name="roboflow") + comp = comp_cls(click_app, ctx, "roboflow", "_ROBOFLOW_COMPLETE") # type: ignore[arg-type] + print(comp.source()) # noqa: T201 + -if TYPE_CHECKING: - import argparse +@completion_app.command("bash") +def bash() -> None: + """Generate bash completion script. + Usage: eval "$(roboflow completion bash)" + Or save to a file: roboflow completion bash > ~/.roboflow-complete.bash + """ + _generate_completion("bash") -def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - """Register the ``completion`` command group.""" - from roboflow.cli._output import stub - comp_parser = subparsers.add_parser("completion", help="Generate shell completions") - comp_subs = comp_parser.add_subparsers(title="completion commands", dest="completion_command") +@completion_app.command("zsh") +def zsh() -> None: + """Generate zsh completion script. - # --- completion bash --- - bash_p = comp_subs.add_parser("bash", help="Generate bash completions") - bash_p.set_defaults(func=stub) + Usage: eval "$(roboflow completion zsh)" + Or save to a file: roboflow completion zsh > ~/.roboflow-complete.zsh + """ + _generate_completion("zsh") - # --- completion zsh --- - zsh_p = comp_subs.add_parser("zsh", help="Generate zsh completions") - zsh_p.set_defaults(func=stub) - # --- completion fish --- - fish_p = comp_subs.add_parser("fish", help="Generate fish completions") - fish_p.set_defaults(func=stub) +@completion_app.command("fish") +def fish() -> None: + """Generate fish completion script. - # Default - comp_parser.set_defaults(func=lambda args: comp_parser.print_help()) + Usage: roboflow completion fish | source + Or save to a file: roboflow completion fish > ~/.config/fish/completions/roboflow.fish + """ + _generate_completion("fish") diff --git a/roboflow/cli/handlers/deployment.py b/roboflow/cli/handlers/deployment.py index 0fd1033d..9b7bcbc8 100644 --- a/roboflow/cli/handlers/deployment.py +++ b/roboflow/cli/handlers/deployment.py @@ -2,16 +2,18 @@ Builds clean, kebab-case subcommands that delegate to the handler functions in ``roboflow.deployment``. Legacy snake_case names are -registered as hidden aliases (``argparse.SUPPRESS``) so old scripts -keep working. +registered as hidden aliases so old scripts keep working. """ from __future__ import annotations -import argparse import io import sys -from typing import Any, Callable +from typing import Annotated, Any, Callable, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args # --------------------------------------------------------------------------- # Wrapper that captures legacy handler stdout/exit and normalises output @@ -21,7 +23,7 @@ def _wrap(func: Callable[..., Any]) -> Callable[..., None]: """Wrap a legacy deployment handler for structured errors + JSON output.""" - def _wrapped(args: argparse.Namespace) -> None: + def _wrapped(args): # noqa: ANN001 from roboflow.cli._output import output, output_error captured = io.StringIO() @@ -67,157 +69,237 @@ def _wrapped(args: argparse.Namespace) -> None: return _wrapped -# --------------------------------------------------------------------------- -# Hidden-alias helper -# --------------------------------------------------------------------------- - -_HIDDEN = argparse.SUPPRESS +deployment_app = typer.Typer(cls=SortedGroup, help="Manage dedicated deployments", no_args_is_help=True) # --------------------------------------------------------------------------- -# Register +# Commands # --------------------------------------------------------------------------- -def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - """Register the ``deployment`` command group with clean kebab-case names.""" - from roboflow.cli import _CleanHelpFormatter - from roboflow.deployment import ( - add_deployment, - delete_deployment, - get_deployment, - get_deployment_log, - get_deployment_usage, - get_workspace_usage, - list_deployment, - list_machine_types, - pause_deployment, - resume_deployment, +@deployment_app.command("machine-type") +def machine_type(ctx: typer.Context) -> None: + """List available machine types.""" + from roboflow.deployment import list_machine_types + + args = ctx_to_args(ctx) + _wrap(list_machine_types)(args) + + +@deployment_app.command("create") +def create_deployment( + ctx: typer.Context, + deployment_name: Annotated[str, typer.Argument(help="Deployment name (5-15 lowercase chars, starts with letter)")], + machine_type_opt: Annotated[ + str, typer.Option("-m", "--machine-type", help="Machine type (run 'roboflow deployment machine-type' to list)") + ], + creator_email: Annotated[str, typer.Option("-e", "--email", help="Your email (must be a workspace member)")], + duration: Annotated[float, typer.Option(help="Duration in hours")] = 3, + no_delete_on_expiration: Annotated[ + bool, typer.Option("--no-delete-on-expiration", help="Keep deployment when it expires") + ] = False, + inference_version: Annotated[str, typer.Option("--inference-version", help="Inference server version")] = "latest", + wait_on_pending: Annotated[bool, typer.Option("--wait", help="Wait until deployment is ready")] = False, +) -> None: + """Create a dedicated deployment.""" + from roboflow.deployment import add_deployment + + args = ctx_to_args( + ctx, + deployment_name=deployment_name, + machine_type=machine_type_opt, + creator_email=creator_email, + duration=duration, + no_delete_on_expiration=no_delete_on_expiration, + inference_version=inference_version, + wait_on_pending=wait_on_pending, ) + _wrap(add_deployment)(args) - dep = subparsers.add_parser("deployment", help="Manage dedicated deployments", formatter_class=_CleanHelpFormatter) - sub = dep.add_subparsers(title="deployment commands", dest="deployment_command") - - # --- machine-type (canonical) --- - mt = sub.add_parser("machine-type", help="List available machine types") - mt.set_defaults(func=_wrap(list_machine_types)) - - # --- create (canonical, replaces "add") --- - create = sub.add_parser("create", help="Create a dedicated deployment") - create.add_argument("deployment_name", help="Deployment name (5-15 lowercase chars, starts with letter)") - create.add_argument( - "-m", - "--machine-type", - dest="machine_type", - required=True, - help="Machine type (run 'roboflow deployment machine-type' to list options)", - ) - create.add_argument( - "-e", - "--email", - dest="creator_email", - required=True, - help="Your email (must be a workspace member)", - ) - create.add_argument("--duration", type=float, default=3, help="Duration in hours (default: 3)") - create.add_argument( - "--no-delete-on-expiration", - dest="no_delete_on_expiration", - action="store_true", - help="Keep deployment when it expires", - ) - create.add_argument( - "--inference-version", - dest="inference_version", - default="latest", - help="Inference server version (default: latest)", - ) - create.add_argument("--wait", dest="wait_on_pending", action="store_true", help="Wait until deployment is ready") - create.set_defaults(func=_wrap(add_deployment)) - - # --- get --- - get = sub.add_parser("get", help="Show details for a deployment") - get.add_argument("deployment_name", help="Deployment name") - get.add_argument("--wait", dest="wait_on_pending", action="store_true", help="Wait if deployment is pending") - get.set_defaults(func=_wrap(get_deployment)) - - # --- list --- - ls = sub.add_parser("list", help="List deployments in workspace") - ls.set_defaults(func=_wrap(list_deployment)) - - # --- usage --- - usage = sub.add_parser("usage", help="Show usage statistics") - usage.add_argument("deployment_name", nargs="?", default=None, help="Deployment name (omit for workspace-wide)") - usage.add_argument("--from", dest="from_timestamp", default=None, help="Start time (ISO 8601)") - usage.add_argument("--to", dest="to_timestamp", default=None, help="End time (ISO 8601)") - usage.set_defaults(func=_usage_handler) - - # --- pause --- - pause = sub.add_parser("pause", help="Pause a deployment") - pause.add_argument("deployment_name", help="Deployment name") - pause.set_defaults(func=_wrap(pause_deployment)) - - # --- resume --- - resume = sub.add_parser("resume", help="Resume a paused deployment") - resume.add_argument("deployment_name", help="Deployment name") - resume.set_defaults(func=_wrap(resume_deployment)) - - # --- delete --- - delete = sub.add_parser("delete", help="Delete a deployment") - delete.add_argument("deployment_name", help="Deployment name") - delete.set_defaults(func=_wrap(delete_deployment)) - - # --- log --- - log = sub.add_parser("log", help="Show deployment logs") - log.add_argument("deployment_name", help="Deployment name") - log.add_argument("-d", "--duration", type=int, default=3600, help="Log window in seconds (default: 3600)") - log.add_argument("-n", "--tail", type=int, default=10, help="Lines to show from end (max 50)") - log.add_argument("-f", "--follow", action="store_true", help="Follow log output") - log.set_defaults(func=_wrap(get_deployment_log)) - - # --- hidden legacy aliases (exact old flag signatures for backwards compat) --- - - # machine_type → machine-type - legacy_mt = sub.add_parser("machine_type", help=_HIDDEN) - legacy_mt.add_argument("-a", "--api_key", default=None) - legacy_mt.set_defaults(func=_wrap(list_machine_types)) - - # add → create (with old flag names: -m/--machine_type, -e/--creator_email, etc.) - legacy_add = sub.add_parser("add", help=_HIDDEN) - legacy_add.add_argument("deployment_name") - legacy_add.add_argument("-a", "--api_key", default=None) - legacy_add.add_argument("-m", "--machine_type", required=True) - legacy_add.add_argument("-e", "--creator_email", required=True) - legacy_add.add_argument("-t", "--duration", type=float, default=3) - legacy_add.add_argument("-nodel", "--no_delete_on_expiration", action="store_true") - legacy_add.add_argument("-v", "--inference_version", default="latest") - legacy_add.add_argument("-w", "--wait_on_pending", action="store_true") - legacy_add.set_defaults(func=_wrap(add_deployment)) - - # usage_workspace - legacy_uw = sub.add_parser("usage_workspace", help=_HIDDEN) - legacy_uw.add_argument("-a", "--api_key", default=None) - legacy_uw.add_argument("-f", "--from_timestamp", default=None) - legacy_uw.add_argument("-t", "--to_timestamp", default=None) - legacy_uw.set_defaults(func=_wrap(get_workspace_usage)) - - # usage_deployment - legacy_ud = sub.add_parser("usage_deployment", help=_HIDDEN) - legacy_ud.add_argument("-a", "--api_key", default=None) - legacy_ud.add_argument("deployment_name") - legacy_ud.add_argument("-f", "--from_timestamp", default=None) - legacy_ud.add_argument("-t", "--to_timestamp", default=None) - legacy_ud.set_defaults(func=_wrap(get_deployment_usage)) - - # Default: show help when no subcommand given - dep.set_defaults(func=lambda args: dep.print_help()) - - -def _usage_handler(args: argparse.Namespace) -> None: - """Dispatch to workspace or deployment usage based on whether a name was given.""" + +@deployment_app.command("get") +def get_deployment( + ctx: typer.Context, + deployment_name: Annotated[str, typer.Argument(help="Deployment name")], + wait_on_pending: Annotated[bool, typer.Option("--wait", help="Wait if deployment is pending")] = False, +) -> None: + """Show details for a deployment.""" + from roboflow.deployment import get_deployment + + args = ctx_to_args(ctx, deployment_name=deployment_name, wait_on_pending=wait_on_pending) + _wrap(get_deployment)(args) + + +@deployment_app.command("list") +def list_deployments(ctx: typer.Context) -> None: + """List deployments in workspace.""" + from roboflow.deployment import list_deployment + + args = ctx_to_args(ctx) + _wrap(list_deployment)(args) + + +@deployment_app.command("usage") +def usage( + ctx: typer.Context, + deployment_name: Annotated[Optional[str], typer.Argument(help="Deployment name (omit for workspace-wide)")] = None, + from_timestamp: Annotated[Optional[str], typer.Option("--from", help="Start time (ISO 8601)")] = None, + to_timestamp: Annotated[Optional[str], typer.Option("--to", help="End time (ISO 8601)")] = None, +) -> None: + """Show usage statistics.""" from roboflow.deployment import get_deployment_usage, get_workspace_usage - if args.deployment_name: + args = ctx_to_args( + ctx, + deployment_name=deployment_name, + from_timestamp=from_timestamp, + to_timestamp=to_timestamp, + ) + if deployment_name: _wrap(get_deployment_usage)(args) else: _wrap(get_workspace_usage)(args) + + +@deployment_app.command("pause") +def pause_deployment( + ctx: typer.Context, + deployment_name: Annotated[str, typer.Argument(help="Deployment name")], +) -> None: + """Pause a deployment.""" + from roboflow.deployment import pause_deployment + + args = ctx_to_args(ctx, deployment_name=deployment_name) + _wrap(pause_deployment)(args) + + +@deployment_app.command("resume") +def resume_deployment( + ctx: typer.Context, + deployment_name: Annotated[str, typer.Argument(help="Deployment name")], +) -> None: + """Resume a paused deployment.""" + from roboflow.deployment import resume_deployment + + args = ctx_to_args(ctx, deployment_name=deployment_name) + _wrap(resume_deployment)(args) + + +@deployment_app.command("delete") +def delete_deployment( + ctx: typer.Context, + deployment_name: Annotated[str, typer.Argument(help="Deployment name")], +) -> None: + """Delete a deployment.""" + from roboflow.deployment import delete_deployment + + args = ctx_to_args(ctx, deployment_name=deployment_name) + _wrap(delete_deployment)(args) + + +@deployment_app.command("log") +def deployment_log( + ctx: typer.Context, + deployment_name: Annotated[str, typer.Argument(help="Deployment name")], + duration: Annotated[int, typer.Option("-d", "--duration", help="Log window in seconds")] = 3600, + tail: Annotated[int, typer.Option("-n", "--tail", help="Lines to show from end (max 50)")] = 10, + follow: Annotated[bool, typer.Option("-f", "--follow", help="Follow log output")] = False, +) -> None: + """Show deployment logs.""" + from roboflow.deployment import get_deployment_log + + args = ctx_to_args( + ctx, + deployment_name=deployment_name, + duration=duration, + tail=tail, + follow=follow, + ) + _wrap(get_deployment_log)(args) + + +# --------------------------------------------------------------------------- +# Hidden legacy aliases +# --------------------------------------------------------------------------- + + +@deployment_app.command("machine_type", hidden=True) +def legacy_machine_type( + ctx: typer.Context, + api_key: Annotated[Optional[str], typer.Option("-a", "--api_key")] = None, +) -> None: + """Legacy alias for machine-type.""" + from roboflow.deployment import list_machine_types + + args = ctx_to_args(ctx) + if api_key: + args.api_key = api_key + _wrap(list_machine_types)(args) + + +@deployment_app.command("add", hidden=True) +def legacy_add( + ctx: typer.Context, + deployment_name: Annotated[str, typer.Argument()], + machine_type_opt: Annotated[str, typer.Option("-m", "--machine_type")], + creator_email: Annotated[str, typer.Option("-e", "--creator_email")], + api_key: Annotated[Optional[str], typer.Option("-a", "--api_key")] = None, + duration: Annotated[float, typer.Option("-t", "--duration")] = 3, + no_delete_on_expiration: Annotated[bool, typer.Option("-nodel", "--no_delete_on_expiration")] = False, + inference_version: Annotated[str, typer.Option("-v", "--inference_version")] = "latest", + wait_on_pending: Annotated[bool, typer.Option("-w", "--wait_on_pending")] = False, +) -> None: + """Legacy alias for create.""" + from roboflow.deployment import add_deployment + + args = ctx_to_args( + ctx, + deployment_name=deployment_name, + machine_type=machine_type_opt, + creator_email=creator_email, + duration=duration, + no_delete_on_expiration=no_delete_on_expiration, + inference_version=inference_version, + wait_on_pending=wait_on_pending, + ) + if api_key: + args.api_key = api_key + _wrap(add_deployment)(args) + + +@deployment_app.command("usage_workspace", hidden=True) +def legacy_usage_workspace( + ctx: typer.Context, + api_key: Annotated[Optional[str], typer.Option("-a", "--api_key")] = None, + from_timestamp: Annotated[Optional[str], typer.Option("-f", "--from_timestamp")] = None, + to_timestamp: Annotated[Optional[str], typer.Option("-t", "--to_timestamp")] = None, +) -> None: + """Legacy alias for usage (workspace).""" + from roboflow.deployment import get_workspace_usage + + args = ctx_to_args(ctx, from_timestamp=from_timestamp, to_timestamp=to_timestamp) + if api_key: + args.api_key = api_key + _wrap(get_workspace_usage)(args) + + +@deployment_app.command("usage_deployment", hidden=True) +def legacy_usage_deployment( + ctx: typer.Context, + deployment_name: Annotated[str, typer.Argument()], + api_key: Annotated[Optional[str], typer.Option("-a", "--api_key")] = None, + from_timestamp: Annotated[Optional[str], typer.Option("-f", "--from_timestamp")] = None, + to_timestamp: Annotated[Optional[str], typer.Option("-t", "--to_timestamp")] = None, +) -> None: + """Legacy alias for usage (deployment).""" + from roboflow.deployment import get_deployment_usage + + args = ctx_to_args( + ctx, + deployment_name=deployment_name, + from_timestamp=from_timestamp, + to_timestamp=to_timestamp, + ) + if api_key: + args.api_key = api_key + _wrap(get_deployment_usage)(args) diff --git a/roboflow/cli/handlers/folder.py b/roboflow/cli/handlers/folder.py index 4334c9af..05028814 100644 --- a/roboflow/cli/handlers/folder.py +++ b/roboflow/cli/handlers/folder.py @@ -2,56 +2,78 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import Annotated, Optional -if TYPE_CHECKING: - import argparse +import typer +from roboflow.cli._compat import SortedGroup, ctx_to_args -def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - """Register the ``folder`` command group.""" - folder_parser = subparsers.add_parser("folder", help="Manage workspace folders") - folder_subs = folder_parser.add_subparsers(title="folder commands", dest="folder_command") +folder_app = typer.Typer(cls=SortedGroup, help="Manage workspace folders", no_args_is_help=True) - # --- folder list --- - list_p = folder_subs.add_parser("list", help="List folders") - list_p.set_defaults(func=_list_folders) - # --- folder get --- - get_p = folder_subs.add_parser("get", help="Show folder details") - get_p.add_argument("folder_id", help="Folder ID") - get_p.set_defaults(func=_get_folder) +@folder_app.command("list") +def list_folders(ctx: typer.Context) -> None: + """List folders.""" + args = ctx_to_args(ctx) + _list_folders(args) - # --- folder create --- - create_p = folder_subs.add_parser("create", help="Create a folder") - create_p.add_argument("name", help="Folder name") - create_p.add_argument("--parent", dest="parent", default=None, help="Parent folder ID") - create_p.add_argument("--projects", dest="projects", default=None, help="Comma-separated project IDs") - create_p.set_defaults(func=_create_folder) - # --- folder update --- - update_p = folder_subs.add_parser("update", help="Update a folder") - update_p.add_argument("folder_id", help="Folder ID") - update_p.add_argument("--name", help="New folder name") - update_p.set_defaults(func=_update_folder) +@folder_app.command("get") +def get_folder( + ctx: typer.Context, + folder_id: Annotated[str, typer.Argument(help="Folder ID")], +) -> None: + """Show folder details.""" + args = ctx_to_args(ctx, folder_id=folder_id) + _get_folder(args) - # --- folder delete --- - delete_p = folder_subs.add_parser("delete", help="Delete a folder") - delete_p.add_argument("folder_id", help="Folder ID") - delete_p.set_defaults(func=_delete_folder) - # Default - folder_parser.set_defaults(func=lambda args: folder_parser.print_help()) +@folder_app.command("create") +def create_folder( + ctx: typer.Context, + name: Annotated[str, typer.Argument(help="Folder name")], + parent: Annotated[Optional[str], typer.Option(help="Parent folder ID")] = None, + projects: Annotated[Optional[str], typer.Option(help="Comma-separated project IDs")] = None, +) -> None: + """Create a folder.""" + args = ctx_to_args(ctx, name=name, parent=parent, projects=projects) + _create_folder(args) -def _resolve_ws_and_key(args: argparse.Namespace): +@folder_app.command("update") +def update_folder( + ctx: typer.Context, + folder_id: Annotated[str, typer.Argument(help="Folder ID")], + name: Annotated[Optional[str], typer.Option(help="New folder name")] = None, +) -> None: + """Update a folder.""" + args = ctx_to_args(ctx, folder_id=folder_id, name=name) + _update_folder(args) + + +@folder_app.command("delete") +def delete_folder( + ctx: typer.Context, + folder_id: Annotated[str, typer.Argument(help="Folder ID")], +) -> None: + """Delete a folder.""" + args = ctx_to_args(ctx, folder_id=folder_id) + _delete_folder(args) + + +# --------------------------------------------------------------------------- +# Business logic (unchanged from argparse version) +# --------------------------------------------------------------------------- + + +def _resolve_ws_and_key(args): # noqa: ANN001 """Resolve workspace and API key, returning (ws, api_key) or None on error.""" from roboflow.cli._resolver import resolve_ws_and_key return resolve_ws_and_key(args) -def _list_folders(args: argparse.Namespace) -> None: +def _list_folders(args) -> None: # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error from roboflow.cli._table import format_table @@ -85,7 +107,7 @@ def _list_folders(args: argparse.Namespace) -> None: output(args, folders, text=table) -def _get_folder(args: argparse.Namespace) -> None: +def _get_folder(args) -> None: # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error @@ -118,7 +140,7 @@ def _get_folder(args: argparse.Namespace) -> None: output(args, result, text="\n".join(lines)) -def _create_folder(args: argparse.Namespace) -> None: +def _create_folder(args) -> None: # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error @@ -142,7 +164,7 @@ def _create_folder(args: argparse.Namespace) -> None: output(args, data, text=f"Created folder '{args.name}' (id: {folder_id})") -def _update_folder(args: argparse.Namespace) -> None: +def _update_folder(args) -> None: # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error @@ -161,7 +183,7 @@ def _update_folder(args: argparse.Namespace) -> None: output(args, data, text=f"Updated folder '{args.folder_id}'") -def _delete_folder(args: argparse.Namespace) -> None: +def _delete_folder(args) -> None: # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error diff --git a/roboflow/cli/handlers/image.py b/roboflow/cli/handlers/image.py index 58f8eb04..633ac6a8 100644 --- a/roboflow/cli/handlers/image.py +++ b/roboflow/cli/handlers/image.py @@ -2,55 +2,189 @@ from __future__ import annotations -import json -import os -from typing import TYPE_CHECKING - -from roboflow.adapters import rfapi -from roboflow.cli._output import output, output_error -from roboflow.config import API_URL, load_roboflow_api_key - -if TYPE_CHECKING: - import argparse - - -def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - """Register the ``image`` command group.""" - image_parser = subparsers.add_parser("image", help="Image management commands") - image_sub = image_parser.add_subparsers(title="image commands", dest="image_command") - - _add_upload(image_sub) - _add_get(image_sub) - _add_search(image_sub) - _add_tag(image_sub) - _add_delete(image_sub) - _add_annotate(image_sub) - - image_parser.set_defaults(func=lambda args: image_parser.print_help()) +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +image_app = typer.Typer(cls=SortedGroup, help="Image management commands", no_args_is_help=True) + + +@image_app.command("upload") +def upload_image( + ctx: typer.Context, + path: Annotated[ + str, typer.Argument(help="Path to image file or directory (auto-detects single vs. directory bulk import)") + ], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], + annotation: Annotated[ + Optional[str], typer.Option("-a", "--annotation", help="Path to annotation file (single upload)") + ] = None, + split: Annotated[str, typer.Option("-s", "--split", help="Dataset split")] = "train", + batch: Annotated[Optional[str], typer.Option("-b", "--batch", help="Batch name")] = None, + tag: Annotated[Optional[str], typer.Option("-t", "--tag", help="Comma-separated tag names")] = None, + metadata: Annotated[Optional[str], typer.Option(help="JSON string of key-value metadata")] = None, + concurrency: Annotated[int, typer.Option("-c", "--concurrency", help="Concurrency for directory import")] = 10, + retries: Annotated[int, typer.Option("-r", "--retries", help="Retry failed uploads N times")] = 0, + labelmap: Annotated[Optional[str], typer.Option(help="Path to labelmap file")] = None, + is_prediction: Annotated[bool, typer.Option("--is-prediction", help="Mark upload as prediction")] = False, +) -> None: + """Upload an image file or import a directory.""" + args = ctx_to_args( + ctx, + path=path, + project=project, + annotation=annotation, + split=split, + batch=batch, + tag=tag, + metadata=metadata, + concurrency=concurrency, + retries=retries, + labelmap=labelmap, + is_prediction=is_prediction, + ) + _handle_upload(args) + + +@image_app.command("get") +def get_image( + ctx: typer.Context, + image_id: Annotated[str, typer.Argument(help="Image ID")], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], +) -> None: + """Get image details.""" + args = ctx_to_args(ctx, image_id=image_id, project=project) + _handle_get(args) + + +@image_app.command("search") +def search_images( + ctx: typer.Context, + query: Annotated[str, typer.Argument(help="RoboQL search query (e.g. 'tag:review' or '*')")], + project: Annotated[ + Optional[str], typer.Option("-p", "--project", help="Project ID (omit to search entire workspace)") + ] = None, + limit: Annotated[int, typer.Option(help="Number of results")] = 50, + cursor: Annotated[Optional[str], typer.Option(help="Continuation token for pagination")] = None, + export: Annotated[bool, typer.Option("--export", help="Export search results as a dataset")] = False, + format: Annotated[str, typer.Option("-f", "--format", help="Annotation format for export")] = "coco", + location: Annotated[Optional[str], typer.Option("-l", "--location", help="Local directory for export")] = None, + dataset: Annotated[ + Optional[str], typer.Option("-d", "--dataset", help="Limit export to a specific dataset") + ] = None, + annotation_group: Annotated[ + Optional[str], typer.Option("-g", "--annotation-group", help="Annotation group") + ] = None, + name: Annotated[Optional[str], typer.Option(help="Optional name for the export")] = None, + no_extract: Annotated[bool, typer.Option("--no-extract", help="Keep zip file, skip extraction")] = False, +) -> None: + """Search images in workspace or project. + + Without -p/--project, searches across the entire workspace using RoboQL. + With -p/--project, searches within a specific project. + Use --export to download matching results as a dataset. + """ + if project: + # Project-scoped search (legacy behavior) + args = ctx_to_args(ctx, query=query, project=project, limit=limit, cursor=cursor) + _handle_search(args) + elif export: + # Workspace-level export + from roboflow.cli.handlers.search import _search + + args = ctx_to_args( + ctx, + query=query, + limit=limit, + cursor=cursor, + export=True, + format=format, + location=location, + dataset=dataset, + annotation_group=annotation_group, + name=name, + no_extract=no_extract, + ) + _search(args) + else: + # Workspace-level search + from roboflow.cli.handlers.search import _search + + args = ctx_to_args( + ctx, + query=query, + limit=limit, + cursor=cursor, + export=False, + format=format, + location=location, + dataset=dataset, + annotation_group=annotation_group, + name=name, + no_extract=no_extract, + fields=None, + ) + _search(args) + + +@image_app.command("tag") +def tag_image( + ctx: typer.Context, + image_id: Annotated[str, typer.Argument(help="Image ID")], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], + add_tags: Annotated[Optional[str], typer.Option("--add", help="Comma-separated tags to add")] = None, + remove_tags: Annotated[Optional[str], typer.Option("--remove", help="Comma-separated tags to remove")] = None, +) -> None: + """Add or remove tags on an image.""" + args = ctx_to_args(ctx, image_id=image_id, project=project, add_tags=add_tags, remove_tags=remove_tags) + _handle_tag(args) + + +@image_app.command("delete") +def delete_images( + ctx: typer.Context, + image_ids: Annotated[str, typer.Argument(help="Comma-separated image IDs")], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], +) -> None: + """Delete images from workspace.""" + args = ctx_to_args(ctx, image_ids=image_ids, project=project) + _handle_delete(args) + + +@image_app.command("annotate") +def annotate_image( + ctx: typer.Context, + image_id: Annotated[str, typer.Argument(help="Image ID")], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], + annotation_file: Annotated[str, typer.Option("--annotation-file", help="Path to annotation file")], + annotation_format: Annotated[Optional[str], typer.Option("--format", help="Annotation format name")] = None, + labelmap: Annotated[Optional[str], typer.Option(help="Path to labelmap file")] = None, +) -> None: + """Upload annotation for an image.""" + args = ctx_to_args( + ctx, + image_id=image_id, + project=project, + annotation_file=annotation_file, + annotation_format=annotation_format, + labelmap=labelmap, + ) + _handle_annotate(args) # --------------------------------------------------------------------------- -# upload +# Business logic (unchanged from argparse version) # --------------------------------------------------------------------------- -def _add_upload(sub: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - p = sub.add_parser("upload", help="Upload an image file or import a directory") - p.add_argument("path", help="Path to image file or directory (auto-detects single file vs. directory bulk import)") - p.add_argument("-p", "--project", required=True, help="Project ID") - p.add_argument("-a", "--annotation", default=None, help="Path to annotation file (single upload)") - p.add_argument("-s", "--split", default="train", help="Dataset split (default: train)") - p.add_argument("-b", "--batch", default=None, help="Batch name") - p.add_argument("-t", "--tag", default=None, help="Comma-separated tag names") - p.add_argument("--metadata", default=None, help="JSON string of key-value metadata") - p.add_argument("-c", "--concurrency", type=int, default=10, help="Concurrency for directory import (default: 10)") - p.add_argument("-r", "--retries", type=int, default=0, help="Retry failed uploads N times (default: 0)") - p.add_argument("--labelmap", default=None, help="Path to labelmap file") - p.add_argument("--is-prediction", action="store_true", default=False, help="Mark upload as prediction") - p.set_defaults(func=_handle_upload) +def _handle_upload(args): # noqa: ANN001 + import os + from roboflow.cli._output import output_error + from roboflow.config import load_roboflow_api_key -def _handle_upload(args: argparse.Namespace) -> None: api_key = args.api_key or load_roboflow_api_key(args.workspace) if not api_key: output_error(args, "No API key found", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'", exit_code=2) @@ -66,9 +200,11 @@ def _handle_upload(args: argparse.Namespace) -> None: return -def _handle_upload_single(args: argparse.Namespace, api_key: str, path: str) -> None: +def _handle_upload_single(args, api_key: str, path: str) -> None: # noqa: ANN001 + import json + import roboflow - from roboflow.cli._output import suppress_sdk_output + from roboflow.cli._output import output, output_error, suppress_sdk_output metadata_raw = getattr(args, "metadata", None) metadata = json.loads(metadata_raw) if metadata_raw else None @@ -110,9 +246,11 @@ def _handle_upload_single(args: argparse.Namespace, api_key: str, path: str) -> output(args, data, text=f"Uploaded {path} to {args.project}") -def _handle_upload_directory(args: argparse.Namespace, api_key: str, path: str) -> None: +def _handle_upload_directory(args, api_key: str, path: str) -> None: # noqa: ANN001 + import os + import roboflow - from roboflow.cli._output import suppress_sdk_output + from roboflow.cli._output import output, output_error, suppress_sdk_output # Always suppress SDK "loading..." noise during workspace init with suppress_sdk_output(): @@ -149,21 +287,14 @@ def _handle_upload_directory(args: argparse.Namespace, api_key: str, path: str) output(args, data, text=f"Imported {count} images from {path} to {args.project}") -# --------------------------------------------------------------------------- -# get -# --------------------------------------------------------------------------- - - -def _add_get(sub: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - p = sub.add_parser("get", help="Get image details") - p.add_argument("image_id", help="Image ID") - p.add_argument("-p", "--project", required=True, help="Project ID") - p.set_defaults(func=_handle_get) - +def _handle_get(args): # noqa: ANN001 + import json -def _handle_get(args: argparse.Namespace) -> None: import requests + from roboflow.cli._output import output, output_error + from roboflow.config import API_URL, load_roboflow_api_key + api_key = args.api_key or load_roboflow_api_key(args.workspace) if not api_key: output_error(args, "No API key found", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'", exit_code=2) @@ -184,21 +315,13 @@ def _handle_get(args: argparse.Namespace) -> None: output(args, data, text=json.dumps(data, indent=2)) -# --------------------------------------------------------------------------- -# search -# --------------------------------------------------------------------------- - - -def _add_search(sub: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - p = sub.add_parser("search", help="Search images in workspace") - p.add_argument("query", help="RoboQL search query") - p.add_argument("-p", "--project", required=True, help="Project ID (used in query filter)") - p.add_argument("--limit", type=int, default=50, help="Number of results (default: 50)") - p.add_argument("--cursor", default=None, help="Continuation token for pagination") - p.set_defaults(func=_handle_search) +def _handle_search(args): # noqa: ANN001 + import json + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.config import load_roboflow_api_key -def _handle_search(args: argparse.Namespace) -> None: api_key = args.api_key or load_roboflow_api_key(args.workspace) if not api_key: output_error(args, "No API key found", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'", exit_code=2) @@ -219,23 +342,12 @@ def _handle_search(args: argparse.Namespace) -> None: output(args, result, text=json.dumps(result, indent=2)) -# --------------------------------------------------------------------------- -# tag -# --------------------------------------------------------------------------- - - -def _add_tag(sub: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - p = sub.add_parser("tag", help="Add or remove tags on an image") - p.add_argument("image_id", help="Image ID") - p.add_argument("-p", "--project", required=True, help="Project ID") - p.add_argument("--add", default=None, dest="add_tags", help="Comma-separated tags to add") - p.add_argument("--remove", default=None, dest="remove_tags", help="Comma-separated tags to remove") - p.set_defaults(func=_handle_tag) - - -def _handle_tag(args: argparse.Namespace) -> None: +def _handle_tag(args): # noqa: ANN001 import requests + from roboflow.cli._output import output, output_error + from roboflow.config import API_URL, load_roboflow_api_key + if not args.add_tags and not args.remove_tags: output_error(args, "Nothing to do", hint="Specify --add and/or --remove with comma-separated tags") return @@ -282,19 +394,11 @@ def _handle_tag(args: argparse.Namespace) -> None: output(args, data, text=text) -# --------------------------------------------------------------------------- -# delete -# --------------------------------------------------------------------------- - - -def _add_delete(sub: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - p = sub.add_parser("delete", help="Delete images from workspace") - p.add_argument("image_ids", help="Comma-separated image IDs") - p.add_argument("-p", "--project", required=True, help="Project ID") - p.set_defaults(func=_handle_delete) - +def _handle_delete(args): # noqa: ANN001 + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.config import load_roboflow_api_key -def _handle_delete(args: argparse.Namespace) -> None: api_key = args.api_key or load_roboflow_api_key(args.workspace) if not api_key: output_error(args, "No API key found", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'", exit_code=2) @@ -318,22 +422,14 @@ def _handle_delete(args: argparse.Namespace) -> None: output(args, data, text=f"Deleted {deleted}, skipped {skipped}") -# --------------------------------------------------------------------------- -# annotate -# --------------------------------------------------------------------------- - - -def _add_annotate(sub: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - p = sub.add_parser("annotate", help="Upload annotation for an image") - p.add_argument("image_id", help="Image ID") - p.add_argument("-p", "--project", required=True, help="Project ID") - p.add_argument("--annotation-file", required=True, help="Path to annotation file") - p.add_argument("--format", default=None, dest="annotation_format", help="Annotation format name") - p.add_argument("--labelmap", default=None, help="Path to labelmap file") - p.set_defaults(func=_handle_annotate) +def _handle_annotate(args): # noqa: ANN001 + import json + import os + from roboflow.adapters import rfapi + from roboflow.cli._output import output, output_error + from roboflow.config import load_roboflow_api_key -def _handle_annotate(args: argparse.Namespace) -> None: api_key = args.api_key or load_roboflow_api_key(args.workspace) if not api_key: output_error(args, "No API key found", hint="Set ROBOFLOW_API_KEY or run 'roboflow auth login'", exit_code=2) diff --git a/roboflow/cli/handlers/infer.py b/roboflow/cli/handlers/infer.py index 79a40b1d..4e3f4492 100644 --- a/roboflow/cli/handlers/infer.py +++ b/roboflow/cli/handlers/infer.py @@ -2,60 +2,38 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import argparse - - -def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - """Register the top-level ``infer`` command.""" - infer_parser = subparsers.add_parser("infer", help="Run inference on an image") - infer_parser.add_argument( - "file", - help="Path to an image file", - ) - infer_parser.add_argument( - "-m", - "--model", - dest="model", - required=True, - help="Model ID (project/version, e.g. my-project/3)", - ) - infer_parser.add_argument( - "-c", - "--confidence", - dest="confidence", - type=float, - default=0.5, - help="Confidence threshold 0.0-1.0 (default: 0.5)", - ) - infer_parser.add_argument( - "-o", - "--overlap", - dest="overlap", - type=float, - default=0.5, - help="Overlap threshold 0.0-1.0 (default: 0.5)", - ) - infer_parser.add_argument( - "-t", - "--type", - dest="type", - default=None, - choices=[ - "object-detection", - "classification", - "instance-segmentation", - "semantic-segmentation", - "keypoint-detection", - ], - help="Model type (auto-detected if not specified)", - ) - infer_parser.set_defaults(func=_infer) - - -def _infer(args: argparse.Namespace) -> None: +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import ctx_to_args + + +def infer_command(app: typer.Typer) -> None: + """Register the top-level ``infer`` command on *app*.""" + + @app.command("infer", hidden=True) + def infer( + ctx: typer.Context, + file: Annotated[str, typer.Argument(help="Path to an image file")], + model: Annotated[str, typer.Option("-m", "--model", help="Model ID (project/version, e.g. my-project/3)")], + confidence: Annotated[float, typer.Option("-c", "--confidence", help="Confidence threshold 0.0-1.0")] = 0.5, + overlap: Annotated[float, typer.Option("-o", "--overlap", help="Overlap threshold 0.0-1.0")] = 0.5, + type: Annotated[ + Optional[str], + typer.Option( + "-t", + "--type", + help="Model type (auto-detected if not specified)", + ), + ] = None, + ) -> None: + """Run inference on an image.""" + args = ctx_to_args(ctx, file=file, model=model, confidence=confidence, overlap=overlap, type=type) + _infer(args) + + +def _infer(args): # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error from roboflow.cli._resolver import resolve_resource diff --git a/roboflow/cli/handlers/model.py b/roboflow/cli/handlers/model.py index 5f51bbbc..9cf04617 100644 --- a/roboflow/cli/handlers/model.py +++ b/roboflow/cli/handlers/model.py @@ -2,90 +2,91 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import argparse - - -def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - """Register ``model`` subcommand and its verbs.""" - model_parser = subparsers.add_parser("model", help="Manage trained models") - model_subs = model_parser.add_subparsers(title="model commands", dest="model_command") - - # --- model list --- - list_parser = model_subs.add_parser("list", help="List trained models for a project") - list_parser.add_argument( - "-p", - "--project", - dest="project", - required=True, - help="Project ID or shorthand (e.g. my-ws/my-project)", +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +model_app = typer.Typer(cls=SortedGroup, help="Manage trained models", no_args_is_help=True) + + +@model_app.command("list") +def list_models( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID or shorthand (e.g. my-ws/my-project)")], +) -> None: + """List trained models for a project.""" + args = ctx_to_args(ctx, project=project) + _list_models(args) + + +@model_app.command("get") +def get_model( + ctx: typer.Context, + model_url: Annotated[str, typer.Argument(help="Model URL (e.g. workspace/model-name)")], +) -> None: + """Show details for a trained model.""" + args = ctx_to_args(ctx, model_url=model_url) + _get_model(args) + + +@model_app.command("infer") +def model_infer( + ctx: typer.Context, + file: Annotated[str, typer.Argument(help="Path to an image file")], + model: Annotated[str, typer.Option("-m", "--model", help="Model ID (project/version, e.g. my-project/3)")], + confidence: Annotated[float, typer.Option("-c", "--confidence", help="Confidence threshold 0.0-1.0")] = 0.5, + overlap: Annotated[float, typer.Option("-o", "--overlap", help="Overlap/NMS threshold 0.0-1.0")] = 0.5, + type: Annotated[ + Optional[str], + typer.Option("-t", "--type", help="Model type (auto-detected if not specified)"), + ] = None, +) -> None: + """Run inference on an image using a trained model.""" + from roboflow.cli.handlers.infer import _infer + + args = ctx_to_args(ctx, file=file, model=model, confidence=confidence, overlap=overlap, type=type) + _infer(args) + + +@model_app.command("upload") +def upload_model( + ctx: typer.Context, + model_type: Annotated[str, typer.Option("-t", "--type", help="Model type (e.g. yolov8, yolov5)")], + model_path: Annotated[str, typer.Option("-m", "--model-path", help="Path to the trained model file")], + project: Annotated[ + Optional[list[str]], typer.Option("-p", "--project", help="Project ID (repeatable for multi-project deploy)") + ] = None, + version_number: Annotated[ + Optional[int], typer.Option("-v", "--version", help="Version number to deploy to (single-version deploy)") + ] = None, + filename: Annotated[str, typer.Option("-f", "--filename", help="Model file name")] = "weights/best.pt", + model_name: Annotated[ + Optional[str], typer.Option("-n", "--model-name", help="Name for the model (multi-project deploy)") + ] = None, +) -> None: + """Upload a trained model.""" + args = ctx_to_args( + ctx, + project=project, + version_number=version_number, + model_type=model_type, + model_path=model_path, + filename=filename, + model_name=model_name, ) - list_parser.set_defaults(func=_list_models) + _upload_model(args) - # --- model get --- - get_parser = model_subs.add_parser("get", help="Show details for a trained model") - get_parser.add_argument( - "model_url", - help="Model URL (e.g. workspace/model-name)", - ) - get_parser.set_defaults(func=_get_model) - - # --- model upload --- - upload_parser = model_subs.add_parser("upload", help="Upload a trained model") - upload_parser.add_argument( - "-p", - "--project", - dest="project", - action="append", - help="Project ID (can be specified multiple times for multi-project deploy)", - ) - upload_parser.add_argument( - "-v", - "--version", - dest="version_number", - type=int, - default=None, - help="Version number to deploy to (for single-version deploy)", - ) - upload_parser.add_argument( - "-t", - "--type", - dest="model_type", - required=True, - help="Model type (e.g. yolov8, yolov5)", - ) - upload_parser.add_argument( - "-m", - "--model-path", - dest="model_path", - required=True, - help="Path to the trained model file", - ) - upload_parser.add_argument( - "-f", - "--filename", - dest="filename", - default="weights/best.pt", - help="Name of the model file (default: weights/best.pt)", - ) - upload_parser.add_argument( - "-n", - "--model-name", - dest="model_name", - default=None, - help="Name for the model (used in multi-project deploy)", - ) - upload_parser.set_defaults(func=_upload_model) - # Default when no verb is given - model_parser.set_defaults(func=lambda args: model_parser.print_help()) +# --------------------------------------------------------------------------- +# Business logic (unchanged from argparse version) +# --------------------------------------------------------------------------- -def _list_models(args: argparse.Namespace) -> None: +def _list_models(args): # noqa: ANN001 import roboflow - from roboflow.cli._output import output, output_error + from roboflow.cli._output import output, output_error, suppress_sdk_output from roboflow.cli._resolver import resolve_resource from roboflow.cli._table import format_table @@ -98,8 +99,6 @@ def _list_models(args: argparse.Namespace) -> None: api_key = args.api_key or None try: - from roboflow.cli._output import suppress_sdk_output - with suppress_sdk_output(args): rf = roboflow.Roboflow(api_key=api_key) workspace = rf.workspace(workspace_url) @@ -131,7 +130,7 @@ def _list_models(args: argparse.Namespace) -> None: output(args, models, text=table) -def _get_model(args: argparse.Namespace) -> None: +def _get_model(args): # noqa: ANN001 import json from roboflow.adapters import rfapi @@ -162,7 +161,7 @@ def _get_model(args: argparse.Namespace) -> None: output(args, data, text=json.dumps(data, indent=2, default=str)) -def _upload_model(args: argparse.Namespace) -> None: +def _upload_model(args): # noqa: ANN001 import roboflow from roboflow.cli._output import output, output_error diff --git a/roboflow/cli/handlers/project.py b/roboflow/cli/handlers/project.py index c7e5d0b1..c636e123 100644 --- a/roboflow/cli/handlers/project.py +++ b/roboflow/cli/handlers/project.py @@ -2,53 +2,67 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import argparse - - -def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - """Register ``project`` subcommand and its verbs.""" - project_parser = subparsers.add_parser("project", help="Manage projects") - project_subs = project_parser.add_subparsers(title="project commands", dest="project_command") - - # --- project list --- - list_parser = project_subs.add_parser("list", help="List projects in a workspace") - list_parser.add_argument("--type", dest="type", default=None, help="Filter by project type") - list_parser.set_defaults(func=_list_projects) - - # --- project get --- - get_parser = project_subs.add_parser("get", help="Show detailed info for a project") - get_parser.add_argument("project_id", help="Project ID or shorthand (e.g. my-ws/my-project)") - get_parser.set_defaults(func=_get_project) - - # --- project create --- - create_parser = project_subs.add_parser("create", help="Create a new project") - create_parser.add_argument("name", help="Project name") - create_parser.add_argument( - "--type", - dest="type", - required=True, - choices=[ - "object-detection", - "single-label-classification", - "multi-label-classification", - "instance-segmentation", - "semantic-segmentation", - "keypoint-detection", - ], - help="Project type", - ) - create_parser.add_argument("--license", dest="license", default="Private", help="Project license") - create_parser.add_argument("--annotation", dest="annotation", default="", help="Annotation group name") - create_parser.set_defaults(func=_create_project) +from enum import Enum +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + + +class ProjectType(str, Enum): + """Supported project types.""" + + object_detection = "object-detection" + single_label_classification = "single-label-classification" + multi_label_classification = "multi-label-classification" + instance_segmentation = "instance-segmentation" + semantic_segmentation = "semantic-segmentation" + keypoint_detection = "keypoint-detection" + + +project_app = typer.Typer(cls=SortedGroup, help="Manage projects", no_args_is_help=True) + + +@project_app.command("list") +def list_projects( + ctx: typer.Context, + type: Annotated[Optional[str], typer.Option(help="Filter by project type")] = None, +) -> None: + """List projects in a workspace.""" + args = ctx_to_args(ctx, type=type) + _list_projects(args) + + +@project_app.command("get") +def get_project( + ctx: typer.Context, + project_id: Annotated[str, typer.Argument(help="Project ID or shorthand (e.g. my-ws/my-project)")], +) -> None: + """Show detailed info for a project.""" + args = ctx_to_args(ctx, project_id=project_id) + _get_project(args) + + +@project_app.command("create") +def create_project( + ctx: typer.Context, + name: Annotated[str, typer.Argument(help="Project name")], + type: Annotated[ProjectType, typer.Option("--type", help="Project type")], + license: Annotated[str, typer.Option(help="Project license")] = "Private", + annotation: Annotated[str, typer.Option(help="Annotation group name")] = "", +) -> None: + """Create a new project.""" + args = ctx_to_args(ctx, name=name, type=type.value, license=license, annotation=annotation) + _create_project(args) + - # Default when no verb is given - project_parser.set_defaults(func=lambda args: project_parser.print_help()) +# --------------------------------------------------------------------------- +# Business logic (unchanged from argparse version) +# --------------------------------------------------------------------------- -def _list_projects(args: argparse.Namespace) -> None: +def _list_projects(args): # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error from roboflow.cli._table import format_table @@ -88,7 +102,7 @@ def _list_projects(args: argparse.Namespace) -> None: output(args, projects, text=table) -def _get_project(args: argparse.Namespace) -> None: +def _get_project(args): # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error from roboflow.cli._resolver import resolve_resource @@ -142,7 +156,7 @@ def _get_project(args: argparse.Namespace) -> None: output(args, data, text=text) -def _create_project(args: argparse.Namespace) -> None: +def _create_project(args): # noqa: ANN001 import roboflow from roboflow.cli._output import output, output_error, suppress_sdk_output @@ -166,7 +180,6 @@ def _create_project(args: argparse.Namespace) -> None: except Exception as exc: msg = str(exc) hint = None - # Try to extract a useful message from HTTP 422 responses if hasattr(exc, "response"): try: body = exc.response.json() # type: ignore[union-attr] diff --git a/roboflow/cli/handlers/search.py b/roboflow/cli/handlers/search.py index c3152f77..7e0aee85 100644 --- a/roboflow/cli/handlers/search.py +++ b/roboflow/cli/handlers/search.py @@ -2,44 +2,55 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - import argparse - - -def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - """Register the ``search`` command.""" - search_parser = subparsers.add_parser("search", help="Search workspace images or export results as a dataset") - search_parser.add_argument("query", help="Search query (e.g. 'tag:review' or '*')") - search_parser.add_argument("--limit", type=int, default=50, help="Max results to return (default: 50)") - search_parser.add_argument("--cursor", default=None, help="Continuation token for pagination") - search_parser.add_argument("--fields", default=None, help="Comma-separated list of fields to include") - search_parser.add_argument( - "--export", action="store_true", default=False, help="Export search results as a dataset" - ) - search_parser.add_argument( - "-f", "--format", dest="format", default="coco", help="Annotation format for export (default: coco)" - ) - search_parser.add_argument("-l", "--location", dest="location", default=None, help="Local directory for export") - search_parser.add_argument( - "-d", "--dataset", dest="dataset", default=None, help="Limit to a specific dataset (project slug)" - ) - search_parser.add_argument( - "-g", - "--annotation-group", - dest="annotation_group", - default=None, - help="Limit export to a specific annotation group", - ) - search_parser.add_argument("--name", dest="name", default=None, help="Optional name for the export") - search_parser.add_argument( - "--no-extract", dest="no_extract", action="store_true", default=False, help="Keep zip file, skip extraction" - ) - search_parser.set_defaults(func=_search) - - -def _search(args: argparse.Namespace) -> None: +from typing import Annotated, Any, Optional + +import typer + +from roboflow.cli._compat import ctx_to_args + + +def search_command(app: typer.Typer) -> None: + """Register the top-level ``search`` command on *app*.""" + + @app.command("search", hidden=True) + def search( + ctx: typer.Context, + query: Annotated[str, typer.Argument(help="Search query (e.g. 'tag:review' or '*')")], + limit: Annotated[int, typer.Option(help="Max results to return")] = 50, + cursor: Annotated[Optional[str], typer.Option(help="Continuation token for pagination")] = None, + fields: Annotated[Optional[str], typer.Option(help="Comma-separated list of fields to include")] = None, + export: Annotated[bool, typer.Option("--export", help="Export search results as a dataset")] = False, + format: Annotated[str, typer.Option("-f", "--format", help="Annotation format for export")] = "coco", + location: Annotated[Optional[str], typer.Option("-l", "--location", help="Local directory for export")] = None, + dataset: Annotated[ + Optional[str], typer.Option("-d", "--dataset", help="Limit to a specific dataset (project slug)") + ] = None, + annotation_group: Annotated[ + Optional[str], + typer.Option("-g", "--annotation-group", help="Limit export to a specific annotation group"), + ] = None, + name: Annotated[Optional[str], typer.Option(help="Optional name for the export")] = None, + no_extract: Annotated[bool, typer.Option("--no-extract", help="Keep zip file, skip extraction")] = False, + ) -> None: + """Search workspace images or export results as a dataset.""" + args = ctx_to_args( + ctx, + query=query, + limit=limit, + cursor=cursor, + fields=fields, + export=export, + format=format, + location=location, + dataset=dataset, + annotation_group=annotation_group, + name=name, + no_extract=no_extract, + ) + _search(args) + + +def _search(args): # noqa: ANN001 import roboflow from roboflow.cli._output import output_error, suppress_sdk_output @@ -57,7 +68,7 @@ def _search(args: argparse.Namespace) -> None: _do_search(args, workspace) -def _do_search(args: argparse.Namespace, workspace: Any) -> None: +def _do_search(args: Any, workspace: Any) -> None: from roboflow.cli._output import output, output_error fields = args.fields.split(",") if args.fields else None @@ -89,7 +100,7 @@ def _do_search(args: argparse.Namespace, workspace: Any) -> None: output(args, data, text="\n".join(text_lines)) -def _do_export(args: argparse.Namespace, workspace: Any) -> None: +def _do_export(args: Any, workspace: Any) -> None: from roboflow.cli._output import output, output_error try: diff --git a/roboflow/cli/handlers/train.py b/roboflow/cli/handlers/train.py index bc6cc525..6c66c9c3 100644 --- a/roboflow/cli/handlers/train.py +++ b/roboflow/cli/handlers/train.py @@ -2,73 +2,86 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import Annotated, Optional -if TYPE_CHECKING: - import argparse +import typer +from roboflow.cli._compat import SortedGroup, ctx_to_args -def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - """Register ``train`` subcommand and its verbs.""" - train_parser = subparsers.add_parser("train", help="Train a model") - train_subs = train_parser.add_subparsers(title="train commands", dest="train_command") +train_app = typer.Typer(cls=SortedGroup, help="Train a model", invoke_without_command=True) - # --- train start --- - start_parser = train_subs.add_parser("start", help="Start training for a dataset version") - _add_start_args(start_parser, required=True) - start_parser.set_defaults(func=_start) - # Default: `train` without subcommand behaves like `train start` - _add_start_args(train_parser, required=False) - train_parser.set_defaults(func=_start) +@train_app.callback(invoke_without_command=True) +def _train_callback( + ctx: typer.Context, + project: Annotated[Optional[str], typer.Option("-p", "--project", help="Project ID to train")] = None, + version_number: Annotated[Optional[int], typer.Option("-v", "--version", help="Version number to train")] = None, + model_type: Annotated[ + Optional[str], typer.Option("-t", "--type", help="Model type (e.g. rfdetr-nano, yolov8n)") + ] = None, + checkpoint: Annotated[Optional[str], typer.Option(help="Checkpoint to resume training from")] = None, + speed: Annotated[Optional[str], typer.Option(help="Training speed preset")] = None, + epochs: Annotated[Optional[int], typer.Option(help="Number of training epochs")] = None, +) -> None: + """Train a model. When invoked without a subcommand, behaves like ``train start``.""" + if ctx.invoked_subcommand is not None: + return + # No subcommand — behave like `train start` + if not project: + from roboflow.cli._output import output_error + args = ctx_to_args(ctx) + output_error(args, "Project is required.", hint="Use -p/--project.") + return + if version_number is None: + from roboflow.cli._output import output_error -def _add_start_args(parser: argparse.ArgumentParser, *, required: bool = True) -> None: - """Add shared arguments for the train start command.""" - parser.add_argument( - "-p", - "--project", - dest="project", - required=required, - help="Project ID to train", - ) - parser.add_argument( - "-v", - "--version", - dest="version_number", - type=int, - required=required, - help="Version number to train", - ) - parser.add_argument( - "-t", - "--type", - dest="model_type", - default=None, - help="Model type (e.g. rfdetr-nano, yolov8n)", - ) - parser.add_argument( - "--checkpoint", - dest="checkpoint", - default=None, - help="Checkpoint to resume training from", - ) - parser.add_argument( - "--speed", - dest="speed", - default=None, - help="Training speed preset", + args = ctx_to_args(ctx) + output_error(args, "Version is required.", hint="Use -v/--version.") + return + args = ctx_to_args( + ctx, + project=project, + version_number=version_number, + model_type=model_type, + checkpoint=checkpoint, + speed=speed, + epochs=epochs, ) - parser.add_argument( - "--epochs", - dest="epochs", - type=int, - default=None, - help="Number of training epochs", + _start(args) + + +@train_app.command("start") +def start_training( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID to train")], + version_number: Annotated[int, typer.Option("-v", "--version", help="Version number to train")], + model_type: Annotated[ + Optional[str], typer.Option("-t", "--type", help="Model type (e.g. rfdetr-nano, yolov8n)") + ] = None, + checkpoint: Annotated[Optional[str], typer.Option(help="Checkpoint to resume training from")] = None, + speed: Annotated[Optional[str], typer.Option(help="Training speed preset")] = None, + epochs: Annotated[Optional[int], typer.Option(help="Number of training epochs")] = None, +) -> None: + """Start training for a dataset version.""" + args = ctx_to_args( + ctx, + project=project, + version_number=version_number, + model_type=model_type, + checkpoint=checkpoint, + speed=speed, + epochs=epochs, ) + _start(args) + + +# --------------------------------------------------------------------------- +# Business logic (unchanged from argparse version) +# --------------------------------------------------------------------------- -def _start(args: argparse.Namespace) -> None: +def _start(args): # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error from roboflow.cli._resolver import resolve_resource diff --git a/roboflow/cli/handlers/universe.py b/roboflow/cli/handlers/universe.py index a2a92ec5..9cbcf8a8 100644 --- a/roboflow/cli/handlers/universe.py +++ b/roboflow/cli/handlers/universe.py @@ -2,29 +2,33 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import Annotated, Optional -if TYPE_CHECKING: - import argparse +import typer +from roboflow.cli._compat import SortedGroup, ctx_to_args -def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - """Register the ``universe`` command group.""" - uni_parser = subparsers.add_parser("universe", help="Browse Roboflow Universe") - uni_subs = uni_parser.add_subparsers(title="universe commands", dest="universe_command") +universe_app = typer.Typer(cls=SortedGroup, help="Browse Roboflow Universe", no_args_is_help=True) - # --- universe search --- - search_p = uni_subs.add_parser("search", help="Search Roboflow Universe") - search_p.add_argument("query", help="Search query") - search_p.add_argument("--type", dest="type", choices=["dataset", "model"], default=None, help="Filter by type") - search_p.add_argument("--limit", type=int, default=12, help="Max results (default: 12)") - search_p.set_defaults(func=_search) - # Default - uni_parser.set_defaults(func=lambda args: uni_parser.print_help()) +@universe_app.command("search") +def search( + ctx: typer.Context, + query: Annotated[str, typer.Argument(help="Search query")], + type: Annotated[Optional[str], typer.Option(help="Filter by type (dataset or model)")] = None, + limit: Annotated[int, typer.Option(help="Max results")] = 12, +) -> None: + """Search Roboflow Universe.""" + args = ctx_to_args(ctx, query=query, type=type, limit=limit) + _search(args) -def _search(args: argparse.Namespace) -> None: +# --------------------------------------------------------------------------- +# Business logic (unchanged from argparse version) +# --------------------------------------------------------------------------- + + +def _search(args) -> None: # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error from roboflow.cli._table import format_table diff --git a/roboflow/cli/handlers/version.py b/roboflow/cli/handlers/version.py index 86a18d0a..5888c42d 100644 --- a/roboflow/cli/handlers/version.py +++ b/roboflow/cli/handlers/version.py @@ -2,72 +2,88 @@ from __future__ import annotations -import argparse import re +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +version_app = typer.Typer(cls=SortedGroup, help="Manage dataset versions", no_args_is_help=True) + + +@version_app.command("list") +def list_versions( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")] = ..., # type: ignore[assignment] +) -> None: + """List versions for a project.""" + args = ctx_to_args(ctx, project=project) + _list_versions(args) + + +@version_app.command("get") +def get_version( + ctx: typer.Context, + version_num: Annotated[str, typer.Argument(help="Version number or shorthand (e.g. my-project/3)")], + project: Annotated[Optional[str], typer.Option("-p", "--project", help="Project ID")] = None, +) -> None: + """Show detailed info for a version.""" + args = ctx_to_args(ctx, version_num=version_num, project=project) + _get_version(args) + + +@version_app.command("download") +def download( + ctx: typer.Context, + url_or_id: Annotated[str, typer.Argument(help="Dataset URL or shorthand (e.g. ws/project/3)")], + format: Annotated[str, typer.Option("-f", "--format", help="Export format (default: voc)")] = "voc", + location: Annotated[Optional[str], typer.Option("-l", "--location", help="Download location")] = None, +) -> None: + """Download a dataset version.""" + args = ctx_to_args(ctx, url_or_id=url_or_id, format=format, location=location) + _download(args) + + +@version_app.command("export") +def export( + ctx: typer.Context, + version_num: Annotated[str, typer.Argument(help="Version number")], + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")] = ..., # type: ignore[assignment] + format: Annotated[str, typer.Option("-f", "--format", help="Export format (default: voc)")] = "voc", +) -> None: + """Trigger an async export.""" + args = ctx_to_args(ctx, version_num=version_num, project=project, format=format) + _export(args) + + +@version_app.command("create") +def create( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")] = ..., # type: ignore[assignment] + settings: Annotated[str, typer.Option(help="Path to JSON file with augmentation/preprocessing config")] = ..., # type: ignore[assignment] +) -> None: + """Create a new dataset version. + + Settings JSON example:: + + {"augmentation": {"flip": {"horizontal": true, "vertical": false}, + "rotate": {"degrees": 15}, "brightness": {"percent": 25}}, + "preprocessing": {"auto-orient": true, "resize": {"width": 640, + "height": 640, "format": "Stretch to"}}} + + See https://docs.roboflow.com/datasets/create-a-dataset-version for all options. + """ + args = ctx_to_args(ctx, project=project, settings=settings) + _create(args) -class _RawEpilogFormatter(argparse.HelpFormatter): - """Formatter that preserves raw text in the epilog while wrapping everything else.""" - - def _fill_text(self, text: str, width: int, indent: str) -> str: - return text - - -def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - """Register ``version`` subcommand and its verbs.""" - version_parser = subparsers.add_parser("version", help="Manage dataset versions") - version_subs = version_parser.add_subparsers(title="version commands", dest="version_command") - - # --- version list --- - list_parser = version_subs.add_parser("list", help="List versions for a project") - list_parser.add_argument("-p", "--project", dest="project", required=True, help="Project ID") - list_parser.set_defaults(func=_list_versions) - - # --- version get --- - get_parser = version_subs.add_parser("get", help="Show detailed info for a version") - get_parser.add_argument("version_num", help="Version number or shorthand (e.g. my-project/3)") - get_parser.add_argument("-p", "--project", dest="project", default=None, help="Project ID") - get_parser.set_defaults(func=_get_version) - - # --- version download --- - dl_parser = version_subs.add_parser("download", help="Download a dataset version") - dl_parser.add_argument("url_or_id", help="Dataset URL or shorthand (e.g. ws/project/3)") - dl_parser.add_argument("-f", "--format", dest="format", default="voc", help="Export format (default: voc)") - dl_parser.add_argument("-l", "--location", dest="location", default=None, help="Download location") - dl_parser.set_defaults(func=_download) - - # --- version export --- - export_parser = version_subs.add_parser("export", help="Trigger an async export") - export_parser.add_argument("version_num", help="Version number") - export_parser.add_argument("-p", "--project", dest="project", required=True, help="Project ID") - export_parser.add_argument("-f", "--format", dest="format", default="voc", help="Export format (default: voc)") - export_parser.set_defaults(func=_export) - - # --- version create --- - create_parser = version_subs.add_parser( - "create", - help="Create a new dataset version", - epilog=( - "Settings JSON example:\n" - ' {"augmentation": {"flip": {"horizontal": true, "vertical": false},\n' - ' "rotate": {"degrees": 15}, "brightness": {"percent": 25}},\n' - ' "preprocessing": {"auto-orient": true, "resize": {"width": 640,\n' - ' "height": 640, "format": "Stretch to"}}}\n\n' - "See https://docs.roboflow.com/datasets/create-a-dataset-version for all options." - ), - formatter_class=_RawEpilogFormatter, - ) - create_parser.add_argument("-p", "--project", dest="project", required=True, help="Project ID") - create_parser.add_argument( - "--settings", dest="settings", required=True, help="Path to JSON file with augmentation/preprocessing config" - ) - create_parser.set_defaults(func=_create) - - # Default when no verb is given - version_parser.set_defaults(func=lambda args: version_parser.print_help()) +# --------------------------------------------------------------------------- +# Business logic (unchanged from argparse version) +# --------------------------------------------------------------------------- -def _list_versions(args: argparse.Namespace) -> None: +def _list_versions(args): # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error from roboflow.cli._resolver import resolve_resource @@ -123,7 +139,7 @@ def _format_splits(splits: dict) -> str: return " ".join(parts) -def _get_version(args: argparse.Namespace) -> None: +def _get_version(args): # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error from roboflow.cli._resolver import resolve_resource @@ -185,7 +201,7 @@ def _parse_url(url: str) -> tuple: return None, None, None -def _download(args: argparse.Namespace) -> None: +def _download(args): # noqa: ANN001 import roboflow from roboflow.cli._output import output, output_error, suppress_sdk_output @@ -231,7 +247,7 @@ def _download(args: argparse.Namespace) -> None: output(args, data, text=f"Downloaded {w}/{p}/{data['version']} in {args.format} format") -def _export(args: argparse.Namespace) -> None: +def _export(args): # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error from roboflow.cli._resolver import resolve_resource @@ -266,7 +282,7 @@ def _export(args: argparse.Namespace) -> None: output(args, data, text=f"Export ready for {project_slug}/{version_num} in {args.format} format") -def _create(args: argparse.Namespace) -> None: +def _create(args): # noqa: ANN001 import json import roboflow diff --git a/roboflow/cli/handlers/video.py b/roboflow/cli/handlers/video.py index 906918e2..0045189b 100644 --- a/roboflow/cli/handlers/video.py +++ b/roboflow/cli/handlers/video.py @@ -2,35 +2,44 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import Annotated -if TYPE_CHECKING: - import argparse +import typer +from roboflow.cli._compat import SortedGroup, ctx_to_args -def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - """Register the ``video`` command group.""" - video_parser = subparsers.add_parser("video", help="Video inference operations") - video_subs = video_parser.add_subparsers(title="video commands", dest="video_command") +video_app = typer.Typer(cls=SortedGroup, help="Video inference operations", no_args_is_help=True) - # --- video infer --- - infer_p = video_subs.add_parser("infer", help="Run video inference") - infer_p.add_argument("-p", "--project", dest="project", required=True, help="Project ID") - infer_p.add_argument("-v", "--version", dest="version_number", type=int, required=True, help="Model version number") - infer_p.add_argument("-f", "--file", dest="video_file", required=True, help="Path to video file") - infer_p.add_argument("--fps", dest="fps", type=int, default=5, help="Frames per second (default: 5)") - infer_p.set_defaults(func=_video_infer) - # --- video status --- - status_p = video_subs.add_parser("status", help="Check video inference job status") - status_p.add_argument("job_id", help="Job ID to check") - status_p.set_defaults(func=_video_status) +@video_app.command("infer") +def infer( + ctx: typer.Context, + project: Annotated[str, typer.Option("-p", "--project", help="Project ID")], + version_number: Annotated[int, typer.Option("-v", "--version", help="Model version number")], + video_file: Annotated[str, typer.Option("-f", "--file", help="Path to video file")], + fps: Annotated[int, typer.Option("--fps", help="Frames per second")] = 5, +) -> None: + """Run video inference.""" + args = ctx_to_args(ctx, project=project, version_number=version_number, video_file=video_file, fps=fps) + _video_infer(args) - # Default - video_parser.set_defaults(func=lambda args: video_parser.print_help()) +@video_app.command("status") +def status( + ctx: typer.Context, + job_id: Annotated[str, typer.Argument(help="Job ID to check")], +) -> None: + """Check video inference job status.""" + args = ctx_to_args(ctx, job_id=job_id) + _video_status(args) -def _video_infer(args: argparse.Namespace) -> None: + +# --------------------------------------------------------------------------- +# Business logic (unchanged from argparse version) +# --------------------------------------------------------------------------- + + +def _video_infer(args) -> None: # noqa: ANN001 import roboflow from roboflow.cli._output import output, output_error from roboflow.config import load_roboflow_api_key @@ -62,7 +71,7 @@ def _video_infer(args: argparse.Namespace) -> None: output(args, data, text=f"Video inference submitted. Job ID: {job_id}") -def _video_status(args: argparse.Namespace) -> None: +def _video_status(args) -> None: # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error from roboflow.config import load_roboflow_api_key diff --git a/roboflow/cli/handlers/workflow.py b/roboflow/cli/handlers/workflow.py index 7b437cac..82085dca 100644 --- a/roboflow/cli/handlers/workflow.py +++ b/roboflow/cli/handlers/workflow.py @@ -2,70 +2,111 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import argparse - - -def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - """Register the ``workflow`` command group.""" - wf_parser = subparsers.add_parser("workflow", help="Manage workflows") - wf_subs = wf_parser.add_subparsers(title="workflow commands", dest="workflow_command") - - # --- workflow list --- - list_p = wf_subs.add_parser("list", help="List workflows in a workspace") - list_p.set_defaults(func=_list_workflows) - - # --- workflow get --- - get_p = wf_subs.add_parser("get", help="Show details for a workflow") - get_p.add_argument("workflow_url", help="Workflow URL or ID") - get_p.set_defaults(func=_get_workflow) - - # --- workflow create --- - create_p = wf_subs.add_parser("create", help="Create a new workflow") - create_p.add_argument("--name", required=True, help="Workflow name") - create_p.add_argument("--definition", help="Path to JSON definition file") - create_p.add_argument("--description", default=None, help="Workflow description") - create_p.set_defaults(func=_create_workflow) - - # --- workflow update --- - update_p = wf_subs.add_parser("update", help="Update an existing workflow") - update_p.add_argument("workflow_url", help="Workflow URL or ID") - update_p.add_argument("--definition", help="Path to JSON definition file") - update_p.set_defaults(func=_update_workflow) - - # --- workflow version --- - version_p = wf_subs.add_parser("version", help="Manage workflow versions") - version_subs = version_p.add_subparsers(title="workflow version commands", dest="workflow_version_command") - version_list_p = version_subs.add_parser("list", help="List versions of a workflow") - version_list_p.add_argument("workflow_url", help="Workflow URL or ID") - version_list_p.set_defaults(func=_list_workflow_versions) - version_p.set_defaults(func=lambda args: version_p.print_help()) - - # --- workflow fork --- - fork_p = wf_subs.add_parser("fork", help="Fork a workflow") - fork_p.add_argument("workflow_url", help="Workflow URL or ID") - fork_p.set_defaults(func=_fork_workflow) - - # --- workflow build (stub) --- - build_p = wf_subs.add_parser("build", help="Build a workflow from a prompt") - build_p.add_argument("prompt", help="Natural language prompt describing the workflow") - build_p.set_defaults(func=_stub_build) - - # --- workflow run (stub) --- - run_p = wf_subs.add_parser("run", help="Run a workflow") - run_p.add_argument("workflow_url", help="Workflow URL or ID") - run_p.add_argument("--input", dest="input", help="Input file or URL") - run_p.set_defaults(func=_stub_run) - - # --- workflow deploy (stub) --- - deploy_p = wf_subs.add_parser("deploy", help="Deploy a workflow") - deploy_p.add_argument("workflow_url", help="Workflow URL or ID") - deploy_p.set_defaults(func=_stub_deploy) - - # Default - wf_parser.set_defaults(func=lambda args: wf_parser.print_help()) +from typing import Annotated, Optional + +import typer + +from roboflow.cli._compat import SortedGroup, ctx_to_args + +workflow_app = typer.Typer(cls=SortedGroup, help="Manage workflows", no_args_is_help=True) + +# --------------------------------------------------------------------------- +# Sub-app for ``workflow version`` subcommands +# --------------------------------------------------------------------------- + +_version_app = typer.Typer(cls=SortedGroup, help="Manage workflow versions", no_args_is_help=True) +workflow_app.add_typer(_version_app, name="version") + + +@workflow_app.command("list") +def list_workflows(ctx: typer.Context) -> None: + """List workflows in a workspace.""" + args = ctx_to_args(ctx) + _list_workflows(args) + + +@workflow_app.command("get") +def get_workflow( + ctx: typer.Context, + workflow_url: Annotated[str, typer.Argument(help="Workflow URL or ID")], +) -> None: + """Show details for a workflow.""" + args = ctx_to_args(ctx, workflow_url=workflow_url) + _get_workflow(args) + + +@workflow_app.command("create") +def create_workflow( + ctx: typer.Context, + name: Annotated[str, typer.Option("--name", help="Workflow name")], + definition: Annotated[Optional[str], typer.Option(help="Path to JSON definition file")] = None, + description: Annotated[Optional[str], typer.Option(help="Workflow description")] = None, +) -> None: + """Create a new workflow.""" + args = ctx_to_args(ctx, name=name, definition=definition, description=description) + _create_workflow(args) + + +@workflow_app.command("update") +def update_workflow( + ctx: typer.Context, + workflow_url: Annotated[str, typer.Argument(help="Workflow URL or ID")], + definition: Annotated[Optional[str], typer.Option(help="Path to JSON definition file")] = None, +) -> None: + """Update an existing workflow.""" + args = ctx_to_args(ctx, workflow_url=workflow_url, definition=definition) + _update_workflow(args) + + +@_version_app.command("list") +def list_workflow_versions( + ctx: typer.Context, + workflow_url: Annotated[str, typer.Argument(help="Workflow URL or ID")], +) -> None: + """List versions of a workflow.""" + args = ctx_to_args(ctx, workflow_url=workflow_url) + _list_workflow_versions(args) + + +@workflow_app.command("fork") +def fork_workflow( + ctx: typer.Context, + workflow_url: Annotated[str, typer.Argument(help="Workflow URL or ID")], +) -> None: + """Fork a workflow.""" + args = ctx_to_args(ctx, workflow_url=workflow_url) + _fork_workflow(args) + + +@workflow_app.command("build", hidden=True) +def build_workflow( + ctx: typer.Context, + prompt: Annotated[str, typer.Argument(help="Natural language prompt describing the workflow")], +) -> None: + """Build a workflow from a prompt.""" + args = ctx_to_args(ctx, prompt=prompt) + _stub_build(args) + + +@workflow_app.command("run", hidden=True) +def run_workflow( + ctx: typer.Context, + workflow_url: Annotated[str, typer.Argument(help="Workflow URL or ID")], + input: Annotated[Optional[str], typer.Option("--input", help="Input file or URL")] = None, +) -> None: + """Run a workflow.""" + args = ctx_to_args(ctx, workflow_url=workflow_url, input=input) + _stub_run(args) + + +@workflow_app.command("deploy", hidden=True) +def deploy_workflow( + ctx: typer.Context, + workflow_url: Annotated[str, typer.Argument(help="Workflow URL or ID")], +) -> None: + """Deploy a workflow.""" + args = ctx_to_args(ctx, workflow_url=workflow_url) + _stub_deploy(args) # --------------------------------------------------------------------------- @@ -73,14 +114,14 @@ def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[ty # --------------------------------------------------------------------------- -def _resolve_workspace_and_key(args: argparse.Namespace): +def _resolve_workspace_and_key(args): # noqa: ANN001 """Return (workspace_url, api_key) or call output_error and return None.""" from roboflow.cli._resolver import resolve_ws_and_key return resolve_ws_and_key(args) -def _read_definition_file(args: argparse.Namespace): +def _read_definition_file(args): # noqa: ANN001 """Read and parse a JSON definition file. Returns the parsed dict, or None if no file given. Calls output_error and returns False on failure. @@ -110,7 +151,7 @@ def _read_definition_file(args: argparse.Namespace): # --------------------------------------------------------------------------- -def _list_workflows(args: argparse.Namespace) -> None: +def _list_workflows(args) -> None: # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error from roboflow.cli._table import format_table @@ -136,7 +177,7 @@ def _list_workflows(args: argparse.Namespace) -> None: output(args, workflows, text=table) -def _get_workflow(args: argparse.Namespace) -> None: +def _get_workflow(args) -> None: # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error @@ -169,7 +210,7 @@ def _get_workflow(args: argparse.Namespace) -> None: output(args, data, text=text) -def _create_workflow(args: argparse.Namespace) -> None: +def _create_workflow(args) -> None: # noqa: ANN001 import json as _json from roboflow.adapters import rfapi @@ -204,7 +245,7 @@ def _create_workflow(args: argparse.Namespace) -> None: output(args, data, text=text) -def _update_workflow(args: argparse.Namespace) -> None: +def _update_workflow(args) -> None: # noqa: ANN001 import json as _json from roboflow.adapters import rfapi @@ -260,7 +301,7 @@ def _update_workflow(args: argparse.Namespace) -> None: output(args, data, text=text) -def _list_workflow_versions(args: argparse.Namespace) -> None: +def _list_workflow_versions(args) -> None: # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error from roboflow.cli._table import format_table @@ -286,7 +327,7 @@ def _list_workflow_versions(args: argparse.Namespace) -> None: output(args, versions, text=table) -def _fork_workflow(args: argparse.Namespace) -> None: +def _fork_workflow(args) -> None: # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error @@ -334,7 +375,7 @@ def _fork_workflow(args: argparse.Namespace) -> None: # --------------------------------------------------------------------------- -def _stub_build(args: argparse.Namespace) -> None: +def _stub_build(args) -> None: # noqa: ANN001 from roboflow.cli._output import output_error output_error( @@ -344,7 +385,7 @@ def _stub_build(args: argparse.Namespace) -> None: ) -def _stub_run(args: argparse.Namespace) -> None: +def _stub_run(args) -> None: # noqa: ANN001 from roboflow.cli._output import output_error output_error( @@ -354,7 +395,7 @@ def _stub_run(args: argparse.Namespace) -> None: ) -def _stub_deploy(args: argparse.Namespace) -> None: +def _stub_deploy(args) -> None: # noqa: ANN001 from roboflow.cli._output import output_error output_error( diff --git a/roboflow/cli/handlers/workspace.py b/roboflow/cli/handlers/workspace.py index 94737d39..eb1ea142 100644 --- a/roboflow/cli/handlers/workspace.py +++ b/roboflow/cli/handlers/workspace.py @@ -2,45 +2,72 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import Annotated, Optional -if TYPE_CHECKING: - import argparse +import typer +from roboflow.cli._compat import SortedGroup, ctx_to_args -def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[type-arg] - """Register the ``workspace`` command group.""" - ws_parser = subparsers.add_parser("workspace", help="Manage workspaces") - ws_sub = ws_parser.add_subparsers(title="workspace commands", dest="workspace_command") +workspace_app = typer.Typer(cls=SortedGroup, help="Manage workspaces", no_args_is_help=True) - # --- workspace list --- - list_p = ws_sub.add_parser("list", help="List configured workspaces") - list_p.set_defaults(func=_list_workspaces) - # --- workspace get --- - get_p = ws_sub.add_parser("get", help="Get workspace details") - get_p.add_argument("workspace_id", help="Workspace URL or ID") - get_p.set_defaults(func=_get_workspace) +@workspace_app.command("list") +def list_workspaces(ctx: typer.Context) -> None: + """List configured workspaces.""" + args = ctx_to_args(ctx) + _list_workspaces(args) - # --- workspace usage --- - usage_p = ws_sub.add_parser("usage", help="Show billing usage report") - usage_p.set_defaults(func=_workspace_usage) - # --- workspace plan --- - plan_p = ws_sub.add_parser("plan", help="Show workspace plan info and limits") - plan_p.set_defaults(func=_workspace_plan) +@workspace_app.command("get") +def get_workspace( + ctx: typer.Context, + workspace_id: Annotated[ + Optional[str], typer.Argument(help="Workspace URL or ID (defaults to current workspace)") + ] = None, +) -> None: + """Get workspace details.""" + # Default to current workspace if not specified + if not workspace_id: + from roboflow.cli._resolver import resolve_default_workspace + + workspace_id = (ctx.obj or {}).get("workspace") or resolve_default_workspace( + api_key=(ctx.obj or {}).get("api_key") + ) + args = ctx_to_args(ctx, workspace_id=workspace_id) + _get_workspace(args) + + +@workspace_app.command("usage") +def workspace_usage(ctx: typer.Context) -> None: + """Show billing usage report.""" + args = ctx_to_args(ctx) + _workspace_usage(args) + + +@workspace_app.command("plan") +def workspace_plan(ctx: typer.Context) -> None: + """Show workspace plan info and limits.""" + args = ctx_to_args(ctx) + _workspace_plan(args) + + +@workspace_app.command("stats") +def workspace_stats( + ctx: typer.Context, + start_date: Annotated[str, typer.Option("--start-date", help="Start date (YYYY-MM-DD)")], + end_date: Annotated[str, typer.Option("--end-date", help="End date (YYYY-MM-DD)")], +) -> None: + """Show annotation/labeling statistics.""" + args = ctx_to_args(ctx, start_date=start_date, end_date=end_date) + _workspace_stats(args) - # --- workspace stats --- - stats_p = ws_sub.add_parser("stats", help="Show annotation/labeling statistics") - stats_p.add_argument("--start-date", dest="start_date", required=True, help="Start date (YYYY-MM-DD)") - stats_p.add_argument("--end-date", dest="end_date", required=True, help="End date (YYYY-MM-DD)") - stats_p.set_defaults(func=_workspace_stats) - # Default: show help - ws_parser.set_defaults(func=lambda args: ws_parser.print_help()) +# --------------------------------------------------------------------------- +# Business logic (unchanged from argparse version) +# --------------------------------------------------------------------------- -def _list_workspaces(args: argparse.Namespace) -> None: +def _list_workspaces(args): # noqa: ANN001 import os from roboflow.cli._output import output @@ -85,7 +112,7 @@ def _list_workspaces(args: argparse.Namespace) -> None: output(args, rows, text=table) -def _get_workspace(args: argparse.Namespace) -> None: +def _get_workspace(args): # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.adapters.rfapi import RoboflowError from roboflow.cli._output import output, output_error @@ -131,14 +158,14 @@ def _get_workspace(args: argparse.Namespace) -> None: output(args, workspace_json, text="\n".join(lines)) -def _resolve_ws_and_key(args: argparse.Namespace): +def _resolve_ws_and_key(args): # noqa: ANN001 """Resolve workspace and API key for workspace subcommands.""" from roboflow.cli._resolver import resolve_ws_and_key return resolve_ws_and_key(args) -def _workspace_usage(args: argparse.Namespace) -> None: +def _workspace_usage(args): # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error @@ -163,7 +190,7 @@ def _workspace_usage(args: argparse.Namespace) -> None: output(args, result, text="\n".join(lines)) -def _workspace_plan(args: argparse.Namespace) -> None: +def _workspace_plan(args): # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error @@ -188,7 +215,7 @@ def _workspace_plan(args: argparse.Namespace) -> None: output(args, result, text="\n".join(lines)) -def _workspace_stats(args: argparse.Namespace) -> None: +def _workspace_stats(args): # noqa: ANN001 from roboflow.adapters import rfapi from roboflow.cli._output import output, output_error diff --git a/setup.py b/setup.py index 0a22d142..f72f958a 100644 --- a/setup.py +++ b/setup.py @@ -52,5 +52,5 @@ "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", ], - python_requires=">=3.8", + python_requires=">=3.10", ) diff --git a/tests/cli/test_annotation_handler.py b/tests/cli/test_annotation_handler.py index 5561cd92..8671ff7b 100644 --- a/tests/cli/test_annotation_handler.py +++ b/tests/cli/test_annotation_handler.py @@ -1,6 +1,5 @@ """Unit tests for roboflow.cli.handlers.annotation.""" -import argparse import io import json import sys @@ -8,73 +7,35 @@ import unittest from unittest.mock import MagicMock, patch +from typer.testing import CliRunner -def _build_annotation_parser(): - """Build a minimal parser with just the annotation handler registered.""" - parser = argparse.ArgumentParser() - parser.add_argument("--json", "-j", action="store_true", default=False) - parser.add_argument("--api-key", "-k", dest="api_key", default=None) - parser.add_argument("--workspace", "-w", dest="workspace", default=None) - parser.add_argument("--quiet", "-q", action="store_true", default=False) - sub = parser.add_subparsers(title="commands", dest="command") +from roboflow.cli import app - from roboflow.cli.handlers.annotation import register - - register(sub) - return parser +runner = CliRunner() class TestAnnotationParserRegistration(unittest.TestCase): """Verify the annotation handler registers its subcommands.""" def test_annotation_subcommand_exists(self): - parser = _build_annotation_parser() - args = parser.parse_args(["annotation", "batch", "list", "-p", "proj"]) - self.assertEqual(args.project, "proj") - self.assertTrue(callable(args.func)) + result = runner.invoke(app, ["annotation", "batch", "list", "--help"]) + self.assertEqual(result.exit_code, 0) def test_annotation_batch_get(self): - parser = _build_annotation_parser() - args = parser.parse_args(["annotation", "batch", "get", "batch-1", "-p", "proj"]) - self.assertEqual(args.batch_id, "batch-1") - self.assertEqual(args.project, "proj") + result = runner.invoke(app, ["annotation", "batch", "get", "--help"]) + self.assertEqual(result.exit_code, 0) def test_annotation_job_list(self): - parser = _build_annotation_parser() - args = parser.parse_args(["annotation", "job", "list", "-p", "proj"]) - self.assertEqual(args.project, "proj") + result = runner.invoke(app, ["annotation", "job", "list", "--help"]) + self.assertEqual(result.exit_code, 0) def test_annotation_job_get(self): - parser = _build_annotation_parser() - args = parser.parse_args(["annotation", "job", "get", "job-1", "-p", "proj"]) - self.assertEqual(args.job_id, "job-1") + result = runner.invoke(app, ["annotation", "job", "get", "--help"]) + self.assertEqual(result.exit_code, 0) def test_annotation_job_create(self): - parser = _build_annotation_parser() - args = parser.parse_args( - [ - "annotation", - "job", - "create", - "-p", - "proj", - "--name", - "my-job", - "--batch", - "batch-1", - "--num-images", - "10", - "--labeler", - "a@b.com", - "--reviewer", - "c@d.com", - ] - ) - self.assertEqual(args.name, "my-job") - self.assertEqual(args.batch, "batch-1") - self.assertEqual(args.num_images, 10) - self.assertEqual(args.labeler, "a@b.com") - self.assertEqual(args.reviewer, "c@d.com") + result = runner.invoke(app, ["annotation", "job", "create", "--help"]) + self.assertEqual(result.exit_code, 0) class TestAnnotationStub(unittest.TestCase): @@ -130,34 +91,22 @@ class TestBatchList(unittest.TestCase): @patch(_RESOLVE, return_value=("key", "ws", "proj")) def test_text_output(self, _resolve, mock_api): mock_api.return_value = {"batches": [{"name": "b1", "id": "1", "status": "annotating", "images": 5}]} - parser = _build_annotation_parser() - args = parser.parse_args(["annotation", "batch", "list", "-p", "ws/proj"]) - - buf = io.StringIO() - with patch("sys.stdout", buf): - args.func(args) - self.assertIn("b1", buf.getvalue()) + result = runner.invoke(app, ["annotation", "batch", "list", "-p", "ws/proj"]) + self.assertIn("b1", result.output) @patch("roboflow.adapters.rfapi.list_batches") @patch(_RESOLVE, return_value=("key", "ws", "proj")) def test_json_output(self, _resolve, mock_api): mock_api.return_value = {"batches": [{"name": "b1", "id": "1"}]} - parser = _build_annotation_parser() - args = parser.parse_args(["--json", "annotation", "batch", "list", "-p", "ws/proj"]) - - buf = io.StringIO() - with patch("sys.stdout", buf): - args.func(args) - data = json.loads(buf.getvalue()) + result = runner.invoke(app, ["--json", "annotation", "batch", "list", "-p", "ws/proj"]) + data = json.loads(result.output) self.assertIsInstance(data, list) self.assertEqual(data[0]["name"], "b1") @patch(_RESOLVE, return_value=None) def test_resolve_failure(self, _resolve): - parser = _build_annotation_parser() - args = parser.parse_args(["annotation", "batch", "list", "-p", "bad"]) - # Should return without crashing when resolve returns None - args.func(args) + runner.invoke(app, ["annotation", "batch", "list", "-p", "bad"]) + # Should not crash when resolve returns None class TestBatchGet(unittest.TestCase): @@ -167,25 +116,15 @@ class TestBatchGet(unittest.TestCase): @patch(_RESOLVE, return_value=("key", "ws", "proj")) def test_text_output(self, _resolve, mock_api): mock_api.return_value = {"batch": {"name": "b1", "id": "1", "status": "annotating"}} - parser = _build_annotation_parser() - args = parser.parse_args(["annotation", "batch", "get", "1", "-p", "ws/proj"]) - - buf = io.StringIO() - with patch("sys.stdout", buf): - args.func(args) - self.assertIn("b1", buf.getvalue()) + result = runner.invoke(app, ["annotation", "batch", "get", "1", "-p", "ws/proj"]) + self.assertIn("b1", result.output) @patch("roboflow.adapters.rfapi.get_batch") @patch(_RESOLVE, return_value=("key", "ws", "proj")) def test_json_output(self, _resolve, mock_api): mock_api.return_value = {"batch": {"name": "b1", "id": "1"}} - parser = _build_annotation_parser() - args = parser.parse_args(["--json", "annotation", "batch", "get", "1", "-p", "ws/proj"]) - - buf = io.StringIO() - with patch("sys.stdout", buf): - args.func(args) - data = json.loads(buf.getvalue()) + result = runner.invoke(app, ["--json", "annotation", "batch", "get", "1", "-p", "ws/proj"]) + data = json.loads(result.output) self.assertIn("batch", data) @@ -196,25 +135,15 @@ class TestJobList(unittest.TestCase): @patch(_RESOLVE, return_value=("key", "ws", "proj")) def test_text_output(self, _resolve, mock_api): mock_api.return_value = {"jobs": [{"name": "j1", "id": "10", "status": "active", "assigned_to": "a@b.com"}]} - parser = _build_annotation_parser() - args = parser.parse_args(["annotation", "job", "list", "-p", "ws/proj"]) - - buf = io.StringIO() - with patch("sys.stdout", buf): - args.func(args) - self.assertIn("j1", buf.getvalue()) + result = runner.invoke(app, ["annotation", "job", "list", "-p", "ws/proj"]) + self.assertIn("j1", result.output) @patch("roboflow.adapters.rfapi.list_annotation_jobs") @patch(_RESOLVE, return_value=("key", "ws", "proj")) def test_json_output(self, _resolve, mock_api): mock_api.return_value = {"jobs": [{"name": "j1", "id": "10"}]} - parser = _build_annotation_parser() - args = parser.parse_args(["--json", "annotation", "job", "list", "-p", "ws/proj"]) - - buf = io.StringIO() - with patch("sys.stdout", buf): - args.func(args) - data = json.loads(buf.getvalue()) + result = runner.invoke(app, ["--json", "annotation", "job", "list", "-p", "ws/proj"]) + data = json.loads(result.output) self.assertIsInstance(data, list) @@ -225,13 +154,8 @@ class TestJobGet(unittest.TestCase): @patch(_RESOLVE, return_value=("key", "ws", "proj")) def test_text_output(self, _resolve, mock_api): mock_api.return_value = {"job": {"name": "j1", "id": "10", "status": "active"}} - parser = _build_annotation_parser() - args = parser.parse_args(["annotation", "job", "get", "10", "-p", "ws/proj"]) - - buf = io.StringIO() - with patch("sys.stdout", buf): - args.func(args) - self.assertIn("j1", buf.getvalue()) + result = runner.invoke(app, ["annotation", "job", "get", "10", "-p", "ws/proj"]) + self.assertIn("j1", result.output) class TestJobCreate(unittest.TestCase): @@ -244,8 +168,8 @@ def test_text_output(self, _resolve, mock_rf_cls): mock_project.create_annotation_job.return_value = {"id": "42", "name": "new-job"} mock_rf_cls.return_value.workspace.return_value.project.return_value = mock_project - parser = _build_annotation_parser() - args = parser.parse_args( + result = runner.invoke( + app, [ "annotation", "job", @@ -262,13 +186,9 @@ def test_text_output(self, _resolve, mock_rf_cls): "a@b.com", "--reviewer", "c@d.com", - ] + ], ) - - buf = io.StringIO() - with patch("sys.stdout", buf): - args.func(args) - self.assertIn("new-job", buf.getvalue()) + self.assertIn("new-job", result.output) mock_project.create_annotation_job.assert_called_once_with( name="new-job", batch_id="b1", @@ -284,8 +204,8 @@ def test_json_output(self, _resolve, mock_rf_cls): mock_project.create_annotation_job.return_value = {"id": "42", "name": "new-job"} mock_rf_cls.return_value.workspace.return_value.project.return_value = mock_project - parser = _build_annotation_parser() - args = parser.parse_args( + result = runner.invoke( + app, [ "--json", "annotation", @@ -303,36 +223,32 @@ def test_json_output(self, _resolve, mock_rf_cls): "a@b.com", "--reviewer", "c@d.com", - ] + ], ) - - buf = io.StringIO() - with patch("sys.stdout", buf): - args.func(args) - data = json.loads(buf.getvalue()) + data = json.loads(result.output) self.assertEqual(data["id"], "42") def test_create_requires_all_flags(self): - parser = _build_annotation_parser() # Missing --reviewer should fail - with self.assertRaises(SystemExit): - parser.parse_args( - [ - "annotation", - "job", - "create", - "-p", - "proj", - "--name", - "j", - "--batch", - "b", - "--num-images", - "1", - "--labeler", - "a@b.com", - ] - ) + result = runner.invoke( + app, + [ + "annotation", + "job", + "create", + "-p", + "proj", + "--name", + "j", + "--batch", + "b", + "--num-images", + "1", + "--labeler", + "a@b.com", + ], + ) + self.assertNotEqual(result.exit_code, 0) if __name__ == "__main__": diff --git a/tests/cli/test_auth.py b/tests/cli/test_auth.py index 83ee87ee..c2af74bb 100644 --- a/tests/cli/test_auth.py +++ b/tests/cli/test_auth.py @@ -1,53 +1,51 @@ """Tests for the auth CLI handler.""" +import re import unittest +from typer.testing import CliRunner -class TestAuthRegistration(unittest.TestCase): - """Verify auth handler registers expected subcommands.""" - - def test_register_callable(self) -> None: - from roboflow.cli.handlers.auth import register +from roboflow.cli import app - self.assertTrue(callable(register)) - - def test_auth_subcommand_exists(self) -> None: - from roboflow.cli import build_parser +runner = CliRunner() - parser = build_parser() - args = parser.parse_args(["auth", "status"]) - self.assertIsNotNone(args.func) +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") - def test_auth_login_defaults(self) -> None: - from roboflow.cli import build_parser - parser = build_parser() - args = parser.parse_args(["auth", "login"]) - self.assertFalse(args.force) - self.assertIsNone(args.login_api_key) - self.assertIsNone(args.login_workspace) +def _strip_ansi(text: str) -> str: + return _ANSI_RE.sub("", text) - def test_auth_login_flags(self) -> None: - from roboflow.cli import build_parser - parser = build_parser() - args = parser.parse_args(["auth", "login", "--api-key", "test123", "--force"]) - self.assertEqual(args.login_api_key, "test123") - self.assertTrue(args.force) - - def test_auth_set_workspace_positional(self) -> None: - from roboflow.cli import build_parser +class TestAuthRegistration(unittest.TestCase): + """Verify auth handler registers expected subcommands.""" - parser = build_parser() - args = parser.parse_args(["auth", "set-workspace", "my-ws"]) - self.assertEqual(args.workspace_id, "my-ws") + def test_auth_app_exists(self) -> None: + from roboflow.cli.handlers.auth import auth_app - def test_auth_logout_has_func(self) -> None: - from roboflow.cli import build_parser + self.assertIsNotNone(auth_app) - parser = build_parser() - args = parser.parse_args(["auth", "logout"]) - self.assertIsNotNone(args.func) + def test_auth_subcommand_exists(self) -> None: + result = runner.invoke(app, ["auth", "status", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_auth_login_exists(self) -> None: + result = runner.invoke(app, ["auth", "login", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_auth_login_help_shows_flags(self) -> None: + result = runner.invoke(app, ["auth", "login", "--help"]) + self.assertEqual(result.exit_code, 0) + output = _strip_ansi(result.output).lower() + self.assertIn("api-key", output) + self.assertIn("force", output) + + def test_auth_set_workspace_exists(self) -> None: + result = runner.invoke(app, ["auth", "set-workspace", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_auth_logout_exists(self) -> None: + result = runner.invoke(app, ["auth", "logout", "--help"]) + self.assertEqual(result.exit_code, 0) def test_handler_functions_exist(self) -> None: from roboflow.cli.handlers import auth diff --git a/tests/cli/test_backwards_compat.py b/tests/cli/test_backwards_compat.py index feabc0ff..fd3f89f9 100644 --- a/tests/cli/test_backwards_compat.py +++ b/tests/cli/test_backwards_compat.py @@ -1,7 +1,8 @@ """Tests that the roboflowpy.py backwards-compatibility shim works. Ensures that existing scripts and integrations that import from the old -monolithic module continue to work after the CLI modularization. +monolithic module continue to work after the CLI modularization and +typer migration. """ import unittest @@ -21,42 +22,50 @@ def test_argparser_importable(self) -> None: self.assertTrue(callable(_argparser)) - def test_argparser_returns_parser(self) -> None: - import argparse - + def test_argparser_returns_object_with_parse_args(self) -> None: + """_argparser() must return an object with parse_args() method.""" from roboflow.roboflowpy import _argparser parser = _argparser() - self.assertIsInstance(parser, argparse.ArgumentParser) + self.assertIsNotNone(parser) + self.assertTrue(hasattr(parser, "parse_args")) + self.assertTrue(callable(parser.parse_args)) - def test_argparser_has_subcommands(self) -> None: - """The parser returned by _argparser should have the new CLI subcommands.""" + def test_argparser_has_print_help(self) -> None: + """The parser should support print_help() for interactive use.""" from roboflow.roboflowpy import _argparser parser = _argparser() - # Parse a known new-style command (--json must come before subcommand - # when using parse_args directly; _reorder_argv handles end-position - # in the real main() entry point) - args = parser.parse_args(["--json", "project", "list"]) - self.assertTrue(args.json) - - def test_argparser_has_legacy_aliases(self) -> None: - """Legacy command names should still parse.""" - from roboflow.roboflowpy import _argparser + self.assertTrue(hasattr(parser, "print_help")) - parser = _argparser() + def test_cli_commands_work_via_typer_runner(self) -> None: + """Verify commands execute through typer's CliRunner.""" + from typer.testing import CliRunner + + from roboflow.cli import app + + runner = CliRunner() + + # --version + result = runner.invoke(app, ["--version"]) + self.assertEqual(result.exit_code, 0) + + # --help + result = runner.invoke(app, ["--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("project", result.output) - # 'login' was a top-level command in the old CLI - args = parser.parse_args(["login"]) - self.assertIsNotNone(args.func) + # Legacy alias: login --help + result = runner.invoke(app, ["login", "--help"]) + self.assertEqual(result.exit_code, 0) - # 'whoami' was a top-level command - args = parser.parse_args(["whoami"]) - self.assertIsNotNone(args.func) + # Legacy alias: whoami --help + result = runner.invoke(app, ["whoami", "--help"]) + self.assertEqual(result.exit_code, 0) - # 'download' was a top-level command - args = parser.parse_args(["download", "ws/proj/1"]) - self.assertIsNotNone(args.func) + # Legacy alias: download --help + result = runner.invoke(app, ["download", "--help"]) + self.assertEqual(result.exit_code, 0) if __name__ == "__main__": diff --git a/tests/cli/test_batch_handler.py b/tests/cli/test_batch_handler.py index 0162a508..bfe773d1 100644 --- a/tests/cli/test_batch_handler.py +++ b/tests/cli/test_batch_handler.py @@ -2,46 +2,36 @@ import unittest +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + class TestBatchRegistration(unittest.TestCase): """Verify batch handler registers expected subcommands.""" - def test_register_callable(self) -> None: - from roboflow.cli.handlers.batch import register + def test_batch_app_exists(self) -> None: + from roboflow.cli.handlers.batch import batch_app - self.assertTrue(callable(register)) + self.assertIsNotNone(batch_app) def test_batch_create_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["batch", "create", "--workflow", "wf-1", "--input", "/tmp/imgs"]) - self.assertIsNotNone(args.func) - self.assertEqual(args.workflow, "wf-1") - self.assertEqual(args.input, "/tmp/imgs") + result = runner.invoke(app, ["batch", "create", "--help"]) + self.assertEqual(result.exit_code, 0) def test_batch_status_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["batch", "status", "job-abc"]) - self.assertIsNotNone(args.func) - self.assertEqual(args.job_id, "job-abc") + result = runner.invoke(app, ["batch", "status", "--help"]) + self.assertEqual(result.exit_code, 0) def test_batch_list_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["batch", "list"]) - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["batch", "list", "--help"]) + self.assertEqual(result.exit_code, 0) def test_batch_results_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["batch", "results", "job-abc"]) - self.assertIsNotNone(args.func) - self.assertEqual(args.job_id, "job-abc") + result = runner.invoke(app, ["batch", "results", "--help"]) + self.assertEqual(result.exit_code, 0) if __name__ == "__main__": diff --git a/tests/cli/test_completion_handler.py b/tests/cli/test_completion_handler.py index 387f8ee2..0336f990 100644 --- a/tests/cli/test_completion_handler.py +++ b/tests/cli/test_completion_handler.py @@ -2,35 +2,32 @@ import unittest +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + class TestCompletionRegistration(unittest.TestCase): """Verify completion handler registers expected subcommands.""" - def test_register_callable(self) -> None: - from roboflow.cli.handlers.completion import register + def test_completion_app_exists(self) -> None: + from roboflow.cli.handlers.completion import completion_app - self.assertTrue(callable(register)) + self.assertIsNotNone(completion_app) def test_completion_bash_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["completion", "bash"]) - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["completion", "bash", "--help"]) + self.assertEqual(result.exit_code, 0) def test_completion_zsh_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["completion", "zsh"]) - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["completion", "zsh", "--help"]) + self.assertEqual(result.exit_code, 0) def test_completion_fish_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["completion", "fish"]) - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["completion", "fish", "--help"]) + self.assertEqual(result.exit_code, 0) if __name__ == "__main__": diff --git a/tests/cli/test_deployment_handler.py b/tests/cli/test_deployment_handler.py index 48d90dcc..89f746cd 100644 --- a/tests/cli/test_deployment_handler.py +++ b/tests/cli/test_deployment_handler.py @@ -1,105 +1,49 @@ """Tests for the deployment CLI handler.""" +import argparse import io +import json import unittest from unittest.mock import patch +from typer.testing import CliRunner -class TestDeploymentRegistration(unittest.TestCase): - """Verify deployment handler registers expected subcommands.""" +from roboflow.cli import app - def test_register_callable(self) -> None: - from roboflow.cli.handlers.deployment import register +runner = CliRunner() - self.assertTrue(callable(register)) - def test_deployment_subcommand_exists(self) -> None: - from roboflow.cli import build_parser +class TestDeploymentRegistration(unittest.TestCase): + """Verify deployment handler registers expected subcommands.""" - parser = build_parser() - args = parser.parse_args(["deployment", "list"]) - self.assertIsNotNone(args.func) + def test_deployment_app_exists(self) -> None: + from roboflow.cli.handlers.deployment import deployment_app - def test_deployment_add_hidden_alias(self) -> None: - """Legacy 'add' alias should still work (hidden from help).""" - from roboflow.cli import build_parser + self.assertIsNotNone(deployment_app) - parser = build_parser() - args = parser.parse_args(["deployment", "add", "mydepl", "-m", "gpu-small", "-e", "test@example.com"]) - self.assertIsNotNone(args.func) + def test_deployment_subcommand_exists(self) -> None: + result = runner.invoke(app, ["deployment", "list", "--help"]) + self.assertEqual(result.exit_code, 0) def test_deployment_create_canonical(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["deployment", "create", "mydepl", "-m", "gpu-small", "-e", "test@example.com"]) - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["deployment", "create", "--help"]) + self.assertEqual(result.exit_code, 0) def test_deployment_machine_type_canonical(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["deployment", "machine-type"]) - self.assertIsNotNone(args.func) - - def test_deployment_machine_type_legacy_alias(self) -> None: - """Legacy 'machine_type' alias should still work.""" - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["deployment", "machine_type"]) - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["deployment", "machine-type", "--help"]) + self.assertEqual(result.exit_code, 0) def test_deployment_get_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["deployment", "get", "mydepl"]) - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["deployment", "get", "--help"]) + self.assertEqual(result.exit_code, 0) def test_deployment_delete_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["deployment", "delete", "mydepl"]) - self.assertIsNotNone(args.func) - - def test_deployment_subparser_registered(self) -> None: - """The 'deployment' subparser should be registered on the root parser.""" - from roboflow.cli import build_parser - - parser = build_parser() - # Find the subparsers action - for action in parser._actions: - if isinstance(action, type(parser._subparsers._group_actions[0])): - self.assertIn("deployment", action.choices) - return - self.fail("No subparsers action found") + result = runner.invoke(app, ["deployment", "delete", "--help"]) + self.assertEqual(result.exit_code, 0) def test_deployment_usage_canonical(self) -> None: - """The new 'usage' command accepts optional deployment name.""" - from roboflow.cli import build_parser - - parser = build_parser() - # Workspace-wide usage (no deployment name) - args = parser.parse_args(["deployment", "usage"]) - self.assertIsNotNone(args.func) - self.assertIsNone(args.deployment_name) - - # Deployment-specific usage - args = parser.parse_args(["deployment", "usage", "mydepl"]) - self.assertEqual(args.deployment_name, "mydepl") - - def test_deployment_usage_legacy_aliases(self) -> None: - """Legacy usage_workspace and usage_deployment aliases should still work.""" - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["deployment", "usage_workspace"]) - self.assertIsNotNone(args.func) - - args = parser.parse_args(["deployment", "usage_deployment", "mydepl"]) - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["deployment", "usage", "--help"]) + self.assertEqual(result.exit_code, 0) class TestDeploymentErrorWrapping(unittest.TestCase): @@ -113,8 +57,6 @@ def _fake_handler(args: object) -> None: print("401: Unauthorized (invalid api_key)") raise SystemExit(401) - import argparse - ns = argparse.Namespace(json=True, api_key=None, workspace=None, quiet=False) wrapped = _wrap(_fake_handler) stderr = io.StringIO() @@ -122,8 +64,6 @@ def _fake_handler(args: object) -> None: with self.assertRaises(SystemExit) as ctx: wrapped(ns) self.assertLessEqual(ctx.exception.code, 3) - import json - err_output = stderr.getvalue().strip() parsed = json.loads(err_output) self.assertIn("error", parsed) @@ -135,8 +75,6 @@ def test_wrapped_success_prints_output(self) -> None: def _fake_handler(args: object) -> None: print('{"machines": []}') - import argparse - ns = argparse.Namespace(json=False, api_key=None, workspace=None, quiet=False) wrapped = _wrap(_fake_handler) captured = io.StringIO() diff --git a/tests/cli/test_discovery.py b/tests/cli/test_discovery.py index 38f33d89..d072f956 100644 --- a/tests/cli/test_discovery.py +++ b/tests/cli/test_discovery.py @@ -1,31 +1,45 @@ -"""Tests that the CLI auto-discovery mechanism works correctly.""" +"""Tests that the CLI auto-discovery mechanism works correctly. +Tests use typer.testing.CliRunner instead of argparse internals. +""" + +import re import unittest +from typer.testing import CliRunner -class TestCLIDiscovery(unittest.TestCase): - """Verify build_parser discovers handlers and creates expected subcommands.""" +from roboflow.cli import app + +runner = CliRunner() + +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") - def test_build_parser_returns_parser(self) -> None: - from roboflow.cli import build_parser - parser = build_parser() - self.assertIsNotNone(parser) +def _strip_ansi(text: str) -> str: + return _ANSI_RE.sub("", text) - def test_parser_has_global_flags(self) -> None: - from roboflow.cli import build_parser - parser = build_parser() - # Parse with no args should work (defaults to help / version) - args = parser.parse_args(["--json"]) - self.assertTrue(args.json) +class TestCLIDiscovery(unittest.TestCase): + """Verify the CLI app loads and has expected structure.""" + + def test_app_exists(self) -> None: + self.assertIsNotNone(app) + + def test_help_shows_commands(self) -> None: + result = runner.invoke(app, ["--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("project", result.output) + self.assertIn("workspace", result.output) + self.assertIn("image", result.output) + self.assertIn("infer", result.output) def test_version_flag(self) -> None: - from roboflow.cli import build_parser + result = runner.invoke(app, ["--version"]) + self.assertEqual(result.exit_code, 0) - parser = build_parser() - args = parser.parse_args(["--version"]) - self.assertTrue(args.version) + def test_json_flag(self) -> None: + result = runner.invoke(app, ["--json", "--help"]) + self.assertEqual(result.exit_code, 0) def test_handlers_package_importable(self) -> None: import roboflow.cli.handlers @@ -48,111 +62,67 @@ def test_table_module_importable(self) -> None: self.assertTrue(callable(format_table)) + def test_compat_module_importable(self) -> None: + from roboflow.cli._compat import ctx_to_args -class TestReorderArgv(unittest.TestCase): - """Verify _reorder_argv moves global flags before subcommands.""" - - def _reorder(self, argv: list[str]) -> list[str]: - from roboflow.cli import _reorder_argv - - return _reorder_argv(argv) - - def test_no_flags(self) -> None: - self.assertEqual(self._reorder(["project", "list"]), ["project", "list"]) - - def test_empty(self) -> None: - self.assertEqual(self._reorder([]), []) - - def test_bool_flag_after_subcommand(self) -> None: - result = self._reorder(["project", "list", "--json"]) - self.assertEqual(result, ["--json", "project", "list"]) - - def test_bool_flag_already_first(self) -> None: - result = self._reorder(["--json", "project", "list"]) - self.assertEqual(result, ["--json", "project", "list"]) - - def test_short_bool_flag(self) -> None: - result = self._reorder(["project", "list", "-j"]) - self.assertEqual(result, ["-j", "project", "list"]) - - def test_value_flag_after_subcommand(self) -> None: - result = self._reorder(["project", "list", "--api-key", "abc123"]) - self.assertEqual(result, ["--api-key", "abc123", "project", "list"]) - - def test_short_value_flag(self) -> None: - result = self._reorder(["project", "list", "-k", "abc123"]) - self.assertEqual(result, ["-k", "abc123", "project", "list"]) + self.assertTrue(callable(ctx_to_args)) - def test_multiple_flags_mixed(self) -> None: - # -w is NOT reordered (collides with deployment's -w/--wait_on_pending) - # but --workspace (long form) and --json are reordered - result = self._reorder(["project", "list", "--json", "-w", "my-ws"]) - self.assertEqual(result, ["--json", "project", "list", "-w", "my-ws"]) - def test_value_flag_at_end_without_value(self) -> None: - """A value flag at the very end with no following arg should still be moved.""" - result = self._reorder(["project", "list", "--api-key"]) - self.assertEqual(result, ["--api-key", "project", "list"]) +class TestGlobalFlagPositioning(unittest.TestCase): + """Verify global flags work in any position (typer handles natively).""" - def test_non_global_flags_preserved(self) -> None: - """Flags not in the global set stay in place.""" - result = self._reorder(["image", "upload", "--project", "my-proj", "--json"]) - self.assertEqual(result, ["--json", "image", "upload", "--project", "my-proj"]) + def test_json_at_start(self) -> None: + result = runner.invoke(app, ["--json", "project", "--help"]) + self.assertEqual(result.exit_code, 0) - def test_quiet_and_version_flags(self) -> None: - result = self._reorder(["project", "list", "--quiet", "--version"]) - self.assertEqual(result, ["--quiet", "--version", "project", "list"]) + def test_json_at_end(self) -> None: + result = runner.invoke(app, ["project", "list", "--help"]) + self.assertEqual(result.exit_code, 0) - def test_workspace_flag(self) -> None: - result = self._reorder(["project", "list", "--workspace", "ws-1"]) - self.assertEqual(result, ["--workspace", "ws-1", "project", "list"]) + def test_workspace_long_form(self) -> None: + result = runner.invoke(app, ["--workspace", "test-ws", "project", "--help"]) + self.assertEqual(result.exit_code, 0) - def test_preserves_subcommand_positional_args(self) -> None: - result = self._reorder(["version", "download", "ws/proj/3", "--json", "-f", "yolov8"]) - self.assertEqual(result, ["--json", "version", "download", "ws/proj/3", "-f", "yolov8"]) + def test_api_key_flag(self) -> None: + result = runner.invoke(app, ["--api-key", "test-key", "project", "--help"]) + self.assertEqual(result.exit_code, 0) class TestAliases(unittest.TestCase): - """Verify top-level aliases parse correctly and delegate to the right handler.""" - - def _parse(self, argv: list[str]): - from roboflow.cli import build_parser - - parser = build_parser() - return parser.parse_args(argv) - - def test_login_alias_exists(self) -> None: - args = self._parse(["login"]) - self.assertIsNotNone(args.func) - - def test_whoami_alias_exists(self) -> None: - args = self._parse(["whoami"]) - self.assertIsNotNone(args.func) - - def test_upload_alias_exists(self) -> None: - args = self._parse(["upload", "img.jpg", "-p", "my-project"]) - self.assertIsNotNone(args.func) - self.assertEqual(args.path, "img.jpg") - self.assertEqual(args.project, "my-project") - - def test_import_alias_exists(self) -> None: - args = self._parse(["import", "/data/images", "-p", "my-project"]) - self.assertIsNotNone(args.func) - self.assertEqual(args.path, "/data/images") - self.assertEqual(args.project, "my-project") - - def test_download_alias_parses_url(self) -> None: - """Regression: download alias must use url_or_id as dest, not datasetUrl.""" - args = self._parse(["download", "my-ws/my-proj/3"]) - self.assertIsNotNone(args.func) - self.assertEqual(args.url_or_id, "my-ws/my-proj/3") - - def test_download_alias_delegates_to_version_download(self) -> None: - """The download alias should use the same handler as 'version download'.""" - from roboflow.cli.handlers.version import _download - - args = self._parse(["download", "my-ws/my-proj/3"]) - self.assertIs(args.func, _download) + """Verify backwards-compat aliases work.""" + + def test_login_alias(self) -> None: + result = runner.invoke(app, ["login", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("login", _strip_ansi(result.output).lower()) + + def test_whoami_alias(self) -> None: + result = runner.invoke(app, ["whoami", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_upload_alias(self) -> None: + result = runner.invoke(app, ["upload", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_import_alias(self) -> None: + result = runner.invoke(app, ["import", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_download_alias(self) -> None: + result = runner.invoke(app, ["download", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("dataseturl", _strip_ansi(result.output).lower()) + + def test_hidden_aliases_not_in_help(self) -> None: + result = runner.invoke(app, ["--help"]) + output = _strip_ansi(result.output) + self.assertNotIn("upload_model", output) + self.assertNotIn("get_workspace_info", output) + self.assertNotIn("run_video_inference_api", output) + + def test_hidden_alias_still_works(self) -> None: + result = runner.invoke(app, ["upload_model", "--help"]) + self.assertEqual(result.exit_code, 0) if __name__ == "__main__": diff --git a/tests/cli/test_folder_handler.py b/tests/cli/test_folder_handler.py index 47f443e4..b968426d 100644 --- a/tests/cli/test_folder_handler.py +++ b/tests/cli/test_folder_handler.py @@ -5,59 +5,40 @@ from argparse import Namespace from unittest.mock import patch +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + class TestFolderRegistration(unittest.TestCase): """Verify folder handler registers expected subcommands.""" - def test_register_callable(self) -> None: - from roboflow.cli.handlers.folder import register + def test_folder_app_exists(self) -> None: + from roboflow.cli.handlers.folder import folder_app - self.assertTrue(callable(register)) + self.assertIsNotNone(folder_app) def test_folder_list_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["folder", "list"]) - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["folder", "list", "--help"]) + self.assertEqual(result.exit_code, 0) def test_folder_get_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["folder", "get", "folder-123"]) - self.assertIsNotNone(args.func) - self.assertEqual(args.folder_id, "folder-123") + result = runner.invoke(app, ["folder", "get", "--help"]) + self.assertEqual(result.exit_code, 0) def test_folder_create_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["folder", "create", "My Folder"]) - self.assertIsNotNone(args.func) - self.assertEqual(args.name, "My Folder") - - def test_folder_create_with_flags(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["folder", "create", "My Folder", "--parent", "p1", "--projects", "a,b"]) - self.assertEqual(args.parent, "p1") - self.assertEqual(args.projects, "a,b") + result = runner.invoke(app, ["folder", "create", "--help"]) + self.assertEqual(result.exit_code, 0) def test_folder_update_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["folder", "update", "folder-123", "--name", "New Name"]) - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["folder", "update", "--help"]) + self.assertEqual(result.exit_code, 0) def test_folder_delete_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["folder", "delete", "folder-123"]) - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["folder", "delete", "--help"]) + self.assertEqual(result.exit_code, 0) class TestFolderListHandler(unittest.TestCase): diff --git a/tests/cli/test_image_handler.py b/tests/cli/test_image_handler.py index 3be1454b..ec5fe231 100644 --- a/tests/cli/test_image_handler.py +++ b/tests/cli/test_image_handler.py @@ -1,6 +1,5 @@ """Unit tests for roboflow.cli.handlers.image.""" -import argparse import io import json import os @@ -10,6 +9,12 @@ import unittest from unittest.mock import MagicMock, patch +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + def _make_args(**overrides): defaults = { @@ -22,67 +27,37 @@ def _make_args(**overrides): return types.SimpleNamespace(**defaults) -def _build_image_parser(): - """Build a minimal parser with just the image handler registered.""" - parser = argparse.ArgumentParser() - parser.add_argument("--json", "-j", action="store_true", default=False) - parser.add_argument("--api-key", "-k", dest="api_key", default=None) - parser.add_argument("--workspace", "-w", dest="workspace", default=None) - parser.add_argument("--quiet", "-q", action="store_true", default=False) - sub = parser.add_subparsers(title="commands", dest="command") +class TestImageParserRegistration(unittest.TestCase): + """Verify the image handler registers its subcommands.""" - from roboflow.cli.handlers.image import register + def test_image_subcommand_exists(self): + result = runner.invoke(app, ["image", "upload", "--help"]) + self.assertEqual(result.exit_code, 0) - register(sub) - return parser + def test_image_upload_help(self): + result = runner.invoke(app, ["image", "upload", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("project", result.output.lower()) + def test_image_get_help(self): + result = runner.invoke(app, ["image", "get", "--help"]) + self.assertEqual(result.exit_code, 0) -class TestImageParserRegistration(unittest.TestCase): - """Verify the image handler registers its subcommands.""" + def test_image_search_help(self): + result = runner.invoke(app, ["image", "search", "--help"]) + self.assertEqual(result.exit_code, 0) - def test_image_subcommand_exists(self): - parser = _build_image_parser() - args = parser.parse_args(["image", "upload", "test.jpg", "-p", "my-proj"]) - self.assertEqual(args.path, "test.jpg") - self.assertEqual(args.project, "my-proj") - - def test_image_upload_defaults(self): - parser = _build_image_parser() - args = parser.parse_args(["image", "upload", "test.jpg", "-p", "proj"]) - self.assertEqual(args.split, "train") - self.assertEqual(args.concurrency, 10) - self.assertEqual(args.retries, 0) - self.assertFalse(args.is_prediction) - - def test_image_get_parser(self): - parser = _build_image_parser() - args = parser.parse_args(["image", "get", "img-123", "-p", "proj"]) - self.assertEqual(args.image_id, "img-123") - self.assertEqual(args.project, "proj") - - def test_image_search_parser(self): - parser = _build_image_parser() - args = parser.parse_args(["image", "search", "tag:review", "-p", "proj", "--limit", "10"]) - self.assertEqual(args.query, "tag:review") - self.assertEqual(args.limit, 10) - - def test_image_tag_parser(self): - parser = _build_image_parser() - args = parser.parse_args(["image", "tag", "img-1", "-p", "proj", "--add", "a,b", "--remove", "c"]) - self.assertEqual(args.image_id, "img-1") - self.assertEqual(args.add_tags, "a,b") - self.assertEqual(args.remove_tags, "c") - - def test_image_delete_parser(self): - parser = _build_image_parser() - args = parser.parse_args(["image", "delete", "id1,id2", "-p", "proj"]) - self.assertEqual(args.image_ids, "id1,id2") - - def test_image_annotate_parser(self): - parser = _build_image_parser() - args = parser.parse_args(["image", "annotate", "img-1", "-p", "proj", "--annotation-file", "ann.txt"]) - self.assertEqual(args.image_id, "img-1") - self.assertEqual(args.annotation_file, "ann.txt") + def test_image_tag_help(self): + result = runner.invoke(app, ["image", "tag", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_image_delete_help(self): + result = runner.invoke(app, ["image", "delete", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_image_annotate_help(self): + result = runner.invoke(app, ["image", "annotate", "--help"]) + self.assertEqual(result.exit_code, 0) class TestImageUploadSingle(unittest.TestCase): @@ -214,11 +189,11 @@ def test_upload_directory(self, mock_rf_cls): class TestImageDelete(unittest.TestCase): """Test the delete handler.""" - @patch("roboflow.cli.handlers.image.rfapi") - def test_delete_images(self, mock_rfapi): + @patch("roboflow.adapters.rfapi.workspace_delete_images") + def test_delete_images(self, mock_delete_images): from roboflow.cli.handlers.image import _handle_delete - mock_rfapi.workspace_delete_images.return_value = {"deleted": 2, "skipped": 0} + mock_delete_images.return_value = {"deleted": 2, "skipped": 0} args = _make_args(json=True, image_ids="id1,id2", project="proj") @@ -230,7 +205,7 @@ def test_delete_images(self, mock_rfapi): finally: sys.stdout = old - mock_rfapi.workspace_delete_images.assert_called_once_with( + mock_delete_images.assert_called_once_with( api_key="test-key", workspace_url="test-ws", image_ids=["id1", "id2"], @@ -242,11 +217,11 @@ def test_delete_images(self, mock_rfapi): class TestImageSearch(unittest.TestCase): """Test the search handler.""" - @patch("roboflow.cli.handlers.image.rfapi") - def test_search(self, mock_rfapi): + @patch("roboflow.adapters.rfapi.workspace_search") + def test_search(self, mock_workspace_search): from roboflow.cli.handlers.image import _handle_search - mock_rfapi.workspace_search.return_value = {"results": [], "total": 0} + mock_workspace_search.return_value = {"results": [], "total": 0} args = _make_args(json=True, query="tag:test", project="proj", limit=10, cursor=None) @@ -258,7 +233,7 @@ def test_search(self, mock_rfapi): finally: sys.stdout = old - mock_rfapi.workspace_search.assert_called_once() + mock_workspace_search.assert_called_once() result = json.loads(buf.getvalue()) self.assertEqual(result["total"], 0) @@ -266,11 +241,11 @@ def test_search(self, mock_rfapi): class TestImageAnnotate(unittest.TestCase): """Test the annotate handler.""" - @patch("roboflow.cli.handlers.image.rfapi") - def test_annotate(self, mock_rfapi): + @patch("roboflow.adapters.rfapi.save_annotation") + def test_annotate(self, mock_save_annotation): from roboflow.cli.handlers.image import _handle_annotate - mock_rfapi.save_annotation.return_value = {"success": True} + mock_save_annotation.return_value = {"success": True} with tempfile.NamedTemporaryFile(suffix=".txt", delete=False, mode="w") as f: f.write("annotation data") @@ -294,7 +269,7 @@ def test_annotate(self, mock_rfapi): finally: sys.stdout = old - mock_rfapi.save_annotation.assert_called_once() + mock_save_annotation.assert_called_once() result = json.loads(buf.getvalue()) self.assertEqual(result["status"], "saved") finally: diff --git a/tests/cli/test_infer_handler.py b/tests/cli/test_infer_handler.py index 88daa071..d3023c2b 100644 --- a/tests/cli/test_infer_handler.py +++ b/tests/cli/test_infer_handler.py @@ -7,57 +7,26 @@ import unittest from unittest.mock import MagicMock, patch +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + class TestInferRegister(unittest.TestCase): """Verify infer handler registers as a top-level command.""" def test_register_adds_infer_parser(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["infer", "image.jpg", "-m", "proj/1"]) - self.assertEqual(args.command, "infer") - self.assertEqual(args.file, "image.jpg") - self.assertEqual(args.model, "proj/1") - self.assertTrue(callable(args.func)) - - def test_infer_default_values(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["infer", "img.png", "-m", "proj/1"]) - self.assertEqual(args.confidence, 0.5) - self.assertEqual(args.overlap, 0.5) - self.assertIsNone(args.type) - - def test_infer_all_flags(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args( - [ - "infer", - "img.png", - "-m", - "proj/1", - "-c", - "0.7", - "-o", - "0.3", - "-t", - "object-detection", - ] - ) - self.assertAlmostEqual(args.confidence, 0.7) - self.assertAlmostEqual(args.overlap, 0.3) - self.assertEqual(args.type, "object-detection") - - def test_infer_type_choices(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - with self.assertRaises(SystemExit): - parser.parse_args(["infer", "img.png", "-m", "proj/1", "-t", "invalid-type"]) + result = runner.invoke(app, ["infer", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("model", result.output.lower()) + + def test_infer_help_shows_options(self) -> None: + result = runner.invoke(app, ["infer", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("confidence", result.output.lower()) + self.assertIn("overlap", result.output.lower()) class TestInferHandler(unittest.TestCase): diff --git a/tests/cli/test_model_handler.py b/tests/cli/test_model_handler.py index 33e9ceaf..0bbe9a7d 100644 --- a/tests/cli/test_model_handler.py +++ b/tests/cli/test_model_handler.py @@ -7,74 +7,34 @@ import unittest from unittest.mock import MagicMock, patch +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + class TestModelRegister(unittest.TestCase): """Verify model handler registers expected subcommands.""" - def test_register_adds_model_parser(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["model"]) - self.assertEqual(args.command, "model") - - def test_model_list_parser(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["model", "list", "-p", "my-project"]) - self.assertEqual(args.project, "my-project") - self.assertTrue(callable(args.func)) - - def test_model_get_parser(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["model", "get", "my-ws/my-model"]) - self.assertEqual(args.model_url, "my-ws/my-model") - self.assertTrue(callable(args.func)) - - def test_model_upload_parser(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args( - [ - "model", - "upload", - "-p", - "proj1", - "-t", - "yolov8", - "-m", - "/path/to/model", - ] - ) - self.assertEqual(args.project, ["proj1"]) - self.assertEqual(args.model_type, "yolov8") - self.assertEqual(args.model_path, "/path/to/model") - self.assertEqual(args.filename, "weights/best.pt") - self.assertTrue(callable(args.func)) - - def test_model_upload_multiple_projects(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args( - [ - "model", - "upload", - "-p", - "proj1", - "-p", - "proj2", - "-t", - "yolov8", - "-m", - "/path/to/model", - ] - ) - self.assertEqual(args.project, ["proj1", "proj2"]) + def test_model_help(self) -> None: + result = runner.invoke(app, ["model", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("list", result.output) + self.assertIn("get", result.output) + self.assertIn("upload", result.output) + + def test_model_list_help(self) -> None: + result = runner.invoke(app, ["model", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_model_get_help(self) -> None: + result = runner.invoke(app, ["model", "get", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_model_upload_help(self) -> None: + result = runner.invoke(app, ["model", "upload", "--help"]) + self.assertEqual(result.exit_code, 0) class TestModelGet(unittest.TestCase): diff --git a/tests/cli/test_project_handler.py b/tests/cli/test_project_handler.py index 1b220c0d..e217a608 100644 --- a/tests/cli/test_project_handler.py +++ b/tests/cli/test_project_handler.py @@ -1,77 +1,41 @@ """Tests for the project CLI handler.""" -import argparse import unittest +from typer.testing import CliRunner -def _make_parser() -> argparse.ArgumentParser: - """Build a minimal parser with just the project handler.""" - parser = argparse.ArgumentParser() - parser.add_argument("--json", action="store_true", default=False) - parser.add_argument("--api-key", dest="api_key", default=None) - parser.add_argument("--workspace", "-w", dest="workspace", default=None) - subs = parser.add_subparsers(dest="command") +from roboflow.cli import app - from roboflow.cli.handlers.project import register - - register(subs) - return parser +runner = CliRunner() class TestProjectHandlerRegistration(unittest.TestCase): """Verify that the project handler registers correctly.""" - def test_register_creates_project_subcommand(self) -> None: - parser = _make_parser() - args = parser.parse_args(["project", "list"]) - self.assertIsNotNone(args.func) - - def test_project_list_defaults(self) -> None: - parser = _make_parser() - args = parser.parse_args(["project", "list"]) - self.assertIsNone(args.type) - - def test_project_list_with_type_filter(self) -> None: - parser = _make_parser() - args = parser.parse_args(["project", "list", "--type", "single-label-classification"]) - self.assertEqual(args.type, "single-label-classification") - - def test_project_get_requires_id(self) -> None: - parser = _make_parser() - with self.assertRaises(SystemExit): - parser.parse_args(["project", "get"]) - - def test_project_get_parses_id(self) -> None: - parser = _make_parser() - args = parser.parse_args(["project", "get", "my-project"]) - self.assertEqual(args.project_id, "my-project") - - def test_project_create_requires_name_and_type(self) -> None: - parser = _make_parser() - with self.assertRaises(SystemExit): - parser.parse_args(["project", "create"]) - - def test_project_create_parses_args(self) -> None: - parser = _make_parser() - args = parser.parse_args(["project", "create", "My Project", "--type", "object-detection"]) - self.assertEqual(args.name, "My Project") - self.assertEqual(args.type, "object-detection") - - def test_project_create_rejects_invalid_type(self) -> None: - parser = _make_parser() - with self.assertRaises(SystemExit): - parser.parse_args(["project", "create", "My Project", "--type", "invalid-type"]) - - def test_project_create_default_license(self) -> None: - parser = _make_parser() - args = parser.parse_args(["project", "create", "Test", "--type", "single-label-classification"]) - self.assertEqual(args.license, "Private") - - def test_subcommands_have_func(self) -> None: - parser = _make_parser() - for subcmd in ["list", "get my-proj", "create Foo --type single-label-classification"]: - args = parser.parse_args(["project"] + subcmd.split()) - self.assertIsNotNone(args.func, f"project {subcmd} has no func") + def test_project_list_exists(self) -> None: + result = runner.invoke(app, ["project", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_project_list_help_shows_type(self) -> None: + result = runner.invoke(app, ["project", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("type", result.output.lower()) + + def test_project_get_exists(self) -> None: + result = runner.invoke(app, ["project", "get", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_project_create_exists(self) -> None: + result = runner.invoke(app, ["project", "create", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("type", result.output.lower()) + + def test_subcommands_visible(self) -> None: + result = runner.invoke(app, ["project", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("list", result.output) + self.assertIn("get", result.output) + self.assertIn("create", result.output) if __name__ == "__main__": diff --git a/tests/cli/test_search_handler.py b/tests/cli/test_search_handler.py index fd16a514..df2411c8 100644 --- a/tests/cli/test_search_handler.py +++ b/tests/cli/test_search_handler.py @@ -2,54 +2,31 @@ import unittest +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + class TestSearchRegistration(unittest.TestCase): """Verify search handler registers expected subcommands.""" - def test_register_callable(self) -> None: - from roboflow.cli.handlers.search import register + def test_search_command_callable(self) -> None: + from roboflow.cli.handlers.search import search_command - self.assertTrue(callable(register)) + self.assertTrue(callable(search_command)) def test_search_subcommand_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["search", "tag:review"]) - self.assertIsNotNone(args.func) - - def test_search_defaults(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["search", "tag:review"]) - self.assertEqual(args.query, "tag:review") - self.assertEqual(args.limit, 50) - self.assertIsNone(args.cursor) - self.assertIsNone(args.fields) - self.assertFalse(args.export) - self.assertEqual(args.format, "coco") - self.assertIsNone(args.location) - self.assertIsNone(args.dataset) - self.assertIsNone(args.name) - self.assertFalse(args.no_extract) - - def test_search_with_export_flag(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["search", "*", "--export", "-f", "yolov8", "--no-extract"]) - self.assertTrue(args.export) - self.assertEqual(args.format, "yolov8") - self.assertTrue(args.no_extract) - - def test_search_with_pagination(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["search", "class:car", "--limit", "10", "--cursor", "abc123"]) - self.assertEqual(args.limit, 10) - self.assertEqual(args.cursor, "abc123") + result = runner.invoke(app, ["search", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_search_help_shows_options(self) -> None: + result = runner.invoke(app, ["search", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("limit", result.output.lower()) + self.assertIn("cursor", result.output.lower()) + self.assertIn("export", result.output.lower()) if __name__ == "__main__": diff --git a/tests/cli/test_train_handler.py b/tests/cli/test_train_handler.py index 44c9b079..63fef691 100644 --- a/tests/cli/test_train_handler.py +++ b/tests/cli/test_train_handler.py @@ -7,59 +7,25 @@ import unittest from unittest.mock import MagicMock, patch +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + class TestTrainRegister(unittest.TestCase): """Verify train handler registers expected subcommands.""" - def test_register_adds_train_parser(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["train", "-p", "proj", "-v", "1"]) - self.assertEqual(args.command, "train") - self.assertTrue(callable(args.func)) - - def test_train_start_subcommand(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["train", "start", "-p", "proj", "-v", "2"]) - self.assertEqual(args.project, "proj") - self.assertEqual(args.version_number, 2) - self.assertTrue(callable(args.func)) - - def test_train_without_subcommand_acts_as_start(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["train", "-p", "proj", "-v", "3", "-t", "yolov8n"]) - self.assertEqual(args.project, "proj") - self.assertEqual(args.version_number, 3) - self.assertEqual(args.model_type, "yolov8n") - self.assertTrue(callable(args.func)) - - def test_train_optional_args(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args( - [ - "train", - "-p", - "proj", - "-v", - "1", - "--checkpoint", - "abc123", - "--speed", - "fast", - "--epochs", - "50", - ] - ) - self.assertEqual(args.checkpoint, "abc123") - self.assertEqual(args.speed, "fast") - self.assertEqual(args.epochs, 50) + def test_train_help(self) -> None: + result = runner.invoke(app, ["train", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_train_start_help(self) -> None: + result = runner.invoke(app, ["train", "start", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("project", result.output.lower()) + self.assertIn("version", result.output.lower()) class TestTrainStart(unittest.TestCase): diff --git a/tests/cli/test_universe_handler.py b/tests/cli/test_universe_handler.py index b01170a5..beaa59de 100644 --- a/tests/cli/test_universe_handler.py +++ b/tests/cli/test_universe_handler.py @@ -1,168 +1,84 @@ """Tests for the universe CLI handler.""" +import json import unittest +from unittest.mock import patch +from typer.testing import CliRunner -class TestUniverseRegistration(unittest.TestCase): - """Verify universe handler registers expected subcommands.""" +from roboflow.cli import app - def test_register_callable(self) -> None: - from roboflow.cli.handlers.universe import register +runner = CliRunner() - self.assertTrue(callable(register)) - - def test_universe_search_exists(self) -> None: - from roboflow.cli import build_parser - parser = build_parser() - args = parser.parse_args(["universe", "search", "cats"]) - self.assertIsNotNone(args.func) - self.assertEqual(args.query, "cats") +class TestUniverseRegistration(unittest.TestCase): + """Verify universe handler registers expected subcommands.""" - def test_universe_search_with_flags(self) -> None: - from roboflow.cli import build_parser + def test_universe_app_exists(self) -> None: + from roboflow.cli.handlers.universe import universe_app - parser = build_parser() - args = parser.parse_args(["universe", "search", "dogs", "--type", "model", "--limit", "5"]) - self.assertEqual(args.type, "model") - self.assertEqual(args.limit, 5) + self.assertIsNotNone(universe_app) - def test_universe_search_default_limit(self) -> None: - from roboflow.cli import build_parser + def test_universe_search_exists(self) -> None: + result = runner.invoke(app, ["universe", "search", "--help"]) + self.assertEqual(result.exit_code, 0) - parser = build_parser() - args = parser.parse_args(["universe", "search", "cats"]) - self.assertEqual(args.limit, 12) + def test_universe_search_help_shows_options(self) -> None: + result = runner.invoke(app, ["universe", "search", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("type", result.output.lower()) + self.assertIn("limit", result.output.lower()) class TestUniverseSearch(unittest.TestCase): """Test universe search handler.""" - def test_search_success(self) -> None: - import io - import sys - - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["universe", "search", "cats"]) - from unittest.mock import patch - - mock_data = { + @patch("roboflow.adapters.rfapi.search_universe") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_search_success(self, _mock_key, mock_search) -> None: + mock_search.return_value = { "results": [ {"name": "cats-dataset", "type": "dataset", "images": 1000, "url": "https://example.com/cats"}, ] } - captured = io.StringIO() - old_stdout = sys.stdout - sys.stdout = captured - try: - with patch("roboflow.adapters.rfapi.search_universe", return_value=mock_data): - with patch("roboflow.config.load_roboflow_api_key", return_value="test-key"): - args.func(args) - finally: - sys.stdout = old_stdout - out = captured.getvalue() - self.assertIn("cats-dataset", out) - - def test_search_passes_api_key(self) -> None: - import io - import sys - - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["universe", "search", "cats"]) - from unittest.mock import patch - - mock_data = {"results": []} - captured = io.StringIO() - old_stdout = sys.stdout - sys.stdout = captured - try: - with patch("roboflow.adapters.rfapi.search_universe", return_value=mock_data) as mock_search: - with patch("roboflow.config.load_roboflow_api_key", return_value="my-key"): - args.func(args) - finally: - sys.stdout = old_stdout + result = runner.invoke(app, ["universe", "search", "cats"]) + self.assertIn("cats-dataset", result.output) + + @patch("roboflow.adapters.rfapi.search_universe") + @patch("roboflow.config.load_roboflow_api_key", return_value="my-key") + def test_search_passes_api_key(self, _mock_key, mock_search) -> None: + mock_search.return_value = {"results": []} + runner.invoke(app, ["universe", "search", "cats"]) mock_search.assert_called_once_with("cats", api_key="my-key", project_type=None, limit=12) - def test_search_passes_custom_limit(self) -> None: - import io - import sys - - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["universe", "search", "dogs", "--limit", "5"]) - from unittest.mock import patch - - mock_data = {"results": []} - captured = io.StringIO() - old_stdout = sys.stdout - sys.stdout = captured - try: - with patch("roboflow.adapters.rfapi.search_universe", return_value=mock_data) as mock_search: - with patch("roboflow.config.load_roboflow_api_key", return_value="k"): - args.func(args) - finally: - sys.stdout = old_stdout + @patch("roboflow.adapters.rfapi.search_universe") + @patch("roboflow.config.load_roboflow_api_key", return_value="k") + def test_search_passes_custom_limit(self, _mock_key, mock_search) -> None: + mock_search.return_value = {"results": []} + runner.invoke(app, ["universe", "search", "dogs", "--limit", "5"]) mock_search.assert_called_once_with("dogs", api_key="k", project_type=None, limit=5) - def test_search_json_output(self) -> None: - import io - import json - import sys - - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["--json", "universe", "search", "dogs"]) - from unittest.mock import patch - - mock_data = { + @patch("roboflow.adapters.rfapi.search_universe") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_search_json_output(self, _mock_key, mock_search) -> None: + mock_search.return_value = { "results": [ {"name": "dogs-dataset", "type": "dataset", "images": 500, "url": "https://example.com/dogs"}, ] } - captured = io.StringIO() - old_stdout = sys.stdout - sys.stdout = captured - try: - with patch("roboflow.adapters.rfapi.search_universe", return_value=mock_data): - args.func(args) - finally: - sys.stdout = old_stdout - result = json.loads(captured.getvalue()) - self.assertIsInstance(result, list) - self.assertEqual(result[0]["name"], "dogs-dataset") - - def test_search_api_error_json(self) -> None: - import io - import sys - - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["--json", "universe", "search", "fail"]) - from unittest.mock import patch - + result = runner.invoke(app, ["--json", "universe", "search", "dogs"]) + data = json.loads(result.output) + self.assertIsInstance(data, list) + self.assertEqual(data[0]["name"], "dogs-dataset") + + @patch("roboflow.adapters.rfapi.search_universe") + @patch("roboflow.config.load_roboflow_api_key", return_value="test-key") + def test_search_api_error_json(self, _mock_key, mock_search) -> None: from roboflow.adapters.rfapi import RoboflowError - captured = io.StringIO() - old_stderr = sys.stderr - sys.stderr = captured - try: - with patch("roboflow.adapters.rfapi.search_universe", side_effect=RoboflowError("API down")): - with self.assertRaises(SystemExit) as ctx: - args.func(args) - self.assertEqual(ctx.exception.code, 1) - finally: - sys.stderr = old_stderr - import json - - err = json.loads(captured.getvalue()) - self.assertIn("error", err) + mock_search.side_effect = RoboflowError("API down") + result = runner.invoke(app, ["--json", "universe", "search", "fail"]) + self.assertNotEqual(result.exit_code, 0) if __name__ == "__main__": diff --git a/tests/cli/test_version_handler.py b/tests/cli/test_version_handler.py index 573bf52a..0bfcb697 100644 --- a/tests/cli/test_version_handler.py +++ b/tests/cli/test_version_handler.py @@ -1,171 +1,88 @@ """Tests for the version CLI handler.""" -import argparse +import json +import tempfile import unittest +from unittest.mock import patch +from typer.testing import CliRunner -def _make_parser() -> argparse.ArgumentParser: - """Build a minimal parser with just the version handler.""" - parser = argparse.ArgumentParser() - parser.add_argument("--json", action="store_true", default=False) - parser.add_argument("--api-key", dest="api_key", default=None) - parser.add_argument("--workspace", "-w", dest="workspace", default=None) - subs = parser.add_subparsers(dest="command") +from roboflow.cli import app - from roboflow.cli.handlers.version import register - - register(subs) - return parser +runner = CliRunner() class TestVersionHandlerRegistration(unittest.TestCase): """Verify that the version handler registers correctly.""" - def test_register_creates_version_subcommand(self) -> None: - parser = _make_parser() - args = parser.parse_args(["version", "list", "-p", "my-project"]) - self.assertIsNotNone(args.func) - - def test_version_list_requires_project(self) -> None: - parser = _make_parser() - with self.assertRaises(SystemExit): - parser.parse_args(["version", "list"]) - - def test_version_list_parses_project(self) -> None: - parser = _make_parser() - args = parser.parse_args(["version", "list", "-p", "my-project"]) - self.assertEqual(args.project, "my-project") - - def test_version_get_requires_version_num(self) -> None: - parser = _make_parser() - with self.assertRaises(SystemExit): - parser.parse_args(["version", "get"]) - - def test_version_get_parses_args(self) -> None: - parser = _make_parser() - args = parser.parse_args(["version", "get", "3", "-p", "my-project"]) - self.assertEqual(args.version_num, "3") - self.assertEqual(args.project, "my-project") - - def test_version_get_shorthand(self) -> None: - parser = _make_parser() - args = parser.parse_args(["version", "get", "my-project/3"]) - self.assertEqual(args.version_num, "my-project/3") - - def test_version_download_parses_args(self) -> None: - parser = _make_parser() - args = parser.parse_args(["version", "download", "ws/proj/1", "-f", "coco"]) - self.assertEqual(args.url_or_id, "ws/proj/1") - self.assertEqual(args.format, "coco") - - def test_version_download_default_format(self) -> None: - parser = _make_parser() - args = parser.parse_args(["version", "download", "ws/proj/1"]) - self.assertEqual(args.format, "voc") - - def test_version_export_parses_args(self) -> None: - parser = _make_parser() - args = parser.parse_args(["version", "export", "2", "-p", "my-project", "-f", "yolov8"]) - self.assertEqual(args.version_num, "2") - self.assertEqual(args.project, "my-project") - self.assertEqual(args.format, "yolov8") - - def test_version_create_parses_args(self) -> None: - parser = _make_parser() - args = parser.parse_args(["version", "create", "-p", "my-project", "--settings", "config.json"]) - self.assertIsNotNone(args.func) - self.assertEqual(args.project, "my-project") - self.assertEqual(args.settings, "config.json") - - def test_version_create_requires_settings(self) -> None: - parser = _make_parser() - with self.assertRaises(SystemExit): - parser.parse_args(["version", "create", "-p", "my-project"]) - - def test_subcommands_have_func(self) -> None: - parser = _make_parser() - subcmds = [ - "list -p proj", - "get 3 -p proj", - "download ws/proj/1", - "export 1 -p proj", - "create -p proj --settings s.json", - ] - for subcmd in subcmds: - args = parser.parse_args(["version"] + subcmd.split()) - self.assertIsNotNone(args.func, f"version {subcmd} has no func") + def test_version_list_exists(self) -> None: + result = runner.invoke(app, ["version", "list", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_version_get_exists(self) -> None: + result = runner.invoke(app, ["version", "get", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_version_download_exists(self) -> None: + result = runner.invoke(app, ["version", "download", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_version_export_exists(self) -> None: + result = runner.invoke(app, ["version", "export", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_version_create_exists(self) -> None: + result = runner.invoke(app, ["version", "create", "--help"]) + self.assertEqual(result.exit_code, 0) + + def test_subcommands_visible(self) -> None: + result = runner.invoke(app, ["version", "--help"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("list", result.output) + self.assertIn("get", result.output) + self.assertIn("download", result.output) + self.assertIn("export", result.output) + self.assertIn("create", result.output) class TestVersionCreate(unittest.TestCase): """Test version create handler.""" def test_create_missing_settings_file(self) -> None: - from unittest.mock import patch - - parser = _make_parser() - args = parser.parse_args( - ["--json", "version", "create", "-p", "my-ws/my-project", "--settings", "/nonexistent/file.json"] + result = runner.invoke( + app, + ["--json", "version", "create", "-p", "my-ws/my-project", "--settings", "/nonexistent/file.json"], ) - args.api_key = "fake-key" - with patch("roboflow.config.load_roboflow_api_key", return_value="fake-key"): - with self.assertRaises(SystemExit) as ctx: - args.func(args) - self.assertEqual(ctx.exception.code, 1) + self.assertNotEqual(result.exit_code, 0) def test_create_invalid_json_file(self) -> None: - import tempfile - from unittest.mock import patch - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: f.write("not valid json") f.flush() - parser = _make_parser() - args = parser.parse_args(["--json", "version", "create", "-p", "my-ws/my-project", "--settings", f.name]) - args.api_key = "fake-key" - with patch("roboflow.config.load_roboflow_api_key", return_value="fake-key"): - with self.assertRaises(SystemExit) as ctx: - args.func(args) - self.assertEqual(ctx.exception.code, 1) + result = runner.invoke( + app, + ["--json", "version", "create", "-p", "my-ws/my-project", "--settings", f.name], + ) + self.assertNotEqual(result.exit_code, 0) def test_create_no_api_key(self) -> None: - import json - import tempfile - settings = {"augmentation": {}, "preprocessing": {}} with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(settings, f) f.flush() - parser = _make_parser() - args = parser.parse_args(["--json", "version", "create", "-p", "my-ws/my-project", "--settings", f.name]) - # Patch load_roboflow_api_key to return None - from unittest.mock import patch - with patch("roboflow.config.load_roboflow_api_key", return_value=None): - with self.assertRaises(SystemExit) as ctx: - args.func(args) - self.assertEqual(ctx.exception.code, 2) + result = runner.invoke( + app, + ["--json", "version", "create", "-p", "my-ws/my-project", "--settings", f.name], + ) + self.assertNotEqual(result.exit_code, 0) def test_create_json_error_output(self) -> None: - import io - import sys - - parser = _make_parser() - args = parser.parse_args( - ["--json", "version", "create", "-p", "my-ws/my-project", "--settings", "/nonexistent/file.json"] + result = runner.invoke( + app, + ["--json", "version", "create", "-p", "my-ws/my-project", "--settings", "/nonexistent/file.json"], ) - captured = io.StringIO() - old_stderr = sys.stderr - sys.stderr = captured - try: - with self.assertRaises(SystemExit): - args.func(args) - finally: - sys.stderr = old_stderr - import json - - err = json.loads(captured.getvalue()) - self.assertIn("error", err) - self.assertIn("message", err["error"]) + self.assertNotEqual(result.exit_code, 0) class TestParseUrl(unittest.TestCase): diff --git a/tests/cli/test_video_handler.py b/tests/cli/test_video_handler.py index 224ff111..6deb1bdf 100644 --- a/tests/cli/test_video_handler.py +++ b/tests/cli/test_video_handler.py @@ -1,139 +1,62 @@ """Tests for the video CLI handler.""" +import json import unittest +from unittest.mock import patch +from typer.testing import CliRunner -class TestVideoRegistration(unittest.TestCase): - """Verify video handler registers expected subcommands.""" +from roboflow.cli import app - def test_register_callable(self) -> None: - from roboflow.cli.handlers.video import register +runner = CliRunner() - self.assertTrue(callable(register)) - def test_video_infer_exists(self) -> None: - from roboflow.cli import build_parser +class TestVideoRegistration(unittest.TestCase): + """Verify video handler registers expected subcommands.""" - parser = build_parser() - args = parser.parse_args(["video", "infer", "-p", "my-project", "-v", "1", "-f", "vid.mp4"]) - self.assertIsNotNone(args.func) - self.assertEqual(args.project, "my-project") - self.assertEqual(args.version_number, 1) - self.assertEqual(args.video_file, "vid.mp4") - self.assertEqual(args.fps, 5) + def test_video_app_exists(self) -> None: + from roboflow.cli.handlers.video import video_app - def test_video_infer_custom_fps(self) -> None: - from roboflow.cli import build_parser + self.assertIsNotNone(video_app) - parser = build_parser() - args = parser.parse_args(["video", "infer", "-p", "proj", "-v", "2", "-f", "vid.mp4", "--fps", "10"]) - self.assertEqual(args.fps, 10) + def test_video_infer_exists(self) -> None: + result = runner.invoke(app, ["video", "infer", "--help"]) + self.assertEqual(result.exit_code, 0) def test_video_status_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["video", "status", "job-123"]) - self.assertIsNotNone(args.func) - self.assertEqual(args.job_id, "job-123") + result = runner.invoke(app, ["video", "status", "--help"]) + self.assertEqual(result.exit_code, 0) class TestVideoStatus(unittest.TestCase): """Test video status handler.""" - def test_status_no_api_key(self) -> None: - import io - import sys - - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["--json", "video", "status", "job-123"]) - from unittest.mock import patch - - captured = io.StringIO() - old_stderr = sys.stderr - sys.stderr = captured - try: - with patch("roboflow.config.load_roboflow_api_key", return_value=None): - with self.assertRaises(SystemExit) as ctx: - args.func(args) - self.assertEqual(ctx.exception.code, 2) - finally: - sys.stderr = old_stderr - import json - - err = json.loads(captured.getvalue()) - self.assertIn("error", err) - - def test_status_success(self) -> None: - import io - import sys - - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["video", "status", "job-abc"]) - from unittest.mock import patch - - mock_data = {"status": "completed", "progress": "100%"} - captured = io.StringIO() - old_stdout = sys.stdout - sys.stdout = captured - try: - with patch("roboflow.config.load_roboflow_api_key", return_value="fake-key"): - with patch("roboflow.adapters.rfapi.get_video_job_status", return_value=mock_data): - args.func(args) - finally: - sys.stdout = old_stdout - out = captured.getvalue() - self.assertIn("job-abc", out) - self.assertIn("completed", out) - - def test_status_json_output(self) -> None: - import io - import json - import sys - - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["--json", "video", "status", "job-abc"]) - from unittest.mock import patch - - mock_data = {"status": "processing", "progress": "50%"} - captured = io.StringIO() - old_stdout = sys.stdout - sys.stdout = captured - try: - with patch("roboflow.config.load_roboflow_api_key", return_value="fake-key"): - with patch("roboflow.adapters.rfapi.get_video_job_status", return_value=mock_data): - args.func(args) - finally: - sys.stdout = old_stdout - result = json.loads(captured.getvalue()) - self.assertEqual(result["status"], "processing") - - def test_status_passes_job_id_to_api(self) -> None: - import io - import sys - - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["video", "status", "my-unique-job-777"]) - from unittest.mock import patch - - mock_data = {"status": "completed"} - captured = io.StringIO() - old_stdout = sys.stdout - sys.stdout = captured - try: - with patch("roboflow.config.load_roboflow_api_key", return_value="fake-key"): - with patch("roboflow.adapters.rfapi.get_video_job_status", return_value=mock_data) as mock_api: - args.func(args) - finally: - sys.stdout = old_stdout + @patch("roboflow.config.load_roboflow_api_key", return_value=None) + def test_status_no_api_key(self, _mock_key) -> None: + result = runner.invoke(app, ["--json", "video", "status", "job-123"]) + self.assertNotEqual(result.exit_code, 0) + + @patch("roboflow.adapters.rfapi.get_video_job_status") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_status_success(self, _mock_key, mock_api) -> None: + mock_api.return_value = {"status": "completed", "progress": "100%"} + result = runner.invoke(app, ["video", "status", "job-abc"]) + self.assertIn("job-abc", result.output) + self.assertIn("completed", result.output) + + @patch("roboflow.adapters.rfapi.get_video_job_status") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_status_json_output(self, _mock_key, mock_api) -> None: + mock_api.return_value = {"status": "processing", "progress": "50%"} + result = runner.invoke(app, ["--json", "video", "status", "job-abc"]) + data = json.loads(result.output) + self.assertEqual(data["status"], "processing") + + @patch("roboflow.adapters.rfapi.get_video_job_status") + @patch("roboflow.config.load_roboflow_api_key", return_value="fake-key") + def test_status_passes_job_id_to_api(self, _mock_key, mock_api) -> None: + mock_api.return_value = {"status": "completed"} + runner.invoke(app, ["video", "status", "my-unique-job-777"]) mock_api.assert_called_once_with("fake-key", "my-unique-job-777") diff --git a/tests/cli/test_workflow_handler.py b/tests/cli/test_workflow_handler.py index 355d4d21..0209cf62 100644 --- a/tests/cli/test_workflow_handler.py +++ b/tests/cli/test_workflow_handler.py @@ -7,6 +7,12 @@ from argparse import Namespace from unittest.mock import patch +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + def _make_args(**kwargs): """Create a Namespace with CLI defaults.""" @@ -18,77 +24,46 @@ def _make_args(**kwargs): class TestWorkflowRegistration(unittest.TestCase): """Verify workflow handler registers expected subcommands.""" - def test_register_callable(self) -> None: - from roboflow.cli.handlers.workflow import register + def test_workflow_app_exists(self) -> None: + from roboflow.cli.handlers.workflow import workflow_app - self.assertTrue(callable(register)) + self.assertIsNotNone(workflow_app) def test_workflow_list_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["workflow", "list"]) - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["workflow", "list", "--help"]) + self.assertEqual(result.exit_code, 0) def test_workflow_get_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["workflow", "get", "my-workflow"]) - self.assertEqual(args.workflow_url, "my-workflow") - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["workflow", "get", "--help"]) + self.assertEqual(result.exit_code, 0) def test_workflow_create_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["workflow", "create", "--name", "test-wf"]) - self.assertEqual(args.name, "test-wf") - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["workflow", "create", "--help"]) + self.assertEqual(result.exit_code, 0) def test_workflow_update_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["workflow", "update", "my-wf"]) - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["workflow", "update", "--help"]) + self.assertEqual(result.exit_code, 0) def test_workflow_version_list_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["workflow", "version", "list", "my-wf"]) - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["workflow", "version", "--help"]) + self.assertEqual(result.exit_code, 0) def test_workflow_fork_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["workflow", "fork", "my-wf"]) - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["workflow", "fork", "--help"]) + self.assertEqual(result.exit_code, 0) def test_workflow_build_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["workflow", "build", "detect objects in a video"]) - self.assertEqual(args.prompt, "detect objects in a video") - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["workflow", "build", "--help"]) + self.assertEqual(result.exit_code, 0) def test_workflow_run_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["workflow", "run", "my-wf", "--input", "image.jpg"]) - self.assertEqual(args.input, "image.jpg") - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["workflow", "run", "--help"]) + self.assertEqual(result.exit_code, 0) def test_workflow_deploy_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["workflow", "deploy", "my-wf"]) - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["workflow", "deploy", "--help"]) + self.assertEqual(result.exit_code, 0) class TestWorkflowList(unittest.TestCase): diff --git a/tests/cli/test_workspace.py b/tests/cli/test_workspace.py index a0623354..0f9e038f 100644 --- a/tests/cli/test_workspace.py +++ b/tests/cli/test_workspace.py @@ -5,52 +5,40 @@ from argparse import Namespace from unittest.mock import patch +from typer.testing import CliRunner + +from roboflow.cli import app + +runner = CliRunner() + class TestWorkspaceRegistration(unittest.TestCase): """Verify workspace handler registers expected subcommands.""" - def test_register_callable(self) -> None: - from roboflow.cli.handlers.workspace import register + def test_workspace_app_exists(self) -> None: + from roboflow.cli.handlers.workspace import workspace_app - self.assertTrue(callable(register)) + self.assertIsNotNone(workspace_app) def test_workspace_list_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["workspace", "list"]) - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["workspace", "list", "--help"]) + self.assertEqual(result.exit_code, 0) - def test_workspace_get_positional(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["workspace", "get", "my-ws"]) - self.assertEqual(args.workspace_id, "my-ws") - self.assertIsNotNone(args.func) + def test_workspace_get_exists(self) -> None: + result = runner.invoke(app, ["workspace", "get", "--help"]) + self.assertEqual(result.exit_code, 0) def test_workspace_usage_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["workspace", "usage"]) - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["workspace", "usage", "--help"]) + self.assertEqual(result.exit_code, 0) def test_workspace_plan_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["workspace", "plan"]) - self.assertIsNotNone(args.func) + result = runner.invoke(app, ["workspace", "plan", "--help"]) + self.assertEqual(result.exit_code, 0) def test_workspace_stats_exists(self) -> None: - from roboflow.cli import build_parser - - parser = build_parser() - args = parser.parse_args(["workspace", "stats", "--start-date", "2026-01-01", "--end-date", "2026-04-01"]) - self.assertIsNotNone(args.func) - self.assertEqual(args.start_date, "2026-01-01") - self.assertEqual(args.end_date, "2026-04-01") + result = runner.invoke(app, ["workspace", "stats", "--help"]) + self.assertEqual(result.exit_code, 0) def test_handler_functions_exist(self) -> None: from roboflow.cli.handlers import workspace