Skip to content
Open
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
10 changes: 9 additions & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## Version 8.4.2

Unreleased
Released 2026-06-24

- Fix Fish shell completion broken in `8.4.0` by {pr}`3126`. Newlines and
tabs in option help text are now escaped, keeping the original completion
Expand All @@ -18,6 +18,14 @@ Unreleased
stream when no external pager runs, completing the partial
`I/O operation on closed file` fix from {pr}`3482`. {issue}`3449`
{pr}`3533`
- Fix CLI usage symopsis for optional arguments producing double square brackets
`[[a|b|c]]...` whose type already brackets their metavar. {pr}`3578`
- {func}`version_option` resolves a `package_name` that does not match an
installed distribution as an import (top-level module) name via
{func}`importlib.metadata.packages_distributions`. Packages whose
top-level module name differs from their distribution name (`PIL` vs
`Pillow`, `jwt` vs `PyJWT`) no longer raise `RuntimeError` out of the
box. {issue}`2331` {issue}`1884` {issue}`3125` {pr}`3582`

## Version 8.4.1

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "click"
version = "8.4.2.dev"
version = "8.4.2"
description = "Composable command line interface toolkit"
readme = "README.md"
license = "BSD-3-Clause"
Expand Down
7 changes: 6 additions & 1 deletion src/click/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -3571,9 +3571,14 @@ def make_metavar(self, ctx: Context) -> str:
var = self.type.get_metavar(param=self, ctx=ctx)
if not var:
var = self.name.upper()
# Types like ``Choice`` and ``DateTime`` already surround their metavar
# with square brackets to enumerate the allowed values. Reuse those
# outer brackets as the optional-argument indicator instead of wrapping
# the metavar in a second pair, which would produce ``[[a|b|c]]``.
already_bracketed = var.startswith("[") and var.endswith("]")
if self.deprecated:
var += "!"
if not self.required:
if not self.required and not already_bracketed:
var = f"[{var}]"
if self.nargs != 1:
var += "..."
Expand Down
36 changes: 30 additions & 6 deletions src/click/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,8 +434,11 @@ def version_option(
``package_name``.

If ``package_name`` is not provided, Click will try to detect it by
inspecting the stack frames. This will be used to detect the
version, so it must match the name of the installed package.
inspecting the stack frames. If the detected (or given) name does
not match an installed distribution, Click resolves it as an import
(top-level module) name via
:func:`importlib.metadata.packages_distributions`, so e.g. ``PIL``
resolves to the ``Pillow`` distribution.

:param version: The version number to show. If not provided, Click
will try to detect it.
Expand All @@ -460,6 +463,10 @@ def version_option(
version is detected based on the package name, not the entry
point name. The Python package name must match the installed
package name, or be passed with ``package_name=``.

.. versionchanged:: 8.4.2
When ``package_name`` does not match an installed distribution,
Click now resolves it as an import (top-level module).
"""
if message is None:
message = _("%(prog)s, version %(version)s")
Expand Down Expand Up @@ -487,6 +494,7 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None:

nonlocal prog_name
nonlocal version
nonlocal package_name

if prog_name is None:
prog_name = ctx.find_root().info_name
Expand All @@ -497,10 +505,26 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None:
try:
version = importlib.metadata.version(package_name)
except importlib.metadata.PackageNotFoundError:
raise RuntimeError(
f"{package_name!r} is not installed. Try passing"
" 'package_name' instead."
) from None
# The given name didn't match an installed distribution.
# Try resolving it as an import (top-level module) name,
# e.g. ``PIL`` is provided by the ``Pillow`` distribution.
distributions = importlib.metadata.packages_distributions().get(
package_name, []
)
if len(distributions) == 1:
package_name = distributions[0]
version = importlib.metadata.version(package_name)
elif len(distributions) > 1:
raise RuntimeError(
f"{package_name!r} maps to multiple installed"
f" distributions ({', '.join(distributions)})."
" Pass 'package_name' to disambiguate."
) from None
else:
raise RuntimeError(
f"{package_name!r} is not installed. Try passing"
" 'package_name' instead."
) from None

if version is None:
raise RuntimeError(
Expand Down
56 changes: 40 additions & 16 deletions src/click/shell_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def shell_complete(
prog_name: str,
complete_var: str,
instruction: str,
) -> int:
) -> t.Literal[0, 1]:
"""Perform shell completion for the given CLI program.

:param cli: Command being called.
Expand Down Expand Up @@ -55,7 +55,16 @@ def shell_complete(
return 1


class CompletionItem:
if t.TYPE_CHECKING:
from typing_extensions import TypeVar

# `Any` is used as default for backwards compatibility (instead of e.g. `str`)
_ValueT_co = TypeVar("_ValueT_co", covariant=True, default=t.Any)
else:
_ValueT_co = t.TypeVar("_ValueT_co", covariant=True)


class CompletionItem(t.Generic[_ValueT_co]):
"""Represents a completion value and metadata about the value. The
default metadata is ``type`` to indicate special shell handling,
and ``help`` if a shell supports showing a help string next to the
Expand All @@ -78,12 +87,12 @@ class CompletionItem:

def __init__(
self,
value: t.Any,
value: _ValueT_co,
type: str = "plain",
help: str | None = None,
**kwargs: t.Any,
) -> None:
self.value: t.Any = value
self.value: _ValueT_co = value
self.type: str = type
self.help: str | None = help
self._info = kwargs
Expand Down Expand Up @@ -198,6 +207,12 @@ def __getattr__(self, name: str) -> t.Any:
"""


class _SourceVarsDict(t.TypedDict):
complete_func: str
complete_var: str
prog_name: str


class ShellComplete:
"""Base class for providing shell completion support. A subclass for
a given shell will override attributes and methods to implement the
Expand Down Expand Up @@ -247,7 +262,7 @@ def func_name(self) -> str:
safe_name = re.sub(r"\W*", "", self.prog_name.replace("-", "_"), flags=re.ASCII)
return f"_{safe_name}_completion"

def source_vars(self) -> dict[str, t.Any]:
def source_vars(self) -> _SourceVarsDict:
"""Vars for formatting :attr:`source_template`.

By default this provides ``complete_func``, ``complete_var``,
Expand All @@ -274,7 +289,9 @@ def get_completion_args(self) -> tuple[list[str], str]:
"""
raise NotImplementedError

def get_completions(self, args: list[str], incomplete: str) -> list[CompletionItem]:
def get_completions(
self, args: list[str], incomplete: str
) -> list[CompletionItem[str]]:
"""Determine the context and last complete command or parameter
from the complete args. Call that object's ``shell_complete``
method to get the completions for the incomplete value.
Expand All @@ -286,7 +303,7 @@ def get_completions(self, args: list[str], incomplete: str) -> list[CompletionIt
obj, incomplete = _resolve_incomplete(ctx, args, incomplete)
return obj.shell_complete(ctx, incomplete)

def format_completion(self, item: CompletionItem) -> str:
def format_completion(self, item: CompletionItem[str]) -> str:
"""Format a completion item into the form recognized by the
shell script. This must be implemented by subclasses.

Expand Down Expand Up @@ -362,7 +379,7 @@ def get_completion_args(self) -> tuple[list[str], str]:

return args, incomplete

def format_completion(self, item: CompletionItem) -> str:
def format_completion(self, item: CompletionItem[t.Any]) -> str:
return f"{item.type},{item.value}"


Expand All @@ -384,7 +401,7 @@ def get_completion_args(self) -> tuple[list[str], str]:

return args, incomplete

def format_completion(self, item: CompletionItem) -> str:
def format_completion(self, item: CompletionItem[str]) -> str:
help_ = item.help or "_"
# The zsh completion script uses `_describe` on items with help
# texts (which splits the item help from the item value at the
Expand Down Expand Up @@ -422,7 +439,7 @@ def get_completion_args(self) -> tuple[list[str], str]:

return args, incomplete

def format_completion(self, item: CompletionItem) -> str:
def format_completion(self, item: CompletionItem[str]) -> str:
"""
.. versionchanged:: 8.4.2
Escape newlines and replace tabs with spaces in the help text to
Expand All @@ -439,19 +456,18 @@ def format_completion(self, item: CompletionItem) -> str:
return f"{item.type},{item.value}"


ShellCompleteType = t.TypeVar("ShellCompleteType", bound="type[ShellComplete]")


_available_shells: dict[str, type[ShellComplete]] = {
_available_shells: t.Final[dict[str, type[ShellComplete]]] = {
"bash": BashComplete,
"fish": FishComplete,
"zsh": ZshComplete,
}

_ShellCompleteT = t.TypeVar("_ShellCompleteT", bound="ShellComplete")


def add_completion_class(
cls: ShellCompleteType, name: str | None = None
) -> ShellCompleteType:
cls: type[_ShellCompleteT], name: str | None = None
) -> type[_ShellCompleteT]:
"""Register a :class:`ShellComplete` subclass under the given name.
The name will be provided by the completion instruction environment
variable during completion.
Expand All @@ -469,6 +485,14 @@ def add_completion_class(
return cls


@t.overload
def get_completion_class(shell: t.Literal["bash"]) -> type[BashComplete]: ...
@t.overload
def get_completion_class(shell: t.Literal["fish"]) -> type[FishComplete]: ...
@t.overload
def get_completion_class(shell: t.Literal["zsh"]) -> type[ZshComplete]: ...
@t.overload
def get_completion_class(shell: str) -> type[ShellComplete] | None: ...
def get_completion_class(shell: str) -> type[ShellComplete] | None:
"""Look up a registered :class:`ShellComplete` subclass by the name
provided by the completion instruction environment variable. If the
Expand Down
119 changes: 119 additions & 0 deletions tests/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,46 @@ def cli(method: str | None):
assert result.output.startswith("Usage: cli [OPTIONS] {not-none|none}\n")


def test_choice_argument_optional_metavar(runner):
"""Optional Choice arguments reuse the type's brackets instead of doubling.

Without this the usage line for a ``Choice`` argument with ``nargs=-1`` or
``required=False`` rendered as ``[[a|b|c]]``: one pair from ``Choice`` to
enumerate values, a second pair from ``Argument`` to mark it optional.
"""

@click.command()
@click.argument("method", type=click.Choice(["foo", "bar", "baz"]), nargs=-1)
def cli_variadic(method):
pass

@click.command()
@click.argument("method", type=click.Choice(["foo", "bar", "baz"]), required=False)
def cli_optional(method):
pass

variadic = runner.invoke(cli_variadic, ["--help"]).output
assert "Usage: cli-variadic [OPTIONS] [foo|bar|baz]...\n" in variadic
assert "[[foo|bar|baz]]" not in variadic

optional = runner.invoke(cli_optional, ["--help"]).output
assert "Usage: cli-optional [OPTIONS] [foo|bar|baz]\n" in optional
assert "[[foo|bar|baz]]" not in optional


def test_datetime_argument_optional_metavar(runner):
"""``DateTime`` arguments behave the same way as ``Choice``."""

@click.command()
@click.argument("when", type=click.DateTime(formats=["%Y-%m-%d"]), required=False)
def cli(when):
pass

result = runner.invoke(cli, ["--help"])
assert "Usage: cli [OPTIONS] [%Y-%m-%d]\n" in result.output
assert "[[%Y-%m-%d]]" not in result.output


def test_datetime_option_default(runner):
@click.command()
@click.option("--start_date", type=click.DateTime())
Expand Down Expand Up @@ -739,3 +779,82 @@ def test_help_invalid_default(runner):
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "default: not found" in result.output


def test_version_option_resolves_import_name_to_distribution(runner, monkeypatch):
"""When ``package_name`` (detected or passed) is an import name that
differs from its installed distribution name (``PIL`` vs ``Pillow``),
``version_option`` resolves it via ``packages_distributions()`` instead
of raising ``RuntimeError``.
"""
import importlib.metadata

def fake_version(name):
if name == "pillow":
return "10.4.0"
raise importlib.metadata.PackageNotFoundError(name)

monkeypatch.setattr(importlib.metadata, "version", fake_version)
monkeypatch.setattr(
importlib.metadata,
"packages_distributions",
lambda: {"PIL": ["pillow"]},
)

@click.command()
@click.version_option(package_name="PIL")
def cli():
pass

result = runner.invoke(cli, ["--version"], prog_name="imageapp")
assert result.exit_code == 0
assert "10.4.0" in result.output


def test_version_option_ambiguous_import_name_errors(runner, monkeypatch):
"""When an import name maps to multiple installed distributions, the
user must disambiguate. The error names the candidates.
"""
import importlib.metadata

def fake_version(name):
raise importlib.metadata.PackageNotFoundError(name)

monkeypatch.setattr(importlib.metadata, "version", fake_version)
monkeypatch.setattr(
importlib.metadata,
"packages_distributions",
lambda: {"plug": ["foo-plug", "bar-plug"]},
)

@click.command()
@click.version_option(package_name="plug")
def cli():
pass

result = runner.invoke(cli, ["--version"])
assert result.exit_code != 0
msg = str(result.exception)
assert "multiple installed distributions" in msg
assert "foo-plug" in msg
assert "bar-plug" in msg


def test_version_option_unknown_package_errors(runner, monkeypatch):
"""When the name resolves to no distribution, keep the existing error."""
import importlib.metadata

def fake_version(name):
raise importlib.metadata.PackageNotFoundError(name)

monkeypatch.setattr(importlib.metadata, "version", fake_version)
monkeypatch.setattr(importlib.metadata, "packages_distributions", lambda: {})

@click.command()
@click.version_option(package_name="nonexistent")
def cli():
pass

result = runner.invoke(cli, ["--version"])
assert result.exit_code != 0
assert "not installed" in str(result.exception)
Loading
Loading