Repository: pallets/click (2022-02-20 ~ 2024-11-03)

problem_statement: Give `Option.show_default` higher priority wrt `Context.show_default`
Currently, `Context.show_default` (a global setting) overrides `Option.show_default` (a local setting). This is counterintuitive and limit expressiveness: once `Context.show_default` is set to `True`, it's impossible to disable `show_default` for a specific option.
date: 2022-02-20
patch: diff --git a/CHANGES.rst b/CHANGES.rst
index 1589305..93db491 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -22,6 +22,8 @@ Version 8.1.0
     without parentheses. :issue:`1359`
 -   The ``Path`` type can check whether the target is executable.
     :issue:`1961`
+-   ``Command.show_default`` overrides ``Context.show_default``, instead
+    of the other way around. :issue:`1963`
 
 
 Version 8.0.4
diff --git a/src/click/core.py b/src/click/core.py
index 3d11ab5..4b96767 100644
--- a/src/click/core.py
+++ b/src/click/core.py
@@ -223,9 +223,14 @@ class Context:
                   codes are used in texts that Click prints which is by
                   default not the case.  This for instance would affect
                   help output.
-    :param show_default: Show defaults for all options. If not set,
-        defaults to the value from a parent context. Overrides an
-        option's ``show_default`` argument.
+    :param show_default: Show the default value for commands. If this
+        value is not set, it defaults to the value from the parent
+        context. ``Command.show_default`` overrides this default for the
+        specific command.
+
+    .. versionchanged:: 8.1
+        The ``show_default`` parameter is overridden by
+        ``Command.show_default``, instead of the other way around.
 
     .. versionchanged:: 8.0
         The ``show_default`` parameter defaults to the value from the
@@ -2372,14 +2377,15 @@ class Option(Parameter):
 
     All other parameters are passed onwards to the parameter constructor.
 
-    :param show_default: Controls if the default value should be shown
-        on the help page. Normally, defaults are not shown. If this
-        value is a string, it shows that string in parentheses instead
-        of the actual value. This is particularly useful for dynamic
-        options. For single option boolean flags, the default remains
-        hidden if its value is ``False``.
+    :param show_default: Show the default value for this option in its
+        help text. Values are not shown by default, unless
+        :attr:`Context.show_default` is ``True``. If this value is a
+        string, it shows that string in parentheses instead of the
+        actual value. This is particularly useful for dynamic options.
+        For single option boolean flags, the default remains hidden if
+        its value is ``False``.
     :param show_envvar: Controls if an environment variable should be
-        shown on the help page. Normally, environment ariables are not
+        shown on the help page. Normally, environment variables are not
         shown.
     :param prompt: If set to ``True`` or a non empty string then the
         user will be prompted for input. If set to ``True`` the prompt
@@ -2409,6 +2415,10 @@ class Option(Parameter):
     :param help: the help string.
     :param hidden: hide this option from help outputs.
 
+    .. versionchanged:: 8.1.0
+        The ``show_default`` parameter overrides
+        ``Context.show_default``.
+
     .. versionchanged:: 8.1.0
         The default of a single option boolean flag is not shown if the
         default value is ``False``.
