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


Pull request:
Move `--help` option defaults from its class to its decorator
This is a proposal to fix #2832, which points to a subtle change in behavior of the help option introduced in 8.1.8.

The root of the problem is that by deduplicating code in 8.1.8 (see: #2563), I introduced a `HelpOption` class, to centralize the setup necessary to make a standard `Option` behave like an help option.

In #2832, a user is relying on a custom class to feed the `@help_option` decorator. This bypass all the custom setup made by `HelpOption`, and produce the discrepancy in behavior between 8.1.7 and 8.1.8.

This PR:
- Reverts back to setting up all parameters of the help option in the decorator instead of the `HelpOption` class
- Aligns `@help_option` implementation with the way Click is doing for the others (like `@password_option`, `@version_option`, ...) 
- Removes the `click.decorators.HelpOption` class as a consequence
- Adds a complete unit test to check combination of custom class and option names leads to expected results


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 e366251..062596b 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 f2360fa..1aa547c 100644
--- a/src/click/__init__.py
+++ b/src/click/__init__.py
@@ -19,7 +19,6 @@ from .decorators import command as command
 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 176a7ca..2e9c1ae 100644
--- a/src/click/core.py
+++ b/src/click/core.py
@@ -1014,25 +1014,21 @@ class Command:
         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 901f831..21f4c34 100644
--- a/src/click/decorators.py
+++ b/src/click/decorators.py
@@ -2,7 +2,6 @@ from __future__ import annotations
 
 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 version_option(
     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 b7267c1..dd87bc8 100644
--- a/tests/test_options.py
+++ b/tests/test_options.py
@@ -724,6 +724,45 @@ def test_option_custom_class_reusable(runner):
         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)