Skip to content

Commit 8c036f7

Browse files
fix: put help text as part of positional argument (#1719)
1 parent cb4e6c5 commit 8c036f7

4 files changed

Lines changed: 51 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
## 4.2.0 (TBD)
2+
3+
- 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)).
9+
110
## 4.1.2 (July 16, 2026)
211

312
- Enhancements

cmd2/annotated.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2827,15 +2827,17 @@ def parser_builder() -> Cmd2ArgumentParser:
28272827
**options.parser_kwargs,
28282828
)
28292829
if base_command:
2830-
# dict[str, Any] is load-bearing: the typeshed stub types title/metavar as non-None,
2831-
# but argparse accepts None at runtime, so splatting avoids a false overload error.
2830+
# dict[str, Any] is load-bearing: title/description are added conditionally below,
2831+
# which the typeshed overloads for add_subparsers cannot express.
28322832
kwargs: dict[str, Any] = {
28332833
"dest": "subcommand",
28342834
"metavar": options.subcommand_metavar,
28352835
"required": options.subcommand_required,
2836-
"title": options.subcommand_title,
2837-
"description": options.subcommand_description,
28382836
}
2837+
if options.subcommand_title is not None:
2838+
kwargs["title"] = options.subcommand_title
2839+
if options.subcommand_description is not None:
2840+
kwargs["description"] = options.subcommand_description
28392841
parser.add_subparsers(**kwargs)
28402842
return parser
28412843

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
@@ -3439,6 +3439,37 @@ def test_subcommand_title_and_description(self) -> None:
34393439
assert group is not None
34403440
assert group.description == "pick one"
34413441

3442+
def test_subparsers_default_to_positionals_group(self) -> None:
3443+
"""Without a title/description the subparsers belong to argparse's own positionals group."""
3444+
parser = self._base_parser()
3445+
action = self._subparsers_action(parser)
3446+
assert action in parser._positionals._group_actions
3447+
# No untitled group is created to hold them.
3448+
assert [g for g in parser._action_groups if g.title is None] == []
3449+
3450+
def test_subparsers_listed_under_positional_arguments_in_help(self) -> None:
3451+
"""The subcommands are documented in --help like argparse and Cmd2ArgumentParser do."""
3452+
parser = self._base_parser()
3453+
help_text = parser.format_help()
3454+
_, header, rest = help_text.partition("Positional Arguments:")
3455+
assert header, f"no positional arguments section in:\n{help_text}"
3456+
assert "SUBCOMMAND" in rest.partition("Options:")[0]
3457+
3458+
@pytest.mark.parametrize(
3459+
("kwargs", "expected_title"),
3460+
[
3461+
pytest.param({"subcommand_title": "Commands"}, "Commands", id="title-only"),
3462+
pytest.param({"subcommand_description": "pick one"}, "subcommands", id="description-only"),
3463+
],
3464+
)
3465+
def test_subparsers_move_out_of_positionals_when_titled(self, kwargs, expected_title) -> None:
3466+
"""Supplying either knob still opts into a dedicated group, argparse's documented behavior."""
3467+
parser = self._base_parser(**kwargs)
3468+
action = self._subparsers_action(parser)
3469+
assert action not in parser._positionals._group_actions
3470+
group = next(g for g in parser._action_groups if action in g._group_actions)
3471+
assert group.title == expected_title
3472+
34423473

34433474
# ---------------------------------------------------------------------------
34443475
# Rich objects are accepted for description / epilog (HelpContent)

0 commit comments

Comments
 (0)