Skip to content

Commit c7b3d97

Browse files
authored
🐛 fix: expand {subcommand} to the sub-command name chain (#358)
`{subcommand}` in `:group_sub_title_prefix:` came from `parser.prog` rather than from the sub-parser name: the section title took the first word after the program name, and group titles inside the section took the whole remainder. With a positional argument declared before `add_subparsers()` the prog is `bar root first`, so `{subcommand}` became `root`, and for a nested command `tool a list` both `tool a` and `tool a list` rendered the heading `tool a`; the existing `test-group-title-prefix-subcommand-replacement` expectation encoded the `root` case. The sub-parser walk now carries the chain of command names (`first`, `first nested`) and passes it to both the section title and the group titles, so the placeholder expands to the command path in every heading. Anchors do not change since they still derive from `parser.prog`. 🧭 The updated expectation in `test_group_title_prefix_sub_command_replacement` reads `bar baronlyfirst (f)` instead of `bar baronlyroot (f)`.
1 parent 84f9966 commit c7b3d97

6 files changed

Lines changed: 60 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ All notable changes to this project will be documented in this file.
2626
in the usage line; user-supplied metavars keep their case instead of being upper-cased.
2727
- Expand argparse format specifiers such as `%(prog)s`, `%(default)s` and `%(choices)s` in help, descriptions and
2828
epilogs, and skip the generated `(default: ...)` when the help already mentions a default in any case.
29+
- Make `{subcommand}` in `:group_sub_title_prefix:` expand to the sub-command names (`first nested`) instead of the
30+
first word after the program name, which was a positional argument or only the outermost command.
2931

3032
## 1.13.1
3133

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: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,8 @@ def _make_id(self) -> Callable[[str], str]:
126126
return make_id_lower if "force_refs_lower" in self.options else make_id
127127

128128
def _load_sub_parsers(
129-
self, sub_parser: _SubParsersAction[ArgumentParser]
130-
) -> Iterator[tuple[list[str], str, ArgumentParser]]:
129+
self, sub_parser: _SubParsersAction[ArgumentParser], parent_cmd: str = ""
130+
) -> Iterator[tuple[list[str], str, ArgumentParser, str]]:
131131
parser_to_args: dict[int, list[str]] = defaultdict(list)
132132
for key, parser in sub_parser._name_parser_map.items(): # noqa: SLF001
133133
parser_to_args[id(parser)].append(key)
@@ -144,12 +144,13 @@ def _load_sub_parsers(
144144
help_msg = next((a.help for a in sub_parser._choices_actions if a.dest == name), None) # noqa: SLF001
145145
if help_msg == SUPPRESS:
146146
continue
147-
yield aliases, help_msg or "", parser
147+
sub_cmd = f"{parent_cmd} {name}".strip()
148+
yield aliases, help_msg or "", parser, sub_cmd
148149

149150
if (sub_sub_parser := _sub_parser_action(parser)) is not None:
150-
yield from self._load_sub_parsers(sub_sub_parser)
151+
yield from self._load_sub_parsers(sub_sub_parser, sub_cmd)
151152

152-
def _iter_sub_commands(self) -> Iterator[tuple[list[str], str, ArgumentParser]]:
153+
def _iter_sub_commands(self) -> Iterator[tuple[list[str], str, ArgumentParser, str]]:
153154
if (sub_parser := _sub_parser_action(self.parser)) is not None:
154155
yield from self._load_sub_parsers(sub_parser)
155156

@@ -177,10 +178,9 @@ def run(self) -> list[Node]:
177178
actions,
178179
self.parser,
179180
prefix=self.parser.prog.split("/")[-1],
180-
prog=self.parser.prog.split("/")[-1],
181181
)
182-
for aliases, help_msg, parser in self._iter_sub_commands():
183-
home_section += self._mk_sub_command(aliases, help_msg, parser)
182+
for aliases, help_msg, parser, sub_cmd in self._iter_sub_commands():
183+
home_section += self._mk_sub_command(aliases, help_msg, parser, sub_cmd)
184184

185185
if epilog := self._pre_format(self.options.get("epilog", self.parser.epilog), self.parser):
186186
home_section += epilog
@@ -204,13 +204,19 @@ def _pre_format(self, block: str | None, parser: ArgumentParser) -> paragraph |
204204
return para
205205