@@ -2422,7 +2432,7 @@ class Option(Parameter):
     def __init__(
         self,
         param_decls: t.Optional[t.Sequence[str]] = None,
-        show_default: t.Union[bool, str] = False,
+        show_default: t.Union[bool, str, None] = None,
         prompt: t.Union[bool, str] = False,
         confirmation_prompt: t.Union[bool, str] = False,
         prompt_required: bool = True,
@@ -2689,11 +2699,18 @@ class Option(Parameter):
         finally:
             ctx.resilient_parsing = resilient
 
-        show_default_is_str = isinstance(self.show_default, str)
+        show_default = False
+        show_default_is_str = False
 
-        if show_default_is_str or (
-            default_value is not None and (self.show_default or ctx.show_default)
-        ):
+        if self.show_default is not None:
+            if isinstance(self.show_default, str):
+                show_default_is_str = show_default = True
+            else:
+                show_default = self.show_default
+        elif ctx.show_default is not None:
+            show_default = ctx.show_default
+
+        if show_default_is_str or (show_default and (default_value is not None)):
             if show_default_is_str:
                 default_string = f"({self.show_default})"
             elif isinstance(default_value, (list, tuple)):

test_patch: diff --git a/tests/test_options.py b/tests/test_options.py
index 7367a32..ab230c4 100644
--- a/tests/test_options.py
+++ b/tests/test_options.py
@@ -643,7 +643,6 @@ def test_option_custom_class_reusable(runner):
 
     # Both of the commands should have the --help option now.
     for cmd in (cmd1, cmd2):
-
         result = runner.invoke(cmd, ["--help"])
         assert "I am a help text" in result.output
         assert "you wont see me" not in result.output
@@ -795,6 +794,28 @@ def test_do_not_show_default_empty_multiple():
     assert message == "values"
 
 
+@pytest.mark.parametrize(
+    ("ctx_value", "opt_value", "expect"),
+    [
+        (None, None, False),
+        (None, False, False),
+        (None, True, True),
+        (False, None, False),
+        (False, False, False),
+        (False, True, True),
+        (True, None, True),
+        (True, False, False),
+        (True, True, True),
+        (False, "one", True),
+    ],
+)
+def test_show_default_precedence(ctx_value, opt_value, expect):
+    ctx = click.Context(click.Command("test"), show_default=ctx_value)
+    opt = click.Option("-a", default=1, help="value", show_default=opt_value)
+    help = opt.get_help_record(ctx)[1]
+    assert ("default:" in help) is expect
+
+
 @pytest.mark.parametrize(
     ("args", "expect"),
     [


----

problem_statement: Split extra help item computing with help record rendering
I maintain [Click Extra, a drop-in replacement for Click which adds colorization](https://github.com/kdeldycke/click-extra/tree/main#readme) of the help screen.

I am currently relying on finicky regular expressions to identify and match the extra items that are displayed at the end of each option's help. These extra items are rendered in square brackets and looks like:
- `[default: None]`
- `[default: (unlimited)]`
- `[env var: COLOR_CLI8_TIME; default: no-time]`
- and a variety of other formats.

The problem is that the generation of these extra items are deeply embedded within Click and are impossible to fetch independently.

That's why I propose a simple refactor of the `Option.get_help_record()` method, and split it in two:
- a new method to compute the values of these extra items
- keep the original `get_help_record()` as-is, but dedicated to help message rendering only

I have a PR that is ready at #2517.
date: 2024-11-02
patch: diff --git a/src/click/core.py b/src/click/core.py
index 66af779..9b1a846 100644
--- a/src/click/core.py
+++ b/src/click/core.py
@@ -2668,7 +2668,28 @@ class Option(Parameter):
             rv.append(_write_opts(self.secondary_opts))
 
         help = self.help or ""
-        extra = []
+
+        extra = self.get_help_extra(ctx)
+        extra_items = []
+        if "envvars" in extra:
+            extra_items.append(
+                _("env var: {var}").format(var=", ".join(extra["envvars"]))
+            )
+        if "default" in extra:
+            extra_items.append(_("default: {default}").format(default=extra["default"]))
+        if "range" in extra:
+            extra_items.append(extra["range"])
+        if "required" in extra:
+            extra_items.append(_(extra["required"]))
+
+        if extra_items:
+            extra_str = "; ".join(extra_items)
+            help = f"{help}  [{extra_str}]" if help else f"[{extra_str}]"
+
+        return ("; " if any_prefix_is_slash else " / ").join(rv), help
+
+    def get_help_extra(self, ctx: Context) -> types.OptionHelpExtra:
+        extra: types.OptionHelpExtra = {}
 
         if self.show_envvar:
             envvar = self.envvar
@@ -2682,12 +2703,10 @@ class Option(Parameter):
                     envvar = f"{ctx.auto_envvar_prefix}_{self.name.upper()}"
 
             if envvar is not None:
-                var_str = (
-                    envvar
-                    if isinstance(envvar, str)
-                    else ", ".join(str(d) for d in envvar)
-                )
-                extra.append(_("env var: {var}").format(var=var_str))
+                if isinstance(envvar, str):
+                    extra["envvars"] = (envvar,)
+                else:
+                    extra["envvars"] = tuple(str(d) for d in envvar)
 
         # Temporarily enable resilient parsing to avoid type casting
         # failing for the default. Might be possible to extend this to
@@ -2732,7 +2751,7 @@ class Option(Parameter):
                 default_string = str(default_value)
 
             if default_string:
-                extra.append(_("default: {default}").format(default=default_string))
+                extra["default"] = default_string
 
         if (
             isinstance(self.type, types._NumberRangeBase)
@@ -2742,16 +2761,12 @@ class Option(Parameter):
             range_str = self.type._describe_range()
 
             if range_str:
-                extra.append(range_str)
+                extra["range"] = range_str
 
         if self.required:
-            extra.append(_("required"))
+            extra["required"] = "required"
 
-        if extra:
-            extra_str = "; ".join(extra)
-            help = f"{help}  [{extra_str}]" if help else f"[{extra_str}]"
-
-        return ("; " if any_prefix_is_slash else " / ").join(rv), help
+        return extra
 
     @t.overload
     def get_default(
diff --git a/src/click/types.py b/src/click/types.py
index 595443b..2195f3a 100644
--- a/src/click/types.py
+++ b/src/click/types.py
@@ -1095,3 +1095,10 @@ BOOL = BoolParamType()
 
 #: A UUID parameter.
 UUID = UUIDParameterType()
+
+
+class OptionHelpExtra(t.TypedDict, total=False):
+    envvars: tuple[str, ...]
+    default: str
+    range: str
+    required: str

test_patch: diff --git a/tests/test_options.py b/tests/test_options.py
index 49df83b..293f253 100644
--- a/tests/test_options.py
+++ b/tests/test_options.py
@@ -316,6 +316,7 @@ def test_dynamic_default_help_special_method(runner):
 
     opt = click.Option(["-a"], default=Value(), show_default=True)
     ctx = click.Context(click.Command("cli"))
+    assert opt.get_help_extra(ctx) == {"default": "special value"}
     assert "special value" in opt.get_help_record(ctx)[1]
 
 
@@ -331,6 +332,7 @@ def test_dynamic_default_help_special_method(runner):
 def test_intrange_default_help_text(type, expect):
     option = click.Option(["--num"], type=type, show_default=True, default=2)
     context = click.Context(click.Command("test"))
+    assert option.get_help_extra(context) == {"default": "2", "range": expect}
     result = option.get_help_record(context)[1]
     assert expect in result
 
@@ -339,6 +341,7 @@ def test_count_default_type_help():
     """A count option with the default type should not show >=0 in help."""
     option = click.Option(["--count"], count=True, help="some words")
     context = click.Context(click.Command("test"))
+    assert option.get_help_extra(context) == {}
     result = option.get_help_record(context)[1]
     assert result == "some words"
 
@@ -354,6 +357,7 @@ def test_file_type_help_default():
         ["--in"], type=click.File(), default=__file__, show_default=True
     )
     context = click.Context(click.Command("test"))
+    assert option.get_help_extra(context) == {"default": __file__}
     result = option.get_help_record(context)[1]
     assert __file__ in result
 
@@ -741,6 +745,7 @@ def test_show_default_boolean_flag_name(runner, default, expect):
         help="Enable/Disable the cache.",
     )
     ctx = click.Context(click.Command("test"))
+    assert opt.get_help_extra(ctx) == {"default": expect}
     message = opt.get_help_record(ctx)[1]
     assert f"[default: {expect}]" in message
 
@@ -757,6 +762,7 @@ def test_show_true_default_boolean_flag_value(runner):
         help="Enable the cache.",
     )
     ctx = click.Context(click.Command("test"))
+    assert opt.get_help_extra(ctx) == {"default": "True"}
     message = opt.get_help_record(ctx)[1]
     assert "[default: True]" in message
 
@@ -774,6 +780,7 @@ def test_hide_false_default_boolean_flag_value(runner, default):
         help="Enable the cache.",
     )
     ctx = click.Context(click.Command("test"))
+    assert opt.get_help_extra(ctx) == {}
     message = opt.get_help_record(ctx)[1]
     assert "[default: " not in message
 
@@ -782,6 +789,7 @@ def test_show_default_string(runner):
     """When show_default is a string show that value as default."""
     opt = click.Option(["--limit"], show_default="unlimited")
     ctx = click.Context(click.Command("cli"))
+    assert opt.get_help_extra(ctx) == {"default": "(unlimited)"}
     message = opt.get_help_record(ctx)[1]
     assert "[default: (unlimited)]" in message
 
@@ -798,6 +806,7 @@ def test_do_not_show_no_default(runner):
     """When show_default is True and no default is set do not show None."""
     opt = click.Option(["--limit"], show_default=True)
     ctx = click.Context(click.Command("cli"))
+    assert opt.get_help_extra(ctx) == {}
     message = opt.get_help_record(ctx)[1]
     assert "[default: None]" not in message
 
@@ -808,28 +817,30 @@ def test_do_not_show_default_empty_multiple():
     """
     opt = click.Option(["-a"], multiple=True, help="values", show_default=True)
     ctx = click.Context(click.Command("cli"))
+    assert opt.get_help_extra(ctx) == {}
     message = opt.get_help_record(ctx)[1]
     assert message == "values"
 
 
 @pytest.mark.parametrize(
-    ("ctx_value", "opt_value", "expect"),
+    ("ctx_value", "opt_value", "extra_value", "expect"),
     [
-        (None, None, False),
-        (None, False, False),
-        (None, True, True),
-        (False, None, False),
-        (False, False, False),
-        (False, True, True),
-        (True, None, True),
-        (True, False, False),
-        (True, True, True),
-        (False, "one", True),
+        (None, None, {}, False),
+        (None, False, {}, False),
+        (None, True, {"default": "1"}, True),
+        (False, None, {}, False),
+        (False, False, {}, False),
+        (False, True, {"default": "1"}, True),
+        (True, None, {"default": "1"}, True),
+        (True, False, {}, False),
+        (True, True, {"default": "1"}, True),
+        (False, "one", {"default": "(one)"}, True),
     ],
 )
-def test_show_default_precedence(ctx_value, opt_value, expect):
+def test_show_default_precedence(ctx_value, opt_value, extra_value, expect):
     ctx = click.Context(click.Command("test"), show_default=ctx_value)
     opt = click.Option("-a", default=1, help="value", show_default=opt_value)
+    assert opt.get_help_extra(ctx) == extra_value
     help = opt.get_help_record(ctx)[1]
     assert ("default:" in help) is expect
 


----

problem_statement: Click doesn't close file options during shell completion
Click doesn't close file options during shell completion, which causes a resource warning if a program uses a file option.

For example, I have group like this:

```python
@click.group()
@click.option('--config_file',
              default=CONFIG,
              type=click.File(mode='r'),
              help='help')
@click.pass_context
def cli(ctx, config_file: typing.TextIO):
```

and I get this warning:

```
/Users/grzesiek/Library/Caches/pypoetry/virtualenvs/findata-fetcher-3hK6JJJX-py3.12/lib/python3.12/site-packages/click/shell_completion.py:293: ResourceWarning: unclosed file <_io.TextIOWrapper name='/Users/grzesiek/.config/findata/fetcher.json' mode='r' encoding='UTF-8'>
  completions = self.get_completions(args, incomplete)
```

## Details

I don't come with reproduction steps, but I can give something equally valuable, I can explain how this bug comes to be.

The issue stems from allocating a context in `core.py` outside of a `with` statement during shell completion. Here's a stack-trace of how that happens:

```
  File "/Users/grzesiek/.local/bin/findata-fetcher", line 8, in <module>
    sys.exit(main())
  File "/Users/grzesiek/.local/pipx/venvs/findata-fetcher/lib/python3.12/site-packages/fetcher/tool.py", line 576, in main
    cli(obj={})
  File "/Users/grzesiek/.local/pipx/venvs/findata-fetcher/lib/python3.12/site-packages/click/core.py", line 1171, in __call__
    return self.main(*args, **kwargs)
  File "/Users/grzesiek/.local/pipx/venvs/findata-fetcher/lib/python3.12/site-packages/click/core.py", line 1084, in main
    self._main_shell_completion(extra, prog_name, complete_var)
  File "/Users/grzesiek/.local/pipx/venvs/findata-fetcher/lib/python3.12/site-packages/click/core.py", line 1165, in _main_shell_completion
    rv = shell_complete(self, ctx_args, prog_name, complete_var,
  File "/Users/grzesiek/.local/pipx/venvs/findata-fetcher/lib/python3.12/site-packages/click/shell_completion.py", line 49, in shell_complete
    echo(comp.complete())
  File "/Users/grzesiek/.local/pipx/venvs/findata-fetcher/lib/python3.12/site-packages/click/shell_completion.py", line 296, in complete
    completions = self.get_completions(args, incomplete)
  File "/Users/grzesiek/.local/pipx/venvs/findata-fetcher/lib/python3.12/site-packages/click/shell_completion.py", line 273, in get_completions
    ctx = _resolve_context(self.cli, self.ctx_args, self.prog_name, args)
  File "/Users/grzesiek/.local/pipx/venvs/findata-fetcher/lib/python3.12/site-packages/click/shell_completion.py", line 513, in _resolve_context
    ctx = cli.make_context(prog_name, args.copy(), **ctx_args)
  File "/Users/grzesiek/.local/pipx/venvs/findata-fetcher/lib/python3.12/site-packages/click/core.py", line 952, in make_context
    with ctx.scope(cleanup=False):
```

This context gets [returned](https://github.com/pallets/click/blob/ca5e1c3d75e95cbc70fa6ed51ef263592e9ac0d0/src/click/core.py#L1147C19-L1147C19) to `get_completions`, but `get_completions` or its caller never call the context's `__exit__` function. 

Calling the `__exit__` function is essential, because `types.File` depends on it for cleanup: https://github.com/pallets/click/blob/ca5e1c3d75e95cbc70fa6ed51ef263592e9ac0d0/src/click/types.py#L740.

## Environment

- Python version: 3.12
- Click version: 8.1.7

date: 2024-11-03
patch: diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py
index 1e9df6f..9d3c87d 100644
--- a/src/click/shell_completion.py
+++ b/src/click/shell_completion.py
@@ -494,42 +494,48 @@ def _resolve_context(
     :param args: List of complete args before the incomplete value.
     """
     ctx_args["resilient_parsing"] = True
-    ctx = cli.make_context(prog_name, args.copy(), **ctx_args)
-    args = ctx.protected_args + ctx.args
+    with cli.make_context(prog_name, args.copy(), **ctx_args) as ctx:
+        args = ctx.protected_args + ctx.args
 
-    while args:
-        command = ctx.command
+        while args:
+            command = ctx.command
 
-        if isinstance(command, MultiCommand):
-            if not command.chain:
-                name, cmd, args = command.resolve_command(ctx, args)
-
-                if cmd is None:
-                    return ctx
-
-                ctx = cmd.make_context(name, args, parent=ctx, resilient_parsing=True)
-                args = ctx.protected_args + ctx.args
-            else:
-                while args:
+            if isinstance(command, MultiCommand):
+                if not command.chain:
                     name, cmd, args = command.resolve_command(ctx, args)
 
                     if cmd is None:
                         return ctx
 
-                    sub_ctx = cmd.make_context(
-                        name,
-                        args,
-                        parent=ctx,
-                        allow_extra_args=True,
-                        allow_interspersed_args=False,
-                        resilient_parsing=True,
-                    )
-                    args = sub_ctx.args
-
-                ctx = sub_ctx
-                args = [*sub_ctx.protected_args, *sub_ctx.args]
-        else:
-            break
+                    with cmd.make_context(
+                        name, args, parent=ctx, resilient_parsing=True
+                    ) as sub_ctx:
+                        args = ctx.protected_args + ctx.args
+                        ctx = sub_ctx
+                else:
+                    sub_ctx = ctx
+
+                    while args:
+                        name, cmd, args = command.resolve_command(ctx, args)
+
+                        if cmd is None:
+                            return ctx
+
+                        with cmd.make_context(
+                            name,
+                            args,
+                            parent=ctx,
+                            allow_extra_args=True,
+                            allow_interspersed_args=False,
+                            resilient_parsing=True,
+                        ) as sub_sub_ctx:
+                            args = sub_ctx.args
+                            sub_ctx = sub_sub_ctx
+
+                    ctx = sub_ctx
+                    args = [*sub_ctx.protected_args, *sub_ctx.args]
+            else:
+                break
 
     return ctx
 

test_patch: diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py
index 08fa057..6db00a6 100644
--- a/tests/test_shell_completion.py
+++ b/tests/test_shell_completion.py
@@ -1,5 +1,8 @@
+import warnings
+
 import pytest
 
+import click
 from click.core import Argument
 from click.core import Command
 from click.core import Group
@@ -303,3 +306,27 @@ def test_choice_case_sensitive(value, expect):
     )
     completions = _get_words(cli, ["-a"], "a")
     assert completions == expect
+
+
+# Don't make the ResourceWarning give an error
+@pytest.mark.filterwarnings("default")
+def test_files_closed(runner) -> None:
+    with runner.isolated_filesystem():
+        config_file = "foo.txt"
+        with open(config_file, "w") as f:
+            f.write("bar")
+
+        @click.group()
+        @click.option(
+            "--config-file",
+            default=config_file,
+            type=click.File(mode="r"),
+        )
+        @click.pass_context
+        def cli(ctx, config_file):
+            pass
+
+        with warnings.catch_warnings(record=True) as current_warnings:
+            assert not current_warnings, "There should be no warnings to start"
+            _get_completions(cli, args=[], incomplete="")
+            assert not current_warnings, "There should be no warnings after either"