@@ -243,6 +243,8 @@ def get_choices(self) -> Choices:
243243 Any ,
244244 ClassVar ,
245245 NoReturn ,
246+ TypeAlias ,
247+ Union ,
246248 cast ,
247249)
248250
@@ -264,9 +266,59 @@ def get_choices(self) -> Choices:
264266if TYPE_CHECKING : # pragma: no cover
265267 from .argparse_completer import ArgparseCompleter
266268
269+ _StaticParserFactory = staticmethod [[], "Cmd2ArgumentParser" ]
270+ _ClassParserFactory = classmethod [CmdOrSetT , [], "Cmd2ArgumentParser" ]
271+ else :
272+ _StaticParserFactory = staticmethod
273+ _ClassParserFactory = classmethod
274+
275+ # Represents a parser factory with no arguments (including staticmethod)
276+ NoParamParserFactory : TypeAlias = Callable [[], "Cmd2ArgumentParser" ] | _StaticParserFactory
277+
278+ # Represents a parser factory with a class argument (including classmethod)
279+ ClassParamParserFactory : TypeAlias = Union [
280+ Callable [[type [CmdOrSetT ]], "Cmd2ArgumentParser" ],
281+ "_ClassParserFactory[CmdOrSetT]" ,
282+ ]
283+
284+ # Represents the various types from which cmd2 can build a parser
285+ ParserSource : TypeAlias = Union [
286+ "Cmd2ArgumentParser" ,
287+ NoParamParserFactory ,
288+ ClassParamParserFactory [CmdOrSetT ],
289+ ]
290+
291+
292+ @dataclass (kw_only = True )
293+ class _SubcommandBase :
294+ """Base metadata shared by all subcommand representations."""
295+
296+ name : str
297+ command : str # The full parent command path (e.g., 'foo bar')
298+ help : str | None = None
299+ aliases : tuple [str , ...] = ()
300+ deprecated : bool = False
301+
302+
303+ @dataclass (kw_only = True )
304+ class SubcommandSpec (_SubcommandBase ):
305+ """Metadata used to build and register a subcommand."""
306+
307+ parser_source : ParserSource [Any ]
308+
309+
310+ @dataclass (kw_only = True )
311+ class SubcommandRecord (_SubcommandBase ):
312+ """A record of a subcommand's configuration and parser.
313+
314+ Used primarily for attaching and detaching subcommands.
315+ """
316+
317+ parser : "Cmd2ArgumentParser"
318+
267319
268320def build_range_error (range_min : int , range_max : float ) -> str :
269- """Build an error message when the the number of arguments provided is not within the expected range."""
321+ """Build an error message when the number of arguments provided is not within the expected range."""
270322 err_msg = "expected "
271323
272324 if range_max == constants .INFINITY :
@@ -532,9 +584,9 @@ def _ActionsContainer_add_argument( # noqa: N802
532584
533585
534586def _SubParsersAction_remove_parser ( # noqa: N802
535- self : argparse ._SubParsersAction , # type: ignore[type-arg]
587+ self : " argparse._SubParsersAction[Cmd2ArgumentParser]" ,
536588 name : str ,
537- ) -> argparse . ArgumentParser :
589+ ) -> SubcommandRecord :
538590 """Remove a subparser from a subparsers group.
539591
540592 This function is added by cmd2 as a method called ``remove_parser()``
@@ -544,7 +596,7 @@ def _SubParsersAction_remove_parser( # noqa: N802
544596
545597 :param self: instance of the _SubParsersAction being edited
546598 :param name: name of the subcommand for the subparser to remove
547- :return: the removed parser
599+ :return: a SubcommandRecord object describing the removed parser
548600 :raises ValueError: if the subcommand doesn't exist
549601 """
550602 if name not in self ._name_parser_map :
@@ -555,22 +607,69 @@ def _SubParsersAction_remove_parser( # noqa: N802
555607 # Find all names (primary and aliases) that map to this subparser
556608 all_names = [cur_name for cur_name , cur_parser in self ._name_parser_map .items () if cur_parser is subparser ]
557609
558- # Remove the help entry for this subparser. To handle the case where
559- # name is an alias, we remove the action whose 'dest' matches any of
560- # the names mapped to this subparser.
610+ # argparse inserts the primary name before the aliases in _name_parser_map
611+ primary_name = all_names [0 ]
612+ aliases = tuple (all_names [1 :])
613+
614+ # Handle Python 3.13+ deprecation
615+ deprecated : bool = False
616+ deprecated_attr = getattr (self , "_deprecated" , None )
617+ if isinstance (deprecated_attr , set ):
618+ if primary_name in deprecated_attr :
619+ deprecated = True
620+ deprecated_attr .discard (primary_name )
621+ for alias in aliases :
622+ deprecated_attr .discard (alias )
623+
624+ # Remove the help entry for this subparser.
625+ help_text = None
561626 for choice_action in self ._choices_actions :
562- if choice_action .dest in all_names :
627+ if choice_action .dest == primary_name :
628+ help_text = choice_action .help
563629 self ._choices_actions .remove (choice_action )
564630 break
565631
566632 # Remove all references to this subparser, including aliases.
567633 for cur_name in all_names :
568634 del self ._name_parser_map [cur_name ]
569635
570- return cast (argparse .ArgumentParser , subparser )
636+ return SubcommandRecord (
637+ name = primary_name ,
638+ command = "" , # To be populated by the caller
639+ help = help_text ,
640+ aliases = aliases ,
641+ deprecated = deprecated ,
642+ parser = subparser ,
643+ )
644+
645+
646+ def _SubParsersAction_remove_all_parsers ( # noqa: N802
647+ self : "argparse._SubParsersAction[Cmd2ArgumentParser]" ,
648+ ) -> list [SubcommandRecord ]:
649+ """Remove all subparsers from a subparsers group.
650+
651+ This function is added by cmd2 as a method called ``remove_all_parsers()``
652+ to ``argparse._SubParsersAction`` class.
653+
654+ To call: ``action.remove_all_parsers()``
655+
656+ :param self: instance of the _SubParsersAction being edited
657+ :return: a list of SubcommandRecord objects for the removed subparsers
658+ """
659+ records : list [SubcommandRecord ] = []
660+
661+ while self ._name_parser_map :
662+ # Get the next subcommand name. remove_parser() will remove
663+ # it and any associated aliases from _name_parser_map.
664+ name = next (iter (self ._name_parser_map ))
665+ record = self .remove_parser (name ) # type: ignore[attr-defined]
666+ records .append (record )
667+
668+ return records
571669
572670
573671argparse ._SubParsersAction .remove_parser = _SubParsersAction_remove_parser # type: ignore[attr-defined]
672+ argparse ._SubParsersAction .remove_all_parsers = _SubParsersAction_remove_all_parsers # type: ignore[attr-defined]
574673
575674
576675@dataclass
@@ -795,28 +894,27 @@ def find_parser(self, subcommand_path: Iterable[str]) -> "Cmd2ArgumentParser":
795894
796895 def attach_subcommand (
797896 self ,
798- subcommand_path : Iterable [str ],
799- subcommand : str ,
800- subcommand_parser : "Cmd2ArgumentParser" ,
801- ** add_parser_kwargs : Any ,
897+ record : SubcommandRecord ,
898+ subcommand_path : Iterable [str ] = (),
802899 ) -> None :
803900 """Attach a parser as a subcommand to a command at the specified path.
804901
902+ Note: `record.command` is not used for navigation here. It is assumed you
903+ are attaching relative to `self` using `subcommand_path`. However,
904+ `record.command` will be updated to reflect the final, absolute path
905+ of the parent parser this subcommand is attached to.
906+
907+ :param record: SubcommandRecord object describing the subcommand
805908 :param subcommand_path: sequence of subcommand names leading to the parser that will
806909 host the new subcommand. An empty sequence indicates this parser.
807- :param subcommand: name of the new subcommand
808- :param subcommand_parser: the parser to attach
809- :param add_parser_kwargs: additional arguments for the subparser registration (e.g. help, aliases)
810- :raises TypeError: if subcommand_parser is not an instance of the following or their subclasses:
811- 1. Cmd2ArgumentParser
812- 2. The parser_class configured for the target subcommand group
910+ :raises TypeError: if record.parser is not an instance of Cmd2ArgumentParser (or subclass)
813911 :raises ValueError: if the command path is invalid, doesn't support subcommands, or the
814912 subcommand already exists
815913 """
816- if not isinstance (subcommand_parser , Cmd2ArgumentParser ):
914+ if not isinstance (record . parser , Cmd2ArgumentParser ):
817915 raise TypeError (
818- f"The attached parser must be an instance of 'Cmd2ArgumentParser' (or a subclass). "
819- f"Received: '{ type (subcommand_parser ).__name__ } '."
916+ f"The attached parser must be an instance of 'Cmd2ArgumentParser' (or subclass). "
917+ f"Received: '{ type (record . parser ).__name__ } '."
820918 )
821919
822920 target_parser = self .find_parser (subcommand_path )
@@ -826,53 +924,87 @@ def attach_subcommand(
826924 # subcommand group. We use isinstance() here to allow for subclasses, providing
827925 # more flexibility than the standard add_parser() factory approach which enforces
828926 # a specific class.
829- if not isinstance (subcommand_parser , subparsers_action ._parser_class ):
927+ if not isinstance (record . parser , subparsers_action ._parser_class ):
830928 raise TypeError (
831929 f"The attached parser must be an instance of '{ subparsers_action ._parser_class .__name__ } ' "
832- f"(or a subclass) to match the 'parser_class' configured for this subcommand group. "
833- f"Received: '{ type (subcommand_parser ).__name__ } '."
930+ f"(or subclass) to match the 'parser_class' configured for this subcommand group. "
931+ f"Received: '{ type (record . parser ).__name__ } '."
834932 )
835933
836934 # Do not overwrite existing subcommands or aliases
837- all_names = (subcommand , * add_parser_kwargs . get ( " aliases" , ()) )
935+ all_names = (record . name , * record . aliases )
838936 for name in all_names :
839937 if name in subparsers_action ._name_parser_map :
840938 raise ValueError (f"Subcommand '{ name } ' already exists for '{ target_parser .prog } '" )
841939
940+ # Registration kwargs
941+ kwargs : dict [str , Any ] = {"aliases" : record .aliases }
942+ if record .help is not None :
943+ kwargs ["help" ] = record .help
944+ if record .deprecated :
945+ kwargs ["deprecated" ] = record .deprecated
946+
842947 # Use add_parser to register the subcommand name and any aliases
843- placeholder_parser = subparsers_action .add_parser (subcommand , ** add_parser_kwargs )
948+ placeholder_parser = subparsers_action .add_parser (record . name , ** kwargs )
844949
845950 # To ensure accurate usage strings, recursively update 'prog' values
846951 # within the injected parser to match its new location in the command hierarchy.
847- subcommand_parser .update_prog (placeholder_parser .prog )
952+ record . parser .update_prog (placeholder_parser .prog )
848953
849954 # Replace the parser created by add_parser() with our pre-configured one
850- subparsers_action ._name_parser_map [subcommand ] = subcommand_parser
955+ subparsers_action ._name_parser_map [record . name ] = record . parser
851956
852957 # Remap any aliases to our pre-configured parser
853- for alias in add_parser_kwargs . get ( " aliases" , ()) :
854- subparsers_action ._name_parser_map [alias ] = subcommand_parser
958+ for alias in record . aliases :
959+ subparsers_action ._name_parser_map [alias ] = record . parser
855960
856- def detach_subcommand (self , subcommand_path : Iterable [str ], subcommand : str ) -> "Cmd2ArgumentParser" :
961+ # Update command to reflect the parent parser's absolute path
962+ record .command = target_parser .prog
963+
964+ def detach_subcommand (self , subcommand_path : Iterable [str ], subcommand : str ) -> SubcommandRecord :
857965 """Detach a subcommand from a command at the specified path.
858966
859967 :param subcommand_path: sequence of subcommand names leading to the parser hosting the
860968 subcommand to be detached. An empty sequence indicates this parser.
861969 :param subcommand: name of the subcommand to detach
862- :return: the detached parser
970+ :return: a SubcommandRecord object describing the detached subcommand
863971 :raises ValueError: if the command path is invalid or the subcommand doesn't exist
864972 """
865973 target_parser = self .find_parser (subcommand_path )
866974 subparsers_action = target_parser .get_subparsers_action ()
867975
868976 try :
869- return cast (
870- Cmd2ArgumentParser ,
977+ record = cast (
978+ SubcommandRecord ,
871979 subparsers_action .remove_parser (subcommand ), # type: ignore[attr-defined]
872980 )
873981 except ValueError :
874982 raise ValueError (f"Subcommand '{ subcommand } ' does not exist for '{ target_parser .prog } '" ) from None
875983
984+ # Update command to reflect the parent parser's absolute path
985+ record .command = target_parser .prog
986+ return record
987+
988+ def detach_all_subcommands (self , subcommand_path : Iterable [str ]) -> list [SubcommandRecord ]:
989+ """Detach all subcommands from a command at the specified path.
990+
991+ :param subcommand_path: sequence of subcommand names leading to the parser hosting the
992+ subcommands to be detached. An empty sequence indicates this parser.
993+ :return: a list of SubcommandRecord objects describing the detached subcommands
994+ :raises ValueError: if the command path is invalid or the command doesn't support subcommands
995+ """
996+ target_parser = self .find_parser (subcommand_path )
997+ subparsers_action = target_parser .get_subparsers_action ()
998+
999+ records = cast (
1000+ list [SubcommandRecord ],
1001+ subparsers_action .remove_all_parsers (), # type: ignore[attr-defined]
1002+ )
1003+ # Update command for each detached subcommand
1004+ for record in records :
1005+ record .command = target_parser .prog
1006+ return records
1007+
8761008 def error (self , message : str ) -> NoReturn :
8771009 """Override that applies custom formatting to the error message."""
8781010 lines = message .split ("\n " )
@@ -940,9 +1072,7 @@ def _check_value(self, action: argparse.Action, value: Any) -> None:
9401072 :param value: value from command line already run through conversion function by argparse
9411073 """
9421074 # Import gettext like argparse does
943- from gettext import (
944- gettext as _ ,
945- )
1075+ from gettext import gettext as _
9461076
9471077 if action .choices is not None and value not in action .choices :
9481078 # If any choice is a CompletionItem, then display its value property.
0 commit comments