4949 Callable ,
5050 Iterable ,
5151 Mapping ,
52- MutableSequence ,
5352 Sequence ,
5453)
5554from 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 :
0 commit comments