Repository: pallets/click

Problem statement:
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

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



Hints:
@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
```
@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
```

Patch:
diff --git a/src/click/__init__.py b/src/click/__init__.py
--- 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
--- 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
--- 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
--- 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)