diff --git a/docs/tutorial/commands/name.md b/docs/tutorial/commands/name.md index 13b25ba7a3..2c692bde95 100644 --- a/docs/tutorial/commands/name.md +++ b/docs/tutorial/commands/name.md @@ -54,3 +54,91 @@ def create_user(username: str): ... ``` Then the command name will be `create-user`. + +## Command Aliases + +You can define aliases for commands so users can call them with different names. + +### Positional Aliases + +Pass additional positional arguments to `@app.command()`: + +{* docs_src/commands/name/tutorial002_py310.py hl[6,9] *} + +The `list` command can be called with `list` or `ls`: + +
+ +```console +$ python main.py --help + +Usage: main.py [OPTIONS] COMMAND [ARGS]... + +Options: + --install-completion Install completion for the current shell. + --show-completion Show completion for the current shell, to copy it or customize the installation. + --help Show this message and exit. + +Commands: + list, ls + remove, rm, delete + +$ python main.py list + +Listing items + +$ python main.py ls + +Listing items + +$ python main.py remove + +Removing items + +$ python main.py rm + +Removing items + +$ python main.py delete + +Removing items +``` + +
+ +### Keyword Aliases + +Use the `aliases` parameter: + +{* docs_src/commands/name/tutorial002_py310.py hl[9] *} + +Positional aliases and the `aliases` parameter can be combined. + +### Hidden Aliases + +Use `hidden_aliases` for aliases that work but don't appear in help: + +{* docs_src/commands/name/tutorial003_py310.py hl[6] *} + +
+ +```console +$ python main.py --help + +Usage: main.py [OPTIONS] COMMAND [ARGS]... + +Options: + --install-completion Install completion for the current shell. + --show-completion Show completion for the current shell, to copy it or customize the installation. + --help Show this message and exit. + +Commands: + list, ls + remove + +$ python main.py secretlist + +Listing items +``` + +
diff --git a/docs_src/commands/name/tutorial002_py310.py b/docs_src/commands/name/tutorial002_py310.py new file mode 100644 index 0000000000..e8f97439aa --- /dev/null +++ b/docs_src/commands/name/tutorial002_py310.py @@ -0,0 +1,17 @@ +import typer + +app = typer.Typer() + + +@app.command("list", "ls") +def list_items(): + print("Listing items") + + +@app.command("remove", aliases=["rm", "delete"]) +def remove_items(): + print("Removing items") + + +if __name__ == "__main__": + app() diff --git a/docs_src/commands/name/tutorial003_py310.py b/docs_src/commands/name/tutorial003_py310.py new file mode 100644 index 0000000000..7f88527272 --- /dev/null +++ b/docs_src/commands/name/tutorial003_py310.py @@ -0,0 +1,17 @@ +import typer + +app = typer.Typer() + + +@app.command("list", "ls", hidden_aliases=["secretlist"]) +def list_items(): + print("Listing items") + + +@app.command("remove") +def remove_items(): + print("Removing items") + + +if __name__ == "__main__": + app() diff --git a/tests/test_commands_aliases.py b/tests/test_commands_aliases.py new file mode 100644 index 0000000000..fca2913f40 --- /dev/null +++ b/tests/test_commands_aliases.py @@ -0,0 +1,459 @@ +import pytest +import typer +import typer.core +from typer.testing import CliRunner + +runner = CliRunner() + + +def test_command_aliases_positional(): + app = typer.Typer() + + @app.command("list", "ls") + def list_items(): + print("listed") + + @app.command("remove") + def remove_items(): + pass # pragma: no cover + + result = runner.invoke(app, ["list"]) + assert result.exit_code == 0 + assert "listed" in result.stdout + + result = runner.invoke(app, ["ls"]) + assert result.exit_code == 0 + assert "listed" in result.stdout + + +def test_command_aliases_keyword(): + app = typer.Typer() + + @app.command("list", aliases=["ls", "l"]) + def list_items(): + print("listed") + + @app.command("remove") + def remove_items(): + pass # pragma: no cover + + result = runner.invoke(app, ["list"]) + assert result.exit_code == 0 + assert "listed" in result.stdout + + result = runner.invoke(app, ["ls"]) + assert result.exit_code == 0 + assert "listed" in result.stdout + + result = runner.invoke(app, ["l"]) + assert result.exit_code == 0 + assert "listed" in result.stdout + + +def test_command_aliases_combined(): + app = typer.Typer() + + @app.command("list", "ls", aliases=["l"]) + def list_items(): + print("listed") + + @app.command("remove") + def remove_items(): + pass # pragma: no cover + + result = runner.invoke(app, ["list"]) + assert result.exit_code == 0 + assert "listed" in result.stdout + + result = runner.invoke(app, ["ls"]) + assert result.exit_code == 0 + assert "listed" in result.stdout + + result = runner.invoke(app, ["l"]) + assert result.exit_code == 0 + assert "listed" in result.stdout + + +def test_command_aliases_help_output(): + app = typer.Typer() + + @app.command("list", "ls") + def list_items(): + pass # pragma: no cover + + @app.command("remove", aliases=["rm", "delete"]) + def remove_items(): + pass # pragma: no cover + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "list, ls" in result.stdout or "ls, list" in result.stdout + assert ( + "remove, rm, delete" in result.stdout or "rm, delete, remove" in result.stdout + ) + + +def test_command_hidden_aliases(): + app = typer.Typer() + + @app.command("list", "ls", hidden_aliases=["secretlist"]) + def list_items(): + pass # pragma: no cover + + @app.command("remove") + def remove_items(): + pass # pragma: no cover + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "list, ls" in result.stdout or "ls, list" in result.stdout + assert "secretlist" not in result.stdout + + result = runner.invoke(app, ["secretlist"]) + assert result.exit_code == 0 + + +def test_command_aliases_subcommands(): + app = typer.Typer() + + @app.command("versions", "ver", "v") + def show_versions(): + print("versions") + + @app.command("documents", aliases=["docs"]) + def show_documents(): + print("documents") + + result = runner.invoke(app, ["versions"]) + assert result.exit_code == 0 + assert "versions" in result.stdout + + result = runner.invoke(app, ["ver"]) + assert result.exit_code == 0 + assert "versions" in result.stdout + + result = runner.invoke(app, ["v"]) + assert result.exit_code == 0 + assert "versions" in result.stdout + + result = runner.invoke(app, ["documents"]) + assert result.exit_code == 0 + assert "documents" in result.stdout + + result = runner.invoke(app, ["docs"]) + assert result.exit_code == 0 + assert "documents" in result.stdout + + +def test_command_no_aliases_help_output(): + app = typer.Typer() + + @app.command("list") + def list_items(): + pass # pragma: no cover + + @app.command("remove") + def remove_items(): + pass # pragma: no cover + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert " list" in result.stdout or "list " in result.stdout + assert " remove" in result.stdout or "remove " in result.stdout + + +def test_command_empty_aliases_list(): + app = typer.Typer() + + @app.command("list", aliases=[]) + def list_items(): + print("listed") + + @app.command("remove") + def remove_items(): + pass # pragma: no cover + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "list" in result.stdout + assert "remove" in result.stdout + + result = runner.invoke(app, ["list"]) + assert result.exit_code == 0 + assert "listed" in result.stdout + + +def test_multiple_commands_with_aliases(): + app = typer.Typer() + + @app.command("cmd1", "c1") + def command1(): + pass # pragma: no cover + + @app.command("cmd2", aliases=["c2"]) + def command2(): + pass # pragma: no cover + + @app.command("cmd3") + def command3(): + pass # pragma: no cover + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "cmd1, c1" in result.stdout or "c1, cmd1" in result.stdout + assert "cmd2, c2" in result.stdout or "c2, cmd2" in result.stdout + assert "cmd3" in result.stdout + + +def test_commands_list_deduplication(): + app = typer.Typer() + + @app.command("same", "alias1") + def cmd1(): + pass # pragma: no cover + + @app.command("other", "alias2") + def cmd2(): + pass # pragma: no cover + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + commands_output = result.stdout + assert "same" in commands_output + assert "other" in commands_output + assert commands_output.count("same") == 1 + + +def test_list_commands_covers_all_branches(): + app = typer.Typer() + + @app.command("cmd1") + def command1(): + pass # pragma: no cover + + @app.command("cmd2", "alias") + def command2(): + pass # pragma: no cover + + @app.command("cmd3", aliases=["a3"]) + def command3(): + pass # pragma: no cover + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "cmd1" in result.stdout + assert "cmd2" in result.stdout or "alias" in result.stdout + assert "cmd3" in result.stdout or "a3" in result.stdout + + +def test_commands_with_hidden_and_aliases(): + app = typer.Typer() + + @app.command("visible", "v", aliases=["vis"]) + def visible_cmd(): + pass # pragma: no cover + + @app.command("hidden", hidden=True) + def hidden_cmd(): + pass # pragma: no cover + + @app.command("another", aliases=["a"]) + def another_cmd(): + pass # pragma: no cover + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "visible" in result.stdout or "v" in result.stdout or "vis" in result.stdout + assert "hidden" not in result.stdout + assert "another" in result.stdout or "a" in result.stdout + + +def test_comprehensive_alias_scenarios(): + app = typer.Typer() + + @app.command("a1", "a2", aliases=["a3", "a4"]) + def cmd_a(): + pass # pragma: no cover + + @app.command("b1", hidden_aliases=["b2"]) + def cmd_b(): + pass # pragma: no cover + + @app.command("c1") + def cmd_c(): + pass # pragma: no cover + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert ( + "a1" in result.stdout + or "a2" in result.stdout + or "a3" in result.stdout + or "a4" in result.stdout + ) + assert "b1" in result.stdout + assert "b2" not in result.stdout + assert "c1" in result.stdout + + result = runner.invoke(app, ["a1"]) + assert result.exit_code == 0 + + result = runner.invoke(app, ["a2"]) + assert result.exit_code == 0 + + result = runner.invoke(app, ["a3"]) + assert result.exit_code == 0 + + result = runner.invoke(app, ["b2"]) + assert result.exit_code == 0 + + +def test_list_commands_deduplication_with_aliases(): + app = typer.Typer() + + @app.command("main1", "alias1", aliases=["a1"]) + def cmd1(): + pass # pragma: no cover + + @app.command("main2", "alias2") + def cmd2(): + pass # pragma: no cover + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert ( + "main1" in result.stdout or "alias1" in result.stdout or "a1" in result.stdout + ) + assert "main2" in result.stdout or "alias2" in result.stdout + assert result.stdout.count("main1") <= 1 + assert result.stdout.count("main2") <= 1 + + result = runner.invoke(app, ["main1"]) + assert result.exit_code == 0 + + result = runner.invoke(app, ["alias1"]) + assert result.exit_code == 0 + + result = runner.invoke(app, ["a1"]) + assert result.exit_code == 0 + + +def test_format_commands_with_aliases_no_rich(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(typer.core, "HAS_RICH", False) + + app = typer.Typer() + + @app.command("list", "ls", aliases=["l"]) + def list_items(): + pass # pragma: no cover + + @app.command("remove", aliases=["rm"]) + def remove_items(): + pass # pragma: no cover + + @app.command("create") + def create_items(): + pass # pragma: no cover + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "list" in result.stdout or "ls" in result.stdout or "l" in result.stdout + assert "remove" in result.stdout or "rm" in result.stdout + assert "create" in result.stdout + + +def test_format_commands_no_aliases_no_rich(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(typer.core, "HAS_RICH", False) + + app = typer.Typer() + + @app.command("list") + def list_items(): + pass # pragma: no cover + + @app.command("remove") + def remove_items(): + pass # pragma: no cover + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "list" in result.stdout + assert "remove" in result.stdout + + +def test_format_commands_with_hidden_no_rich(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(typer.core, "HAS_RICH", False) + + app = typer.Typer() + + @app.command("visible") + def visible_cmd(): + pass # pragma: no cover + + @app.command("hidden", hidden=True) + def hidden_cmd(): + pass # pragma: no cover + + @app.command("another") + def another_cmd(): + pass # pragma: no cover + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "visible" in result.stdout + assert "hidden" not in result.stdout + assert "another" in result.stdout + + +def test_format_commands_empty_no_rich(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(typer.core, "HAS_RICH", False) + + app = typer.Typer() + + @app.callback(invoke_without_command=True) + def main(): + pass # pragma: no cover + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Commands" not in result.stdout or "Commands:" not in result.stdout + + +def test_format_help_command_no_rich(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(typer.core, "HAS_RICH", False) + + app = typer.Typer() + + @app.command(help="Test command") + def test_cmd(): + pass # pragma: no cover + + result = runner.invoke(app, ["test-cmd", "--help"]) + assert result.exit_code == 0 + assert "Test command" in result.stdout + + +def test_format_help_group_no_rich(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(typer.core, "HAS_RICH", False) + + app = typer.Typer(help="Test group") + + @app.command() + def test_cmd(): + pass # pragma: no cover + + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Test group" in result.stdout or "Usage:" in result.stdout + + +def test_format_help_rich_markup_mode_none(): + app = typer.Typer(rich_markup_mode=None) + + @app.command(help="Test command") + def test_cmd(): + pass # pragma: no cover + + result = runner.invoke(app, ["test-cmd", "--help"]) + assert result.exit_code == 0 + assert "Test command" in result.stdout diff --git a/tests/test_tutorial/test_commands/test_name/test_tutorial002.py b/tests/test_tutorial/test_commands/test_name/test_tutorial002.py new file mode 100644 index 0000000000..efa07fe122 --- /dev/null +++ b/tests/test_tutorial/test_commands/test_name/test_tutorial002.py @@ -0,0 +1,47 @@ +from typer.testing import CliRunner + +from docs_src.commands.name import tutorial002_py310 as mod + +app = mod.app + +runner = CliRunner() + + +def test_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Commands" in result.output + assert "list" in result.output or "ls" in result.output + assert ( + "remove" in result.output or "rm" in result.output or "delete" in result.output + ) + + +def test_list(): + result = runner.invoke(app, ["list"]) + assert result.exit_code == 0 + assert "Listing items" in result.output + + +def test_ls(): + result = runner.invoke(app, ["ls"]) + assert result.exit_code == 0 + assert "Listing items" in result.output + + +def test_remove(): + result = runner.invoke(app, ["remove"]) + assert result.exit_code == 0 + assert "Removing items" in result.output + + +def test_rm(): + result = runner.invoke(app, ["rm"]) + assert result.exit_code == 0 + assert "Removing items" in result.output + + +def test_delete(): + result = runner.invoke(app, ["delete"]) + assert result.exit_code == 0 + assert "Removing items" in result.output diff --git a/tests/test_tutorial/test_commands/test_name/test_tutorial003.py b/tests/test_tutorial/test_commands/test_name/test_tutorial003.py new file mode 100644 index 0000000000..5bb8376c7a --- /dev/null +++ b/tests/test_tutorial/test_commands/test_name/test_tutorial003.py @@ -0,0 +1,40 @@ +from typer.testing import CliRunner + +from docs_src.commands.name import tutorial003_py310 as mod + +app = mod.app + +runner = CliRunner() + + +def test_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Commands" in result.output + assert "list" in result.output or "ls" in result.output + assert "remove" in result.output + assert "secretlist" not in result.output + + +def test_list(): + result = runner.invoke(app, ["list"]) + assert result.exit_code == 0 + assert "Listing items" in result.output + + +def test_ls(): + result = runner.invoke(app, ["ls"]) + assert result.exit_code == 0 + assert "Listing items" in result.output + + +def test_secretlist(): + result = runner.invoke(app, ["secretlist"]) + assert result.exit_code == 0 + assert "Listing items" in result.output + + +def test_remove(): + result = runner.invoke(app, ["remove"]) + assert result.exit_code == 0 + assert "Removing items" in result.output diff --git a/typer/core.py b/typer/core.py index 3e72d83989..5ebe350e9b 100644 --- a/typer/core.py +++ b/typer/core.py @@ -756,6 +756,30 @@ def format_options( _typer_format_options(self, ctx=ctx, formatter=formatter) self.format_commands(ctx, formatter) + def format_commands( + self, ctx: click.Context, formatter: click.HelpFormatter + ) -> None: + commands = [] + for name in self.list_commands(ctx): + cmd = self.get_command(ctx, name) + if cmd is None or cmd.hidden: + continue + aliases = getattr(cmd, "_typer_aliases", []) + hidden_aliases = getattr(cmd, "_typer_hidden_aliases", []) + visible_aliases = [a for a in aliases if a not in hidden_aliases] + + if visible_aliases: + cmd_name = ", ".join([name] + visible_aliases) + else: + cmd_name = name + + help_text = cmd.short_help or cmd.help or "" + commands.append((cmd_name, help_text)) + + if commands: + with formatter.section(_("Commands")): + formatter.write_dl(commands) + def _main_shell_completion( self, ctx_args: MutableMapping[str, Any], @@ -818,4 +842,10 @@ def list_commands(self, ctx: click.Context) -> list[str]: """Returns a list of subcommand names. Note that in Click's Group class, these are sorted. In Typer, we wish to maintain the original order of creation (cf Issue #933)""" - return [n for n, c in self.commands.items()] + seen = set() + result = [] + for _name, cmd in self.commands.items(): + if cmd.name and cmd.name not in seen: + seen.add(cmd.name) + result.append(cmd.name) + return result diff --git a/typer/main.py b/typer/main.py index f4f21bb844..5ad99e4fd9 100644 --- a/typer/main.py +++ b/typer/main.py @@ -758,7 +758,7 @@ def command( """ ), ] = None, - *, + *positional_aliases: str, cls: Annotated[ type[TyperCommand] | None, Doc( @@ -850,6 +850,8 @@ def command( """ ), ] = False, + aliases: Sequence[str] | None = None, + hidden_aliases: Sequence[str] | None = None, # Rich settings rich_help_panel: Annotated[ str | None, @@ -885,6 +887,12 @@ def delete(): if cls is None: cls = TyperCommand + all_aliases_list: list[str] = [] + if positional_aliases: + all_aliases_list.extend(positional_aliases) + if aliases: + all_aliases_list.extend(aliases) + def decorator(f: CommandFunctionType) -> CommandFunctionType: self.registered_commands.append( CommandInfo( @@ -902,6 +910,8 @@ def decorator(f: CommandFunctionType) -> CommandFunctionType: no_args_is_help=no_args_is_help, hidden=hidden, deprecated=deprecated, + aliases=all_aliases_list if all_aliases_list else None, + hidden_aliases=hidden_aliases, # Rich settings rich_help_panel=rich_help_panel, ) @@ -1297,6 +1307,10 @@ def get_group_from_info( ) if command.name: commands[command.name] = command + cmd_aliases = getattr(command, "_typer_aliases", []) + cmd_hidden_aliases = getattr(command, "_typer_hidden_aliases", []) + for alias in cmd_aliases + cmd_hidden_aliases: + commands[alias] = command for sub_group_info in group_info.typer_instance.registered_groups: sub_group = get_group_from_info( sub_group_info, @@ -1421,6 +1435,8 @@ def get_command_from_info( # Rich settings rich_help_panel=command_info.rich_help_panel, ) + command._typer_aliases = command_info.aliases or [] # type: ignore + command._typer_hidden_aliases = command_info.hidden_aliases or [] # type: ignore return command diff --git a/typer/models.py b/typer/models.py index 3285a96a24..08d03ecb22 100644 --- a/typer/models.py +++ b/typer/models.py @@ -208,6 +208,8 @@ def __init__( no_args_is_help: bool = False, hidden: bool = False, deprecated: bool = False, + aliases: Sequence[str] | None = None, + hidden_aliases: Sequence[str] | None = None, # Rich settings rich_help_panel: str | None = None, ): @@ -223,6 +225,8 @@ def __init__( self.no_args_is_help = no_args_is_help self.hidden = hidden self.deprecated = deprecated + self.aliases = aliases or [] + self.hidden_aliases = hidden_aliases or [] # Rich settings self.rich_help_panel = rich_help_panel diff --git a/typer/rich_utils.py b/typer/rich_utils.py index d85043238c..312382d858 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -498,12 +498,23 @@ def _print_commands_panel( deprecated_rows: list[RenderableType | None] = [] for command in commands: helptext = command.short_help or command.help or "" - command_name = command.name or "" + cmd_name = command.name or "" + aliases = getattr(command, "_typer_aliases", []) + hidden_aliases = getattr(command, "_typer_hidden_aliases", []) + visible_aliases = [a for a in aliases if a not in hidden_aliases] + + if visible_aliases: + cmd_name_display = ", ".join([cmd_name] + visible_aliases) + else: + cmd_name_display = cmd_name + if command.deprecated: - command_name_text = Text(f"{command_name}", style=STYLE_DEPRECATED_COMMAND) + command_name_text = Text( + f"{cmd_name_display}", style=STYLE_DEPRECATED_COMMAND + ) deprecated_rows.append(Text(DEPRECATED_STRING, style=STYLE_DEPRECATED)) else: - command_name_text = Text(command_name) + command_name_text = Text(cmd_name_display) deprecated_rows.append(None) rows.append( [ @@ -634,12 +645,20 @@ def rich_format_help( ) panel_to_commands[panel_name].append(command) - # Identify the longest command name in all panels max_cmd_len = max( [ - len(command.name or "") - for commands in panel_to_commands.values() - for command in commands + len( + ", ".join( + [cmd.name or ""] + + [ + a + for a in getattr(cmd, "_typer_aliases", []) + if a not in getattr(cmd, "_typer_hidden_aliases", []) + ] + ) + ) + for cmds in panel_to_commands.values() + for cmd in cmds ], default=0, )