Skip to content

Commit b0502bd

Browse files
authored
Merge branch 'main' into feat/annotated-argparse
2 parents 2118018 + 6b12864 commit b0502bd

14 files changed

+621
-378
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ prompt is displayed.
6868
class of it.
6969
- Removed `set_ap_completer_type()` and `get_ap_completer_type()` since `ap_completer_type` is
7070
now a public member of `Cmd2ArgumentParser`.
71+
- Moved `set_parser_prog()` to `Cmd2ArgumentParser.update_prog()`.
7172
- Enhancements
7273
- New `cmd2.Cmd` parameters
7374
- **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These

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: 197 additions & 159 deletions
Large diffs are not rendered by default.

cmd2/cmd2.py

Lines changed: 105 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@
4949
Callable,
5050
Iterable,
5151
Mapping,
52-
MutableSequence,
5352
Sequence,
5453
)
5554
from dataclasses import (
@@ -274,7 +273,11 @@ def get(self, command_method: CommandFunc) -> Cmd2ArgumentParser | None:
274273
return None
275274

276275
parent = self._cmd.find_commandset_for_command(command) or self._cmd
277-
parser = self._cmd._build_parser(parent, parser_builder, command)
276+
parser = self._cmd._build_parser(parent, parser_builder)
277+
278+
# To ensure accurate usage strings, recursively update 'prog' values
279+
# within the parser to match the command name.
280+
parser.update_prog(command)
278281

279282
# If the description has not been set, then use the method docstring if one exists
280283
if parser.description is None and command_method.__doc__:
@@ -889,7 +892,6 @@ def _build_parser(
889892
self,
890893
parent: CmdOrSet,
891894
parser_builder: Cmd2ArgumentParser | Callable[[], Cmd2ArgumentParser] | StaticArgParseBuilder | ClassArgParseBuilder,
892-
prog: str,
893895
) -> Cmd2ArgumentParser:
894896
"""Build argument parser for a command/subcommand.
895897
@@ -898,7 +900,6 @@ def _build_parser(
898900
parent's class to it.
899901
:param parser_builder: an existing Cmd2ArgumentParser instance or a factory
900902
(callable, staticmethod, or classmethod) that returns one.
901-
:param prog: prog value to set in new parser
902903
:return: new parser
903904
:raises TypeError: if parser_builder is an invalid type or if the factory fails
904905
to return a Cmd2ArgumentParser
@@ -921,8 +922,6 @@ def _build_parser(
921922
builder_name = getattr(parser_builder, "__name__", str(parser_builder)) # type: ignore[unreachable]
922923
raise TypeError(f"The parser returned by '{builder_name}' must be a Cmd2ArgumentParser or a subclass of it")
923924

924-
argparse_custom.set_parser_prog(parser, prog)
925-
926925
return parser
927926

928927
def _install_command_function(self, command_func_name: str, command_method: CommandFunc, context: str = '') -> None:
@@ -1026,18 +1025,29 @@ def unregister_command_set(self, cmdset: CommandSet) -> None:
10261025
self._installed_command_sets.remove(cmdset)
10271026

10281027
def _check_uninstallable(self, cmdset: CommandSet) -> None:
1028+
cmdset_id = id(cmdset)
1029+
10291030
def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None:
1030-
cmdset_id = id(cmdset)
1031-
for action in parser._actions:
1032-
if isinstance(action, argparse._SubParsersAction):
1033-
for subparser in action.choices.values():
1034-
attached_cmdset_id = getattr(subparser, constants.PARSER_ATTR_COMMANDSET_ID, None)
1035-
if attached_cmdset_id is not None and attached_cmdset_id != cmdset_id:
1036-
raise CommandSetRegistrationError(
1037-
'Cannot uninstall CommandSet when another CommandSet depends on it'
1038-
)
1039-
check_parser_uninstallable(subparser)
1040-
break
1031+
try:
1032+
subparsers_action = parser._get_subparsers_action()
1033+
except ValueError:
1034+
# No subcommands to check
1035+
return
1036+
1037+
# Prevent redundant traversal of parser aliases
1038+
checked_parsers: set[Cmd2ArgumentParser] = set()
1039+
1040+
for subparser in subparsers_action.choices.values():
1041+
if subparser in checked_parsers:
1042+
continue
1043+
checked_parsers.add(subparser)
1044+
1045+
attached_cmdset_id = getattr(subparser, constants.PARSER_ATTR_COMMANDSET_ID, None)
1046+
if attached_cmdset_id is not None and attached_cmdset_id != cmdset_id:
1047+
raise CommandSetRegistrationError(
1048+
f"Cannot uninstall CommandSet: '{subparser.prog}' is required by another CommandSet"
1049+
)
1050+
check_parser_uninstallable(subparser)
10411051

10421052
methods: list[tuple[str, Callable[..., Any]]] = inspect.getmembers(
10431053
cmdset,
@@ -1085,40 +1095,8 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
10851095
if not subcommand_valid:
10861096
raise CommandSetRegistrationError(f'Subcommand {subcommand_name} is not valid: {errmsg}')
10871097

1088-
command_tokens = full_command_name.split()
1089-
command_name = command_tokens[0]
1090-
subcommand_names = command_tokens[1:]
1091-
1092-
# Search for the base command function and verify it has an argparser defined
1093-
if command_name in self.disabled_commands:
1094-
command_func = self.disabled_commands[command_name].command_function
1095-
else:
1096-
command_func = self.cmd_func(command_name)
1097-
1098-
if command_func is None:
1099-
raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method}")
1100-
command_parser = self._command_parsers.get(command_func)
1101-
if command_parser is None:
1102-
raise CommandSetRegistrationError(
1103-
f"Could not find argparser for command '{command_name}' needed by subcommand: {method}"
1104-
)
1105-
1106-
def find_subcommand(action: Cmd2ArgumentParser, subcmd_names: MutableSequence[str]) -> Cmd2ArgumentParser:
1107-
if not subcmd_names:
1108-
return action
1109-
cur_subcmd = subcmd_names.pop(0)
1110-
for sub_action in action._actions:
1111-
if isinstance(sub_action, argparse._SubParsersAction):
1112-
for choice_name, choice in sub_action.choices.items():
1113-
if choice_name == cur_subcmd:
1114-
return find_subcommand(choice, subcmd_names)
1115-
break
1116-
raise CommandSetRegistrationError(f"Could not find subcommand '{action}'")
1117-
1118-
target_parser = find_subcommand(command_parser, subcommand_names)
1119-
11201098
# Create the subcommand parser and configure it
1121-
subcmd_parser = self._build_parser(cmdset, subcmd_parser_builder, f'{command_name} {subcommand_name}')
1099+
subcmd_parser = self._build_parser(cmdset, subcmd_parser_builder)
11221100
if subcmd_parser.description is None and method.__doc__:
11231101
subcmd_parser.description = strip_doc_annotations(method.__doc__)
11241102

@@ -1129,19 +1107,14 @@ def find_subcommand(action: Cmd2ArgumentParser, subcmd_names: MutableSequence[st
11291107
# Set what instance the handler is bound to
11301108
setattr(subcmd_parser, constants.PARSER_ATTR_COMMANDSET_ID, id(cmdset))
11311109

1132-
# Find the argparse action that handles subcommands
1133-
for action in target_parser._actions:
1134-
if isinstance(action, argparse._SubParsersAction):
1135-
# Get add_parser() kwargs (aliases, help, etc.) defined by the decorator
1136-
add_parser_kwargs = getattr(method, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, {})
1137-
1138-
# Attach existing parser as a subcommand
1139-
action.attach_parser( # type: ignore[attr-defined]
1140-
subcommand_name,
1141-
subcmd_parser,
1142-
**add_parser_kwargs,
1143-
)
1144-
break
1110+
# Get add_parser() kwargs (aliases, help, etc.) defined by the decorator
1111+
add_parser_kwargs = getattr(method, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, {})
1112+
1113+
# Attach existing parser as a subcommand
1114+
try:
1115+
self.attach_subcommand(full_command_name, subcommand_name, subcmd_parser, **add_parser_kwargs)
1116+
except ValueError as ex:
1117+
raise CommandSetRegistrationError(str(ex)) from ex
11451118

11461119
def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
11471120
"""Unregister subcommands from their base command.
@@ -1165,30 +1138,77 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
11651138
# iterate through all matching methods
11661139
for _method_name, method in methods:
11671140
subcommand_name = getattr(method, constants.SUBCMD_ATTR_NAME)
1168-
command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND)
1141+
full_command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND)
11691142

