Skip to content

Commit e727360

Browse files
committed
Added attach_subcommand() and detach_subcommand() to public API.
1 parent bf86bd0 commit e727360

3 files changed

Lines changed: 142 additions & 70 deletions

File tree

cmd2/argparse_custom.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ def get_choices(self) -> Choices:
243243
from argparse import ArgumentError
244244
from collections.abc import (
245245
Callable,
246+
Iterable,
246247
Iterator,
247248
Sequence,
248249
)
@@ -927,6 +928,68 @@ def add_subparsers(self, **kwargs: Any) -> argparse._SubParsersAction: # type:
927928

928929
return super().add_subparsers(**kwargs)
929930

931+
def _find_subparsers_action(self) -> argparse._SubParsersAction: # type: ignore[type-arg]
932+
"""Find the _SubParsersAction for this parser.
933+
934+
:return: the _SubParsersAction for this parser
935+
:raises ValueError: if this parser does not support subcommands
936+
"""
937+
for action in self._actions:
938+
if isinstance(action, argparse._SubParsersAction):
939+
return action
940+
raise ValueError(f"Command '{self.prog}' does not support subcommands")
941+
942+
def _find_parser(self, subcommand_path: Iterable[str]) -> 'Cmd2ArgumentParser':
943+
"""Find a parser in the hierarchy based on a sequence of subcommand names.
944+
945+
:param subcommand_path: sequence of subcommand names leading to the target parser
946+
:return: the discovered Cmd2ArgumentParser
947+
:raises ValueError: if any subcommand in the path is not found or a level doesn't support subcommands
948+
"""
949+
parser = self
950+
for name in subcommand_path:
951+
subparsers_action = parser._find_subparsers_action()
952+
if name not in subparsers_action.choices:
953+
raise ValueError(f"Subcommand '{name}' not found in '{parser.prog}'")
954+
parser = cast(Cmd2ArgumentParser, subparsers_action.choices[name])
955+
return parser
956+
957+
def attach_subcommand(
958+
self,
959+
subcommand_path: Iterable[str],
960+
subcommand: str,
961+
parser: 'Cmd2ArgumentParser',
962+
**add_parser_kwargs: Any,
963+
) -> None:
964+
"""Attach a parser as a subcommand to a command at the specified path.
965+
966+
:param subcommand_path: sequence of subcommand names leading to the parser that will
967+
host the new subcommand. An empty sequence indicates this parser.
968+
:param subcommand: name of the new subcommand
969+
:param parser: the parser to attach
970+
:param add_parser_kwargs: additional arguments for the subparser registration (e.g. help, aliases)
971+
:raises ValueError: if the command path is invalid or doesn't support subcommands
972+
"""
973+
target_parser = self._find_parser(subcommand_path)
974+
subparsers_action = target_parser._find_subparsers_action()
975+
subparsers_action.attach_parser(subcommand, parser, **add_parser_kwargs) # type: ignore[attr-defined]
976+
977+
def detach_subcommand(self, subcommand_path: Iterable[str], subcommand: str) -> 'Cmd2ArgumentParser':
978+
"""Detach a subcommand from a command at the specified path.
979+
980+
:param subcommand_path: sequence of subcommand names leading to the parser hosting the
981+
subcommand to be detached. An empty sequence indicates this parser.
982+
:param subcommand: name of the subcommand to detach
983+
:return: the detached parser
984+
:raises ValueError: if the command path is invalid or the subcommand doesn't exist
985+
"""
986+
target_parser = self._find_parser(subcommand_path)
987+
subparsers_action = target_parser._find_subparsers_action()
988+
detached = subparsers_action.detach_parser(subcommand) # type: ignore[attr-defined]
989+
if detached is None:
990+
raise ValueError(f"Subcommand '{subcommand}' not found in '{target_parser.prog}'")
991+
return cast(Cmd2ArgumentParser, detached)
992+
930993
def error(self, message: str) -> NoReturn:
931994
"""Override that applies custom formatting to the error message."""
932995
lines = message.split('\n')

cmd2/cmd2.py

Lines changed: 78 additions & 69 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 (
@@ -1085,40 +1084,8 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
10851084
if not subcommand_valid:
10861085
raise CommandSetRegistrationError(f'Subcommand {subcommand_name} is not valid: {errmsg}')
10871086

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-
11201087
# Create the subcommand parser and configure it
1121-
subcmd_parser = self._build_parser(cmdset, subcmd_parser_builder, f'{command_name} {subcommand_name}')
1088+
subcmd_parser = self._build_parser(cmdset, subcmd_parser_builder, f'{full_command_name} {subcommand_name}')
11221089
if subcmd_parser.description is None and method.__doc__:
11231090
subcmd_parser.description = strip_doc_annotations(method.__doc__)
11241091

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

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
1099+
# Get add_parser() kwargs (aliases, help, etc.) defined by the decorator
1100+
add_parser_kwargs = getattr(method, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, {})
1101+
1102+
# Attach existing parser as a subcommand
1103+
try:
1104+
self.attach_subcommand(full_command_name, subcommand_name, subcmd_parser, **add_parser_kwargs)
1105+
except ValueError as ex:
1106+
raise CommandSetRegistrationError(str(ex)) from ex
11451107

11461108
def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
11471109
"""Unregister subcommands from their base command.
@@ -1165,30 +1127,77 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
11651127
# iterate through all matching methods
11661128
for _method_name, method in methods:
11671129
subcommand_name = getattr(method, constants.SUBCMD_ATTR_NAME)
1168-
command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND)
1130+
full_command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND)
11691131

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-
)
1132+
with contextlib.suppress(ValueError):
1133+
self.detach_subcommand(full_command_name, subcommand_name)
11871134

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

11931202
@property
11941203
def always_prefix_settables(self) -> bool:

tests/test_completion.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -942,7 +942,7 @@ def test_clean_display() -> None:
942942
assert completion_item.display == expected
943943
assert completion_item.display_meta == expected
944944

945-
# Verify that text derived display is also sanitized
945+
# Verify that text-derived display is also sanitized
946946
text = "item\nwith\nnewlines"
947947
expected_text_display = "item with newlines"
948948
completion_item = CompletionItem(text)

0 commit comments

Comments
 (0)