Repository: pallets/click
Pull request #3006 (2025-07-22 09:39:45)

Merge Stable into master


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index fbb3254..2dcffe0 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -8,6 +8,13 @@ Unreleased
 -   Fix reconciliation of `default`, `flag_value` and `type` parameters for
     flag options, as well as parsing and normalization of environment variables.
     :issue:`2952` :pr:`2956`
+-   Fix typing issue in ``BadParameter`` and ``MissingParameter`` exceptions for the
+    parameter ``param_hint`` that did not allow for a sequence of string where the
+    underlying functino ``_join_param_hints`` allows for it. :issue:`2777` :pr:`2990`
+-   Use the value of ``Enum`` choices to render their default value in help
+    screen. Refs :issue:`2911` :pr:`3004`
+-   Fix completion for the Z shell (``zsh``) for completion items containing
+    colons. :issue:`2703` :pr:`2846`
 
 Version 8.2.1
 -------------
diff --git a/src/click/core.py b/src/click/core.py
index 445460b..4bebb77 100644
--- a/src/click/core.py
+++ b/src/click/core.py
@@ -2872,6 +2872,8 @@ class Option(Parameter):
                 default_string = f"({self.show_default})"
             elif isinstance(default_value, (list, tuple)):
                 default_string = ", ".join(str(d) for d in default_value)
+            elif isinstance(default_value, enum.Enum):
+                default_string = default_value.name
             elif inspect.isfunction(default_value):
                 default_string = _("(dynamic)")
             elif self.is_bool_flag and self.secondary_opts:
diff --git a/src/click/exceptions.py b/src/click/exceptions.py
index f141a83..4d782ee 100644
--- a/src/click/exceptions.py
+++ b/src/click/exceptions.py
@@ -115,7 +115,7 @@ class BadParameter(UsageError):
         message: str,
         ctx: Context | None = None,
         param: Parameter | None = None,
-        param_hint: str | None = None,
+        param_hint: cabc.Sequence[str] | str | None = None,
     ) -> None:
         super().__init__(message, ctx)
         self.param = param
@@ -151,7 +151,7 @@ class MissingParameter(BadParameter):
         message: str | None = None,
         ctx: Context | None = None,
         param: Parameter | None = None,
-        param_hint: str | None = None,
+        param_hint: cabc.Sequence[str] | str | None = None,
         param_type: str | None = None,
     ) -> None:
         super().__init__(message or "", ctx, param, param_hint)
@@ -159,7 +159,7 @@ class MissingParameter(BadParameter):
 
     def format_message(self) -> str:
         if self.param_hint is not None:
-            param_hint: str | None = self.param_hint
+            param_hint: cabc.Sequence[str] | str | None = self.param_hint
         elif self.param is not None:
             param_hint = self.param.get_error_hint(self.ctx)  # type: ignore
         else:
diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py
index 6c39d5e..4cb53b9 100644
--- a/src/click/shell_completion.py
+++ b/src/click/shell_completion.py
@@ -124,6 +124,12 @@ _SOURCE_BASH = """\
 %(complete_func)s_setup;
 """
 