1170-
# Search for the base command function and verify it has an argparser defined
1171-
if command_name in self.disabled_commands:
1172-
command_func = self.disabled_commands[command_name].command_function
1173-
else:
1174-
command_func = self.cmd_func(command_name)
1175-
1176-
if command_func is None: # pragma: no cover
1177-
# This really shouldn't be possible since _register_subcommands would prevent this from happening
1178-
# but keeping in case it does for some strange reason
1179-
raise CommandSetRegistrationError(f"Could not find command '{command_name}' needed by subcommand: {method}")
1180-
command_parser = self._command_parsers.get(command_func)
1181-
if command_parser is None: # pragma: no cover
1182-
# This really shouldn't be possible since _register_subcommands would prevent this from happening
1183-
# but keeping in case it does for some strange reason
1184-
raise CommandSetRegistrationError(
1185-
f"Could not find argparser for command '{command_name}' needed by subcommand: {method}"
1186-
)
1143+
with contextlib.suppress(ValueError):
1144+
self.detach_subcommand(full_command_name, subcommand_name)
11871145

1188-
for action in command_parser._actions:
1189-
if isinstance(action, argparse._SubParsersAction):
1190-
action.detach_parser(subcommand_name) # type: ignore[attr-defined]
1191-
break
1146+
def _get_root_parser_and_subcmd_path(self, command: str) -> tuple[Cmd2ArgumentParser, list[str]]:
1147+
"""Tokenize a command string and resolve the associated root parser and relative subcommand path.
1148+
1149+
This helper handles the initial resolution of a command string (e.g., 'foo bar baz') by
1150+
identifying 'foo' as the root command (even if disabled), retrieving its associated
1151+
parser, and returning any remaining tokens (['bar', 'baz']) as a path relative
1152+
to that parser for further traversal.
1153+
1154+
:param command: full space-delimited command path leading to a parser (e.g. 'foo' or 'foo bar')
1155+
:return: a tuple containing the Cmd2ArgumentParser for the root command and a list of
1156+
strings representing the relative path to the desired hosting parser.
1157+
:raises ValueError: if the command is empty, the root command is not found, or
1158+
the root command does not use an argparse parser.
1159+
"""
1160+
tokens = command.split()
1161+
if not tokens:
1162+
raise ValueError("Command path cannot be empty")
1163+
1164+
root_command = tokens[0]
1165+
subcommand_path = tokens[1:]
1166+
1167+
# Search for the base command function and verify it has an argparser defined
1168+
if root_command in self.disabled_commands:
1169+
command_func = self.disabled_commands[root_command].command_function
1170+
else:
1171+
command_func = self.cmd_func(root_command)
1172+
1173+
if command_func is None:
1174+
raise ValueError(f"Root command '{root_command}' not found")
1175+
1176+
root_parser = self._command_parsers.get(command_func)
1177+
if root_parser is None:
1178+
raise ValueError(f"Command '{root_command}' does not use argparse")
1179+
1180+
return root_parser, subcommand_path
1181+
1182+
def attach_subcommand(
1183+
self,
1184+
command: str,
1185+
subcommand: str,
1186+
parser: Cmd2ArgumentParser,
1187+
**add_parser_kwargs: Any,
1188+
) -> None:
1189+
"""Attach a parser as a subcommand to a command at the specified path.
1190+
1191+
:param command: full command path (space-delimited) leading to the parser that will
1192+
host the new subcommand (e.g. 'foo bar')
1193+
:param subcommand: name of the new subcommand
1194+
:param parser: the parser to attach
1195+
:param add_parser_kwargs: additional arguments for the subparser registration (e.g. help, aliases)
1196+
:raises ValueError: if the command path is invalid or doesn't support subcommands
1197+
"""
1198+
root_parser, subcommand_path = self._get_root_parser_and_subcmd_path(command)
1199+
root_parser.attach_subcommand(subcommand_path, subcommand, parser, **add_parser_kwargs)
1200+
1201+
def detach_subcommand(self, command: str, subcommand: str) -> Cmd2ArgumentParser:
1202+
"""Detach a subcommand from a command at the specified path.
1203+
1204+
:param command: full command path (space-delimited) leading to the parser hosting the
1205+
subcommand to be detached (e.g. 'foo bar')
1206+
:param subcommand: name of the subcommand to detach
1207+
:return: the detached parser
1208+
:raises ValueError: if the command path is invalid or the subcommand doesn't exist
1209+
"""
1210+
root_parser, subcommand_path = self._get_root_parser_and_subcmd_path(command)
1211+
return root_parser.detach_subcommand(subcommand_path, subcommand)
11921212

