Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 6 additions & 4 deletions cmd2/annotated.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions docs/features/annotated.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
31 changes: 31 additions & 0 deletions tests/test_annotated.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading