Skip to content

Commit bff32b9

Browse files
autocompletion install for shells (#471)
* autocompletion install for shells * fix(pre_commit): 🎨 auto format pre-commit hooks * adding docs --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent cffc59f commit bff32b9

6 files changed

Lines changed: 341 additions & 42 deletions

File tree

CLI-COMMANDS.md

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,32 @@ roboflow video status <job-id>
179179

180180
### Shell completion
181181

182+
The fastest path: let the CLI install completion for you. Auto-detects your shell from `$SHELL`.
183+
184+
```bash
185+
roboflow completion install
186+
```
187+
188+
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.
189+
190+
Supported shells: `bash`, `zsh`, `fish`. Windows / PowerShell is not supported.
191+
192+
Override detection or scope to one shell:
193+
194+
```bash
195+
roboflow completion install --shell zsh
196+
roboflow completion install --shell bash
197+
roboflow completion install --shell fish
198+
```
199+
200+
Hidden commands (legacy aliases, snake_case shims, not-yet-implemented stubs) are filtered from completion automatically.
201+
202+
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`.
203+
204+
#### Advanced: print the script yourself
205+
206+
If you want full control, generate the raw script and source it however you like:
207+
182208
```bash
183209
# Zsh
184210
eval "$(roboflow completion zsh)"
@@ -237,7 +263,7 @@ Version numbers are always numeric — that's how `x/y` is disambiguated between
237263
| `universe` | Search Roboflow Universe |
238264
| `video` | Video inference |
239265
| `batch` | Batch processing jobs *(coming soon)* |
240-
| `completion` | Generate shell completion scripts (bash, zsh, fish) |
266+
| `completion` | Install or generate shell completion scripts (bash, zsh, fish) |
241267

242268
Run `roboflow <command> --help` for details on any command.
243269

roboflow/cli/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from __future__ import annotations
88

99
import json
10+
import os
1011
from typing import Annotated, Any, Optional
1112

1213
import click
@@ -31,6 +32,9 @@
3132
cls=SortedGroup,
3233
pretty_exceptions_enable=False,
3334
rich_markup_mode="rich",
35+
# We expose shell completion through our own `completion` command group
36+
# (see roboflow/cli/handlers/completion.py) so that there is exactly one
37+
# documented entry-point.
3438
add_completion=False,
3539
context_settings={"help_option_names": ["-h", "--help"]},
3640
)
@@ -163,6 +167,16 @@ def _walk(group: Any, prefix: str = "") -> None:
163167
styled_name.append(parts[1], style="bold")
164168
cmd_table.add_row(styled_name, help_text)
165169
console.print(Panel(cmd_table, title="Commands", title_align="left", border_style="dim"))
170+
# Footer tip: nudge users to enable shell completion. Suppressed under
171+
# --quiet (explicit opt-out of non-essential output). --json doesn't apply
172+
# here because the flattened help only renders in non-JSON mode anyway.
173+
import sys as _sys
174+
175+
if "--quiet" not in _sys.argv and "-q" not in _sys.argv:
176+
console.print(
177+
" Tip: enable shell completion with [bold]roboflow completion install[/bold]",
178+
highlight=False,
179+
)
166180
console.print()
167181

168182

@@ -362,6 +376,12 @@ def main() -> None:
362376
"""CLI entry point — called by ``roboflow`` console script."""
363377
import sys
364378

379+
complete_mode = os.environ.get("_ROBOFLOW_COMPLETE")
380+
if complete_mode in {"complete_bash", "bash_complete"} and (
381+
"COMP_WORDS" not in os.environ or "COMP_CWORD" not in os.environ
382+
):
383+
sys.exit(0)
384+
365385
sys.argv[1:] = _reorder_argv(sys.argv[1:])
366386

367387
# Intercept root-level --help/-h: show our flattened help instead of typer's grouped view.

roboflow/cli/handlers/auth.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,17 @@ def _mask_key(key: str) -> str:
9595
return key[:2] + "*" * (len(key) - 4) + key[-2:]
9696

9797

