Skip to content

Commit caac18e

Browse files
committed
Using argparse to build 'prog' prefix instead of trying to reproduce their code.
1 parent a1e253a commit caac18e

File tree

3 files changed

+83
-48
lines changed

3 files changed

+83
-48
lines changed

cmd2/argparse_completer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727

2828
from .argparse_custom import (
2929
Cmd2ArgumentParser,
30-
generate_range_error,
30+
build_range_error,
3131
)
3232
from .command_definition import CommandSet
3333
from .completion import (
@@ -137,7 +137,7 @@ def __init__(self, flag_arg_state: _ArgumentState) -> None:
137137
:param flag_arg_state: information about the unfinished flag action.
138138
"""
139139
arg = f'{argparse._get_action_name(flag_arg_state.action)}'
140-
err = f'{generate_range_error(flag_arg_state.min, flag_arg_state.max)}'
140+
err = f'{build_range_error(flag_arg_state.min, flag_arg_state.max)}'
141141
error = f"Error: argument {arg}: {err} ({flag_arg_state.count} entered)"
142142
super().__init__(error)
143143

cmd2/argparse_custom.py

Lines changed: 73 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,8 @@ def get_choices(self) -> Choices:
271271
from .argparse_completer import ArgparseCompleter
272272

273273

274-
def generate_range_error(range_min: int, range_max: float) -> str:
275-
"""Generate an error message when the the number of arguments provided is not within the expected range."""
274+
def build_range_error(range_min: int, range_max: float) -> str:
275+
"""Build an error message when the the number of arguments provided is not within the expected range."""
276276
err_msg = "expected "
277277

278278
if range_max == constants.INFINITY:
@@ -577,7 +577,7 @@ def _set_color(self, color: bool, **kwargs: Any) -> None:
577577
super()._set_color(color)
578578

579579
def _build_nargs_range_str(self, nargs_range: tuple[int, int | float]) -> str:
580-
"""Generate nargs range string for help text."""
580+
"""Build nargs range string for help text."""
581581
if nargs_range[1] == constants.INFINITY:
582582
# {min+}
583583
range_str = f"{{{nargs_range[0]}+}}"
@@ -761,16 +761,17 @@ def __init__(
761761
conflict_handler=conflict_handler,
762762
add_help=add_help,
763763
allow_abbrev=allow_abbrev,
764-
exit_on_error=exit_on_error, # added in Python 3.9
764+
exit_on_error=exit_on_error,
765765
**kwargs, # added in Python 3.14
766766
)
767767

768-
# Recast to assist type checkers since these can be Rich renderables in a Cmd2HelpFormatter.
768+
self.ap_completer_type = ap_completer_type
769+
770+
# To assist type checkers, recast these to reflect our usage of rich-argparse.
771+
self.formatter_class: type[Cmd2HelpFormatter]
769772
self.description: RenderableType | None # type: ignore[assignment]
770773
self.epilog: RenderableType | None # type: ignore[assignment]
771774

772-
self.ap_completer_type = ap_completer_type
773-
774775
def add_subparsers( # type: ignore[override]
775776
self,
776777
**kwargs: Any,
@@ -802,6 +803,48 @@ def _get_subparsers_action(self) -> "argparse._SubParsersAction[Cmd2ArgumentPars
802803
return action
803804
raise ValueError(f"Command '{self.prog}' does not support subcommands")
804805

806+
def _build_subparsers_prog_prefix(self, positionals: list[argparse.Action]) -> str:
807+
"""Build the 'prog' prefix for a subparsers action.
808+
809+
This prefix is stored in the _SubParsersAction's '_prog_prefix' attribute and
810+
is used to construct the 'prog' attribute for its child parsers. It
811+
typically consists of the current parser's 'prog' name followed by any
812+
positional arguments that appear before the _SubParsersAction.
813+
814+
This method uses a temporary Cmd2ArgumentParser to leverage argparse's
815+
functionality for generating these strings. Subclasses can override this if
816+
they need to change how subcommand 'prog' values are constructed (e.g., if
817+
add_subparsers() was overridden with custom naming logic or if a different
818+
formatting style is desired).
819+
820+
Note: This method explicitly instantiates Cmd2ArgumentParser rather than
821+
type(self) to avoid potential side effects or mandatory constructor
822+
arguments in user-defined subclasses.
823+
824+
:param positionals: positional arguments which appear before the _SubParsersAction
825+
:return: the built 'prog' prefix
826+
"""
827+
# 1. usage=None: In Python < 3.14, this prevents the default usage
828+
# string from affecting subparser prog strings. This was fixed in 3.14:
829+
# https://github.com/python/cpython/commit/0cb4d6c6549d2299f7518f083bbe7d10314ecd66
830+
#
831+
# 2. add_help=False: No need for a help action since we already know which
832+
# actions are needed to build the prefix and have passed them in
833+
# via the 'positionals' argument.
834+
temp_parser = Cmd2ArgumentParser(
835+
prog=self.prog,
836+
usage=None,
837+
formatter_class=self.formatter_class,
838+
add_help=False,
839+
)
840+
841+
# Inject the current positional state so add_subparsers() has the right context
842+
temp_parser._actions = positionals
843+
temp_parser._mutually_exclusive_groups = self._mutually_exclusive_groups
844+
845+
# Call add_subparsers() to build _prog_prefix
846+
return temp_parser.add_subparsers()._prog_prefix
847+
805848
def update_prog(self, prog: str) -> None:
806849
"""Recursively update the prog attribute of this parser and all of its subparsers.
807850
@@ -810,47 +853,39 @@ def update_prog(self, prog: str) -> None:
810853
# Set the prog value for this parser
811854
self.prog = prog
812855

813-
if self._subparsers is None:
856+
try:
857+
subparsers_action = self._get_subparsers_action()
858+
except ValueError:
814859
# This parser has no subcommands
815860
return
816861

817-
# argparse includes positional arguments that appear before the subcommand in its
818-
# subparser prog strings. Track these while iterating through actions.
862+
# Get all positional arguments which appear before the subcommand.
819863
positionals: list[argparse.Action] = []
820-
821-
# Set the prog value for the parser's subcommands
822864
for action in self._actions:
823-
if isinstance(action, argparse._SubParsersAction):
824-
# Use a formatter to generate _prog_prefix exactly as argparse does in
825-
# add_subparsers(). This ensures that any subcommands added later via
826-
# add_parser() will have the correct prog value.
827-
formatter = self._get_formatter()
828-
formatter.add_usage(self.usage, positionals, self._mutually_exclusive_groups, '')
829-
action._prog_prefix = formatter.format_help().strip()
830-
831-
# Note: action.choices contains both subcommand names and aliases.
832-
# To ensure subcommands (and not aliases) are used in 'prog':
833-
# 1. We can't use action._choices_actions because it excludes subcommands without help text.
834-
# 2. Since dictionaries are ordered and argparse inserts the primary name before aliases,
835-
# we assume the first time we encounter a parser, the key is the true subcommand name.
836-
updated_parsers: set[Cmd2ArgumentParser] = set()
837-
838-
# Set the prog value for each subcommand's parser
839-
for subcmd_name, subcmd_parser in action.choices.items():
840-
if subcmd_parser in updated_parsers:
841-
continue
842-
843-
subcmd_prog = f"{action._prog_prefix} {subcmd_name}"
844-
subcmd_parser.update_prog(subcmd_prog) # type: ignore[attr-defined]
845-
updated_parsers.add(subcmd_parser)
846-
847-
# We can break since argparse only allows 1 group of subcommands per level
865+
if action is subparsers_action:
848866
break
849867

850868
# Save positional argument
851869
if not action.option_strings:
852870
positionals.append(action)
853871

872+
# Update _prog_prefix. This ensures that any subcommands added later via
873+
# add_parser() will have the correct prog value.
874+
subparsers_action._prog_prefix = self._build_subparsers_prog_prefix(positionals)
875+
876+
# subparsers_action.choices includes aliases. Since primary names are inserted first,
877+
# we skip already updated parsers to ensure primary names are used in 'prog'.
878+
updated_parsers: set[Cmd2ArgumentParser] = set()
879+
880+
# Set the prog value for each subcommand's parser
881+
for subcmd_name, subcmd_parser in subparsers_action.choices.items():
882+
if subcmd_parser in updated_parsers:
883+
continue
884+
885+
subcmd_prog = f"{subparsers_action._prog_prefix} {subcmd_name}"
886+
subcmd_parser.update_prog(subcmd_prog)
887+
updated_parsers.add(subcmd_parser)
888+
854889
def _find_parser(self, subcommand_path: Iterable[str]) -> 'Cmd2ArgumentParser':
855890
"""Find a parser in the hierarchy based on a sequence of subcommand names.
856891
@@ -987,7 +1022,7 @@ def _match_argument(self, action: argparse.Action, arg_strings_pattern: str) ->
9871022
if match is None:
9881023
nargs_range = action.get_nargs_range() # type: ignore[attr-defined]
9891024
if nargs_range is not None:
990-
raise ArgumentError(action, generate_range_error(nargs_range[0], nargs_range[1]))
1025+
raise ArgumentError(action, build_range_error(nargs_range[0], nargs_range[1]))
9911026

9921027
return super()._match_argument(action, arg_strings_pattern)
9931028

tests/test_argparse_custom.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
)
1515
from cmd2.argparse_custom import (
1616
Cmd2HelpFormatter,
17-
generate_range_error,
17+
build_range_error,
1818
register_argparse_argument_parameter,
1919
)
2020
from cmd2.rich_utils import Cmd2RichArgparseConsole
@@ -258,26 +258,26 @@ def test_apcustom_print_message(capsys) -> None:
258258
assert test_message in err
259259

260260

261-
def test_generate_range_error() -> None:
261+
def test_build_range_error() -> None:
262262
# max is INFINITY
263-
err_msg = generate_range_error(1, constants.INFINITY)
263+
err_msg = build_range_error(1, constants.INFINITY)
264264
assert err_msg == "expected at least 1 argument"
265265

266-
err_msg = generate_range_error(2, constants.INFINITY)
266+
err_msg = build_range_error(2, constants.INFINITY)
267267
assert err_msg == "expected at least 2 arguments"
268268

269269
# min and max are equal
270-
err_msg = generate_range_error(1, 1)
270+
err_msg = build_range_error(1, 1)
271271
assert err_msg == "expected 1 argument"
272272

273-
err_msg = generate_range_error(2, 2)
273+
err_msg = build_range_error(2, 2)
274274
assert err_msg == "expected 2 arguments"
275275

276276
# min and max are not equal
277-
err_msg = generate_range_error(0, 1)
277+
err_msg = build_range_error(0, 1)
278278
assert err_msg == "expected 0 to 1 argument"
279279

280-
err_msg = generate_range_error(0, 2)
280+
err_msg = build_range_error(0, 2)
281281
assert err_msg == "expected 0 to 2 arguments"
282282

283283

0 commit comments

Comments
 (0)