diff --git a/CLI-COMMANDS.md b/CLI-COMMANDS.md index dabba66e..34539368 100644 --- a/CLI-COMMANDS.md +++ b/CLI-COMMANDS.md @@ -179,6 +179,32 @@ roboflow video status ### Shell completion +The fastest path: let the CLI install completion for you. Auto-detects your shell from `$SHELL`. + +```bash +roboflow completion install +``` + +This writes the completion script to a per-user location and updates your shell rc file (`~/.bashrc` or `~/.zshrc`) so completion works in new shells. Idempotent — safe to re-run. Delegates to `typer.completion.install` under the hood. + +Supported shells: `bash`, `zsh`, `fish`. Windows / PowerShell is not supported. + +Override detection or scope to one shell: + +```bash +roboflow completion install --shell zsh +roboflow completion install --shell bash +roboflow completion install --shell fish +``` + +Hidden commands (legacy aliases, snake_case shims, not-yet-implemented stubs) are filtered from completion automatically. + +To uninstall, delete the completion script (location depends on your shell — typer writes to `~/.bash_completions/roboflow.sh`, `~/.zfunc/_roboflow`, or `~/.config/fish/completions/roboflow.fish`) and remove any `source ...` line typer added to your `~/.bashrc`. + +#### Advanced: print the script yourself + +If you want full control, generate the raw script and source it however you like: + ```bash # Zsh eval "$(roboflow completion zsh)" @@ -237,7 +263,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` | Generate shell completion scripts (bash, zsh, fish) | +| `completion` | Install or generate shell completion scripts (bash, zsh, fish) | Run `roboflow --help` for details on any command. diff --git a/roboflow/cli/__init__.py b/roboflow/cli/__init__.py index ae48f979..e998c65b 100644 --- a/roboflow/cli/__init__.py +++ b/roboflow/cli/__init__.py @@ -7,6 +7,7 @@ from __future__ import annotations import json +import os from typing import Annotated, Any, Optional import click @@ -31,6 +32,9 @@ cls=SortedGroup, pretty_exceptions_enable=False, rich_markup_mode="rich", + # We expose shell completion through our own `completion` command group + # (see roboflow/cli/handlers/completion.py) so that there is exactly one + # documented entry-point. add_completion=False, context_settings={"help_option_names": ["-h", "--help"]}, ) @@ -163,6 +167,16 @@ def _walk(group: Any, prefix: str = "") -> None: 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")) + # Footer tip: nudge users to enable shell completion. Suppressed under + # --quiet (explicit opt-out of non-essential output). --json doesn't apply + # here because the flattened help only renders in non-JSON mode anyway. + import sys as _sys + + if "--quiet" not in _sys.argv and "-q" not in _sys.argv: + console.print( + " Tip: enable shell completion with [bold]roboflow completion install[/bold]", + highlight=False, + ) console.print() @@ -362,6 +376,12 @@ def main() -> None: """CLI entry point — called by ``roboflow`` console script.""" import sys + complete_mode = os.environ.get("_ROBOFLOW_COMPLETE") + if complete_mode in {"complete_bash", "bash_complete"} and ( + "COMP_WORDS" not in os.environ or "COMP_CWORD" not in os.environ + ): + sys.exit(0) + sys.argv[1:] = _reorder_argv(sys.argv[1:]) # Intercept root-level --help/-h: show our flattened help instead of typer's grouped view. diff --git a/roboflow/cli/handlers/auth.py b/roboflow/cli/handlers/auth.py index e2c57fd9..374b7fc2 100644 --- a/roboflow/cli/handlers/auth.py +++ b/roboflow/cli/handlers/auth.py @@ -95,6 +95,17 @@ def _mask_key(key: str) -> str: return key[:2] + "*" * (len(key) - 4) + key[-2:] +def _print_completion_tip(args) -> None: # noqa: ANN001 + """Nudge users towards shell completion after a successful login. + + Suppressed under --json (would corrupt the JSON output) and --quiet + (user explicitly opted out of non-essential output). + """ + if getattr(args, "json", False) or getattr(args, "quiet", False): + return + print("\nTip: enable shell completion with 'roboflow completion install'") # noqa: T201 + + def _login(args): # noqa: ANN001 from roboflow.cli._output import output, output_error @@ -154,6 +165,7 @@ def _login(args): # noqa: ANN001 {"status": "logged_in", "workspace": ws_url, "api_key": _mask_key(api_key)}, text=f"Logged in. Default workspace: {ws_url}{note}", ) + _print_completion_tip(args) else: # Interactive flow import roboflow @@ -181,6 +193,8 @@ def _login(args): # noqa: ANN001 {"status": "logged_in", "workspace": ws, "api_key": "****"}, text=f"Logged in. Default workspace: {ws}", ) + _print_completion_tip(args) + _print_completion_tip(args) def _status(args): # noqa: ANN001 diff --git a/roboflow/cli/handlers/completion.py b/roboflow/cli/handlers/completion.py index f08d4e9f..8acebd71 100644 --- a/roboflow/cli/handlers/completion.py +++ b/roboflow/cli/handlers/completion.py @@ -1,65 +1,88 @@ -"""Shell completion commands. +"""Shell completion: install + raw script generators. -Generates completion scripts for bash, zsh, and fish shells. -Uses Click's built-in completion generation via the ``_ROBOFLOW_COMPLETE`` -environment variable. +Delegates installation to ``typer.completion.install`` (which itself +wraps Click's ``shell_completion`` and auto-detects the shell via +shellingham). Hidden commands are filtered by Click automatically. """ from __future__ import annotations -import sys +import shutil +from typing import Annotated, Optional import click import typer +from typer._completion_classes import completion_init +from typer._completion_shared import get_completion_script +from typer.completion import install as typer_install -from roboflow.cli._compat import SortedGroup +from roboflow.cli._compat import SortedGroup, ctx_to_args +from roboflow.cli._output import output, output_error -completion_app = typer.Typer(cls=SortedGroup, help="Generate shell completions", no_args_is_help=True) +completion_app = typer.Typer( + cls=SortedGroup, + help="Generate and install shell completions", + no_args_is_help=True, +) +completion_init() -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 +def _generate_completion(shell: str) -> str: + return get_completion_script(prog_name="roboflow", complete_var="_ROBOFLOW_COMPLETE", shell=shell) @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") + """Print bash completion script. Usage: eval "$(roboflow completion bash)".""" + print(_generate_completion("bash")) # noqa: T201 @completion_app.command("zsh") def zsh() -> None: - """Generate zsh completion script. - - Usage: eval "$(roboflow completion zsh)" - Or save to a file: roboflow completion zsh > ~/.roboflow-complete.zsh - """ - _generate_completion("zsh") + """Print zsh completion script. Usage: eval "$(roboflow completion zsh)".""" + print(_generate_completion("zsh")) # noqa: T201 @completion_app.command("fish") def fish() -> None: - """Generate fish completion script. - - Usage: roboflow completion fish | source - Or save to a file: roboflow completion fish > ~/.config/fish/completions/roboflow.fish - """ - _generate_completion("fish") + """Print fish completion script. Usage: roboflow completion fish | source.""" + print(_generate_completion("fish")) # noqa: T201 + + +@completion_app.command("install") +def install( + ctx: typer.Context, + shell: Annotated[ + Optional[str], + typer.Option("--shell", help="bash, zsh, or fish. Auto-detected when omitted."), + ] = None, +) -> None: + """Install shell completion. Writes the script and updates your shell rc. Idempotent.""" + args = ctx_to_args(ctx, shell=shell) + + if shutil.which("roboflow") is None: + output_error( + args, + "The 'roboflow' command is not on your PATH.", + hint="Ensure your install bin directory (e.g. ~/.local/bin) is on PATH.", + exit_code=1, + ) + return + + try: + installed_shell, path = typer_install(shell=shell, prog_name="roboflow", complete_var="_ROBOFLOW_COMPLETE") + except click.exceptions.Exit: + output_error( + args, + "Could not detect or install completion.", + hint="Pass --shell with one of: bash, zsh, fish.", + exit_code=3, + ) + return + + output( + args, + {"shell": installed_shell, "path": str(path)}, + text=f"Installed {installed_shell} completion to {path}.\nOpen a new shell to enable it.", + ) diff --git a/tests/cli/test_auth.py b/tests/cli/test_auth.py index c2af74bb..0a20dc56 100644 --- a/tests/cli/test_auth.py +++ b/tests/cli/test_auth.py @@ -1,11 +1,13 @@ """Tests for the auth CLI handler.""" import re +import types import unittest from typer.testing import CliRunner from roboflow.cli import app +from roboflow.cli.handlers import auth as auth_module runner = CliRunner() @@ -64,5 +66,34 @@ def test_mask_key(self) -> None: self.assertEqual(_mask_key(""), "****") +class TestCompletionTip(unittest.TestCase): + """Verify the post-login completion tip honours --json and --quiet.""" + + def _capture(self, args_ns) -> str: # noqa: ANN001 + import io + import sys + + buf = io.StringIO() + prev = sys.stdout + sys.stdout = buf + try: + auth_module._print_completion_tip(args_ns) + finally: + sys.stdout = prev + return buf.getvalue() + + def test_tip_printed_in_normal_mode(self) -> None: + out = self._capture(types.SimpleNamespace(json=False, quiet=False)) + self.assertIn("roboflow completion install", out) + + def test_tip_suppressed_in_json_mode(self) -> None: + out = self._capture(types.SimpleNamespace(json=True, quiet=False)) + self.assertEqual(out, "") + + def test_tip_suppressed_in_quiet_mode(self) -> None: + out = self._capture(types.SimpleNamespace(json=False, quiet=True)) + self.assertEqual(out, "") + + if __name__ == "__main__": unittest.main() diff --git a/tests/cli/test_completion_handler.py b/tests/cli/test_completion_handler.py index 0336f990..41a57ed6 100644 --- a/tests/cli/test_completion_handler.py +++ b/tests/cli/test_completion_handler.py @@ -1,7 +1,18 @@ -"""Tests for the completion CLI handler.""" +"""Tests for the completion CLI handler. +Covers script generation and the install flow (which delegates to +``typer.completion.install``). +""" + +import json +import os +import sys +import tempfile import unittest +from pathlib import Path +from unittest import mock +import click from typer.testing import CliRunner from roboflow.cli import app @@ -29,6 +40,180 @@ def test_completion_fish_exists(self) -> None: result = runner.invoke(app, ["completion", "fish", "--help"]) self.assertEqual(result.exit_code, 0) + def test_completion_install_exists(self) -> None: + result = runner.invoke(app, ["completion", "install", "--help"]) + self.assertEqual(result.exit_code, 0) + + +class TestCompletionScriptGeneration(unittest.TestCase): + """Raw script generation paths (`completion bash|zsh|fish`).""" + + def test_bash_script_contains_marker(self) -> None: + result = runner.invoke(app, ["completion", "bash"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("_ROBOFLOW_COMPLETE", result.output) + + def test_zsh_script_contains_marker(self) -> None: + result = runner.invoke(app, ["completion", "zsh"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("_ROBOFLOW_COMPLETE", result.output) + self.assertIn("complete_zsh", result.output) + self.assertNotIn("zsh_complete", result.output) + self.assertIn("compdef", result.output) + + def test_fish_script_contains_marker(self) -> None: + result = runner.invoke(app, ["completion", "fish"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("_ROBOFLOW_COMPLETE", result.output) + + def test_hidden_commands_filtered_from_completion(self) -> None: + """Click filters hidden commands from completion at iteration time. + + Anything registered with ``hidden=True`` (legacy aliases, snake_case + shims, stubbed groups) must not be visible. We replicate Click's + filter without depending on a private symbol. + """ + import typer + + from roboflow.cli import app as rf_app + + click_app = typer.main.get_command(rf_app) + ctx = click.Context(click_app, info_name="roboflow") + visible = { + name + for name in click_app.list_commands(ctx) + if (cmd := click_app.get_command(ctx, name)) is not None and not cmd.hidden + } + hidden_examples = { + "download", + "login", + "whoami", + "upload", + "import", + "search-export", + "upload_model", + "get_workspace_info", + "run_video_inference_api", + "help", + "batch", + } + leaked = hidden_examples & visible + self.assertFalse(leaked, f"Hidden commands leaked into completion: {leaked}") + + def test_bad_completion_invocation_exits_without_traceback(self) -> None: + from roboflow.cli import main + + with mock.patch.dict(os.environ, {"_ROBOFLOW_COMPLETE": "bash_complete"}, clear=False): + os.environ.pop("COMP_WORDS", None) + os.environ.pop("COMP_CWORD", None) + with mock.patch.object(sys, "argv", ["roboflow", "im"]): + with self.assertRaises(SystemExit) as exc: + main() + self.assertEqual(exc.exception.code, 0) + + +class _IsolatedHomeMixin: + """Mixin: isolated $HOME, $SHELL=zsh, and roboflow-on-PATH stub.""" + + def setUp(self) -> None: # type: ignore[override] + self.tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(self.tmpdir.cleanup) + self.home = Path(self.tmpdir.name) + + # Stub `shutil.which("roboflow")` only — Click's BashComplete also + # calls shutil.which("bash") for version detection; don't intercept + # that. + import shutil as _shutil + + real_which = _shutil.which + + def _fake_which(cmd, *args, **kwargs): # noqa: ANN001, ANN002, ANN003 + if cmd == "roboflow": + return "/usr/local/bin/roboflow" + return real_which(cmd, *args, **kwargs) + + self._which_patch = mock.patch.object(_shutil, "which", side_effect=_fake_which) + self._which_patch.start() + self.addCleanup(self._which_patch.stop) + + self._env_patch = mock.patch.dict( + os.environ, + {"HOME": str(self.home), "SHELL": "/bin/zsh"}, + clear=False, + ) + self._env_patch.start() + self.addCleanup(self._env_patch.stop) + + # typer.completion.install reads Path.home() to pick rc/script paths. + self._home_patch = mock.patch.object(Path, "home", return_value=self.home) + self._home_patch.start() + self.addCleanup(self._home_patch.stop) + + +class TestInstall(_IsolatedHomeMixin, unittest.TestCase): + def test_install_zsh_writes_file(self) -> None: + result = runner.invoke(app, ["completion", "install", "--shell", "zsh"]) + self.assertEqual(result.exit_code, 0, msg=result.output) + target = self.home / ".zfunc" / "_roboflow" + self.assertTrue(target.exists()) + self.assertIn("_ROBOFLOW_COMPLETE", target.read_text()) + + def test_install_bash_writes_file(self) -> None: + result = runner.invoke(app, ["completion", "install", "--shell", "bash"]) + self.assertEqual(result.exit_code, 0, msg=result.output) + target = self.home / ".bash_completions" / "roboflow.sh" + self.assertTrue(target.exists()) + + def test_install_bash_appends_source_line_to_bashrc(self) -> None: + runner.invoke(app, ["completion", "install", "--shell", "bash"]) + rc = (self.home / ".bashrc").read_text() + target = self.home / ".bash_completions" / "roboflow.sh" + self.assertTrue( + any(line.lstrip().startswith("source ") and str(target) in line for line in rc.splitlines()), + msg=f"no source line for {target} in {rc!r}", + ) + + def test_install_fish_writes_file(self) -> None: + result = runner.invoke(app, ["completion", "install", "--shell", "fish"]) + self.assertEqual(result.exit_code, 0, msg=result.output) + target = self.home / ".config" / "fish" / "completions" / "roboflow.fish" + self.assertTrue(target.exists()) + + def test_install_unsupported_shell_errors(self) -> None: + result = runner.invoke(app, ["completion", "install", "--shell", "csh"]) + self.assertEqual(result.exit_code, 3, msg=result.output) + + def test_install_missing_binary_hard_errors(self) -> None: + import shutil as _shutil + + with mock.patch.object(_shutil, "which", return_value=None): + result = runner.invoke(app, ["completion", "install", "--shell", "zsh"]) + self.assertEqual(result.exit_code, 1, msg=result.output) + combined = result.output + (result.stderr or "") + self.assertIn("PATH", combined) + + def test_install_idempotent(self) -> None: + first = runner.invoke(app, ["completion", "install", "--shell", "zsh"]) + second = runner.invoke(app, ["completion", "install", "--shell", "zsh"]) + self.assertEqual(first.exit_code, 0) + self.assertEqual(second.exit_code, 0) + self.assertTrue((self.home / ".zfunc" / "_roboflow").exists()) + + def test_install_bash_idempotent_does_not_duplicate_source_line(self) -> None: + runner.invoke(app, ["completion", "install", "--shell", "bash"]) + runner.invoke(app, ["completion", "install", "--shell", "bash"]) + rc = (self.home / ".bashrc").read_text() + target = self.home / ".bash_completions" / "roboflow.sh" + source_lines = [line for line in rc.splitlines() if line.lstrip().startswith("source ") and str(target) in line] + self.assertEqual(len(source_lines), 1, msg=f"unexpected rc: {rc!r}") + + def test_install_json_schema(self) -> None: + result = runner.invoke(app, ["--json", "completion", "install", "--shell", "fish"]) + self.assertEqual(result.exit_code, 0, msg=result.output) + payload = json.loads(result.output) + self.assertEqual(payload["shell"], "fish") + self.assertIn("path", payload) + if __name__ == "__main__": unittest.main()