11931213
@property
11941214
def always_prefix_settables(self) -> bool:

cmd2/decorators.py

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -176,9 +176,9 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
176176
:return: return value of command function
177177
"""
178178
cmd2_app, statement = _parse_positionals(args)
179-
_, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(command_name, statement, preserve_quotes)
180-
args_list = _arg_swap(args, statement, parsed_arglist)
181-
return func(*args_list, **kwargs)
179+
_, command_arg_list = cmd2_app.statement_parser.get_command_arg_list(command_name, statement, preserve_quotes)
180+
func_arg_list = _arg_swap(args, statement, command_arg_list)
181+
return func(*func_arg_list, **kwargs)
182182

183183
command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX) :]
184184
cmd_wrapper.__doc__ = func.__doc__
@@ -294,7 +294,7 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
294294
:raises Cmd2ArgparseError: if argparse has error parsing command line
295295
"""
296296
cmd2_app, statement_arg = _parse_positionals(args)
297-
statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(
297+
statement, command_arg_list = cmd2_app.statement_parser.get_command_arg_list(
298298
command_name, statement_arg, preserve_quotes
299299
)
300300

@@ -305,38 +305,40 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None:
305305
raise ValueError(f'No argument parser found for {command_name}') # pragma: no cover
306306

307307
if ns_provider is None:
308-
namespace = None
308+
initial_namespace = None
309309
else:
310310
# The namespace provider may or may not be defined in the same class as the command. Since provider
311311
# functions are registered with the command argparser before anything is instantiated, we
312312
# need to find an instance at runtime that matches the types during declaration
313313
provider_self = cmd2_app._resolve_func_self(ns_provider, args[0])
314-
namespace = ns_provider(provider_self if provider_self is not None else cmd2_app)
314+
initial_namespace = ns_provider(provider_self if provider_self is not None else cmd2_app)
315315

316316
try:
317-
new_args: tuple[argparse.Namespace] | tuple[argparse.Namespace, list[str]]
317+
parsing_results: tuple[argparse.Namespace] | tuple[argparse.Namespace, list[str]]
318318
if with_unknown_args:
319-
new_args = arg_parser.parse_known_args(parsed_arglist, namespace)
319+
parsing_results = arg_parser.parse_known_args(command_arg_list, initial_namespace)
320320
else:
321-
new_args = (arg_parser.parse_args(parsed_arglist, namespace),)
322-
ns = new_args[0]
321+
parsing_results = (arg_parser.parse_args(command_arg_list, initial_namespace),)
323322
except SystemExit as exc:
324323
raise Cmd2ArgparseError from exc
325-
else:
326-
# Add wrapped statement to Namespace as cmd2_statement
327-
ns.cmd2_statement = Cmd2AttributeWrapper(statement)
328324

329-
# Add wrapped subcmd handler (which can be None) to Namespace as cmd2_handler
330-
handler = getattr(ns, constants.NS_ATTR_SUBCMD_HANDLER, None)
331-
ns.cmd2_handler = Cmd2AttributeWrapper(handler)
325+
# Add cmd2-specific metadata to the Namespace
326+
parsed_namespace = parsing_results[0]
327+
328+
# Add wrapped statement to Namespace as cmd2_statement
329+
parsed_namespace.cmd2_statement = Cmd2AttributeWrapper(statement)
330+
331+
# Add wrapped subcmd handler (which can be None) to Namespace as cmd2_handler
332+
handler = getattr(parsed_namespace, constants.NS_ATTR_SUBCMD_HANDLER, None)
333+
parsed_namespace.cmd2_handler = Cmd2AttributeWrapper(handler)
332334

333-
# Remove the subcmd handler attribute from the Namespace
334-
# since cmd2_handler is how a developer accesses it.
335-
if hasattr(ns, constants.NS_ATTR_SUBCMD_HANDLER):
336-
delattr(ns, constants.NS_ATTR_SUBCMD_HANDLER)
335+
# Remove the subcmd handler attribute from the Namespace
336+
# since cmd2_handler is how a developer accesses it.
337+
if hasattr(parsed_namespace, constants.NS_ATTR_SUBCMD_HANDLER):
338+
delattr(parsed_namespace, constants.NS_ATTR_SUBCMD_HANDLER)
337339

338-
args_list = _arg_swap(args, statement_arg, *new_args)
339-
return func(*args_list, **kwargs)
340+
func_arg_list = _arg_swap(args, statement_arg, *parsing_results)
341+
return func(*func_arg_list, **kwargs)
340342

341343
command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX) :]
342344

0 commit comments

Comments
 (0)