diff --git a/docs/tutorial/commands/name.md b/docs/tutorial/commands/name.md index ab18ff633a..7937ada1c1 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.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.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.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.py b/docs_src/commands/name/tutorial002.py new file mode 100644 index 0000000000..e8f97439aa --- /dev/null +++ b/docs_src/commands/name/tutorial002.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.py b/docs_src/commands/name/tutorial003.py new file mode 100644 index 0000000000..7f88527272 --- /dev/null +++ b/docs_src/commands/name/tutorial003.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..67531e84e3 --- /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 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..43dc9060e8 --- /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 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 83eb8ec52b..2d98bf32c3 100644 --- a/typer/core.py +++ b/typer/core.py @@ -764,6 +764,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], @@ -826,4 +850,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 e8c6b9e429..edb5f7c5e5 100644 --- a/typer/main.py +++ b/typer/main.py @@ -221,7 +221,7 @@ def decorator(f: CommandFunctionType) -> CommandFunctionType: def command( self, name: Optional[str] = None, - *, + *positional_aliases: str, cls: Optional[type[TyperCommand]] = None, context_settings: Optional[dict[Any, Any]] = None, help: Optional[str] = None, @@ -232,12 +232,20 @@ def command( no_args_is_help: bool = False, hidden: bool = False, deprecated: bool = False, + aliases: Optional[Sequence[str]] = None, + hidden_aliases: Optional[Sequence[str]] = None, # Rich settings rich_help_panel: Union[str, None] = Default(None), ) -> Callable[[CommandFunctionType], CommandFunctionType]: 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( @@ -255,6 +263,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, ) @@ -489,6 +499,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, @@ -613,6 +627,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 78d1a5354d..ce436d7447 100644 --- a/typer/models.py +++ b/typer/models.py @@ -95,6 +95,8 @@ def __init__( no_args_is_help: bool = False, hidden: bool = False, deprecated: bool = False, + aliases: Optional[Sequence[str]] = None, + hidden_aliases: Optional[Sequence[str]] = None, # Rich settings rich_help_panel: Union[str, None] = None, ): @@ -110,6 +112,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 ad110cb8d6..f9a54cbfb9 100644 --- a/typer/rich_utils.py +++ b/typer/rich_utils.py @@ -497,12 +497,23 @@ def _print_commands_panel( deprecated_rows: list[Union[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( [ @@ -633,12 +644,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, )