Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------
Expand Down
2 changes: 2 additions & 0 deletions src/click/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2872,6 +2872,8 @@ def get_help_extra(self, ctx: Context) -> types.OptionHelpExtra:
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:
Expand Down
6 changes: 3 additions & 3 deletions src/click/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def __init__(
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
Expand Down Expand Up @@ -151,15 +151,15 @@ def __init__(
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)
self.param_type = param_type

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:
Expand Down
22 changes: 21 additions & 1 deletion src/click/shell_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,12 @@ def __getattr__(self, name: str) -> t.Any:
%(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

Expand Down Expand Up @@ -373,7 +379,21 @@ def get_completion_args(self) -> tuple[list[str], str]:
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):
Expand Down
12 changes: 12 additions & 0 deletions src/click/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,18 @@ def __init__(self) -> None:
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__(
Expand Down
88 changes: 87 additions & 1 deletion tests/test_options.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -1300,6 +1305,33 @@ def cmd(foo):
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"),
[
Expand All @@ -1308,6 +1340,11 @@ def cmd(foo):
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):
Expand All @@ -1330,12 +1367,61 @@ def cli_without_choices(g):
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",
[
Expand Down
75 changes: 75 additions & 0 deletions tests/test_shell_completion.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import textwrap
import warnings
from collections.abc import Mapping

import pytest

Expand Down Expand Up @@ -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):
Expand Down
6 changes: 2 additions & 4 deletions tests/test_testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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():
Expand Down
Loading