diff --git a/CHANGELOG.md b/CHANGELOG.md index f31b6cc..aebff47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ All notable changes to this project will be documented in this file. and emitting duplicate label warnings. - Fix a crash on whitespace-only help text, render help that parses to lists or several paragraphs as blocks under the argument instead of inside its paragraph, and drop the empty paragraph an empty `:description:` produced. +- Fix a crash and render positional arguments when they are added before `add_subparsers()`; skip headings for groups + whose arguments are all suppressed. ## 1.13.1 diff --git a/roots/test-subparsers-positional/conf.py b/roots/test-subparsers-positional/conf.py new file mode 100644 index 0000000..9f2a54a --- /dev/null +++ b/roots/test-subparsers-positional/conf.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +extensions = ["sphinx_argparse_cli"] +nitpicky = True diff --git a/roots/test-subparsers-positional/index.rst b/roots/test-subparsers-positional/index.rst new file mode 100644 index 0000000..708ad9c --- /dev/null +++ b/roots/test-subparsers-positional/index.rst @@ -0,0 +1,3 @@ +.. sphinx_argparse_cli:: + :module: parser + :func: make diff --git a/roots/test-subparsers-positional/parser.py b/roots/test-subparsers-positional/parser.py new file mode 100644 index 0000000..c374c65 --- /dev/null +++ b/roots/test-subparsers-positional/parser.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from argparse import ArgumentParser + + +def make() -> ArgumentParser: + parser = ArgumentParser(prog="prog", add_help=False) + parser.add_argument("root", help="root positional") + run = parser.add_subparsers().add_parser("run", add_help=False) + run.add_argument("target", help="run positional") + run.add_subparsers().add_parser("go", add_help=False).add_argument("--flag", help="go flag") + return parser diff --git a/src/sphinx_argparse_cli/_logic.py b/src/sphinx_argparse_cli/_logic.py index ecf1097..31ecb74 100644 --- a/src/sphinx_argparse_cli/_logic.py +++ b/src/sphinx_argparse_cli/_logic.py @@ -151,17 +151,12 @@ def _load_sub_parsers( continue yield aliases, help_msg or "", parser - if parser._subparsers: # noqa: SLF001 - sub_sub_parser: _SubParsersAction[ArgumentParser] = parser._subparsers._group_actions[0] # type: ignore[assignment] # noqa: SLF001 - if isinstance(sub_sub_parser, _SubParsersAction): - yield from self._load_sub_parsers(sub_sub_parser) + if (sub_sub_parser := _sub_parser_action(parser)) is not None: + yield from self._load_sub_parsers(sub_sub_parser) def _iter_sub_commands(self) -> Iterator[tuple[list[str], str, ArgumentParser]]: - top_sub_parser = self.parser._subparsers # noqa: SLF001 - if not top_sub_parser: - return - sub_parser: _SubParsersAction[ArgumentParser] = top_sub_parser._group_actions[0] # type: ignore[assignment] # noqa: SLF001 - yield from self._load_sub_parsers(sub_parser) + if (sub_parser := _sub_parser_action(self.parser)) is not None: + yield from self._load_sub_parsers(sub_parser) def run(self) -> list[Node]: self.env.note_reread() # this document needs to always be rebuilt @@ -181,11 +176,10 @@ def run(self) -> list[Node]: home_section += self._mk_usage(self.parser) for group in self.parser._action_groups: # noqa: SLF001 - if not group._group_actions or group is self.parser._subparsers: # noqa: SLF001 - continue - home_section += self._mk_option_group( - group, prefix=self.parser.prog.split("/")[-1], prog=self.parser.prog.split("/")[-1] - ) + if actions := _visible_actions(group): + home_section += self._mk_option_group( + group, actions, prefix=self.parser.prog.split("/")[-1], prog=self.parser.prog.split("/")[-1] + ) for aliases, help_msg, parser in self._iter_sub_commands(): home_section += self._mk_sub_command(aliases, help_msg, parser) @@ -208,7 +202,7 @@ def _pre_format(self, block: str | None) -> paragraph | literal_block | None: _protect_option_dashes(para) return para - def _mk_option_group(self, group: _ArgumentGroup, prefix: str, prog: str) -> section: + def _mk_option_group(self, group: _ArgumentGroup, actions: list[Action], prefix: str, prog: str) -> section: sub_title_prefix: str = self.options.get("group_sub_title_prefix") title_prefix = self.options.get("group_title_prefix") # an untitled group borrows its description as heading so its anchor stays unique @@ -223,11 +217,8 @@ def _mk_option_group(self, group: _ArgumentGroup, prefix: str, prog: str) -> sec group_section += description self._register_ref(ref_id, title_text, group_section) opt_group = bullet_list() - for action in group._group_actions: # noqa: SLF001 - if action.help == SUPPRESS: - continue - point = self._mk_option_line(action, prefix) - opt_group += point + for action in actions: + opt_group += self._mk_option_line(action, prefix) group_section += opt_group return group_section @@ -350,11 +341,10 @@ def _mk_sub_command(self, aliases: list[str], help_msg: str, parser: ArgumentPar group_section += self._mk_usage(parser) for group in parser._action_groups: # noqa: SLF001 - if not group._group_actions: # noqa: SLF001 - continue - if isinstance(group._group_actions[0], _SubParsersAction): # noqa: SLF001 - continue - group_section += self._mk_option_group(group, prefix=parser.prog, prog=self.parser.prog.split("/")[-1]) + if actions := _visible_actions(group): + group_section += self._mk_option_group( + group, actions, prefix=parser.prog, prog=self.parser.prog.split("/")[-1] + ) return group_section def _build_sub_cmd_title(self, parser: ArgumentParser, sub_title_prefix: str, title_prefix: str) -> str: @@ -419,6 +409,22 @@ def make_id(key: str) -> str: return "-".join(key.split()).rstrip("-") +def _sub_parser_action(parser: ArgumentParser) -> _SubParsersAction[ArgumentParser] | None: + # add_subparsers reuses the positional group, so the action may sit after positional arguments; + # isinstance cannot recover the type parameter, hence the cast + found = next((action for action in parser._actions if isinstance(action, _SubParsersAction)), None) # noqa: SLF001 + return cast("_SubParsersAction[ArgumentParser] | None", found) + + +def _visible_actions(group: _ArgumentGroup) -> list[Action]: + # sub-commands get their own sections, and a heading over an empty list helps nobody + return [ + action + for action in group._group_actions # noqa: SLF001 + if action.help != SUPPRESS and not isinstance(action, _SubParsersAction) + ] + + _HELP_SUBSTITUTIONS: Final[list[tuple[re.Pattern[str], str]]] = [ # a quote glued to a word character is an apostrophe (don't, it's), not the edge of a quoted span (re.compile(r"(? str: # pragma: >=3.14 cover def _update_sub_parser_prog(parser: ArgumentParser, old_prog: str, new_prog: str) -> None: - if not (sub_parsers := parser._subparsers): # noqa: SLF001 + if (sub_action := _sub_parser_action(parser)) is None: return - sub_action: _SubParsersAction[ArgumentParser] = sub_parsers._group_actions[0] # type: ignore[assignment] # noqa: SLF001 for sub_parser in sub_action.choices.values(): sub_parser.prog = sub_parser.prog.replace(old_prog, new_prog, 1) _update_sub_parser_prog(sub_parser, old_prog, new_prog) diff --git a/tests/test_logic.py b/tests/test_logic.py index a14c5ef..56cdff0 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -216,7 +216,58 @@ def test_help_apostrophe(build_outcome: str, warning: StringIO) -> None: @pytest.mark.sphinx(buildername="text", testroot="suppressed-action") def test_suppressed_action(build_outcome: str) -> None: - assert "--activities-since" not in build_outcome + assert ( + build_outcome + == """foo - CLI interface +******************* + +desc + + foo +""" + ) + + +@pytest.mark.sphinx(buildername="text", testroot="subparsers-positional") +def test_subparsers_after_positional(build_outcome: str) -> None: + assert ( + build_outcome + == """prog - CLI interface +******************** + + prog root {run} ... + + +prog positional arguments +========================= + +* **"root"** - root positional + + +prog root run +============= + + prog root run target {go} ... + + +prog root run positional arguments +---------------------------------- + +* **"target"** - run positional + + +prog root run target go +======================= + + prog root run target go [--flag FLAG] + + +prog root run target go options +------------------------------- + +* **"--flag"** "FLAG" - go flag +""" + ) @pytest.mark.parametrize(