Repository: pallets/click (Python)

Issue:
Regression: Help option subclassing no longer works with verion 8.1.8 
Click 8.18 has a regression wrt. to help with Option subclasses.

This was found in this issue originally:
- https://github.com/aboutcode-org/scancode-toolkit/issues/4044

This was introduced in this PR by @kdeldycke :wave: :
- https://github.com/pallets/click/pull/2563

It breaks code that uses option subclasses. 


- This code works in 8.1.7 and  8.1.8 with no custom class
```Python
import click

@click.command()
@click.help_option("-h", "--help")
def scancode():
    """OK in pkg:pypi/click@8.1.8 and pkg:pypi/click@8.1.7"""
    pass


if __name__ == "__main__":
    scancode()
```
and then with 8.1.7 and 8.1.8:
```
$ python  works.py --help
Usage: works.py [OPTIONS]

  Regression in pkg:pypi/click@8.1.8

Options:
  -h, --help  Show this message and exit.
```




- This code works in 8.1.7 and fails in 8.1.8 with a custom class
```Python
import click


class PluggableCommandLineOption(click.Option):
    pass


@click.command()
@click.help_option("-h", "--help", cls=PluggableCommandLineOption)
def scancode():
    """Regression in pkg:pypi/click@8.1.8"""
    pass


if __name__ == "__main__":
    scancode()
```

and then with 8.1.7
```
 python  failing.py --help
Usage: failing.py [OPTIONS]

  Regression in pkg:pypi/click@8.1.8

Options:
  -h, --help  Show this message and exit.


$ python  failing.py -h
Usage: failing.py [OPTIONS]

  Regression in pkg:pypi/click@8.1.8

Options:
  -h, --help  Show this message and exit.

```

and then with 8.1.8
```
$ python  failing.py -h
Error: Option '-h' requires an argument.


$ python  failing.py --help
Error: Option '--help' requires an argument.


```



- This code works more or less in 8.1.7 and 8.1.8 with custom class and no "--help" option
```Python
import click


class PluggableCommandLineOption(click.Option):
    pass


@click.command()
@click.help_option("-h", cls=PluggableCommandLineOption)
def scancode():
    """Regression in pkg:pypi/click@8.1.8"""
    pass


if __name__ == "__main__":
    scancode()

```
and then with 8.1.7
```
$ python  works2.py -h
Usage: works2.py [OPTIONS]

  Regression in pkg:pypi/click@8.1.8

Options:
  -h      Show this message and exit.
  --help  Show this message and exit.
```

and then with 8.1.7
```
$ python  works2.py --help
Usage: works2.py [OPTIONS]

  Regression in pkg:pypi/click@8.1.8

Options:
  -h      Show this message and exit.
  --help  Show this message and exit
```

and then with 8.1.8
```
$ python  works2.py -h
Error: Option '-h' requires an argument.

```
and then with 8.1.8 note the changes in `-h TEXT`
```
$ python  works2.py --help
Usage: works2.py [OPTIONS]

  Regression in pkg:pypi/click@8.1.8

Options:
  -h TEXT
  --help   Show this message and exit.

```





Environment:

- Python version: 3.9 to 3.11
- Click version: 8.1.8



Discussion:
@kdeldycke I guess that no refactor goes unpunished!
The short term fix by @AyanSinhaMahapatra is that we skip using 8.1.8 entirely in https://github.com/aboutcode-org/scancode-toolkit/pull/4043/files#diff-fa602a8a75dc9dcc92261bac5f533c2a85e34fcceaff63b3a3a81d9acde2fc52R70

`click >= 6.7, !=7.0, !=8.1.8` 
I just started to update Click Extra to the new v8.1.8 (bad release timing 😅), but I've already seen some strange behavior with the help option.

Thanks @pombredanne for the details, I'll investigate the issue and keep you informed.
An immediate solution would be to make the custom class inherit `click.decorators.HelpOption` instead of `click.Option`, like so:
```python
class PluggableCommandLineOption(click.decorators.HelpOption):
    pass
```
The root of the problem is that by deduplicating the code generating the help option in 8.1.8, I introduced a `HelpOption` class. This class carry all the setup necessary to make a standard `Option` behave like an help option.