98+
def _print_completion_tip(args) -> None: # noqa: ANN001
99+
"""Nudge users towards shell completion after a successful login.
100+
101+
Suppressed under --json (would corrupt the JSON output) and --quiet
102+
(user explicitly opted out of non-essential output).
103+
"""
104+
if getattr(args, "json", False) or getattr(args, "quiet", False):
105+
return
106+
print("\nTip: enable shell completion with 'roboflow completion install'") # noqa: T201
107+
108+
98109
def _login(args): # noqa: ANN001
99110
from roboflow.cli._output import output, output_error
100111

@@ -154,6 +165,7 @@ def _login(args): # noqa: ANN001
154165
{"status": "logged_in", "workspace": ws_url, "api_key": _mask_key(api_key)},
155166
text=f"Logged in. Default workspace: {ws_url}{note}",
156167
)
168+
_print_completion_tip(args)
157169
else:
158170
# Interactive flow
159171
import roboflow
@@ -181,6 +193,8 @@ def _login(args): # noqa: ANN001
181193
{"status": "logged_in", "workspace": ws, "api_key": "****"},
182194
text=f"Logged in. Default workspace: {ws}",
183195
)
196+
_print_completion_tip(args)
197+
_print_completion_tip(args)
184198

185199

186200
def _status(args): # noqa: ANN001
Lines changed: 63 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,88 @@
1-
"""Shell completion commands.
1+
"""Shell completion: install + raw script generators.
22
3-
Generates completion scripts for bash, zsh, and fish shells.
4-
Uses Click's built-in completion generation via the ``_ROBOFLOW_COMPLETE``
5-
environment variable.
3+
Delegates installation to ``typer.completion.install`` (which itself
4+
wraps Click's ``shell_completion`` and auto-detects the shell via
5+
shellingham). Hidden commands are filtered by Click automatically.
66
"""
77

88
from __future__ import annotations
99

10-
import sys
10+
import shutil
11+
from typing import Annotated, Optional
1112

1213
import click
1314
import typer
15+
from typer._completion_classes import completion_init
16+
from typer._completion_shared import get_completion_script
17+
from typer.completion import install as typer_install
1418

15-
from roboflow.cli._compat import SortedGroup
19+
from roboflow.cli._compat import SortedGroup, ctx_to_args
20+
from roboflow.cli._output import output, output_error
1621

17-
completion_app = typer.Typer(cls=SortedGroup, help="Generate shell completions", no_args_is_help=True)
22+
completion_app = typer.Typer(
23+
cls=SortedGroup,
24+
help="Generate and install shell completions",
25+
no_args_is_help=True,
26+
)
1827

28+
completion_init()
1929

20-
def _generate_completion(shell: str) -> None:
21-
"""Generate completion script for the given shell using Click's completion system."""
22-
from click.shell_completion import get_completion_class
2330

24-
comp_cls = get_completion_class(shell)
25-
if comp_cls is None:
26-
print(f"Shell '{shell}' is not supported for completion.", file=sys.stderr)
27-
raise typer.Exit(code=1)
28-
29-
from roboflow.cli import app
30-
31-
# Access the underlying Click command
32-
click_app = typer.main.get_command(app)
33-
ctx = click.Context(click_app, info_name="roboflow")
34-
comp = comp_cls(click_app, ctx, "roboflow", "_ROBOFLOW_COMPLETE") # type: ignore[arg-type]
35-
print(comp.source()) # noqa: T201
31+
def _generate_completion(shell: str) -> str:
32+
return get_completion_script(prog_name="roboflow", complete_var="_ROBOFLOW_COMPLETE", shell=shell)
3633

3734

3835
@completion_app.command("bash")
3936
def bash() -> None:
40-
"""Generate bash completion script.
41-
42-
Usage: eval "$(roboflow completion bash)"
43-
Or save to a file: roboflow completion bash > ~/.roboflow-complete.bash
44-
"""
45-
_generate_completion("bash")
37+
"""Print bash completion script. Usage: eval "$(roboflow completion bash)"."""
38+
print(_generate_completion("bash")) # noqa: T201
4639

