Skip to content

Commit aa87688

Browse files
committed
🐛 fix: keep raw formatter after rendering usage
_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.
1 parent 98467e5 commit aa87688

7 files changed

Lines changed: 115 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ All notable changes to this project will be documented in this file.
2020
argument instead of inside its paragraph, and drop the empty paragraph an empty `:description:` produced.
2121
- Fix a crash and render positional arguments when they are added before `add_subparsers()`; skip headings for groups
2222
whose arguments are all suppressed.
23+
- Keep `RawDescriptionHelpFormatter` line breaks in epilogs and in descriptions rendered after the usage block, and
24+
render sub-command epilogs.
2325

2426
## 1.13.1
2527

roots/test-description-multiline/parser.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ def make() -> ArgumentParser:
1818
add_help=False,
1919
)
2020
group = parser.add_argument_group(
21+
"group",
2122
description="""This group description
2223
2324
spans multiple lines.
24-
"""
25+
""",
2526
)
2627
group.add_argument("--dummy")
2728
return parser
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from __future__ import annotations
2+
3+
import sys
4+
from pathlib import Path
5+
6+
sys.path.insert(0, str(Path(__file__).parent))
7+
extensions = ["sphinx_argparse_cli"]
8+
nitpicky = True
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.. sphinx_argparse_cli::
2+
:module: parser
3+
:func: make
4+
:usage_first:
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from __future__ import annotations
2+
3+
from argparse import ArgumentParser, RawDescriptionHelpFormatter
4+
5+
6+
def make() -> ArgumentParser:
7+
parser = ArgumentParser(
8+
prog="prog",
9+
description="root description\n kept as is",
10+
epilog="root epilog\n kept as is",
11+
formatter_class=RawDescriptionHelpFormatter,
12+
add_help=False,
13+
)
14+
sub = parser.add_subparsers()
15+
sub.add_parser(
16+
"raw",
17+
description="raw description\n kept as is",
18+
epilog="raw epilog\n kept as is",
19+
formatter_class=RawDescriptionHelpFormatter,
20+
add_help=False,
21+
).add_argument("--flag", help="raw flag")
22+
sub.add_parser(
23+
"plain", description="plain description\n reflowed", epilog="plain epilog\n reflowed", add_help=False
24+
)
25+
return parser

src/sphinx_argparse_cli/_logic.py

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -125,11 +125,6 @@ def _std_domain(self) -> StandardDomain:
125125
def _make_id(self) -> Callable[[str], str]:
126126
return make_id_lower if "force_refs_lower" in self.options else make_id
127127

128-
@property
129-
def _raw_format(self) -> bool:
130-
formatter = self.parser.formatter_class
131-
return isinstance(formatter, type) and issubclass(formatter, RawDescriptionHelpFormatter)
132-
133128
def _load_sub_parsers(
134129
self, sub_parser: _SubParsersAction[ArgumentParser]
135130
) -> Iterator[tuple[list[str], str, ArgumentParser]]:
@@ -169,7 +164,7 @@ def run(self) -> list[Node]:
169164
if "usage_first" in self.options:
170165
home_section += self._mk_usage(self.parser)
171166

172-
if description := self._pre_format(self.options.get("description", self.parser.description)):
167+
if description := self._pre_format(self.options.get("description", self.parser.description), self.parser):
173168
home_section += description
174169

175170
if "usage_first" not in self.options:
@@ -178,31 +173,38 @@ def run(self) -> list[Node]:
178173
for group in self.parser._action_groups: # noqa: SLF001
179174
if actions := _visible_actions(group):
180175
home_section += self._mk_option_group(
181-
group, actions, prefix=self.parser.prog.split("/")[-1], prog=self.parser.prog.split("/")[-1]
176+
group,
177+
actions,
178+
self.parser,
179+
prefix=self.parser.prog.split("/")[-1],
180+
prog=self.parser.prog.split("/")[-1],
182181
)
183182
for aliases, help_msg, parser in self._iter_sub_commands():
184183
home_section += self._mk_sub_command(aliases, help_msg, parser)
185184

186-
if epilog := self._pre_format(self.options.get("epilog", self.parser.epilog)):
185+
if epilog := self._pre_format(self.options.get("epilog", self.parser.epilog), self.parser):
187186
home_section += epilog
188187

189188
if self.content:
190189
self.state.nested_parse(self.content, self.content_offset, home_section)
191190

192191
return [home_section]
193192

194-
def _pre_format(self, block: str | None) -> paragraph | literal_block | None:
193+
def _pre_format(self, block: str | None, parser: ArgumentParser) -> paragraph | literal_block | None:
195194
if block is None or not block.strip():
196195
return None
197-
if self._raw_format and "\n" in block:
196+
formatter = parser.formatter_class
197+
if "\n" in block and isinstance(formatter, type) and issubclass(formatter, RawDescriptionHelpFormatter):
198198
lit = literal_block("", Text(block), classes=["sphinx-argparse-cli-wrap"])
199199
lit["language"] = "none"
200200
return lit
201201
para = paragraph("", Text(block))
202202
_protect_option_dashes(para)
203203
return para
204204

205-
def _mk_option_group(self, group: _ArgumentGroup, actions: list[Action], prefix: str, prog: str) -> section:
205+
def _mk_option_group(
206+
self, group: _ArgumentGroup, actions: list[Action], parser: ArgumentParser, prefix: str, prog: str
207+
) -> section:
206208
sub_title_prefix: str = self.options.get("group_sub_title_prefix")
207209
title_prefix = self.options.get("group_title_prefix")
208210
# 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:
213215
# the text sadly needs to be prefixed, because otherwise the autosectionlabel will conflict
214216
header = title("", Text(title_text))
215217
group_section = section("", header, ids=[ref_id], names=[ref_id])
216-
if group.title and (description := self._pre_format(group.description)):
218+
if group.title and (description := self._pre_format(group.description, parser)):
217219
group_section += description
218220
self._register_ref(ref_id, title_text, group_section)
219221
opt_group = bullet_list()
@@ -331,20 +333,19 @@ def _mk_sub_command(self, aliases: list[str], help_msg: str, parser: ArgumentPar
331333
if "usage_first" in self.options:
332334
group_section += self._mk_usage(parser)
333335

334-
command_desc = (parser.description or help_msg or "").strip()
335-
if command_desc:
336-
desc_paragraph = paragraph("", Text(command_desc))
337-
_protect_option_dashes(desc_paragraph)
338-
group_section += desc_paragraph
336+
if command_desc := (parser.description or help_msg).strip():
337+
group_section += self._pre_format(command_desc, parser)
339338

340339
if "usage_first" not in self.options:
341340
group_section += self._mk_usage(parser)
342341

343342
for group in parser._action_groups: # noqa: SLF001
344343
if actions := _visible_actions(group):
345344
group_section += self._mk_option_group(
346-
group, actions, prefix=parser.prog, prog=self.parser.prog.split("/")[-1]
345+
group, actions, parser, prefix=parser.prog, prog=self.parser.prog.split("/")[-1]
347346
)
347+
if epilog := self._pre_format(parser.epilog, parser):
348+
group_section += epilog
348349
return group_section
349350

350351
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:
388389
return title_text
389390

390391
def _mk_usage(self, parser: ArgumentParser) -> literal_block:
391-
parser.formatter_class = lambda prog: HelpFormatter(prog, width=self.options.get("usage_width", 100))
392-
with self._no_color():
392+
width = self.options.get("usage_width", 100)
393+
with patch.object(parser, "formatter_class", lambda prog: HelpFormatter(prog, width=width)), self._no_color():
393394
texts = parser.format_usage()[len("usage: ") :].splitlines()
394395
texts = [line if at == 0 else f"{' ' * (len(parser.prog) + 1)}{line.lstrip()}" for at, line in enumerate(texts)]
395396
return literal_block("", Text("\n".join(texts)), classes=["sphinx-argparse-cli-wrap"])

tests/test_logic.py

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -124,12 +124,12 @@ def test_empty_description_as_text(build_outcome: str) -> None:
124124
@pytest.mark.sphinx(buildername="html", testroot="description-multiline")
125125
def test_multiline_description_as_html(build_outcome: str) -> None:
126126
ref = (
127-
"This description\nspans multiple lines.\n\n this line is indented.\n and also this.\n\nNow this should be"
128-
" a separate paragraph.\n"
127+
"<pre><span></span>This description\nspans multiple lines.\n\n this line is indented.\n and also this.\n\n"
128+
"Now this should be a separate paragraph.\n</pre>"
129129
)
130130
assert ref in build_outcome
131131

132-
ref = "This group description\n\nspans multiple lines.\n"
132+
ref = "<pre><span></span>This group description\n\nspans multiple lines.\n</pre>"
133133
assert ref in build_outcome
134134

135135

@@ -146,21 +146,67 @@ def test_empty_epilog_as_text(build_outcome: str) -> None:
146146
@pytest.mark.sphinx(buildername="html", testroot="epilog-multiline")
147147
def test_multiline_epilog_as_html(build_outcome: str) -> None:
148148
ref = (
149-
"This epilog\nspans multiple lines.\n\n this line is indented.\n and also this.\n\nNow this should be"
150-
" a separate paragraph.\n"
149+
"<pre><span></span>This epilog\nspans multiple lines.\n\n this line is indented.\n and also this.\n\n"
150+
"Now this should be a separate paragraph.\n</pre>"
151151
)
152152
assert ref in build_outcome
153153

154154

155155
@pytest.mark.sphinx(buildername="html", testroot="epilog-multiline-subclass")
156156
def test_multiline_epilog_subclass_formatter_as_html(build_outcome: str) -> None:
157157
ref = (
158-
"This epilog\nspans multiple lines.\n\n this line is indented.\n and also this.\n\nNow this should be"
159-
" a separate paragraph.\n"
158+
"<pre><span></span>This epilog\nspans multiple lines.\n\n this line is indented.\n and also this.\n\n"
159+
"Now this should be a separate paragraph.\n</pre>"
160160
)
161161
assert ref in build_outcome
162162

163163

164+
@pytest.mark.sphinx(buildername="text", testroot="subcommand-epilog-raw")
165+
def test_sub_command_description_epilog_as_text(build_outcome: str) -> None:
166+
assert (
167+
build_outcome
168+
== """prog - CLI interface
169+
********************
170+
171+
prog {raw,plain} ...
172+
173+
root description
174+
kept as is
175+
176+
177+
prog raw
178+
========
179+
180+
prog raw [--flag FLAG]
181+
182+
raw description
183+
kept as is
184+
185+
186+
prog raw options
187+
----------------
188+
189+
* **"--flag"** "FLAG" - raw flag
190+
191+
raw epilog
192+
kept as is
193+
194+
195+
prog plain
196+
==========
197+
198+
prog plain
199+
200+
plain description reflowed
201+
202+
plain epilog reflowed
203+
204+
root epilog
205+
kept as is
206+
"""
207+
)
208+
209+
164210
@pytest.mark.sphinx(buildername="html", testroot="smartquotes")
165211
def test_option_dashes_survive_smartquotes(build_outcome: str) -> None:
166212
# option names mentioned in parser-supplied text keep their double hyphen

0 commit comments

Comments
 (0)