From 65721511c77277fccaf374e62c2359abbff7616b Mon Sep 17 00:00:00 2001 From: John Aziz Date: Sun, 25 May 2025 00:09:09 +0300 Subject: [PATCH 01/11] Add workflow to automatically close pull requests from organization accounts --- .github/workflows/close-org-prs.yml | 51 +++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/close-org-prs.yml diff --git a/.github/workflows/close-org-prs.yml b/.github/workflows/close-org-prs.yml new file mode 100644 index 0000000000..ed5833618f --- /dev/null +++ b/.github/workflows/close-org-prs.yml @@ -0,0 +1,51 @@ +name: Close PRs from Orgs + +on: + pull_request_target: + types: [opened, reopened, synchronize] + +permissions: + pull-requests: write + issues: write + +jobs: + close-org-prs: + runs-on: ubuntu-latest + steps: + - name: Check if PR is from an org and close if so + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const isFork = pr.head.repo.full_name !== pr.base.repo.full_name; + const forkOwnerType = pr.head.repo.owner.type; + if (isFork && forkOwnerType === 'Organization') { + // Add 'invalid' label + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: ['invalid'] + }); + // Close the PR + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + state: 'closed' + }); + // Leave a comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: "We don't allow pull requests from organization accounts. This PR has been closed." + }); + // Lock the conversation + await github.rest.issues.lock({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + lock_reason: 'spam' + }); + } From 3b735deb69850c49cc758d06f5e600a678b9f12f Mon Sep 17 00:00:00 2001 From: John Aziz Date: Sun, 25 May 2025 03:31:10 +0300 Subject: [PATCH 02/11] remove locking pr, add better comment --- .github/workflows/close-org-prs.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/close-org-prs.yml b/.github/workflows/close-org-prs.yml index ed5833618f..45a877426e 100644 --- a/.github/workflows/close-org-prs.yml +++ b/.github/workflows/close-org-prs.yml @@ -39,13 +39,6 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number, - body: "We don't allow pull requests from organization accounts. This PR has been closed." - }); - // Lock the conversation - await github.rest.issues.lock({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - lock_reason: 'spam' + body: "Please don't open PRs from a fork owned by a different organization rather than your account. It causes GitHub to disable the ability for maintainers to push changes, so we can't update this to prepare it for merge." }); } From 72ac33e14591a1f6c08f1f83373db5ae5e4f48c8 Mon Sep 17 00:00:00 2001 From: John Aziz Date: Sun, 25 May 2025 16:14:02 +0300 Subject: [PATCH 03/11] remove label --- .github/workflows/close-org-prs.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/close-org-prs.yml b/.github/workflows/close-org-prs.yml index 45a877426e..b96a02ca0c 100644 --- a/.github/workflows/close-org-prs.yml +++ b/.github/workflows/close-org-prs.yml @@ -20,13 +20,6 @@ jobs: const isFork = pr.head.repo.full_name !== pr.base.repo.full_name; const forkOwnerType = pr.head.repo.owner.type; if (isFork && forkOwnerType === 'Organization') { - // Add 'invalid' label - await github.rest.issues.addLabels({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - labels: ['invalid'] - }); // Close the PR await github.rest.pulls.update({ owner: context.repo.owner, From 8065cb4864ab9c18bf62f968f29b9111a6604832 Mon Sep 17 00:00:00 2001 From: John Aziz Date: Sun, 25 May 2025 16:25:05 +0300 Subject: [PATCH 04/11] add better comment based on David's review --- .github/workflows/close-org-prs.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/close-org-prs.yml b/.github/workflows/close-org-prs.yml index b96a02ca0c..bebaa9d960 100644 --- a/.github/workflows/close-org-prs.yml +++ b/.github/workflows/close-org-prs.yml @@ -6,7 +6,6 @@ on: permissions: pull-requests: write - issues: write jobs: close-org-prs: @@ -32,6 +31,11 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number, - body: "Please don't open PRs from a fork owned by a different organization rather than your account. It causes GitHub to disable the ability for maintainers to push changes, so we can't update this to prepare it for merge." + body: `Please don't open PRs from a fork owned by a different organization rather than your account. It causes GitHub to disable the ability for maintainers to push changes, so we can't update this to prepare it for merge. + To fix this: + 1. Fork this project with your user account. + 2. Push this branch there. + 3. Create the PR again from that fork/branch. + ` }); } From cae592283cd6363fe4e1e59f37e48e3a0220ea6f Mon Sep 17 00:00:00 2001 From: John Aziz Date: Sun, 25 May 2025 16:25:59 +0300 Subject: [PATCH 05/11] fix code style --- .github/workflows/close-org-prs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/close-org-prs.yml b/.github/workflows/close-org-prs.yml index bebaa9d960..9106ddc69b 100644 --- a/.github/workflows/close-org-prs.yml +++ b/.github/workflows/close-org-prs.yml @@ -16,9 +16,9 @@ jobs: with: script: | const pr = context.payload.pull_request; - const isFork = pr.head.repo.full_name !== pr.base.repo.full_name; + const isOwner = pr.head.repo.full_name !== pr.base.repo.full_name; const forkOwnerType = pr.head.repo.owner.type; - if (isFork && forkOwnerType === 'Organization') { + if (isOwner && forkOwnerType === 'Organization') { // Close the PR await github.rest.pulls.update({ owner: context.repo.owner, From aaf3a7c8119893a76ca9acbcd4c7ff9e2eccc85d Mon Sep 17 00:00:00 2001 From: John Aziz Date: Sun, 25 May 2025 16:31:10 +0300 Subject: [PATCH 06/11] add more spaces --- .github/workflows/close-org-prs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/close-org-prs.yml b/.github/workflows/close-org-prs.yml index 9106ddc69b..a3da68554b 100644 --- a/.github/workflows/close-org-prs.yml +++ b/.github/workflows/close-org-prs.yml @@ -32,7 +32,9 @@ jobs: repo: context.repo.repo, issue_number: pr.number, body: `Please don't open PRs from a fork owned by a different organization rather than your account. It causes GitHub to disable the ability for maintainers to push changes, so we can't update this to prepare it for merge. + To fix this: + 1. Fork this project with your user account. 2. Push this branch there. 3. Create the PR again from that fork/branch. From ea03bc2f665eb55cd00e67e89db3894392064e17 Mon Sep 17 00:00:00 2001 From: John Aziz Date: Sun, 25 May 2025 16:33:37 +0300 Subject: [PATCH 07/11] fix indentation --- .github/workflows/close-org-prs.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/close-org-prs.yml b/.github/workflows/close-org-prs.yml index a3da68554b..e43a141f33 100644 --- a/.github/workflows/close-org-prs.yml +++ b/.github/workflows/close-org-prs.yml @@ -33,11 +33,10 @@ jobs: issue_number: pr.number, body: `Please don't open PRs from a fork owned by a different organization rather than your account. It causes GitHub to disable the ability for maintainers to push changes, so we can't update this to prepare it for merge. - To fix this: - - 1. Fork this project with your user account. - 2. Push this branch there. - 3. Create the PR again from that fork/branch. + To fix this: + 1. Fork this project with your user account. + 2. Push this branch there. + 3. Create the PR again from that fork/branch. ` }); } From 596ceaaf60b603dbb78cf4d5480a07f8451a87c7 Mon Sep 17 00:00:00 2001 From: John Aziz Date: Sun, 25 May 2025 17:51:22 +0300 Subject: [PATCH 08/11] remove syncronize since it was causing the action to run twice when you reopen prs --- .github/workflows/close-org-prs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/close-org-prs.yml b/.github/workflows/close-org-prs.yml index e43a141f33..95e51d1e1b 100644 --- a/.github/workflows/close-org-prs.yml +++ b/.github/workflows/close-org-prs.yml @@ -2,7 +2,7 @@ name: Close PRs from Orgs on: pull_request_target: - types: [opened, reopened, synchronize] + types: [opened, reopened] permissions: pull-requests: write From 25748ec0d3f01066722e6c1c55e93b67e84fb440 Mon Sep 17 00:00:00 2001 From: John Aziz Date: Mon, 26 May 2025 02:21:57 +0300 Subject: [PATCH 09/11] only trigger action when a new pr is firstly opened --- .github/workflows/close-org-prs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/close-org-prs.yml b/.github/workflows/close-org-prs.yml index 95e51d1e1b..0b3ca5f171 100644 --- a/.github/workflows/close-org-prs.yml +++ b/.github/workflows/close-org-prs.yml @@ -2,7 +2,7 @@ name: Close PRs from Orgs on: pull_request_target: - types: [opened, reopened] + types: [ opened ] permissions: pull-requests: write From 8bc912710774c7b4c84f5d4effd0c764f3e2bbb9 Mon Sep 17 00:00:00 2001 From: Rowlando13 <67291205+Rowlando13@users.noreply.github.com> Date: Tue, 22 Jul 2025 03:04:08 -0700 Subject: [PATCH 10/11] Revert "Merge Stable into master" --- CHANGES.rst | 7 --- src/click/core.py | 2 - src/click/exceptions.py | 6 +-- src/click/shell_completion.py | 22 +-------- src/click/testing.py | 12 ----- tests/test_options.py | 88 +--------------------------------- tests/test_shell_completion.py | 75 ----------------------------- tests/test_testing.py | 6 ++- 8 files changed, 9 insertions(+), 209 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 2dcffe023c..fbb3254d30 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,13 +8,6 @@ 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 4bebb77891..445460bc58 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -2872,8 +2872,6 @@ 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: diff --git a/src/click/exceptions.py b/src/click/exceptions.py index 4d782ee361..f141a832e1 100644 --- a/src/click/exceptions.py +++ b/src/click/exceptions.py @@ -115,7 +115,7 @@ def __init__( message: str, ctx: Context | None = None, param: Parameter | None = None, - param_hint: cabc.Sequence[str] | str | None = None, + param_hint: str | None = None, ) -> None: super().__init__(message, ctx) self.param = param @@ -151,7 +151,7 @@ def __init__( message: str | None = None, ctx: Context | None = None, param: Parameter | None = None, - param_hint: cabc.Sequence[str] | str | None = None, + param_hint: str | None = None, param_type: str | None = None, ) -> None: super().__init__(message or "", ctx, param, param_hint) @@ -159,7 +159,7 @@ def __init__( def format_message(self) -> str: if self.param_hint is not None: - param_hint: cabc.Sequence[str] | str | None = self.param_hint + param_hint: 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 4cb53b91a5..6c39d5eb38 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -124,12 +124,6 @@ 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 @@ -379,21 +373,7 @@ def get_completion_args(self) -> tuple[list[str], str]: return args, incomplete def format_completion(self, item: CompletionItem) -> 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 - # 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_}" + return f"{item.type}\n{item.value}\n{item.help if item.help else '_'}" class FishComplete(ShellComplete): diff --git a/src/click/testing.py b/src/click/testing.py index 04d263eeac..7c0e8741e2 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -99,18 +99,6 @@ 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__( diff --git a/tests/test_options.py b/tests/test_options.py index 1e7826d82a..f5017384d8 100644 --- a/tests/test_options.py +++ b/tests/test_options.py @@ -1,10 +1,5 @@ -import enum import os import re -import sys - -if sys.version_info < (3, 11): - enum.StrEnum = enum.Enum # type: ignore[assignment] import pytest @@ -1305,33 +1300,6 @@ 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"), [ @@ -1340,11 +1308,6 @@ class ColorInt(enum.IntFlag): 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): @@ -1367,61 +1330,12 @@ def cli_without_choices(g): pass result = runner.invoke(cli_with_choices, ["--help"]) - assert ( - f"[{'|'.join(i.name if isinstance(i, enum.Enum) else str(i) for i in choices)}]" - in result.output - ) + assert f"[{'|'.join([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 819ca8bd57..fb39486dcc 100644 --- a/tests/test_shell_completion.py +++ b/tests/test_shell_completion.py @@ -1,6 +1,4 @@ -import textwrap import warnings -from collections.abc import Mapping import pytest @@ -356,79 +354,6 @@ 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 11fe29dc5d..0fd6973ae8 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -448,7 +448,8 @@ 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(): @@ -459,7 +460,8 @@ 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(): From e252437313eb364212fdb2d200902e2e8d28f3f9 Mon Sep 17 00:00:00 2001 From: Edward G Date: Tue, 22 Jul 2025 03:20:02 -0700 Subject: [PATCH 11/11] Revert "Revert "Merge Stable into master"" This reverts commit 8bc912710774c7b4c84f5d4effd0c764f3e2bbb9. --- CHANGES.rst | 7 +++ src/click/core.py | 2 + src/click/exceptions.py | 6 +-- src/click/shell_completion.py | 22 ++++++++- src/click/testing.py | 12 +++++ tests/test_options.py | 88 +++++++++++++++++++++++++++++++++- tests/test_shell_completion.py | 75 +++++++++++++++++++++++++++++ tests/test_testing.py | 6 +-- 8 files changed, 209 insertions(+), 9 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index fbb3254d30..2dcffe023c 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 445460bc58..4bebb77891 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -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: diff --git a/src/click/exceptions.py b/src/click/exceptions.py index f141a832e1..4d782ee361 100644 --- a/src/click/exceptions.py +++ b/src/click/exceptions.py @@ -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 @@ -151,7 +151,7 @@ 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) @@ -159,7 +159,7 @@ def __init__( 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 6c39d5eb38..4cb53b91a5 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -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 @@ -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): diff --git a/src/click/testing.py b/src/click/testing.py index 7c0e8741e2..04d263eeac 100644 --- a/src/click/testing.py +++ b/src/click/testing.py @@ -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__( diff --git a/tests/test_options.py b/tests/test_options.py index f5017384d8..1e7826d82a 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 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"), [ @@ -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): @@ -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", [ diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py index fb39486dcc..819ca8bd57 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 0fd6973ae8..11fe29dc5d 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():