4740

4841
@completion_app.command("zsh")
4942
def zsh() -> None:
50-
"""Generate zsh completion script.
51-
52-
Usage: eval "$(roboflow completion zsh)"
53-
Or save to a file: roboflow completion zsh > ~/.roboflow-complete.zsh
54-
"""
55-
_generate_completion("zsh")
43+
"""Print zsh completion script. Usage: eval "$(roboflow completion zsh)"."""
44+
print(_generate_completion("zsh")) # noqa: T201
5645

5746

5847
@completion_app.command("fish")
5948
def fish() -> None:
60-
"""Generate fish completion script.
61-
62-
Usage: roboflow completion fish | source
63-
Or save to a file: roboflow completion fish > ~/.config/fish/completions/roboflow.fish
64-
"""
65-
_generate_completion("fish")
49+
"""Print fish completion script. Usage: roboflow completion fish | source."""
50+
print(_generate_completion("fish")) # noqa: T201
51+
52+
53+
@completion_app.command("install")
54+
def install(
55+
ctx: typer.Context,
56+
shell: Annotated[
57+
Optional[str],
58+
typer.Option("--shell", help="bash, zsh, or fish. Auto-detected when omitted."),
59+
] = None,
60+
) -> None:
61+
"""Install shell completion. Writes the script and updates your shell rc. Idempotent."""
62+
args = ctx_to_args(ctx, shell=shell)
63+
64+
if shutil.which("roboflow") is None:
65+
output_error(
66+
args,
67+
"The 'roboflow' command is not on your PATH.",
68+
hint="Ensure your install bin directory (e.g. ~/.local/bin) is on PATH.",
69+
exit_code=1,
70+
)
71+
return
72+
73+
try:
74+
installed_shell, path = typer_install(shell=shell, prog_name="roboflow", complete_var="_ROBOFLOW_COMPLETE")
75+
except click.exceptions.Exit:
76+
output_error(
77+
args,
78+
"Could not detect or install completion.",
79+
hint="Pass --shell with one of: bash, zsh, fish.",
80+
exit_code=3,
81+
)
82+
return
83+
84+
output(
85+
args,
86+
{"shell": installed_shell, "path": str(path)},
87+
text=f"Installed {installed_shell} completion to {path}.\nOpen a new shell to enable it.",
88+
)

tests/cli/test_auth.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
"""Tests for the auth CLI handler."""
22

33
import re
4+
import types
45
import unittest
56

67
from typer.testing import CliRunner
78

89
from roboflow.cli import app
10+
from roboflow.cli.handlers import auth as auth_module
911

1012
runner = CliRunner()
1113

@@ -64,5 +66,34 @@ def test_mask_key(self) -> None:
6466
self.assertEqual(_mask_key(""), "****")
6567

6668

69+
class TestCompletionTip(unittest.TestCase):
70+
"""Verify the post-login completion tip honours --json and --quiet."""
71+
72+
def _capture(self, args_ns) -> str: # noqa: ANN001
73+
import io
74+
import sys
75+
76+
buf = io.StringIO()
77+
prev = sys.stdout
78+
sys.stdout = buf
79+
try:
80+
auth_module._print_completion_tip(args_ns)
81+
finally:
82+
sys.stdout = prev
83+
return buf.getvalue()
84+
85+
def test_tip_printed_in_normal_mode(self) -> None:
86+
out = self._capture(types.SimpleNamespace(json=False, quiet=False))
87+
self.assertIn("roboflow completion install", out)
88+
89+
def test_tip_suppressed_in_json_mode(self) -> None:
90+
out = self._capture(types.SimpleNamespace(json=True, quiet=False))
91+
self.assertEqual(out, "")
92+
93+
def test_tip_suppressed_in_quiet_mode(self) -> None:
94+
out = self._capture(types.SimpleNamespace(json=False, quiet=True))
95+
self.assertEqual(out, "")
96+
97+
6798
if __name__ == "__main__":
6899
unittest.main()

0 commit comments

Comments
 (0)