From bb0cd175d574a06fb4a5a7276ebe9570bb5c083d Mon Sep 17 00:00:00 2001 From: jorenham Date: Mon, 18 May 2026 17:37:38 +0200 Subject: [PATCH 1/5] Static typing improvements in `click.shell_completion` --- src/click/shell_completion.py | 56 +++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py index f9da4a3aa..3ebabf30f 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -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. @@ -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 @@ -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 @@ -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 @@ -242,7 +257,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``, @@ -269,7 +284,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. @@ -281,7 +298,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. @@ -357,7 +374,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}" @@ -379,7 +396,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 @@ -417,7 +434,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 @@ -434,19 +451,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. @@ -464,6 +480,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 From 762c97eef7c1b3779678992f26a553a2a8c80793 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Wed, 10 Jun 2026 17:26:48 +0400 Subject: [PATCH 2/5] Fix double-bracketing of choices in synopsis --- CHANGES.md | 2 ++ src/click/core.py | 7 ++++++- tests/test_basic.py | 40 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 8991e8257..90b93ed80 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -18,6 +18,8 @@ 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` ## Version 8.4.1 diff --git a/src/click/core.py b/src/click/core.py index d2db291d0..d7ecbefbc 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -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 += "..." diff --git a/tests/test_basic.py b/tests/test_basic.py index 3f7352879..0d5411586 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -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()) From 1557e265222c431b1e4d6c4dd6754006ed10dc02 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Sat, 13 Jun 2026 06:53:52 +0400 Subject: [PATCH 3/5] Check for warning exception with idiomatic context manager Closes #3476 --- tests/test_options.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_options.py b/tests/test_options.py index 1b93e136e..ec3ba23ef 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -3320,10 +3320,8 @@ def test_flag_group_competition_duplicate_option_name(runner): def cli(xyz): click.echo(repr(xyz), nl=False) - result = runner.invoke(cli, []) - assert result.exit_code == 1 - assert isinstance(result.exception, UserWarning) - assert "used more than once" in str(result.exception) + with pytest.warns(UserWarning, match="used more than once"): + runner.invoke(cli, []) @pytest.mark.parametrize( From bec59289d8cf9b9b4010642b2fee483e5f8eeefc Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 11 Jun 2026 09:59:39 +0400 Subject: [PATCH 4/5] Fix `package_name` resolution when top-level module differs from distribution name Refs: #1884 #2331 #3125 --- CHANGES.md | 6 ++++ src/click/decorators.py | 36 +++++++++++++++---- tests/test_basic.py | 79 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 6 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 90b93ed80..f383a541a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -20,6 +20,12 @@ Unreleased {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 diff --git a/src/click/decorators.py b/src/click/decorators.py index 14aee42ea..db6a45ebb 100644 --- a/src/click/decorators.py +++ b/src/click/decorators.py @@ -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. @@ -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") @@ -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 @@ -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( diff --git a/tests/test_basic.py b/tests/test_basic.py index 0d5411586..34d22e792 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -779,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) From b2e30a175449cfda909ee4fbf4a29a6a071cad53 Mon Sep 17 00:00:00 2001 From: Edward G Date: Tue, 23 Jun 2026 23:22:10 -0700 Subject: [PATCH 5/5] Release version 8.4.2 --- CHANGES.md | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index f383a541a..f4e05d27e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index ed91e5d74..e194a03b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/uv.lock b/uv.lock index 72a401248..f4e4cd1f4 100644 --- a/uv.lock +++ b/uv.lock @@ -173,7 +173,7 @@ wheels = [ [[package]] name = "click" -version = "8.4.2.dev0" +version = "8.4.2" source = { editable = "." } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" },