Skip to content

Commit ce68117

Browse files
committed
🐛 fix: derive {subcommand} from sub-parser names
The placeholder took its value from parser.prog: the first word after the program name for section titles and the whole remainder for group titles. A positional declared before add_subparsers() made it the positional's name, and nested commands collapsed onto their parent's heading. Carry the chain of sub-parser names through the walk and use it for both title kinds. Anchors still derive from parser.prog.
1 parent 3cfee42 commit ce68117

6 files changed

Lines changed: 52 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ All notable changes to this project will be documented in this file.
99
- Add `force_refs_lower` to enable `:ref:` links with mixed-case program names and arguments.
1010
- Fix Sphinx smart quotes rewriting `--` to an en dash in `--option` names within descriptions, epilogs, and help text.
1111
- Register flags and positional arguments as Sphinx program options so the `:option:` role links to them.
12+
- Make `{subcommand}` in `:group_sub_title_prefix:` expand to the sub-command names (`first nested`) instead of the
13+
first word after the program name, which was a positional argument or only the outermost command.
1214

1315
## 1.13.1
1416

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+
:group_sub_title_prefix: {subcommand}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from __future__ import annotations
2+
3+
from argparse import ArgumentParser
4+
5+
6+
def make() -> ArgumentParser:
7+
parser = ArgumentParser(prog="prog", add_help=False)
8+
parser.add_argument("root", help="root positional")
9+
first = parser.add_subparsers(title="commands").add_parser("first", aliases=["f"], add_help=False)
10+
first.add_argument("--flag", help="first flag")
11+
first.add_subparsers(title="commands").add_parser("nested", add_help=False).add_argument("--deep", help="deep flag")
12+
return parser

src/sphinx_argparse_cli/_logic.py

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,8 @@ def _raw_format(self) -> bool:
130130
return isinstance(formatter, type) and issubclass(formatter, RawDescriptionHelpFormatter)
131131

