Repository: pallets/click (python)

Issue:
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.

Pull request:
Split generation of help extra items and rendering
This:
- fixes #2516.

This is a small refactor of [the original `Option.get_help_record()` method](https://github.com/pallets/click/blob/main/src/click/core.py#L2708), splitting it in two to separate the generation of extra items in help screen and their rendering.

This PR:
- extracts from `get_help_record()` the [code responsible for extra items generation](https://github.com/pallets/click/blob/b0538dffe889ff5d03934927c31a9b71e5129848/src/click/core.py#L2735-L2792C3), and move it to its own `get_help_extra()` method
- leaves the original `get_help_record()` as-is, but rely on the new `get_help_extra()` method to fetch the extra items to render for the help
- makes the new `get_help_extra()` returns a dictionary of extra items to be rendered, so `get_help_record()` can pick them up to render (and translate) them the way it wants
- augments the original `get_help_record()` unittests to inspect values returned by `get_help_extra()` 

Checklist:

- [x] Add tests that demonstrate the correct behavior of the change. Tests should fail without the change.
- [x] Add or update relevant docs, in the docs folder and in code.
- [x] Add an entry in `CHANGES.rst` summarizing the change and linking to the issue.
- [x] Add `.. versionchanged::` entries in any relevant code docs.
- [x] Run `pre-commit` hooks and fix any issues.
- [x] Run `pytest` and `tox`, no tests failed.


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index ad62712..a43a553 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -36,6 +36,9 @@ Unreleased
     :issue:`2356`
 -   Do not display default values in prompts when ``Option.show_default`` is
     ``False``. :pr:`2509`
+-   Add ``get_help_extra`` method on ``Option`` to fetch the generated extra
+    items used in ``get_help_record`` to render help text. :issue:`2516`
+    :pr:`2517`
 
 
 Version 8.1.8
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