From 34d25338a4b707a92b2a346cc79dae548e8680dc Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Thu, 9 Jan 2025 11:58:35 -0800 Subject: [PATCH] Allow customization of help option right from its decorator. Removes `click.decorators.HelpOption` class. Closes #2832. --- CHANGES.rst | 2 ++ src/click/__init__.py | 1 - src/click/core.py | 33 +++++++++++++++++---------------- src/click/decorators.py | 38 ++++++++++++-------------------------- tests/test_options.py | 39 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 70 insertions(+), 43 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 057d700df..9b975f2bd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -95,6 +95,8 @@ Unreleased ``Option.default`` if ``Option.is_flag`` is ``False``. This results in ``Option.default`` not needing to implement `__bool__`. :pr:`2829` - Incorrect ``click.edit`` typing has been corrected. :pr:`2804` +- 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 e783729c7..869892a2a 100644 --- a/src/click/core.py +++ b/src/click/core.py @@ -1014,25 +1014,26 @@ 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: + Unless ``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 + + def dummy_func() -> None: + pass + + # Call @help_option decorator to produce an help option with proper + # defaults and attach it to the dummy function defined above. + help_option(*help_option_names)(dummy_func) + + return dummy_func.__click_params__.pop() # type: ignore[no-any-return, attr-defined] 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 ```` 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) 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)