From aa87688155bb663cfe886c5058534a257e6946e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= Date: Thu, 27 Aug 2026 08:16:47 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20keep=20raw=20formatter=20?= =?UTF-8?q?after=20rendering=20usage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _mk_usage swapped parser.formatter_class for a width-setting lambda and left it there, so the RawDescriptionHelpFormatter check failed for every block formatted after the first usage: epilogs, descriptions under :usage_first:, and group descriptions all lost their line breaks. Scope the swap with patch.object and pass the owning parser to _pre_format, so sub-commands honour their own formatter_class. Their description now takes the same path as the root, and their epilog renders after the option groups instead of being dropped. --- CHANGELOG.md | 2 + roots/test-description-multiline/parser.py | 3 +- roots/test-subcommand-epilog-raw/conf.py | 8 +++ roots/test-subcommand-epilog-raw/index.rst | 4 ++ roots/test-subcommand-epilog-raw/parser.py | 25 +++++++++ src/sphinx_argparse_cli/_logic.py | 41 +++++++-------- tests/test_logic.py | 60 +++++++++++++++++++--- 7 files changed, 115 insertions(+), 28 deletions(-) create mode 100644 roots/test-subcommand-epilog-raw/conf.py create mode 100644 roots/test-subcommand-epilog-raw/index.rst create mode 100644 roots/test-subcommand-epilog-raw/parser.py diff --git a/CHANGELOG.md b/CHANGELOG.md index aebff47..54e6af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ All notable changes to this project will be documented in this file. argument instead of inside its paragraph, and drop the empty paragraph an empty `:description:` produced. - Fix a crash and render positional arguments when they are added before `add_subparsers()`; skip headings for groups whose arguments are all suppressed. +- Keep `RawDescriptionHelpFormatter` line breaks in epilogs and in descriptions rendered after the usage block, and + render sub-command epilogs. ## 1.13.1 diff --git a/roots/test-description-multiline/parser.py b/roots/test-description-multiline/parser.py index dcb2dd6..1c48119 100644 --- a/roots/test-description-multiline/parser.py +++ b/roots/test-description-multiline/parser.py @@ -18,10 +18,11 @@ def make() -> ArgumentParser: add_help=False, ) group = parser.add_argument_group( + "group", description="""This group description spans multiple lines. -""" +""", ) group.add_argument("--dummy") return parser diff --git a/roots/test-subcommand-epilog-raw/conf.py b/roots/test-subcommand-epilog-raw/conf.py new file mode 100644 index 0000000..9f2a54a --- /dev/null +++ b/roots/test-subcommand-epilog-raw/conf.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +extensions = ["sphinx_argparse_cli"] +nitpicky = True diff --git a/roots/test-subcommand-epilog-raw/index.rst b/roots/test-subcommand-epilog-raw/index.rst new file mode 100644 index 0000000..7ddfafc --- /dev/null +++ b/roots/test-subcommand-epilog-raw/index.rst @@ -0,0 +1,4 @@ +.. sphinx_argparse_cli:: + :module: parser + :func: make + :usage_first: diff --git a/roots/test-subcommand-epilog-raw/parser.py b/roots/test-subcommand-epilog-raw/parser.py new file mode 100644 index 0000000..59fa1bc --- /dev/null +++ b/roots/test-subcommand-epilog-raw/parser.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from argparse import ArgumentParser, RawDescriptionHelpFormatter + + +def make() -> ArgumentParser: + parser = ArgumentParser( + prog="prog", + description="root description\n kept as is", + epilog="root epilog\n kept as is", + formatter_class=RawDescriptionHelpFormatter, + add_help=False, + ) + sub = parser.add_subparsers() + sub.add_parser( + "raw", + description="raw description\n kept as is", + epilog="raw epilog\n kept as is", + formatter_class=RawDescriptionHelpFormatter, + add_help=False, + ).add_argument("--flag", help="raw flag") + sub.add_parser( + "plain", description="plain description\n reflowed", epilog="plain epilog\n reflowed", add_help=False + ) + return parser diff --git a/src/sphinx_argparse_cli/_logic.py b/src/sphinx_argparse_cli/_logic.py index 31ecb74..8b9643e 100644 --- a/src/sphinx_argparse_cli/_logic.py +++ b/src/sphinx_argparse_cli/_logic.py @@ -125,11 +125,6 @@ def _std_domain(self) -> StandardDomain: def _make_id(self) -> Callable[[str], str]: return make_id_lower if "force_refs_lower" in self.options else make_id - @property - def _raw_format(self) -> bool: - formatter = self.parser.formatter_class - return isinstance(formatter, type) and issubclass(formatter, RawDescriptionHelpFormatter) - def _load_sub_parsers( self, sub_parser: _SubParsersAction[ArgumentParser] ) -> Iterator[tuple[list[str], str, ArgumentParser]]: @@ -169,7 +164,7 @@ def run(self) -> list[Node]: if "usage_first" in self.options: home_section += self._mk_usage(self.parser) - if description := self._pre_format(self.options.get("description", self.parser.description)): + if description := self._pre_format(self.options.get("description", self.parser.description), self.parser): home_section += description if "usage_first" not in self.options: @@ -178,12 +173,16 @@ def run(self) -> list[Node]: for group in self.parser._action_groups: # noqa: SLF001 if actions := _visible_actions(group): home_section += self._mk_option_group( - group, actions, prefix=self.parser.prog.split("/")[-1], prog=self.parser.prog.split("/")[-1] + group, + actions, + self.parser, + prefix=self.parser.prog.split("/")[-1], + prog=self.parser.prog.split("/")[-1], ) for aliases, help_msg, parser in self._iter_sub_commands(): home_section += self._mk_sub_command(aliases, help_msg, parser) - if epilog := self._pre_format(self.options.get("epilog", self.parser.epilog)): + if epilog := self._pre_format(self.options.get("epilog", self.parser.epilog), self.parser): home_section += epilog if self.content: @@ -191,10 +190,11 @@ def run(self) -> list[Node]: return [home_section] - def _pre_format(self, block: str | None) -> paragraph | literal_block | None: + def _pre_format(self, block: str | None, parser: ArgumentParser) -> paragraph | literal_block | None: if block is None or not block.strip(): return None - if self._raw_format and "\n" in block: + formatter = parser.formatter_class + if "\n" in block and isinstance(formatter, type) and issubclass(formatter, RawDescriptionHelpFormatter): lit = literal_block("", Text(block), classes=["sphinx-argparse-cli-wrap"]) lit["language"] = "none" return lit @@ -202,7 +202,9 @@ def _pre_format(self, block: str | None) -> paragraph | literal_block | None: _protect_option_dashes(para) return para - def _mk_option_group(self, group: _ArgumentGroup, actions: list[Action], prefix: str, prog: str) -> section: + def _mk_option_group( + self, group: _ArgumentGroup, actions: list[Action], parser: ArgumentParser, prefix: str, prog: str + ) -> section: sub_title_prefix: str = self.options.get("group_sub_title_prefix") title_prefix = self.options.get("group_title_prefix") # an untitled group borrows its description as heading so its anchor stays unique @@ -213,7 +215,7 @@ def _mk_option_group(self, group: _ArgumentGroup, actions: list[Action], prefix: # the text sadly needs to be prefixed, because otherwise the autosectionlabel will conflict header = title("", Text(title_text)) group_section = section("", header, ids=[ref_id], names=[ref_id]) - if group.title and (description := self._pre_format(group.description)): + if group.title and (description := self._pre_format(group.description, parser)): group_section += description self._register_ref(ref_id, title_text, group_section) opt_group = bullet_list() @@ -331,11 +333,8 @@ def _mk_sub_command(self, aliases: list[str], help_msg: str, parser: ArgumentPar if "usage_first" in self.options: group_section += self._mk_usage(parser) - command_desc = (parser.description or help_msg or "").strip() - if command_desc: - desc_paragraph = paragraph("", Text(command_desc)) - _protect_option_dashes(desc_paragraph) - group_section += desc_paragraph + if command_desc := (parser.description or help_msg).strip(): + group_section += self._pre_format(command_desc, parser) if "usage_first" not in self.options: group_section += self._mk_usage(parser) @@ -343,8 +342,10 @@ def _mk_sub_command(self, aliases: list[str], help_msg: str, parser: ArgumentPar for group in parser._action_groups: # noqa: SLF001 if actions := _visible_actions(group): group_section += self._mk_option_group( - group, actions, prefix=parser.prog, prog=self.parser.prog.split("/")[-1] + group, actions, parser, prefix=parser.prog, prog=self.parser.prog.split("/")[-1] ) + if epilog := self._pre_format(parser.epilog, parser): + group_section += epilog return group_section def _build_sub_cmd_title(self, parser: ArgumentParser, sub_title_prefix: str, title_prefix: str) -> str: @@ -388,8 +389,8 @@ def _apply_sub_title(title_text: str, sub_title_prefix: str, prog: str, sub_cmd: return title_text def _mk_usage(self, parser: ArgumentParser) -> literal_block: - parser.formatter_class = lambda prog: HelpFormatter(prog, width=self.options.get("usage_width", 100)) - with self._no_color(): + width = self.options.get("usage_width", 100) + with patch.object(parser, "formatter_class", lambda prog: HelpFormatter(prog, width=width)), self._no_color(): texts = parser.format_usage()[len("usage: ") :].splitlines() texts = [line if at == 0 else f"{' ' * (len(parser.prog) + 1)}{line.lstrip()}" for at, line in enumerate(texts)] return literal_block("", Text("\n".join(texts)), classes=["sphinx-argparse-cli-wrap"]) diff --git a/tests/test_logic.py b/tests/test_logic.py index 56cdff0..264846c 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -124,12 +124,12 @@ def test_empty_description_as_text(build_outcome: str) -> None: @pytest.mark.sphinx(buildername="html", testroot="description-multiline") def test_multiline_description_as_html(build_outcome: str) -> None: ref = ( - "This description\nspans multiple lines.\n\n this line is indented.\n and also this.\n\nNow this should be" - " a separate paragraph.\n" + "
This description\nspans multiple lines.\n\n  this line is indented.\n    and also this.\n\n"
+        "Now this should be a separate paragraph.\n
" ) assert ref in build_outcome - ref = "This group description\n\nspans multiple lines.\n" + ref = "
This group description\n\nspans multiple lines.\n
" assert ref in build_outcome @@ -146,8 +146,8 @@ def test_empty_epilog_as_text(build_outcome: str) -> None: @pytest.mark.sphinx(buildername="html", testroot="epilog-multiline") def test_multiline_epilog_as_html(build_outcome: str) -> None: ref = ( - "This epilog\nspans multiple lines.\n\n this line is indented.\n and also this.\n\nNow this should be" - " a separate paragraph.\n" + "
This epilog\nspans multiple lines.\n\n  this line is indented.\n    and also this.\n\n"
+        "Now this should be a separate paragraph.\n
" ) assert ref in build_outcome @@ -155,12 +155,58 @@ def test_multiline_epilog_as_html(build_outcome: str) -> None: @pytest.mark.sphinx(buildername="html", testroot="epilog-multiline-subclass") def test_multiline_epilog_subclass_formatter_as_html(build_outcome: str) -> None: ref = ( - "This epilog\nspans multiple lines.\n\n this line is indented.\n and also this.\n\nNow this should be" - " a separate paragraph.\n" + "
This epilog\nspans multiple lines.\n\n  this line is indented.\n    and also this.\n\n"
+        "Now this should be a separate paragraph.\n
" ) assert ref in build_outcome +@pytest.mark.sphinx(buildername="text", testroot="subcommand-epilog-raw") +def test_sub_command_description_epilog_as_text(build_outcome: str) -> None: + assert ( + build_outcome + == """prog - CLI interface +******************** + + prog {raw,plain} ... + + root description + kept as is + + +prog raw +======== + + prog raw [--flag FLAG] + + raw description + kept as is + + +prog raw options +---------------- + +* **"--flag"** "FLAG" - raw flag + + raw epilog + kept as is + + +prog plain +========== + + prog plain + +plain description reflowed + +plain epilog reflowed + + root epilog + kept as is +""" + ) + + @pytest.mark.sphinx(buildername="html", testroot="smartquotes") def test_option_dashes_survive_smartquotes(build_outcome: str) -> None: # option names mentioned in parser-supplied text keep their double hyphen