diff --git a/CHANGELOG.md b/CHANGELOG.md index f68841173..f5285c7de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 4.2.0 (TBD) + +- Bug Fixes + - Fixed `@with_annotated(base_command=True)` not listing its subcommands under the positional + arguments section of the parent command's `--help`, unlike `argparse` and + `Cmd2ArgumentParser`. They were placed in an untitled section of their own instead. Passing + `subcommand_title` or `subcommand_description` still gives the subcommands a dedicated section + ([#1715](https://github.com/python-cmd2/cmd2/issues/1715)). + ## 4.1.2 (July 16, 2026) - Enhancements diff --git a/cmd2/annotated.py b/cmd2/annotated.py index d502f9009..b059c8c5a 100644 --- a/cmd2/annotated.py +++ b/cmd2/annotated.py @@ -2827,15 +2827,17 @@ def parser_builder() -> Cmd2ArgumentParser: **options.parser_kwargs, ) if base_command: - # dict[str, Any] is load-bearing: the typeshed stub types title/metavar as non-None, - # but argparse accepts None at runtime, so splatting avoids a false overload error. + # dict[str, Any] is load-bearing: title/description are added conditionally below, + # which the typeshed overloads for add_subparsers cannot express. kwargs: dict[str, Any] = { "dest": "subcommand", "metavar": options.subcommand_metavar, "required": options.subcommand_required, - "title": options.subcommand_title, - "description": options.subcommand_description, } + if options.subcommand_title is not None: + kwargs["title"] = options.subcommand_title + if options.subcommand_description is not None: + kwargs["description"] = options.subcommand_description parser.add_subparsers(**kwargs) return parser diff --git a/docs/features/annotated.md b/docs/features/annotated.md index e769a44c7..6895a03ec 100644 --- a/docs/features/annotated.md +++ b/docs/features/annotated.md @@ -436,9 +436,12 @@ token to convert. Both raise `TypeError` at decoration time. default `True`) - `subcommand_metavar` -- metavar shown for the subcommands group (only with `base_command=True`, default `"SUBCOMMAND"`) -- `subcommand_title` -- title for the subcommands `--help` section (only with `base_command=True`) +- `subcommand_title` -- title for the subcommands `--help` section (only with `base_command=True`). + Setting either this or `subcommand_description` moves the subcommands out of the positional + arguments section into a section of their own, as it does in argparse. - `subcommand_description` -- description for the subcommands `--help` section (only with - `base_command=True`) + `base_command=True`). Supplying this without `subcommand_title` gives that section argparse's + default title, `subcommands`. - `help` -- help text for an annotated subcommand (only valid with `subcommand_to`) - `aliases` -- aliases for an annotated subcommand (only valid with `subcommand_to`) - `deprecated` -- mark the subcommand as deprecated in `--help` (only valid with `subcommand_to`) diff --git a/tests/test_annotated.py b/tests/test_annotated.py index 5011195ca..6ed8f15e3 100644 --- a/tests/test_annotated.py +++ b/tests/test_annotated.py @@ -3439,6 +3439,37 @@ def test_subcommand_title_and_description(self) -> None: assert group is not None assert group.description == "pick one" + def test_subparsers_default_to_positionals_group(self) -> None: + """Without a title/description the subparsers belong to argparse's own positionals group.""" + parser = self._base_parser() + action = self._subparsers_action(parser) + assert action in parser._positionals._group_actions + # No untitled group is created to hold them. + assert [g for g in parser._action_groups if g.title is None] == [] + + def test_subparsers_listed_under_positional_arguments_in_help(self) -> None: + """The subcommands are documented in --help like argparse and Cmd2ArgumentParser do.""" + parser = self._base_parser() + help_text = parser.format_help() + _, header, rest = help_text.partition("Positional Arguments:") + assert header, f"no positional arguments section in:\n{help_text}" + assert "SUBCOMMAND" in rest.partition("Options:")[0] + + @pytest.mark.parametrize( + ("kwargs", "expected_title"), + [ + pytest.param({"subcommand_title": "Commands"}, "Commands", id="title-only"), + pytest.param({"subcommand_description": "pick one"}, "subcommands", id="description-only"), + ], + ) + def test_subparsers_move_out_of_positionals_when_titled(self, kwargs, expected_title) -> None: + """Supplying either knob still opts into a dedicated group, argparse's documented behavior.""" + parser = self._base_parser(**kwargs) + action = self._subparsers_action(parser) + assert action not in parser._positionals._group_actions + group = next(g for g in parser._action_groups if action in g._group_actions) + assert group.title == expected_title + # --------------------------------------------------------------------------- # Rich objects are accepted for description / epilog (HelpContent)