diff --git a/docs/tutorial/commands/help.md b/docs/tutorial/commands/help.md index d5ec229d68..3a0e7170ad 100644 --- a/docs/tutorial/commands/help.md +++ b/docs/tutorial/commands/help.md @@ -176,6 +176,30 @@ $ python main.py delete --help +## Suggest Commands + +As of version 0.20.0, Typer added support for suggesting mistyped command names. This feature is **enabled by default**, but you can disable it with the parameter `suggest_commands=False`: + +{* docs_src/commands/index/tutorial005.py hl[3] *} + +If a user mistypes a command, they'll see a helpful suggestion: + +
+ +```console +$ python main.py crate + +Usage: main.py [OPTIONS] COMMAND [ARGS]... +Try 'main.py --help' for help. +╭─ Error ───────────────────────────────────────────────────────────╮ + No such command 'crate'. Did you mean 'create'? +╰───────────────────────────────────────────────────────────────────╯ +``` + +
+ +If there are multiple close matches, Typer will suggest them all. This feature uses Python's built-in `difflib.get_close_matches()` to find similar command names, making your CLI more user-friendly by helping users recover from typos. + ## Rich Markdown and Markup If you have **Rich** installed as described in [Printing and Colors](../printing.md){.internal-link target=_blank}, you can configure your app to enable markup text with the parameter `rich_markup_mode`. diff --git a/docs_src/commands/index/tutorial005.py b/docs_src/commands/index/tutorial005.py new file mode 100644 index 0000000000..ef9e5ded7b --- /dev/null +++ b/docs_src/commands/index/tutorial005.py @@ -0,0 +1,17 @@ +import typer + +app = typer.Typer(suggest_commands=True) + + +@app.command() +def create(): + typer.echo("Creating...") + + +@app.command() +def delete(): + typer.echo("Deleting...") + + +if __name__ == "__main__": + app() diff --git a/tests/test_suggest_commands.py b/tests/test_suggest_commands.py new file mode 100644 index 0000000000..71f2398bb9 --- /dev/null +++ b/tests/test_suggest_commands.py @@ -0,0 +1,98 @@ +import typer +from typer.testing import CliRunner + +runner = CliRunner() + + +def test_typo_suggestion_enabled(): + """Test that typo suggestions work when enabled""" + app = typer.Typer() + + @app.command() + def create(): # pragma: no cover + typer.echo("Creating...") + + @app.command() + def delete(): # pragma: no cover + typer.echo("Deleting...") + + result = runner.invoke(app, ["crate"]) + assert result.exit_code != 0 + assert "No such command" in result.output + assert "Did you mean 'create'?" in result.output + + +def test_typo_suggestion_multiple_matches(): + """Test that multiple suggestions are shown when there are multiple close matches""" + app = typer.Typer() + + @app.command() + def create(): # pragma: no cover + typer.echo("Creating...") + + @app.command() + def createnew(): # pragma: no cover + typer.echo("Creating new...") + + result = runner.invoke(app, ["crate"]) + assert result.exit_code != 0 + assert "No such command" in result.output + assert "Did you mean" in result.output + assert "create" in result.output and "createnew" in result.output + + +def test_typo_suggestion_no_matches(): + """Test that no suggestions are shown when there are no close matches""" + app = typer.Typer() + + @app.command() + def create(): # pragma: no cover + typer.echo("Creating...") + + @app.command() + def delete(): # pragma: no cover + typer.echo("Deleting...") + + result = runner.invoke(app, ["xyz"]) + assert result.exit_code != 0 + assert "No such command" in result.output + assert "Did you mean" not in result.output + + +def test_typo_suggestion_exact_match_works(): + """Test that exact matches still work normally""" + app = typer.Typer() + + @app.command() + def create(): + typer.echo("Creating...") + + @app.command() + def delete(): + typer.echo("Deleting...") + + result = runner.invoke(app, ["create"]) + assert result.exit_code == 0 + assert "Creating..." in result.output + + result = runner.invoke(app, ["delete"]) + assert result.exit_code == 0 + assert "Deleting..." in result.output + + +def test_typo_suggestion_disabled(): + """Test that typo suggestions can be explicitly disabled""" + app = typer.Typer(suggest_commands=False) + + @app.command() + def create(): # pragma: no cover + typer.echo("Creating...") + + @app.command() + def delete(): # pragma: no cover + typer.echo("Deleting...") + + result = runner.invoke(app, ["crate"]) + assert result.exit_code != 0 + assert "No such command" in result.output + assert "Did you mean" not in result.output diff --git a/tests/test_tutorial/test_commands/test_index/test_tutorial005.py b/tests/test_tutorial/test_commands/test_index/test_tutorial005.py new file mode 100644 index 0000000000..4f97c74ad9 --- /dev/null +++ b/tests/test_tutorial/test_commands/test_index/test_tutorial005.py @@ -0,0 +1,24 @@ +from typer.testing import CliRunner + +from docs_src.commands.index import tutorial005 as mod + +app = mod.app +runner = CliRunner() + + +def test_creates_successfully(): + """Verify the example runs without errors""" + result = runner.invoke(app, ["create"]) + assert result.exit_code == 0 + assert "Creating..." in result.output + + result = runner.invoke(app, ["delete"]) + assert result.exit_code == 0 + assert "Deleting..." in result.output + + +def test_shows_suggestion(): + """Verify command suggestions appear for typos""" + result = runner.invoke(app, ["crate"]) + assert result.exit_code != 0 + assert "Did you mean 'create'?" in result.output diff --git a/typer/core.py b/typer/core.py index 048f28c137..e9631e56cf 100644 --- a/typer/core.py +++ b/typer/core.py @@ -3,6 +3,7 @@ import inspect import os import sys +from difflib import get_close_matches from enum import Enum from gettext import gettext as _ from typing import ( @@ -22,7 +23,6 @@ import click import click.core import click.formatting -import click.parser import click.shell_completion import click.types import click.utils @@ -750,11 +750,13 @@ def __init__( # Rich settings rich_markup_mode: MarkupMode = DEFAULT_MARKUP_MODE, rich_help_panel: Union[str, None] = None, + suggest_commands: bool = True, **attrs: Any, ) -> None: super().__init__(name=name, commands=commands, **attrs) self.rich_markup_mode: MarkupMode = rich_markup_mode self.rich_help_panel = rich_help_panel + self.suggest_commands = suggest_commands def format_options( self, ctx: click.Context, formatter: click.HelpFormatter @@ -772,6 +774,23 @@ def _main_shell_completion( self, ctx_args=ctx_args, prog_name=prog_name, complete_var=complete_var ) + def resolve_command( + self, ctx: click.Context, args: List[str] + ) -> Tuple[Optional[str], Optional[click.Command], List[str]]: + try: + return super().resolve_command(ctx, args) + except click.UsageError as e: + if self.suggest_commands: + available_commands = list(self.commands.keys()) + if available_commands and args: + typo = args[0] + matches = get_close_matches(typo, available_commands) + if matches: + suggestions = ", ".join(f"{m!r}" for m in matches) + message = e.message.rstrip(".") + e.message = f"{message}. Did you mean {suggestions}?" + raise + def main( self, args: Optional[Sequence[str]] = None, diff --git a/typer/main.py b/typer/main.py index 2fdc09e1ac..71a25e6c4b 100644 --- a/typer/main.py +++ b/typer/main.py @@ -135,6 +135,7 @@ def __init__( # Rich settings rich_markup_mode: MarkupMode = Default(DEFAULT_MARKUP_MODE), rich_help_panel: Union[str, None] = Default(None), + suggest_commands: bool = True, pretty_exceptions_enable: bool = True, pretty_exceptions_show_locals: bool = True, pretty_exceptions_short: bool = True, @@ -142,6 +143,7 @@ def __init__( self._add_completion = add_completion self.rich_markup_mode: MarkupMode = rich_markup_mode self.rich_help_panel = rich_help_panel + self.suggest_commands = suggest_commands self.pretty_exceptions_enable = pretty_exceptions_enable self.pretty_exceptions_show_locals = pretty_exceptions_show_locals self.pretty_exceptions_short = pretty_exceptions_short @@ -330,6 +332,7 @@ def get_group(typer_instance: Typer) -> TyperGroup: TyperInfo(typer_instance), pretty_exceptions_short=typer_instance.pretty_exceptions_short, rich_markup_mode=typer_instance.rich_markup_mode, + suggest_commands=typer_instance.suggest_commands, ) return group @@ -456,6 +459,7 @@ def get_group_from_info( group_info: TyperInfo, *, pretty_exceptions_short: bool, + suggest_commands: bool, rich_markup_mode: MarkupMode, ) -> TyperGroup: assert group_info.typer_instance, ( @@ -475,6 +479,7 @@ def get_group_from_info( sub_group_info, pretty_exceptions_short=pretty_exceptions_short, rich_markup_mode=rich_markup_mode, + suggest_commands=suggest_commands, ) if sub_group.name: commands[sub_group.name] = sub_group @@ -523,6 +528,7 @@ def get_group_from_info( rich_markup_mode=rich_markup_mode, # Rich settings rich_help_panel=solved_info.rich_help_panel, + suggest_commands=suggest_commands, ) return group