Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
34 changes: 32 additions & 2 deletions src/click/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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: ...

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down
152 changes: 152 additions & 0 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
4 changes: 4 additions & 0 deletions tests/test_info_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"short_help": None,
"hidden": False,
"deprecated": False,
"aliases": [],
},
)
HELLO_GROUP = (
Expand All @@ -85,6 +86,7 @@
"short_help": None,
"hidden": False,
"deprecated": False,
"aliases": [],
"commands": {"hello": HELLO_COMMAND[1]},
"chain": False,
},
Expand Down Expand Up @@ -231,6 +233,7 @@ def test_parameter(obj, expect):
"short_help": None,
"hidden": False,
"deprecated": False,
"aliases": [],
"commands": {
"cli": HELLO_GROUP[1],
"test": {
Expand All @@ -241,6 +244,7 @@ def test_parameter(obj, expect):
"short_help": None,
"hidden": False,
"deprecated": False,
"aliases": [],
},
},
"chain": False,
Expand Down
Loading