Skip to content

Commit 3c8a604

Browse files
committed
Merge branch 'main' into annotated_argumentblock
2 parents 9c53cbf + 8c036f7 commit 3c8a604

4 files changed

Lines changed: 48 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
## 4.1.3 (TBD)
1+
## 4.2.0 (TBD)
22

33
- Bug Fixes
4+
- Fixed `@with_annotated(base_command=True)` not listing its subcommands under the positional
5+
arguments section of the parent command's `--help`, unlike `argparse` and
6+
`Cmd2ArgumentParser`. They were placed in an untitled section of their own instead. Passing
7+
`subcommand_title` or `subcommand_description` still gives the subcommands a dedicated section
8+
([#1715](https://github.com/python-cmd2/cmd2/issues/1715)).
49
- Fix `@with_annotated` decorator so using `ArgumentBlock` works with groups
510

611
## 4.1.2 (July 16, 2026)

cmd2/annotated.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2847,15 +2847,17 @@ def parser_builder() -> Cmd2ArgumentParser:
28472847
**options.parser_kwargs,
28482848
)
28492849
if base_command:
2850-
# dict[str, Any] is load-bearing: the typeshed stub types title/metavar as non-None,
2851-
# but argparse accepts None at runtime, so splatting avoids a false overload error.
2850+
# dict[str, Any] is load-bearing: title/description are added conditionally below,
2851+
# which the typeshed overloads for add_subparsers cannot express.
28522852
kwargs: dict[str, Any] = {
28532853
"dest": "subcommand",
28542854
"metavar": options.subcommand_metavar,
28552855
"required": options.subcommand_required,
2856-
"title": options.subcommand_title,
2857-
"description": options.subcommand_description,
28582856
}
2857+
if options.subcommand_title is not None:
2858+
kwargs["title"] = options.subcommand_title
2859+
if options.subcommand_description is not None:
2860+
kwargs["description"] = options.subcommand_description
28592861
parser.add_subparsers(**kwargs)
28602862
return parser
28612863

docs/features/annotated.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -436,9 +436,12 @@ token to convert. Both raise `TypeError` at decoration time.
436436
default `True`)
437437
- `subcommand_metavar` -- metavar shown for the subcommands group (only with `base_command=True`,
438438
default `"SUBCOMMAND"`)
439-
- `subcommand_title` -- title for the subcommands `--help` section (only with `base_command=True`)
439+
- `subcommand_title` -- title for the subcommands `--help` section (only with `base_command=True`).
440+
Setting either this or `subcommand_description` moves the subcommands out of the positional
441+
arguments section into a section of their own, as it does in argparse.
440442
- `subcommand_description` -- description for the subcommands `--help` section (only with
441-
`base_command=True`)
443+
`base_command=True`). Supplying this without `subcommand_title` gives that section argparse's
444+
default title, `subcommands`.
442445
- `help` -- help text for an annotated subcommand (only valid with `subcommand_to`)
443446
- `aliases` -- aliases for an annotated subcommand (only valid with `subcommand_to`)
444447
- `deprecated` -- mark the subcommand as deprecated in `--help` (only valid with `subcommand_to`)

tests/test_annotated.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3470,6 +3470,37 @@ def test_subcommand_title_and_description(self) -> None:
34703470
assert group is not None
34713471
assert group.description == "pick one"
34723472

3473+
def test_subparsers_default_to_positionals_group(self) -> None:
3474+
"""Without a title/description the subparsers belong to argparse's own positionals group."""
3475+
parser = self._base_parser()
3476+
action = self._subparsers_action(parser)
3477+
assert action in parser._positionals._group_actions
3478+
# No untitled group is created to hold them.
3479+
assert [g for g in parser._action_groups if g.title is None] == []
3480+
3481+
def test_subparsers_listed_under_positional_arguments_in_help(self) -> None:
3482+
"""The subcommands are documented in --help like argparse and Cmd2ArgumentParser do."""
3483+
parser = self._base_parser()
3484+
help_text = parser.format_help()
3485+
_, header, rest = help_text.partition("Positional Arguments:")
3486+
assert header, f"no positional arguments section in:\n{help_text}"
3487+
assert "SUBCOMMAND" in rest.partition("Options:")[0]
3488+
3489+
@pytest.mark.parametrize(
3490+
("kwargs", "expected_title"),
3491+
[
3492+
pytest.param({"subcommand_title": "Commands"}, "Commands", id="title-only"),
3493+
pytest.param({"subcommand_description": "pick one"}, "subcommands", id="description-only"),
3494+
],
3495+
)
3496+
def test_subparsers_move_out_of_positionals_when_titled(self, kwargs, expected_title) -> None:
3497+
"""Supplying either knob still opts into a dedicated group, argparse's documented behavior."""
3498+
parser = self._base_parser(**kwargs)
3499+
action = self._subparsers_action(parser)
3500+
assert action not in parser._positionals._group_actions
3501+
group = next(g for g in parser._action_groups if action in g._group_actions)
3502+
assert group.title == expected_title
3503+
34733504

34743505
# ---------------------------------------------------------------------------
34753506
# Rich objects are accepted for description / epilog (HelpContent)

0 commit comments

Comments
 (0)