206206
def _mk_option_group(
207-
self, group: _ArgumentGroup, actions: list[Action], parser: ArgumentParser, prefix: str, prog: str
207+
self,
208+
group: _ArgumentGroup,
209+
actions: list[Action],
210+
parser: ArgumentParser,
211+
prefix: str,
212+
sub_cmd: str | None = None,
208213
) -> section:
214+
prog = self.parser.prog.split("/")[-1]
209215
sub_title_prefix: str = self.options.get("group_sub_title_prefix")
210216
title_prefix = self.options.get("group_title_prefix")
211217
# an untitled group borrows its description as heading so its anchor stays unique
212218
group_title = group.title or group.description or "arguments"
213-
title_text = self._build_opt_grp_title(group_title, prefix, prog, sub_title_prefix, title_prefix)
219+
title_text = self._resolve_prefix(prog, sub_cmd, prefix, title_prefix, sub_title_prefix) + group_title
214220
title_ref: str = f"{prefix}{' ' if prefix else ''}{group_title}"
215221
ref_id = self._make_id(title_ref)
216222
# the text sadly needs to be prefixed, because otherwise the autosectionlabel will conflict
@@ -225,12 +231,6 @@ def _mk_option_group(
225231
group_section += opt_group
226232
return group_section
227233

228-
def _build_opt_grp_title(
229-
self, group_title: str, prefix: str, prog: str, sub_title_prefix: str, title_prefix: str
230-
) -> str:
231-
sub_cmd = prefix[len(prog) :].strip() or None if prefix != prog else None
232-
return self._resolve_prefix(prog, sub_cmd, prefix, title_prefix, sub_title_prefix) + group_title
233-
234234
def _mk_option_line(self, parser: ArgumentParser, action: Action, prefix: str) -> list_item:
235235
line = paragraph()
236236
if action.option_strings:
@@ -308,15 +308,16 @@ def _register_ref(
308308
self._std_domain.anonlabels[name] = doc_name, ref_name
309309
self._std_domain.labels[name] = doc_name, ref_name, ref_title
310310

311-
def _mk_sub_command(self, aliases: list[str], help_msg: str, parser: ArgumentParser) -> section:
311+
def _mk_sub_command(self, aliases: list[str], help_msg: str, parser: ArgumentParser, sub_cmd: str) -> section:
312312
sub_title_prefix: str = self.options.get("group_sub_title_prefix")
313313
title_prefix: str = self.options.get("group_title_prefix")
314314

315315
if sys.version_info >= (3, 14): # pragma: >=3.14 cover
316316
# https://github.com/python/cpython/issues/139809
317317
parser.prog = _strip_ansi_colors(parser.prog)
318318

319-
title_text = self._build_sub_cmd_title(parser, sub_title_prefix, title_prefix)
319+
root_prog = self.parser.prog.split("/")[-1]
320+
title_text = self._resolve_prefix(root_prog, sub_cmd, parser.prog, title_prefix, sub_title_prefix).rstrip()
320321
title_ref: str = parser.prog
321322
if aliases:
322323
aliases_text: str = f" ({', '.join(aliases)})"
@@ -338,18 +339,11 @@ def _mk_sub_command(self, aliases: list[str], help_msg: str, parser: ArgumentPar
338339

339340
for group in parser._action_groups: # noqa: SLF001
340341
if actions := _visible_actions(group):
341-
group_section += self._mk_option_group(
342-
group, actions, parser, prefix=parser.prog, prog=self.parser.prog.split("/")[-1]
343-
)
342+
group_section += self._mk_option_group(group, actions, parser, prefix=parser.prog, sub_cmd=sub_cmd)
344343
if epilog := self._pre_format(parser.epilog, parser):
345344
group_section += epilog
346345
return group_section
347346

348-
def _build_sub_cmd_title(self, parser: ArgumentParser, sub_title_prefix: str, title_prefix: str) -> str:
349-
root_prog = self.parser.prog.split("/")[-1]
350-
sub_cmd = parser.prog[len(root_prog) :].strip().split(" ", maxsplit=1)[0]
351-
return self._resolve_prefix(root_prog, sub_cmd, parser.prog, title_prefix, sub_title_prefix).rstrip()
352-
353347
def _resolve_prefix(
354348
self,
355349
prog_name: str,

tests/test_logic.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,18 @@ def test_usage_ignores_python_colors(build_outcome: str) -> None:
420420
)
421421

422422

423+
@pytest.mark.sphinx(buildername="html", testroot="subcommand-placeholder")
424+
def test_sub_command_placeholder_nested(build_outcome: str) -> None:
425+
headings = re.findall(r'<h[23]>([^<]*)<a class="headerlink" href="(#[^"]*)"', build_outcome)
426+
assert headings == [
427+
("prog positional arguments", "#prog-positional-arguments"),
428+
("prog first (f)", "#prog-root-first-(f)"),
429+
("prog first options", "#prog-root-first-options"),
430+
("prog first nested", "#prog-root-first-nested"),
431+
("prog first nested options", "#prog-root-first-nested-options"),
432+
]
433+
434+
423435
@pytest.mark.sphinx(buildername="text", testroot="help-nodes")
424436
def test_help_nodes_as_text(build_outcome: str) -> None:
425437
assert (
@@ -538,8 +550,8 @@ def test_group_title_prefix_sub_command_replacement(build_outcome: str, opt_grp_
538550
grp, anchor = opt_grp_name
539551
assert f'<h2>bar {grp}<a class="headerlink" href="#bar-{anchor}"' in build_outcome
540552
assert '<h2>bar Exclusive<a class="headerlink" href="#bar-Exclusive"' in build_outcome
541-
assert '<h2>bar baronlyroot (f)<a class="headerlink" href="#bar-root-first-(f)"' in build_outcome
542-
assert '<h3>bar baronlyroot first positional arguments<a class="headerlink"' in build_outcome
553+
assert '<h2>bar baronlyfirst (f)<a class="headerlink" href="#bar-root-first-(f)"' in build_outcome
554+
assert '<h3>bar baronlyfirst positional arguments<a class="headerlink"' in build_outcome
543555

544556

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

0 commit comments

Comments
 (0)