From 5c223eac7d64e1bb8dfab8b5a2ae24922f7ac61d Mon Sep 17 00:00:00 2001 From: ouyu <1986834078@qq.com> Date: Mon, 20 Apr 2026 20:33:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(commands):=20=E4=B8=BA=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=88=AB=E5=90=8D=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加对命令别名的支持,允许通过别名调用命令而不影响帮助信息显示。主要修改包括: 1. 在Command类中添加aliases字段 2. 在Group类中实现别名到规范命令名的映射 3. 确保别名不影响帮助信息中的命令列表显示 4. 添加相关测试用例验证别名功能 --- src/click/core.py | 34 ++++++++- tests/test_commands.py | 152 ++++++++++++++++++++++++++++++++++++++++ tests/test_info_dict.py | 4 ++ 3 files changed, 188 insertions(+), 2 deletions(-) diff --git a/src/click/core.py b/src/click/core.py index f0a624be3b..8b70e7ffc3 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -946,6 +946,7 @@ def __init__( no_args_is_help: bool = False, hidden: bool = False, deprecated: bool | str = False, + aliases: list[str] | None = None, ) -> None: #: the name the command thinks it has. Upon registering a command #: on a :class:`Group` the group will default the command name @@ -975,6 +976,7 @@ def __init__( self.no_args_is_help = no_args_is_help self.hidden = hidden self.deprecated = deprecated + self.aliases: list[str] = aliases or [] def to_info_dict(self, ctx: Context) -> dict[str, t.Any]: return { @@ -985,6 +987,7 @@ def to_info_dict(self, ctx: Context) -> dict[str, t.Any]: "short_help": self.short_help, "hidden": self.hidden, "deprecated": self.deprecated, + "aliases": self.aliases, } def __repr__(self) -> str: @@ -1576,6 +1579,13 @@ def __init__( #: The registered subcommands by their exported names. self.commands: cabc.MutableMapping[str, Command] = commands + #: Maps aliases to their canonical command names. + self._aliases: cabc.MutableMapping[str, str] = {} + + for cmd_name, cmd in self.commands.items(): + for alias in cmd.aliases: + self._aliases[alias] = cmd_name + if no_args_is_help is None: no_args_is_help = not invoke_without_command @@ -1629,6 +1639,9 @@ def add_command(self, cmd: Command, name: str | None = None) -> None: _check_nested_chain(self, name, cmd, register=True) self.commands[name] = cmd + for alias in cmd.aliases: + self._aliases[alias] = name + @t.overload def command(self, __func: t.Callable[..., t.Any]) -> Command: ... @@ -1779,7 +1792,15 @@ def get_command(self, ctx: Context, cmd_name: str) -> Command | None: """Given a context and a command name, this returns a :class:`Command` object if it exists or returns ``None``. """ - return self.commands.get(cmd_name) + rv = self.commands.get(cmd_name) + if rv is not None: + return rv + + canonical_name = self._aliases.get(cmd_name) + if canonical_name is not None: + return self.commands.get(canonical_name) + + return None def list_commands(self, ctx: Context) -> list[str]: """Returns a list of subcommand names in the order they should appear.""" @@ -1929,7 +1950,16 @@ def resolve_command( if _split_opt(cmd_name)[0]: self.parse_args(ctx, args) ctx.fail(_("No such command {name!r}.").format(name=original_cmd_name)) - return cmd_name if cmd else None, cmd, args[1:] + + # Determine what name to return. + # - If the user used an alias (looked up via _aliases), return cmd.name + # - Otherwise (command was found directly in self.commands, possibly via + # a renamed registration like add_command(cmd, "b")), return cmd_name + if cmd is not None: + if cmd_name in self._aliases or (cmd_name not in self.commands): + return cmd.name, cmd, args[1:] + return cmd_name, cmd, args[1:] + return None, None, args[1:] def shell_complete(self, ctx: Context, incomplete: str) -> list[CompletionItem]: """Return a list of completions for the incomplete value. Looks diff --git a/tests/test_commands.py b/tests/test_commands.py index f26529a542..04ad080647 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -573,3 +573,155 @@ def cli(): assert rv.exit_code == 1 assert isinstance(rv.exception.__cause__, exc) assert rv.exception.__cause__.args == ("catch me!",) + + +def test_command_aliases_basic(runner): + @click.group() + def cli(): + pass + + @cli.command(aliases=["st", "stat"]) + def status(): + """Show the status.""" + click.echo("Status: OK") + + result = runner.invoke(cli, ["status"]) + assert result.exit_code == 0 + assert "Status: OK" in result.output + + result = runner.invoke(cli, ["st"]) + assert result.exit_code == 0 + assert "Status: OK" in result.output + + result = runner.invoke(cli, ["stat"]) + assert result.exit_code == 0 + assert "Status: OK" in result.output + + +def test_command_aliases_not_in_help(runner): + @click.group() + def cli(): + pass + + @cli.command(aliases=["st"]) + def status(): + """Show the status.""" + click.echo("Status") + + @cli.command() + def commit(): + """Commit changes.""" + click.echo("Commit") + + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "status" in result.output + assert "commit" in result.output + lines = result.output.split("\n") + in_commands = False + command_names = [] + for line in lines: + if "Commands:" in line: + in_commands = True + continue + if in_commands and line.strip(): + parts = line.strip().split() + if parts: + command_names.append(parts[0]) + assert "st" not in command_names, "Alias 'st' should not appear as separate command in help" + + +def test_command_aliases_help_shows_canonical_name(runner): + @click.group() + def cli(): + pass + + @cli.command(aliases=["st"]) + def status(): + """Show the status.""" + pass + + result = runner.invoke(cli, ["st", "--help"]) + assert result.exit_code == 0 + assert "Usage: cli status" in result.output + usage_line = [line for line in result.output.split("\n") if line.startswith("Usage:")][0] + assert usage_line.strip() == "Usage: cli status [OPTIONS]" + + +def test_command_aliases_invoked_subcommand(runner): + @click.group() + def cli(): + pass + + invoked_name = [] + + @cli.command(aliases=["st"]) + @click.pass_context + def status(ctx): + if ctx.parent: + invoked_name.append(ctx.parent.invoked_subcommand) + + result = runner.invoke(cli, ["st"]) + assert result.exit_code == 0 + assert invoked_name == ["status"], f"Expected ['status'], got {invoked_name}" + + +def test_command_aliases_list_commands(runner): + @click.group() + def cli(): + pass + + @cli.command(aliases=["st"]) + def status(): + pass + + @cli.command() + def commit(): + pass + + ctx = click.Context(cli) + commands = cli.list_commands(ctx) + assert "status" in commands + assert "commit" in commands + assert "st" not in commands, "Aliases should not appear in list_commands" + + +def test_command_aliases_with_explicit_add_command(): + cmd = click.Command("status", aliases=["st", "stat"]) + group = click.Group() + group.add_command(cmd) + + ctx = click.Context(group) + assert group.get_command(ctx, "status") is cmd + assert group.get_command(ctx, "st") is cmd + assert group.get_command(ctx, "stat") is cmd + + +def test_command_aliases_in_commands_list_init(): + cmd = click.Command("status", aliases=["st"]) + group = click.Group(commands=[cmd]) + + ctx = click.Context(group) + assert group.get_command(ctx, "status") is cmd + assert group.get_command(ctx, "st") is cmd + + +def test_command_aliases_default_map(runner): + @click.group() + def cli(): + pass + + @cli.command(aliases=["st"]) + @click.option("--name", default="default") + def status(name): + click.echo(f"Name: {name}") + + result = runner.invoke(cli, ["st"], default_map={"status": {"name": "custom"}}) + assert result.exit_code == 0 + assert "Name: custom" in result.output + + +def test_command_aliases_without_group(): + cmd = click.Command("test", aliases=["t"]) + assert cmd.name == "test" + assert cmd.aliases == ["t"] diff --git a/tests/test_info_dict.py b/tests/test_info_dict.py index 20fe68cc13..0e1148dcb4 100644 --- a/tests/test_info_dict.py +++ b/tests/test_info_dict.py @@ -73,6 +73,7 @@ "short_help": None, "hidden": False, "deprecated": False, + "aliases": [], }, ) HELLO_GROUP = ( @@ -85,6 +86,7 @@ "short_help": None, "hidden": False, "deprecated": False, + "aliases": [], "commands": {"hello": HELLO_COMMAND[1]}, "chain": False, }, @@ -231,6 +233,7 @@ def test_parameter(obj, expect): "short_help": None, "hidden": False, "deprecated": False, + "aliases": [], "commands": { "cli": HELLO_GROUP[1], "test": { @@ -241,6 +244,7 @@ def test_parameter(obj, expect): "short_help": None, "hidden": False, "deprecated": False, + "aliases": [], }, }, "chain": False,