Skip to content

Commit 724e596

Browse files
authored
Handle nested subparsers (#207)
This handles outputting for nested sub-parsers. First we modify the load_sub_parsers iterator to check if any of the arguments are subparsers, and if so, recurse into that subparser and yield its values back too. (I am as skeptical of yielding from recursive generator functions as anyone :) If you have hundreds of levels of nested subparsers I guess this blows up ... but that seems impractical). In _mk_sub_command, avoid adding subparsers so they don't show up as positional arguments (their Action has action.dest of "==SUPPRESS==" which looks wrong and they don't show up in cmd line help). The subparsers are listed in the usage-string, e.g. test subparser [-h] [--foo FOO] {child_two} ... In _build_opt_grp_title we are taking elements[:2] as the title text for the option group. This ends up cutting off the full title when you have nested subparsers. I have to admit I can't really determine why this is done, but it does not seem to affect any of the test cases and the output looks correct to me for nested subparsers, with the full command listed as the option title.
1 parent d39f663 commit 724e596

File tree

5 files changed

+73
-8
lines changed

5 files changed

+73
-8
lines changed

roots/test-subparsers/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-subparsers/index.rst

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

roots/test-subparsers/parser.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from __future__ import annotations
2+
3+
from argparse import ArgumentParser
4+
5+
6+
def make() -> ArgumentParser:
7+
parser = ArgumentParser(prog="test")
8+
9+
sub_parsers = parser.add_subparsers()
10+
sub_parser = sub_parsers.add_parser("subparser")
11+
sub_parser.add_argument("--foo")
12+
13+
sub_parser_no_child = sub_parsers.add_parser("no_child")
14+
sub_parser_no_child.add_argument("argument_one", help="no_child argument")
15+
16+
sub_sub_parsers = sub_parser.add_subparsers()
17+
sub_sub_parser = sub_sub_parsers.add_parser("child_two")
18+
19+
sub_sub_sub_parsers = sub_sub_parser.add_subparsers()
20+
sub_sub_sub_parser = sub_sub_sub_parsers.add_parser("child_three")
21+
sub_sub_sub_parser.add_argument("argument", help="sub sub sub child pos argument")
22+
sub_sub_sub_parser.add_argument("--flag", help="sub sub sub child argument")
23+
24+
return parser

src/sphinx_argparse_cli/_logic.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -132,18 +132,16 @@ def parser(self) -> ArgumentParser:
132132
self._raw_format = self._parser.formatter_class == RawDescriptionHelpFormatter
133133
return self._parser
134134

135-
def load_sub_parsers(self) -> Iterator[tuple[list[str], str, ArgumentParser]]:
136-
top_sub_parser = self.parser._subparsers # noqa: SLF001
137-
if not top_sub_parser:
138-
return
135+
def _load_sub_parsers(
136+
self, sub_parser: _SubParsersAction[ArgumentParser]
137+
) -> Iterator[tuple[list[str], str, ArgumentParser]]:
139138
parser_to_args: dict[int, list[str]] = defaultdict(list)
140139
str_to_parser: dict[str, ArgumentParser] = {}
141-
sub_parser: _SubParsersAction[ArgumentParser]
142-
sub_parser = top_sub_parser._group_actions[0] # type: ignore[assignment] # noqa: SLF001
143140
for key, parser in sub_parser._name_parser_map.items(): # noqa: SLF001
144141
parser_to_args[id(parser)].append(key)
145142
str_to_parser[key] = parser
146143
done_parser: set[int] = set()
144+
147145
for name, parser in sub_parser.choices.items():
148146
parser_id = id(parser)
149147
if parser_id in done_parser:
@@ -155,6 +153,21 @@ def load_sub_parsers(self) -> Iterator[tuple[list[str], str, ArgumentParser]]:
155153
help_msg = next((a.help for a in sub_parser._choices_actions if a.dest == name), None) or "" # noqa: SLF001
156154
yield aliases, help_msg, parser
157155

