Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/click/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from .exceptions import ClickException as ClickException
from .exceptions import FileError as FileError
from .exceptions import MissingParameter as MissingParameter
from .exceptions import NoSuchCommand as NoSuchCommand
from .exceptions import NoSuchOption as NoSuchOption
from .exceptions import UsageError as UsageError
from .formatting import HelpFormatter as HelpFormatter
Expand Down
4 changes: 2 additions & 2 deletions src/click/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from .exceptions import Exit
from .exceptions import MissingParameter
from .exceptions import NoArgsIsHelpError
from .exceptions import NoSuchCommand
from .exceptions import UsageError
from .formatting import HelpFormatter
from .formatting import join_options
Expand Down Expand Up @@ -1908,7 +1909,6 @@ def resolve_command(
self, ctx: Context, args: list[str]
) -> tuple[str | None, Command | None, list[str]]:
cmd_name = make_str(args[0])
original_cmd_name = cmd_name

# Get the command
cmd = self.get_command(ctx, cmd_name)
Expand All @@ -1928,7 +1928,7 @@ def resolve_command(
if cmd is None and not ctx.resilient_parsing:
if _split_opt(cmd_name)[0]:
self.parse_args(ctx, args)
ctx.fail(_("No such command {name!r}.").format(name=original_cmd_name))
raise NoSuchCommand(cmd_name, possibilities=self.commands, ctx=ctx)
return cmd_name if cmd else None, cmd, args[1:]

def shell_complete(self, ctx: Context, incomplete: str) -> list[CompletionItem]:
Expand Down
56 changes: 44 additions & 12 deletions src/click/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ def _join_param_hints(param_hint: cabc.Sequence[str] | str | None) -> str | None
return param_hint


def _format_possibilities(possibilities: list[str]) -> str:
possibility_str = ", ".join(repr(p) for p in sorted(possibilities))
return ngettext(
"Did you mean {possibility}?",
"(Did you mean one of: {possibilities}?)",
len(possibilities),
).format(possibility=possibility_str, possibilities=possibility_str)


class ClickException(Exception):
"""An exception that Click can handle and show to the user."""

Expand Down Expand Up @@ -206,8 +215,7 @@ def __str__(self) -> str:


class NoSuchOption(UsageError):
"""Raised if click attempted to handle an option that does not
exist.
"""Raised if Click attempted to handle an option that does not exist.

.. versionadded:: 4.0
"""
Expand All @@ -216,27 +224,51 @@ def __init__(
self,
option_name: str,
message: str | None = None,
possibilities: cabc.Sequence[str] | None = None,
possibilities: cabc.Iterable[str] | None = None,
ctx: Context | None = None,
) -> None:
if message is None:
message = _("No such option: {name}").format(name=option_name)
message = _("No such option {name!r}.").format(name=option_name)

super().__init__(message, ctx)
self.option_name = option_name
self.possibilities = possibilities
self.possibilities: list[str] | None = None
if possibilities:
from difflib import get_close_matches

self.possibilities = get_close_matches(option_name, possibilities)

def format_message(self) -> str:
if not self.possibilities:
return self.message
return f"{self.message} {_format_possibilities(self.possibilities)}"


class NoSuchCommand(UsageError):
"""Raised if Click attempted to handle a command that does not exist."""

def __init__(
self,
command_name: str,
message: str | None = None,
possibilities: cabc.Iterable[str] | None = None,
ctx: Context | None = None,
) -> None:
if message is None:
message = _("No such command {name!r}.").format(name=command_name)

super().__init__(message, ctx)
self.command_name = command_name
self.possibilities: list[str] | None = None
if possibilities:
from difflib import get_close_matches

possibility_str = ", ".join(sorted(self.possibilities))
suggest = ngettext(
"Did you mean {possibility}?",
"(Possible options: {possibilities})",
len(self.possibilities),
).format(possibility=possibility_str, possibilities=possibility_str)
return f"{self.message} {suggest}"
self.possibilities = get_close_matches(command_name, possibilities)

def format_message(self) -> str:
if not self.possibilities:
return self.message
return f"{self.message} {_format_possibilities(self.possibilities)}"


class BadOptionUsage(UsageError):
Expand Down
5 changes: 1 addition & 4 deletions src/click/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,10 +360,7 @@ def _match_long_opt(
self, opt: str, explicit_value: str | None, state: _ParsingState
) -> None:
if opt not in self._long_opt:
from difflib import get_close_matches

possibilities = get_close_matches(opt, self._long_opt)
raise NoSuchOption(opt, possibilities=possibilities, ctx=self.ctx)
raise NoSuchOption(opt, possibilities=self._long_opt, ctx=self.ctx)

option = self._long_opt[opt]
if option.takes_value:
Expand Down
32 changes: 32 additions & 0 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -573,3 +573,35 @@ def cli():
assert rv.exit_code == 1
assert isinstance(rv.exception.__cause__, exc)
assert rv.exception.__cause__.args == ("catch me!",)


def test_unknown_command(runner):
result = runner.invoke(click.Group(), "unknown")
assert result.exception
assert "No such command 'unknown'." in result.output


@pytest.mark.parametrize(
("value", "expect"),
[
("pause", "Did you mean 'push'?"),
("decline", "(Did you mean one of: 'declare', 'refine'?)"),
],
)
def test_suggest_possible_commands(runner, value, expect):
cli = click.Group()

@cli.command()
def push():
pass

@cli.command()
def declare():
pass

@cli.command()
def refine():
pass

result = runner.invoke(cli, [value])
assert expect in result.output
8 changes: 4 additions & 4 deletions tests/test_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,15 +141,15 @@ def cli():

result = runner.invoke(cli, [unknown_flag])
assert result.exception
assert f"No such option: {unknown_flag}" in result.output
assert f"No such option '{unknown_flag}'." in result.output


@pytest.mark.parametrize(
("value", "expect"),
[
("--cat", "Did you mean --count?"),
("--bounds", "(Possible options: --bound, --count)"),
("--bount", "(Possible options: --bound, --count)"),
("--cat", "Did you mean '--count'?"),
("--bounds", "(Did you mean one of: '--bound', '--count'?)"),
("--bount", "(Did you mean one of: '--bound', '--count'?)"),
],
)
def test_suggest_possible_options(runner, value, expect):
Expand Down
Loading