Skip to content

Commit 68cbf4e

Browse files
committed
🐛 fix: give untitled argument groups a heading
An argument group created with only a description rendered its heading as the program name followed by nothing, because the title was None, and every such group shared the label tool-None; two of them raised a duplicate label warning that fails builds run with -W. The description now serves as the heading when the title is missing, and a group with neither falls back to "arguments", so each section keeps a distinct, stable anchor. Titled groups are untouched.
1 parent 1c81a47 commit 68cbf4e

6 files changed

Lines changed: 44 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ All notable changes to this project will be documented in this file.
1414
- Make `:hook:` intercept `parse_intermixed_args()` as well as `parse_args()`.
1515
- Keep ANSI color codes out of usage blocks when `PYTHON_COLORS=1` is set on Python 3.14 or newer.
1616
- Skip sub-commands added with `help=argparse.SUPPRESS` instead of rendering them with a `==SUPPRESS==` description.
17+
- Use the description, or `arguments`, as the heading of an argument group without a title instead of rendering `None`
18+
and emitting duplicate label warnings.
1719

1820
## 1.13.1
1921

roots/test-group-untitled/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
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.. sphinx_argparse_cli::
2+
:module: parser
3+
:func: make
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
4+
5+
6+
def make() -> ArgumentParser:
7+
parser = ArgumentParser(prog="tool")
8+
parser.add_argument_group(description="only a description").add_argument("--x", help="x help")
9+
parser.add_argument_group(description="another").add_argument("--y", help="y help")
10+
parser.add_argument_group().add_argument("--z", help="z help")
11+
return parser

src/sphinx_argparse_cli/_logic.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -211,13 +211,15 @@ def _pre_format(self, block: str | None) -> paragraph | literal_block | None:
211211
def _mk_option_group(self, group: _ArgumentGroup, prefix: str, prog: str) -> section:
212212
sub_title_prefix: str = self.options.get("group_sub_title_prefix")
213213
title_prefix = self.options.get("group_title_prefix")
214-
title_text = self._build_opt_grp_title(group, prefix, prog, sub_title_prefix, title_prefix)
215-
title_ref: str = f"{prefix}{' ' if prefix else ''}{group.title}"
214+
# an untitled group borrows its description as heading so its anchor stays unique
215+
group_title = group.title or group.description or "arguments"
216+
title_text = self._build_opt_grp_title(group_title, prefix, prog, sub_title_prefix, title_prefix)
217+
title_ref: str = f"{prefix}{' ' if prefix else ''}{group_title}"
216218
ref_id = self._make_id(title_ref)
217219
# the text sadly needs to be prefixed, because otherwise the autosectionlabel will conflict
218220
header = title("", Text(title_text))
219221
group_section = section("", header, ids=[ref_id], names=[ref_id])
220-
if description := self._pre_format(group.description):
222+
if group.title and (description := self._pre_format(group.description)):
221223
group_section += description
222224
self._register_ref(ref_id, title_text, group_section)
223225
opt_group = bullet_list()
@@ -230,12 +232,10 @@ def _mk_option_group(self, group: _ArgumentGroup, prefix: str, prog: str) -> sec
230232
return group_section
231233

232234
def _build_opt_grp_title(
233-
self, group: _ArgumentGroup, prefix: str, prog: str, sub_title_prefix: str, title_prefix: str
235+
self, group_title: str, prefix: str, prog: str, sub_title_prefix: str, title_prefix: str
234236
) -> str:
235237
sub_cmd = prefix[len(prog) :].strip() or None if prefix != prog else None
236-
title_text = self._resolve_prefix(prog, sub_cmd, prefix, title_prefix, sub_title_prefix)
237-
title_text += group.title or ""
238-
return title_text
238+
return self._resolve_prefix(prog, sub_cmd, prefix, title_prefix, sub_title_prefix) + group_title
239239

240240
def _mk_option_line(self, action: Action, prefix: str) -> list_item:
241241
line = paragraph()

tests/test_logic.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,19 @@ def test_usage_ignores_python_colors(build_outcome: str) -> None:
323323
)
324324

325325

326+
@pytest.mark.sphinx(buildername="html", testroot="group-untitled")
327+
def test_group_untitled(build_outcome: str, warning: StringIO) -> None:
328+
headings = re.findall(r'<h2>(.*?)<a class="headerlink" href="(#[^"]+)"', build_outcome)
329+
assert headings == [
330+
("tool options", "#tool-options"),
331+
("tool only a description", "#tool-only-a-description"),
332+
("tool another", "#tool-another"),
333+
("tool arguments", "#tool-arguments"),
334+
]
335+
assert "<p>only a description</p>" not in build_outcome
336+
assert not warning.getvalue()
337+
338+
326339
@pytest.mark.sphinx(buildername="text", testroot="ref-duplicate-label")
327340
def test_ref_duplicate_label(build_outcome: tuple[str, str], warning: StringIO) -> None:
328341
assert build_outcome

0 commit comments

Comments
 (0)