The problem is by using a custom class on the `@help_option` decorator, you bypass all the custom setup made by `HelpOption`. Hence the discrepency in behavior between 8.1.7 and 8.1.8.

I just made a proposal in PR #2840 to revert the behavior to what it was in 8.1.7.
@kdeldycke I assume we want to keep the `HelpOption` in 8.2.0 but just not include it in 8.1.x, right? If so, could you verify this is documented in 8.2.0? I don't think we should stop 8.2.0's release for this, but it's good to have it documented.
In retrospect I think introducing the `HelpOption` was a mistake.

Yes, it was more in line with the original (duplicated) implementation of the `--help` option. And it made the code within the `get_help_option()` less hackish than my proposal in #2840 (see that ugly construct ending with `dummy_func.__click_params__.pop()`).

But on the other hand we ended up with the `@help_option` decorator being too dissimilar from the `@password_option`, `@version_option` and other pre-configured option proposed by Click.

For the sake of simplicity and keeping the code aligned, I would propose to go with my implementation in #2840 for both 8.1.x and 8.2.0, and forget about changing the option decorators from decorators to classes altogether.
@davidism Could you weigh in on this?

The PR in #2840 looks good, with a couple suggestions before merging.

Long term, it would definitely be good to attempt to unify the decorators with the classes more.
@Rowlando13 I just merged back the suggestions from @davidism, so unless you find something else, #2840 is ready to be merged upstream.
Thanks @Rowlando13 for the merge!



Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index e3662514c..062596b79 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -98,6 +98,8 @@ Unreleased
 -   ``Choice`` is now generic and supports any iterable value.
     This allows you to use enums and other non-``str`` values. :pr:`2796`
     :issue:`605`
+-   Fix setup of help option's defaults when using a custom class on its
+    decorator. Removes ``HelpOption``. :issue:`2832` :pr:`2840`
 
 Version 8.1.8
 -------------
diff --git a/src/click/__init__.py b/src/click/__init__.py
index f2360fa15..1aa547c57 100644
--- a/src/click/__init__.py
+++ b/src/click/__init__.py
@@ -19,7 +19,6 @@
 from .decorators import confirmation_option as confirmation_option
 from .decorators import group as group
 from .decorators import help_option as help_option
-from .decorators import HelpOption as HelpOption
 from .decorators import make_pass_decorator as make_pass_decorator
 from .decorators import option as option
 from .decorators import pass_context as pass_context
diff --git a/src/click/core.py b/src/click/core.py
index 176a7ca60..2e9c1ae5a 100644
--- a/src/click/core.py
+++ b/src/click/core.py
@@ -1014,25 +1014,21 @@ def get_help_option_names(self, ctx: Context) -> list[str]:
         return list(all_names)
 
     def get_help_option(self, ctx: Context) -> Option | None:
-        """Returns the help option object."""
-        help_options = self.get_help_option_names(ctx)
+        """Returns the help option object.
 
-        if not help_options or not self.add_help_option:
+        Skipped if :attr:`add_help_option` is ``False``.
+        """
+        help_option_names = self.get_help_option_names(ctx)
+
+        if not help_option_names or not self.add_help_option:
             return None
 
-        def show_help(ctx: Context, param: Parameter, value: str) -> None:
-            if value and not ctx.resilient_parsing:
-                echo(ctx.get_help(), color=ctx.color)
-                ctx.exit()
-
-        return Option(
-            help_options,
-            is_flag=True,
-            is_eager=True,
-            expose_value=False,
-            callback=show_help,
-            help=_("Show this message and exit."),
-        )
+        # Avoid circular import.
+        from .decorators import help_option
+
+        # apply help_option decorator and pop resulting option
+        help_option(*help_option_names)(self)
+        return self.params.pop() # type: ignore[return-value]
 
     def make_parser(self, ctx: Context) -> _OptionParser:
         """Creates the underlying option parser for this command."""
