Skip to content

Commit 3f69c2c

Browse files
committed
🐛 fix: keep non-paragraph help nodes out of the option line
Whitespace-only help crashed with IndexError because nested_parse returned no nodes and the code indexed the first one. Help that parsed to a bullet list or several paragraphs got spliced into the option's paragraph, which nests <li> inside <p> and drops every paragraph after the first. An empty :description: override left an empty <p>. Only a leading paragraph now shares the option line; other nodes go under the list item as blocks, and blank description or epilog text renders nothing.
1 parent 6bd79f0 commit 3f69c2c

6 files changed

Lines changed: 75 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ All notable changes to this project will be documented in this file.
1212
- Leave apostrophes inside words (`don't`, `it's`) alone in help text instead of turning them into broken inline
1313
literals.
1414
- Make `:hook:` intercept `parse_intermixed_args()` as well as `parse_args()`.
15+
- Fix a crash on whitespace-only help text, render help that parses to lists or several paragraphs as blocks under the
16+
argument instead of inside its paragraph, and drop the empty paragraph an empty `:description:` produced.
1517

1618
## 1.13.1
1719

roots/test-help-nodes/conf.py

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

roots/test-help-nodes/index.rst

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+
:description:

roots/test-help-nodes/parser.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from __future__ import annotations
2+
3+
from argparse import ArgumentParser, RawTextHelpFormatter
4+
5+
6+
def make() -> ArgumentParser:
7+
parser = ArgumentParser(prog="prog", formatter_class=RawTextHelpFormatter, add_help=False)
8+
parser.add_argument("--blank", help=" ")
9+
parser.add_argument("--list", help="- item one\n- item two")
10+
parser.add_argument("--two", help="first paragraph\n\nsecond paragraph")
11+
return parser

src/sphinx_argparse_cli/_logic.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
from sphinx.util.logging import getLogger
4949

5050
if TYPE_CHECKING:
51-
from collections.abc import Callable, Iterator
51+
from collections.abc import Callable, Iterator, Sequence
5252

5353
from sphinx.domains.std import StandardDomain
5454
from sphinx.util.logging import SphinxLoggerAdapter
@@ -196,7 +196,7 @@ def run(self) -> list[Node]:
196196
return [home_section]
197197

198198
def _pre_format(self, block: str | None) -> paragraph | literal_block | None:
199-
if block is None:
199+
if block is None or not block.strip():
200200
return None
201201
if self._raw_format and "\n" in block:
202202
lit = literal_block("", Text(block), classes=["sphinx-argparse-cli-wrap"])
@@ -256,13 +256,17 @@ def _mk_option_line(self, action: Action, prefix: str) -> list_item:
256256
else:
257257
self._mk_option_name(line, prefix, as_key)
258258

259+
extra: Sequence[Node] = ()
259260
if action.help:
260-
help_text = load_help_text(action.help)
261261
temp = paragraph()
262-
self.state.nested_parse(StringList(help_text.split("\n")), 0, temp)
263-
line += Text(" - ")
264-
for content in cast("paragraph", temp.children[0]).children:
265-
line += content
262+
self.state.nested_parse(StringList(load_help_text(action.help).split("\n")), 0, temp)
263+
# only a leading paragraph can share the option's line; anything else becomes a block under it
264+
if temp.children and isinstance(temp.children[0], paragraph):
265+
line += Text(" - ")
266+
line += temp.children[0].children
267+
extra = temp.children[1:]
268+
else:
269+
extra = temp.children
266270
if (
267271
"no_default_values" not in self.options
268272
and action.default is not None
@@ -273,8 +277,9 @@ def _mk_option_line(self, action: Action, prefix: str) -> list_item:
273277
line += Text(" (default: ")
274278
line += literal(text=str(action.default).replace(str(Path.cwd()), "{cwd}"))
275279
line += Text(")")
276-
_protect_option_dashes(line)
277-
return list_item("", line, ids=[])
280+
item = list_item("", line, *extra, ids=[])
281+
_protect_option_dashes(item)
282+
return item
278283

279284
def _mk_option_name(self, line: paragraph, prefix: str, opt: str) -> None:
280285
ref_id = self._make_id(f"{prefix}-{opt}")

tests/test_logic.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def test_set_description_as_text(build_outcome: str) -> None:
118118

119119
@pytest.mark.sphinx(buildername="text", testroot="description-empty")
120120
def test_empty_description_as_text(build_outcome: str) -> None:
121-
assert build_outcome == "foo - CLI interface\n*******************\n\n\n foo\n"
121+
assert build_outcome == "foo - CLI interface\n*******************\n\n foo\n"
122122

123123

124124
@pytest.mark.sphinx(buildername="html", testroot="description-multiline")
@@ -140,7 +140,7 @@ def test_set_epilog_as_text(build_outcome: str) -> None:
140140

141141
@pytest.mark.sphinx(buildername="text", testroot="epilog-empty")
142142
def test_empty_epilog_as_text(build_outcome: str) -> None:
143-
assert build_outcome == "foo - CLI interface\n*******************\n\n foo\n\n"
143+
assert build_outcome == "foo - CLI interface\n*******************\n\n foo\n"
144144

145145

146146
@pytest.mark.sphinx(buildername="html", testroot="epilog-multiline")
@@ -273,6 +273,40 @@ def test_option_role_as_html(build_outcome: str, warning: StringIO) -> None:
273273
assert not warning.getvalue()
274274

275275

276+
@pytest.mark.sphinx(buildername="text", testroot="help-nodes")
277+
def test_help_nodes_as_text(build_outcome: str) -> None:
278+
assert (
279+
build_outcome
280+
== """prog - CLI interface
281+
********************
282+
283+
prog [--blank BLANK] [--list LIST] [--two TWO]
284+
285+
286+
prog options
287+
============
288+
289+
* **"--blank"** "BLANK"
290+
291+
* **"--list"** "LIST"
292+
293+
* item one
294+
295+
* item two
296+
297+
* **"--two"** "TWO" - first paragraph
298+
299+
second paragraph
300+
"""
301+
)
302+
303+
304+
@pytest.mark.sphinx(buildername="html", testroot="help-nodes")
305+
def test_help_nodes_as_html(build_outcome: str, warning: StringIO) -> None:
306+
assert "<p></p>" not in build_outcome
307+
assert not warning.getvalue()
308+
309+
276310
@pytest.mark.sphinx(buildername="text", testroot="ref-duplicate-label")
277311
def test_ref_duplicate_label(build_outcome: tuple[str, str], warning: StringIO) -> None:
278312
assert build_outcome

0 commit comments

Comments
 (0)