132132
def _load_sub_parsers(
133-
self, sub_parser: _SubParsersAction[ArgumentParser]
134-
) -> Iterator[tuple[list[str], str, ArgumentParser]]:
133+
self, sub_parser: _SubParsersAction[ArgumentParser], parent_cmd: str = ""
134+
) -> Iterator[tuple[list[str], str, ArgumentParser, str]]:
135135
parser_to_args: dict[int, list[str]] = defaultdict(list)
136136
for key, parser in sub_parser._name_parser_map.items(): # noqa: SLF001
137137
parser_to_args[id(parser)].append(key)
@@ -146,14 +146,15 @@ def _load_sub_parsers(
146146
aliases.remove(name)
147147
# help is stored in a pseudo action
148148
help_msg = next((a.help for a in sub_parser._choices_actions if a.dest == name), None) or "" # noqa: SLF001
149-
yield aliases, help_msg, parser
149+
sub_cmd = f"{parent_cmd} {name}".strip()
150+
yield aliases, help_msg, parser, sub_cmd
150151

151152
if parser._subparsers: # noqa: SLF001
152153
sub_sub_parser: _SubParsersAction[ArgumentParser] = parser._subparsers._group_actions[0] # type: ignore[assignment] # noqa: SLF001
153154
if isinstance(sub_sub_parser, _SubParsersAction):
154-
yield from self._load_sub_parsers(sub_sub_parser)
155+
yield from self._load_sub_parsers(sub_sub_parser, sub_cmd)
155156

156-
def _iter_sub_commands(self) -> Iterator[tuple[list[str], str, ArgumentParser]]:
157+
def _iter_sub_commands(self) -> Iterator[tuple[list[str], str, ArgumentParser, str]]:
157158
top_sub_parser = self.parser._subparsers # noqa: SLF001
158159
if not top_sub_parser:
159160
return
@@ -183,8 +184,8 @@ def run(self) -> list[Node]:
183184
home_section += self._mk_option_group(
184185
group, prefix=self.parser.prog.split("/")[-1], prog=self.parser.prog.split("/")[-1]
185186
)
186-
for aliases, help_msg, parser in self._iter_sub_commands():
187-
home_section += self._mk_sub_command(aliases, help_msg, parser)
187+
for aliases, help_msg, parser, sub_cmd in self._iter_sub_commands():
188+
home_section += self._mk_sub_command(aliases, help_msg, parser, sub_cmd)
188189

189190
if epilog := self._pre_format(self.options.get("epilog", self.parser.epilog)):
190191
home_section += epilog
@@ -205,10 +206,10 @@ def _pre_format(self, block: str | None) -> paragraph | literal_block | None:
205206
_protect_option_dashes(para)
206207
return para
207208

208-
def _mk_option_group(self, group: _ArgumentGroup, prefix: str, prog: str) -> section:
209+
def _mk_option_group(self, group: _ArgumentGroup, prefix: str, prog: str, sub_cmd: str | None = None) -> section:
209210
sub_title_prefix: str = self.options.get("group_sub_title_prefix")
210211
title_prefix = self.options.get("group_title_prefix")
211-
title_text = self._build_opt_grp_title(group, prefix, prog, sub_title_prefix, title_prefix)
212+
title_text = self._resolve_prefix(prog, sub_cmd, prefix, title_prefix, sub_title_prefix) + (group.title or "")
212213
title_ref: str = f"{prefix}{' ' if prefix else ''}{group.title}"
213214
ref_id = self._make_id(title_ref)
214215
# the text sadly needs to be prefixed, because otherwise the autosectionlabel will conflict
@@ -226,14 +227,6 @@ def _mk_option_group(self, group: _ArgumentGroup, prefix: str, prog: str) -> sec
226227
group_section += opt_group
227228
return group_section
228229

229-
def _build_opt_grp_title(
230-
self, group: _ArgumentGroup, prefix: str, prog: str, sub_title_prefix: str, title_prefix: str
231-
) -> str:
232-
sub_cmd = prefix[len(prog) :].strip() or None if prefix != prog else None
233-
title_text = self._resolve_prefix(prog, sub_cmd, prefix, title_prefix, sub_title_prefix)
234-
title_text += group.title or ""
235-
return title_text
236-
237230
def _mk_option_line(self, action: Action, prefix: str) -> list_item:
238231
line = paragraph()
239232
as_key = action.dest
@@ -310,15 +303,16 @@ def _register_ref(
310303
self._std_domain.anonlabels[name] = doc_name, ref_name
311304
self._std_domain.labels[name] = doc_name, ref_name, ref_title
312305

313-
def _mk_sub_command(self, aliases: list[str], help_msg: str, parser: ArgumentParser) -> section:
306+
def _mk_sub_command(self, aliases: list[str], help_msg: str, parser: ArgumentParser, sub_cmd: str) -> section:
314307
sub_title_prefix: str = self.options.get("group_sub_title_prefix")
315308
title_prefix: str = self.options.get("group_title_prefix")
316309

317310
if sys.version_info >= (3, 14): # pragma: >=3.14 cover
318311
# https://github.com/python/cpython/issues/139809
319312
parser.prog = _strip_ansi_colors(parser.prog)
320313

321-
title_text = self._build_sub_cmd_title(parser, sub_title_prefix, title_prefix)
314+
root_prog = self.parser.prog.split("/")[-1]
315+
title_text = self._resolve_prefix(root_prog, sub_cmd, parser.prog, title_prefix, sub_title_prefix).rstrip()
322316
title_ref: str = parser.prog
323317
if aliases:
324318
aliases_text: str = f" ({', '.join(aliases)})"
@@ -346,14 +340,9 @@ def _mk_sub_command(self, aliases: list[str], help_msg: str, parser: ArgumentPar
346340
continue
347341
if isinstance(group._group_actions[0], _SubParsersAction): # noqa: SLF001
348342
continue
349-
group_section += self._mk_option_group(group, prefix=parser.prog, prog=self.parser.prog.split("/")[-1])
343+
group_section += self._mk_option_group(group, prefix=parser.prog, prog=root_prog, sub_cmd=sub_cmd)
350344
return group_section
351345

352-
def _build_sub_cmd_title(self, parser: ArgumentParser, sub_title_prefix: str, title_prefix: str) -> str:
353-
root_prog = self.parser.prog.split("/")[-1]
354-
sub_cmd = parser.prog[len(root_prog) :].strip().split(" ", maxsplit=1)[0]
355-
return self._resolve_prefix(root_prog, sub_cmd, parser.prog, title_prefix, sub_title_prefix).rstrip()
356-
357346
def _resolve_prefix(
358347
self,
359348
prog_name: str,

tests/test_logic.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,16 @@ def test_option_role_as_html(build_outcome: str, warning: StringIO) -> None:
228228
"#prog-run---magic",
229229
]
230230
assert not warning.getvalue()
231+
@pytest.mark.sphinx(buildername="html", testroot="subcommand-placeholder")
232+
def test_sub_command_placeholder_nested(build_outcome: str) -> None:
233+
headings = re.findall(r'<h[23]>([^<]*)<a class="headerlink" href="(#[^"]*)"', build_outcome)
234+
assert headings == [
235+
("prog positional arguments", "#prog-positional-arguments"),
236+
("prog first (f)", "#prog-root-first-(f)"),
237+
("prog first options", "#prog-root-first-options"),
238+
("prog first nested", "#prog-root-first-nested"),
239+
("prog first nested options", "#prog-root-first-nested-options"),
240+
]
231241

232242

233243
@pytest.mark.sphinx(buildername="text", testroot="ref-duplicate-label")
@@ -301,8 +311,8 @@ def test_group_title_prefix_sub_command_replacement(build_outcome: str, opt_grp_
301311
grp, anchor = opt_grp_name
302312
assert f'<h2>bar {grp}<a class="headerlink" href="#bar-{anchor}"' in build_outcome
303313
assert '<h2>bar Exclusive<a class="headerlink" href="#bar-Exclusive"' in build_outcome
304-
assert '<h2>bar baronlyroot (f)<a class="headerlink" href="#bar-root-first-(f)"' in build_outcome
305-
assert '<h3>bar baronlyroot first positional arguments<a class="headerlink"' in build_outcome
314+
assert '<h2>bar baronlyfirst (f)<a class="headerlink" href="#bar-root-first-(f)"' in build_outcome
315+
assert '<h3>bar baronlyfirst positional arguments<a class="headerlink"' in build_outcome
306316

307317

308318
@pytest.mark.sphinx(buildername="html", testroot="store-true-false")

0 commit comments

Comments
 (0)