diff --git a/src/click/decorators.py b/src/click/decorators.py
index 901f831ad..21f4c3422 100644
--- a/src/click/decorators.py
+++ b/src/click/decorators.py
@@ -2,7 +2,6 @@
 
 import inspect
 import typing as t
-from collections import abc
 from functools import update_wrapper
 from gettext import gettext as _
 
@@ -525,41 +524,28 @@ def callback(ctx: Context, param: Parameter, value: bool) -> None:
     return option(*param_decls, **kwargs)
 
 
-class HelpOption(Option):
+def help_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]:
     """Pre-configured ``--help`` option which immediately prints the help page
     and exits the program.
-    """
-
-    def __init__(
-        self,
-        param_decls: abc.Sequence[str] | None = None,
-        **kwargs: t.Any,
-    ) -> None:
-        if not param_decls:
-            param_decls = ("--help",)
 
-        kwargs.setdefault("is_flag", True)
-        kwargs.setdefault("expose_value", False)
-        kwargs.setdefault("is_eager", True)
-        kwargs.setdefault("help", _("Show this message and exit."))
-        kwargs.setdefault("callback", self.show_help)
-
-        super().__init__(param_decls, **kwargs)
+    :param param_decls: One or more option names. Defaults to the single
+        value ``"--help"``.
+    :param kwargs: Extra arguments are passed to :func:`option`.
+    """
 
-    @staticmethod
     def show_help(ctx: Context, param: Parameter, value: bool) -> None:
         """Callback that print the help page on ``<stdout>`` and exits."""
         if value and not ctx.resilient_parsing:
             echo(ctx.get_help(), color=ctx.color)
             ctx.exit()
 
+    if not param_decls:
+        param_decls = ("--help",)
 
-def help_option(*param_decls: str, **kwargs: t.Any) -> t.Callable[[FC], FC]:
-    """Decorator for the pre-configured ``--help`` option defined above.
+    kwargs.setdefault("is_flag", True)
+    kwargs.setdefault("expose_value", False)
+    kwargs.setdefault("is_eager", True)
+    kwargs.setdefault("help", _("Show this message and exit."))
+    kwargs.setdefault("callback", show_help)
 
-    :param param_decls: One or more option names. Defaults to the single
-        value ``"--help"``.
-    :param kwargs: Extra arguments are passed to :func:`option`.
-    """
-    kwargs.setdefault("cls", HelpOption)
     return option(*param_decls, **kwargs)


Test patch:
diff --git a/tests/test_options.py b/tests/test_options.py
index b7267c182..dd87bc8cd 100644
--- a/tests/test_options.py
+++ b/tests/test_options.py
@@ -724,6 +724,45 @@ def cmd2(testoption):
         assert "you wont see me" not in result.output
 
 
+@pytest.mark.parametrize("custom_class", (True, False))
+@pytest.mark.parametrize(
+    ("name_specs", "expected"),
+    (
+        (
+            ("-h", "--help"),
+            "  -h, --help  Show this message and exit.\n",
+        ),
+        (
+            ("-h",),
+            "  -h      Show this message and exit.\n"
+            "  --help  Show this message and exit.\n",
+        ),
+        (
+            ("--help",),
+            "  --help  Show this message and exit.\n",
+        ),
+    ),
+)
+def test_help_option_custom_names_and_class(runner, custom_class, name_specs, expected):
+    class CustomHelpOption(click.Option):
+        pass
+
+    option_attrs = {}
+    if custom_class:
+        option_attrs["cls"] = CustomHelpOption
+
+    @click.command()
+    @click.help_option(*name_specs, **option_attrs)
+    def cmd():
+        pass
+
+    for arg in name_specs:
+        result = runner.invoke(cmd, [arg])
+        assert not result.exception
+        assert result.exit_code == 0
+        assert expected in result.output
+
+
 def test_bool_flag_with_type(runner):
     @click.command()
     @click.option("--shout/--no-shout", default=False, type=bool)