+# See ZshComplete.format_completion below, and issue #2703, before
+# changing this script.
+#
+# (TL;DR: _describe is picky about the format, but this Zsh script snippet
+# is already widely deployed.  So freeze this script, and use clever-ish
+# handling of colons in ZshComplet.format_completion.)
 _SOURCE_ZSH = """\
 #compdef %(prog_name)s
 
@@ -373,7 +379,21 @@ class ZshComplete(ShellComplete):
         return args, incomplete
 
     def format_completion(self, item: CompletionItem) -> str:
-        return f"{item.type}\n{item.value}\n{item.help if item.help else '_'}"
+        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
+        # first unescaped colon) and `compadd` on items without help
+        # text (which uses the item value as-is and does not support
+        # colon escaping).  So escape colons in the item value if and
+        # only if the item help is not the sentinel "_" value, as used
+        # by the completion script.
+        #
+        # (The zsh completion script is potentially widely deployed, and
+        # thus harder to fix than this method.)
+        #
+        # See issue #1812 and issue #2703 for further context.
+        value = item.value.replace(":", r"\:") if help_ != "_" else item.value
+        return f"{item.type}\n{value}\n{help_}"
 
 
 class FishComplete(ShellComplete):


Test patch:
diff --git a/src/click/testing.py b/src/click/testing.py
index 7c0e874..04d263e 100644
--- a/src/click/testing.py
+++ b/src/click/testing.py
@@ -99,6 +99,18 @@ class StreamMixer:
         self.stdout: io.BytesIO = BytesIOCopy(copy_to=self.output)
         self.stderr: io.BytesIO = BytesIOCopy(copy_to=self.output)
 
+    def __del__(self) -> None:
+        """
+        Guarantee that embedded file-like objects are closed in a
+        predictable order, protecting against races between
+        self.output being closed and other streams being flushed on close
+
+        .. versionadded:: 8.2.2
+        """
+        self.stderr.close()
+        self.stdout.close()
+        self.output.close()
+
 
 class _NamedTextIOWrapper(io.TextIOWrapper):
     def __init__(
diff --git a/tests/test_options.py b/tests/test_options.py
index f501738..1e7826d 100644
--- a/tests/test_options.py
+++ b/tests/test_options.py
@@ -1,5 +1,10 @@
+import enum
 import os
 import re
+import sys
+
+if sys.version_info < (3, 11):
+    enum.StrEnum = enum.Enum  # type: ignore[assignment]
 
 import pytest
 
@@ -1300,6 +1305,33 @@ def test_non_flag_with_non_negatable_default(runner):
     assert result.exit_code == 0
 
 
+class HashType(enum.Enum):
+    MD5 = enum.auto()
+    SHA1 = enum.auto()
+
+
+class Number(enum.IntEnum):
+    ONE = enum.auto()
+    TWO = enum.auto()
+
+
+class Letter(enum.StrEnum):
+    A = enum.auto()
+    B = enum.auto()
+
+
+class Color(enum.Flag):
+    RED = enum.auto()
+    GREEN = enum.auto()
+    BLUE = enum.auto()
+
+
+class ColorInt(enum.IntFlag):
+    RED = enum.auto()
+    GREEN = enum.auto()
+    BLUE = enum.auto()
+
+
 @pytest.mark.parametrize(
     ("choices", "metavars"),
     [
@@ -1308,6 +1340,11 @@ def test_non_flag_with_non_negatable_default(runner):
         pytest.param([1.0, 2.0], "[FLOAT]", id="float choices"),
         pytest.param([True, False], "[BOOLEAN]", id="bool choices"),
         pytest.param(["foo", 1], "[TEXT|INTEGER]", id="text/int choices"),
+        pytest.param(HashType, "[HASHTYPE]", id="enum choices"),
+        pytest.param(Number, "[NUMBER]", id="int enum choices"),
+        pytest.param(Letter, "[LETTER]", id="str enum choices"),
+        pytest.param(Color, "[COLOR]", id="flag enum choices"),
+        pytest.param(ColorInt, "[COLORINT]", id="int flag enum choices"),
     ],
 )
 def test_usage_show_choices(runner, choices, metavars):
@@ -1330,12 +1367,61 @@ def test_usage_show_choices(runner, choices, metavars):
         pass
 
     result = runner.invoke(cli_with_choices, ["--help"])
-    assert f"[{'|'.join([str(i) for i in choices])}]" in result.output
+    assert (
+        f"[{'|'.join(i.name if isinstance(i, enum.Enum) else str(i) for i in choices)}]"
+        in result.output
+    )
 
     result = runner.invoke(cli_without_choices, ["--help"])
     assert metavars in result.output
 
 
+@pytest.mark.parametrize(
+    ("choices", "default", "default_string"),
+    [
+        (["foo", "bar"], "bar", "bar"),
+        # The default value is not enforced to be in the choices.
+        (["foo", "bar"], "random", "random"),
+        # None cannot be a default value as-is: it left the default value as unset.
+        (["foo", "bar"], None, None),
+        ([0, 1], 0, "0"),
+        # Values are not coerced to the type of the choice, even if equivalent.
+        ([0, 1], 0.0, "0.0"),
+        ([1, 2], 2, "2"),
+        ([1.0, 2.0], 2, "2"),
+        ([1.0, 2.0], 2.0, "2.0"),
+        ([True, False], True, "True"),
+        ([True, False], False, "False"),
+        (["foo", 1], "foo", "foo"),
+        (["foo", 1], 1, "1"),
+        # Enum choices are rendered as their names.
+        # See: https://github.com/pallets/click/issues/2911
+        (HashType, HashType.SHA1, "SHA1"),
+        # Enum choices allow defaults strings that are their names.
+        (HashType, "SHA1", "SHA1"),
+        (Number, Number.TWO, "TWO"),
+        (Letter, Letter.B, "B"),
+        (Color, Color.GREEN, "GREEN"),
+        (ColorInt, ColorInt.GREEN, "GREEN"),
+    ],
+)
+def test_choice_default_rendering(runner, choices, default, default_string):
+    @click.command()
+    @click.option("-g", type=click.Choice(choices), default=default, show_default=True)
+    def cli_with_choices(g):
+        pass
+
+    # Check that the default value is kept normalized to the type of the choice.
+    assert cli_with_choices.params[0].default == default
+
+    result = runner.invoke(cli_with_choices, ["--help"])
+    extra_usage = f"[default: {default_string}]"
+    if default_string is None:
+        assert extra_usage not in result.output
+    else:
+        assert extra_usage in result.output
+
+
 @pytest.mark.parametrize(
     "opts_one,opts_two",
     [
diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py
index fb39486..819ca8b 100644
--- a/tests/test_shell_completion.py
+++ b/tests/test_shell_completion.py
@@ -1,4 +1,6 @@
+import textwrap
 import warnings
+from collections.abc import Mapping
 
 import pytest
 
@@ -354,6 +356,79 @@ def test_full_complete(runner, shell, env, expect):
     assert result.output == expect
 
 
+@pytest.mark.parametrize(
+    ("env", "expect"),
+    [
+        (
+            {"COMP_WORDS": "", "COMP_CWORD": "0"},
+            textwrap.dedent(
+                """\
+                    plain
+                    a
+                    _
+                    plain
+                    b
+                    bee
+                    plain
+                    c\\:d
+                    cee:dee
+                    plain
+                    c:e
+                    _
+                """
+            ),
+        ),
+        (
+            {"COMP_WORDS": "a c", "COMP_CWORD": "1"},
+            textwrap.dedent(
+                """\
+                    plain
+                    c\\:d
+                    cee:dee
+                    plain
+                    c:e
+                    _
+                """
+            ),
+        ),
+        (
+            {"COMP_WORDS": "a c:", "COMP_CWORD": "1"},
+            textwrap.dedent(
+                """\
+                    plain
+                    c\\:d
+                    cee:dee
+                    plain
+                    c:e
+                    _
+                """
+            ),
+        ),
+    ],
+)
+@pytest.mark.usefixtures("_patch_for_completion")
+def test_zsh_full_complete_with_colons(
+    runner, env: Mapping[str, str], expect: str
+) -> None:
+    cli = Group(
+        "cli",
+        commands=[
+            Command("a"),
+            Command("b", help="bee"),
+            Command("c:d", help="cee:dee"),
+            Command("c:e"),
+        ],
+    )
+    result = runner.invoke(
+        cli,
+        env={
+            **env,
+            "_CLI_COMPLETE": "zsh_complete",
+        },
+    )
+    assert result.output == expect
+
+
 @pytest.mark.usefixtures("_patch_for_completion")
 def test_context_settings(runner):
     def complete(ctx, param, incomplete):
diff --git a/tests/test_testing.py b/tests/test_testing.py
index 0fd6973..11fe29d 100644
--- a/tests/test_testing.py
+++ b/tests/test_testing.py
@@ -448,8 +448,7 @@ def test_isolation_stderr_errors():
 
     with runner.isolation() as (_, err, _):
         click.echo("\udce2", err=True, nl=False)
-
-    assert err.getvalue() == b"\\udce2"
+        assert err.getvalue() == b"\\udce2"
 
 
 def test_isolation_flushes_unflushed_stderr():
@@ -460,8 +459,7 @@ def test_isolation_flushes_unflushed_stderr():
 
     with runner.isolation() as (_, err, _):
         click.echo("\udce2", err=True, nl=False)
-
-    assert err.getvalue() == b"\\udce2"
+        assert err.getvalue() == b"\\udce2"
 
     @click.command()
     def cli():