Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
24 changes: 24 additions & 0 deletions docs/tutorial/commands/help.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,30 @@ $ python main.py delete --help

</div>

## Suggest Commands

When users mistype a command name, Typer can suggest the correct command. This feature is **disabled by default**, but you can enable it with the parameter `suggest_commands=True`:

{* docs_src/commands/index/tutorial005.py hl[3] *}

Now if a user mistypes a command, they'll see a helpful suggestion:

<div class="termy">

```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'? │
╰──────────────────────────────────────────────────────────────────────────────╯
```

</div>

If there are multiple close matches, Typer will suggest them. 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`.
Expand Down
17 changes: 17 additions & 0 deletions docs_src/commands/index/tutorial005.py
Original file line number Diff line number Diff line change
@@ -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()
138 changes: 138 additions & 0 deletions tests/test_suggest_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import typer
from typer.testing import CliRunner

runner = CliRunner()


def test_typo_suggestion_disabled_by_default():
"""Test that typo suggestions are disabled by default"""
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" not in result.output


def test_typo_suggestion_enabled():
"""Test that typo suggestions work when enabled"""
app = typer.Typer(suggest_commands=True)

@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(suggest_commands=True)

@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(suggest_commands=True)

@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(suggest_commands=True)

@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_explicitly():
"""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


def test_typo_suggestion_multiple_similar_commands():
"""Test that multiple similar commands are suggested with quotes around each"""
app = typer.Typer(suggest_commands=True)

@app.command()
def start(): # pragma: no cover
typer.echo("Starting...")

@app.command()
def stop(): # pragma: no cover
typer.echo("Stopping...")

@app.command()
def status(): # pragma: no cover
typer.echo("Status...")

result = runner.invoke(app, ["sta"])
assert result.exit_code != 0
assert "No such command 'sta'" in result.output
assert "Did you mean 'start', 'status'?" in result.output
24 changes: 24 additions & 0 deletions tests/test_tutorial/test_commands/test_index/test_tutorial005.py
Original file line number Diff line number Diff line change
@@ -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
23 changes: 21 additions & 2 deletions typer/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
Expand Down Expand Up @@ -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 = False,
**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.rich_markup_mode: MarkupMode = rich_markup_mode
self.suggest_commands = suggest_commands

def format_options(
self, ctx: click.Context, formatter: click.HelpFormatter
Expand All @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions typer/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,15 @@ def __init__(
# Rich settings
rich_markup_mode: MarkupMode = Default(DEFAULT_MARKUP_MODE),
rich_help_panel: Union[str, None] = Default(None),
suggest_commands: bool = False,
pretty_exceptions_enable: bool = True,
pretty_exceptions_show_locals: bool = True,
pretty_exceptions_short: bool = True,
):
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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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, (
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading