Skip to content

Commit 4e2337d

Browse files
[CLI] enrich CLI errors with available options and commands (#4034)
* cli unknown option/command hints * fix * Enrich CLI errors with available options and commands * docstring * nit: add error.ctx is None guard --------- Co-authored-by: Lucain <lucain@huggingface.co>
1 parent ea1f4b7 commit 4e2337d

1 file changed

Lines changed: 75 additions & 1 deletion

File tree

src/huggingface_hub/cli/_cli_utils.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import dataclasses
1717
import datetime
18+
import difflib
1819
import importlib.metadata
1920
import json
2021
import os
@@ -107,8 +108,40 @@ class HFCliTyperGroup(TyperGroup):
107108
- supports aliases via pipe-separated names (e.g. ``name="list | ls"``).
108109
- rewrites ``--json`` to ``--format json`` for commands that accept ``--format``.
109110
- rewrites ``spaces/user/repo`` to ``user/repo --type space`` for commands that accept ``--type``.
111+
- enriches "No such option" / "No such command" errors with available options or commands.
110112
"""
111113

114+
def invoke(self, ctx: click.Context) -> None:
115+
"""Enrich unknown-option errors with available options or subcommands.
116+
117+
Catches `NoSuchOption` raised during subcommand `make_context()`
118+
(option parsing). For leaf commands (e.g. `hf repos create --test`)
119+
we list the command's options; for groups (e.g. `hf cache --test`)
120+
we list subcommands since groups have no user-facing options.
121+
"""
122+
try:
123+
return super().invoke(ctx)
124+
except click.NoSuchOption as e:
125+
if e.ctx is not None and e.ctx.command is not None:
126+
cmd = e.ctx.command
127+
if isinstance(cmd, click.Group):
128+
# Group has no user-facing options -> show subcommands instead
129+
items = [
130+
(name, sub.get_short_help_str(limit=80))
131+
for name in cmd.list_commands(e.ctx)
132+
if (sub := cmd.get_command(e.ctx, name)) is not None and not sub.hidden
133+
]
134+
_enrich_usage_error(e, "commands", items)
135+
else:
136+
# Leaf command -> show its options using Click's rich formatting
137+
items = [
138+
record
139+
for p in cmd.get_params(e.ctx)
140+
if isinstance(p, click.Option) and not p.hidden and (record := p.get_help_record(e.ctx))
141+
]
142+
_enrich_usage_error(e, "options", items)
143+
raise
144+
112145
def resolve_command(self, ctx: click.Context, args: list[str]) -> tuple:
113146
cmd_name = args[0] if args and not args[0].startswith("-") else None
114147
cmd = self.get_command(ctx, cmd_name) if cmd_name else None
@@ -118,7 +151,29 @@ def resolve_command(self, ctx: click.Context, args: list[str]) -> tuple:
118151
self._rewrite_quiet_shorthand(cmd, args)
119152
self._rewrite_repo_type_prefix(cmd, args)
120153

121-
return super().resolve_command(ctx, args)
154+
try:
155+
return super().resolve_command(ctx, args)
156+
except click.UsageError as e:
157+
# Unknown subcommand -> add fuzzy suggestions and list available commands.
158+
if cmd is None and cmd_name is not None:
159+
# Expand aliases ("list | ls" → ["list", "ls"]) for accurate fuzzy matching.
160+
visible_names = [
161+
alias
162+
for key, registered in self.commands.items()
163+
if not registered.hidden
164+
for alias in _ALIAS_SPLIT.split(key)
165+
]
166+
matches = difflib.get_close_matches(cmd_name, visible_names)
167+
if matches:
168+
suggestions = ", ".join(f"'{m}'" for m in matches)
169+
e.message = f"{e.message.rstrip('.')}. Did you mean {suggestions}?"
170+
items = [
171+
(name, sub.get_short_help_str(limit=80))
172+
for name in self.list_commands(ctx)
173+
if (sub := self.get_command(ctx, name)) is not None and not sub.hidden
174+
]
175+
_enrich_usage_error(e, "commands", items)
176+
raise
122177

123178
@staticmethod
124179
def _rewrite_json_shorthand(cmd: click.Command, args: list[str]) -> None:
@@ -327,6 +382,21 @@ def list_commands(self, ctx: click.Context) -> list[str]: # type: ignore[name-d
327382
return sorted(primary_names)
328383

329384

385+
def _enrich_usage_error(error: click.UsageError, label: str, items: list[tuple[str, str]]) -> None:
386+
"""Append a list of available options or commands to a usage error message."""
387+
if not items or error.ctx is None or f"Available {label} for" in error.message:
388+
return
389+
cmd_path = error.ctx.command_path
390+
lines = [f"\n\nAvailable {label} for '{cmd_path}':"]
391+
for name, help_text in items:
392+
lines.append(f" {name:30s} {help_text}")
393+
lines.append(f"\nRun '{cmd_path} --help' for full details.")
394+
if isinstance(error, click.NoSuchOption) and error.possibilities:
395+
lines.append(f"\nDid you mean: {', '.join(sorted(error.possibilities))}?")
396+
error.possibilities = []
397+
error.message += "\n".join(lines)
398+
399+
330400
def fallback_typer_group_factory(
331401
fallback_handler: FallbackHandlerT,
332402
extra_commands_provider: Callable[[], list[tuple[str, str]]] | None = None,
@@ -428,6 +498,10 @@ def typer_factory(help: str, epilog: str | None = None, cls: type[TyperGroup] |
428498
rich_markup_mode=None,
429499
rich_help_panel=None,
430500
pretty_exceptions_enable=False,
501+
# Disable TyperGroup's suggest_commands, it matches against raw aliased
502+
# keys ("list | ls") leaking pipe syntax into user-facing messages.
503+
# HFCliTyperGroup.resolve_command() handles suggestions with expanded names.
504+
suggest_commands=False,
431505
# Increase max content width for better readability
432506
context_settings={
433507
"max_content_width": 120,

0 commit comments

Comments
 (0)