From a5858d735f73468621d4b387f33789904990804e Mon Sep 17 00:00:00 2001 From: Thibault-dlr Date: Tue, 27 Oct 2026 15:16:09 -0300 Subject: [PATCH] Add help output for a single option group --- changelog/9452.feature.rst | 1 + doc/en/reference/reference.rst | 8 +++ src/_pytest/config/argparsing.py | 48 +++++++++++++++++- src/_pytest/helpconfig.py | 48 +++++++++++++++++- testing/test_helpconfig.py | 86 ++++++++++++++++++++++++++++++++ 5 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 changelog/9452.feature.rst diff --git a/changelog/9452.feature.rst b/changelog/9452.feature.rst new file mode 100644 index 00000000000..efaaf2efe9b --- /dev/null +++ b/changelog/9452.feature.rst @@ -0,0 +1 @@ +Added the :option:`--help-section` option to display help for a single option group, including custom options and options added by plugins. diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index 87abcc5f74d..f849fa1ced3 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -3467,6 +3467,14 @@ Version and Help Show help message and configuration info. +.. option:: --help-section=NAME + + Show help for the option group identified by ``NAME``. Use ``custom`` for options + registered without an explicit group, or the name passed to + :meth:`pytest.Parser.getgroup` for a named group. To select a group named + ``custom`` or one whose name starts with ``group:``, prefix its name with + ``group:``. + Complete Help Output ~~~~~~~~~~~~~~~~~~~~ diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 7180cde2c4c..4f00fe28b65 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -133,7 +133,11 @@ def __init__( self.optparser = PytestArgumentParser(usage, self.extra_info, prog=prog) anonymous_arggroup = self.optparser.add_argument_group("Custom options") self._anonymous = OptionGroup( - anonymous_arggroup, "_anonymous", self, _ispytest=True + anonymous_arggroup, + "_anonymous", + self, + _ispytest=True, + description="Custom options", ) self._groups = [self._anonymous] # Maps option strings -> dest, e.g. "-V" and "--version" to "version". @@ -178,7 +182,9 @@ def getgroup( return group arggroup = self.optparser.add_argument_group(description or name) - group = OptionGroup(arggroup, name, self, _ispytest=True) + group = OptionGroup( + arggroup, name, self, _ispytest=True, description=description + ) i = 0 for i, grp in enumerate(self._groups): if grp.name == after: @@ -189,6 +195,41 @@ def getgroup( self.optparser._action_groups.insert(i + 1, self.optparser._action_groups.pop()) return group + def _format_group_help(self, name: str) -> str: + """Format the help for one option group, identified by its name.""" + if name == "custom": + group = self._anonymous + else: + group = next( + ( + group + for group in self._groups[1:] + if self._get_group_help_name(group) == name + ), + None, + ) + if group is None: + available = [ + "custom", + *(self._get_group_help_name(group) for group in self._groups[1:]), + ] + raise ValueError( + f"unknown help section {name!r}; available sections: " + f"{', '.join(available)}" + ) + + formatter = self.optparser._get_formatter() + formatter.start_section(group.description) + formatter.add_arguments([option._action for option in group.options]) + formatter.end_section() + return formatter.format_help() + + @staticmethod + def _get_group_help_name(group: OptionGroup) -> str: + if group.name == "custom" or group.name.startswith("group:"): + return f"group:{group.name}" + return group.name + def addoption(self, *opts: str, **attrs: Any) -> None: """Register a command line option. @@ -429,10 +470,13 @@ def __init__( name: str, parser: Parser | None, _ispytest: bool = False, + *, + description: str | None = None, ) -> None: check_ispytest(_ispytest) self._arggroup = arggroup self.name = name + self.description = description or name self.options: list[Argument] = [] self.parser = parser diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index 8d9d44699c1..861ac53cb89 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -13,6 +13,7 @@ from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import PrintHelp +from _pytest.config import UsageError from _pytest.config.argparsing import _ini_type_repr from _pytest.config.argparsing import _split_help_text from _pytest.config.argparsing import Parser @@ -58,6 +59,31 @@ def __call__( raise PrintHelp +class HelpSectionAction(argparse.Action): + def __init__( + self, + option_strings: Sequence[str], + dest: str, + *, + default: Any = None, + help: str | None = None, + ) -> None: + super().__init__( + option_strings, dest, default=default, help=help, metavar="NAME" + ) + + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + values: str | Sequence[Any] | None, + option_string: str | None = None, + ) -> None: + setattr(namespace, self.dest, values) + if getattr(namespace, "_raise_print_help", False): + raise PrintHelp + + def pytest_addoption(parser: Parser) -> None: group = parser.getgroup("debugconfig") group.addoption( @@ -76,6 +102,12 @@ def pytest_addoption(parser: Parser) -> None: dest="help", help="Show help message and configuration info", ) + group.addoption( + "--help-section", + action=HelpSectionAction, + default=None, + help="Show help for one option group by its name.", + ) group._addoption( # private to use reserved lower-case short option "-p", action="append", @@ -171,6 +203,13 @@ def pytest_cmdline_main(config: Config) -> int | ExitCode | None: if config.option.version > 1: show_version_verbose(config) return ExitCode.OK + elif config.option.help_section is not None: + config._do_configure() + try: + showhelp(config, section=config.option.help_section) + finally: + config._ensure_unconfigure() + return ExitCode.OK elif config.option.help: config._do_configure() showhelp(config) @@ -179,12 +218,19 @@ def pytest_cmdline_main(config: Config) -> int | ExitCode | None: return None -def showhelp(config: Config) -> None: +def showhelp(config: Config, *, section: str | None = None) -> None: reporter: TerminalReporter | None = config.pluginmanager.get_plugin( "terminalreporter" ) assert reporter is not None tw = reporter._tw + if section is not None: + try: + tw.write(config._parser._format_group_help(section)) + except ValueError as exc: + raise UsageError(str(exc)) from exc + return + tw.write(config._parser.optparser.format_help()) tw.line() tw.line( diff --git a/testing/test_helpconfig.py b/testing/test_helpconfig.py index a833e0e6fa2..f67fa5751cd 100644 --- a/testing/test_helpconfig.py +++ b/testing/test_helpconfig.py @@ -51,6 +51,92 @@ def test_help(pytester: Pytester) -> None: ) +def test_help_section_custom(pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addoption("--custom-option", help="custom option help") + group = parser.getgroup("example-plugin", "Example plugin") + group.addoption("--plugin-option", help="plugin option help") + """ + ) + + result = pytester.runpytest("--help-section=custom") + assert result.ret == ExitCode.OK + result.stdout.fnmatch_lines( + ["Custom options:", "*--custom-option=CUSTOM_OPTION*", "*custom option help*"] + ) + result.stdout.no_fnmatch_line("*--plugin-option*") + result.stdout.no_fnmatch_line("*usage:*") + + +def test_help_section_skips_required_options(pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addoption("--required-option", required=True, help="required option") + """ + ) + + result = pytester.runpytest("--help-section=custom") + assert result.ret == ExitCode.OK + result.stdout.fnmatch_lines(["*--required-option=REQUIRED_OPTION*"]) + + +def test_help_section_named_group(pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addoption("--custom-option", help="custom option help") + group = parser.getgroup("example-plugin", "Example plugin") + group.addoption("--plugin-option", help="plugin option help") + """ + ) + + result = pytester.runpytest("--help-section=example-plugin") + assert result.ret == ExitCode.OK + result.stdout.fnmatch_lines( + ["Example plugin:", "*--plugin-option=PLUGIN_OPTION*", "*plugin option help*"] + ) + result.stdout.no_fnmatch_line("*--custom-option*") + result.stdout.no_fnmatch_line("*usage:*") + + +def test_help_section_unknown(pytester: Pytester) -> None: + result = pytester.runpytest("--help-section=unknown") + assert result.ret == ExitCode.USAGE_ERROR + result.stderr.fnmatch_lines( + ["*unknown help section 'unknown'; available sections: custom, *"] + ) + + +def test_help_section_group_named_custom(pytester: Pytester) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addoption("--anonymous-option", help="anonymous option help") + group = parser.getgroup("custom", "Custom plugin") + group.addoption("--named-custom-option", help="named custom option help") + group = parser.getgroup("group:plugin", "Prefixed plugin") + group.addoption("--prefixed-option", help="prefixed option help") + """ + ) + + result = pytester.runpytest("--help-section=custom") + assert result.ret == ExitCode.OK + result.stdout.fnmatch_lines(["*--anonymous-option=ANONYMOUS_OPTION*"]) + result.stdout.no_fnmatch_line("*--named-custom-option*") + + result = pytester.runpytest("--help-section=group:custom") + assert result.ret == ExitCode.OK + result.stdout.fnmatch_lines(["*--named-custom-option=NAMED_CUSTOM_OPTION*"]) + result.stdout.no_fnmatch_line("*--anonymous-option*") + + result = pytester.runpytest("--help-section=group:group:plugin") + assert result.ret == ExitCode.OK + result.stdout.fnmatch_lines(["*--prefixed-option=PREFIXED_OPTION*"]) + + def test_help_ini_union_and_literal_types(pytester: Pytester) -> None: """Union and Literal ini options display their members/choices in --help.""" pytester.makeconftest(