156+
# If this parser has a subparser, recurse into it
157+
if parser._subparsers: # noqa: SLF001
158+
sub_sub_parser: _SubParsersAction[ArgumentParser]
159+
sub_sub_parser = parser._subparsers._group_actions[0] # type: ignore[assignment] # noqa: SLF001
160+
yield from self._load_sub_parsers(sub_sub_parser)
161+
162+
def load_sub_parsers(self) -> Iterator[tuple[list[str], str, ArgumentParser]]:
163+
top_sub_parser = self.parser._subparsers # noqa: SLF001
164+
if not top_sub_parser:
165+
return
166+
sub_parser: _SubParsersAction[ArgumentParser]
167+
sub_parser = top_sub_parser._group_actions[0] # type: ignore[assignment] # noqa: SLF001
168+
169+
yield from self._load_sub_parsers(sub_parser)
170+
158171
def run(self) -> list[Node]:
159172
# construct headers
160173
self.env.note_reread() # this document needs to be always updated
@@ -202,7 +215,6 @@ def _pre_format(self, block: None | str) -> None | paragraph | literal_block:
202215
def _mk_option_group(self, group: _ArgumentGroup, prefix: str) -> section:
203216
sub_title_prefix: str = self.options["group_sub_title_prefix"]
204217
title_prefix = self.options["group_title_prefix"]
205-
206218
title_text = self._build_opt_grp_title(group, prefix, sub_title_prefix, title_prefix)
207219
title_ref: str = f"{prefix}{' ' if prefix else ''}{group.title}"
208220
ref_id = self.make_id(title_ref)
@@ -237,7 +249,7 @@ def _build_opt_grp_title(self, group: _ArgumentGroup, prefix: str, sub_title_pre
237249
title_text += f"{elements[0]} "
238250
title_text = self._append_title(title_text, sub_title_prefix, elements[0], " ".join(elements[1:]))
239251
else:
240-
title_text += f"{' '.join(elements[:2])} "
252+
title_text += f"{' '.join(elements)} "
241253
else:
242254
title_text += f"{prefix} "
243255
title_text += group.title or ""
@@ -347,6 +359,9 @@ def _mk_sub_command(self, aliases: list[str], help_msg: str, parser: ArgumentPar
347359
for group in parser._action_groups: # noqa: SLF001
348360
if not group._group_actions: # do not show empty groups # noqa: SLF001
349361
continue
362+
if isinstance(group._group_actions[0], _SubParsersAction): # noqa: SLF001
363+
# If this is a subparser, ignore it
364+
continue
350365
group_section += self._mk_option_group(group, prefix=parser.prog)
351366
return group_section
352367

tests/test_logic.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,3 +331,18 @@ def test_nested_content(build_outcome: str) -> None:
331331
assert "<h3>basic-2 opt" in build_outcome
332332
assert "<p>Some text inside second directive.</p>" in build_outcome
333333
assert "<p>Some text after directives.</p>" in build_outcome
334+
335+
336+
@pytest.mark.sphinx(buildername="html", testroot="subparsers")
337+
def test_subparsers(build_outcome: str) -> None:
338+
assert '<section id="test-options">' in build_outcome
339+
assert '<section id="test-subparser">' in build_outcome
340+
assert '<section id="test-subparser-options">' in build_outcome
341+
assert '<section id="test-subparser-child_two">' in build_outcome
342+
assert '<section id="test-subparser-child_two-options">' in build_outcome
343+
assert '<section id="test-subparser-child_two-child_three">' in build_outcome
344+
assert '<section id="test-subparser-child_two-child_three-positional-arguments">' in build_outcome
345+
assert '<section id="test-subparser-child_two-child_three-options">' in build_outcome
346+
assert '<section id="test-no_child">' in build_outcome
347+
assert '<section id="test-no_child-positional-arguments">' in build_outcome
348+
assert '<section id="test-no_child-options">' in build_outcome

0 commit comments

Comments
 (0)