@@ -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,61 @@ def get_choices(self) -> Choices:
264266if TYPE_CHECKING : # pragma: no cover
265267 from .argparse_completer import ArgparseCompleter
266268
269+ # In Python 3.14+, move these definitions outside the TYPE_CHECKING
270+ # block as staticmethod/classmethod become subscriptable at runtime.
271+ _StaticParserFactory = staticmethod [[], "Cmd2ArgumentParser" ]
272+ _ClassParserFactory = classmethod [CmdOrSetT , [], "Cmd2ArgumentParser" ]
273+ else :
274+ _StaticParserFactory = staticmethod
275+ _ClassParserFactory = classmethod
276+
277+ # Represents a parser factory with no arguments (including staticmethod)
278+ NoParamParserFactory : TypeAlias = Callable [[], "Cmd2ArgumentParser" ] | _StaticParserFactory
279+
280+ # Represents a parser factory with a class argument (including classmethod)
281+ ClassParamParserFactory : TypeAlias = Union [
282+ Callable [[type [CmdOrSetT ]], "Cmd2ArgumentParser" ],
283+ "_ClassParserFactory[CmdOrSetT]" ,
284+ ]
285+
286+ # Represents the various types from which cmd2 can build a parser
287+ ParserSource : TypeAlias = Union [
288+ "Cmd2ArgumentParser" ,
289+ NoParamParserFactory ,
290+ ClassParamParserFactory [CmdOrSetT ],
291+ ]
292+
293+
294+ @dataclass (kw_only = True )
295+ class _SubcommandBase :
296+ """Base metadata shared by all subcommand representations."""
297+
298+ name : str
299+ command : str # The full parent command path (e.g., 'foo bar')
300+ help : str | None = None
301+ aliases : tuple [str , ...] = ()
302+ deprecated : bool = False
303+
304+
305+ @dataclass (kw_only = True )
306+ class SubcommandSpec (_SubcommandBase ):
307+ """Metadata used to build and register a subcommand."""
308+
309+ parser_source : ParserSource [Any ]
310+
311+
312+ @dataclass (kw_only = True )
313+ class SubcommandRecord (_SubcommandBase ):
314+ """A record of a subcommand's configuration and parser.
315+
316+ Used primarily for attaching and detaching subcommands.
317+ """
318+
319+ parser : "Cmd2ArgumentParser"
320+
267321
268322def 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."""
323+ """Build an error message when the number of arguments provided is not within the expected range."""
270324 err_msg = "expected "
271325
272326 if range_max == constants .INFINITY :
@@ -532,9 +586,9 @@ def _ActionsContainer_add_argument( # noqa: N802
532586
533587
534588def _SubParsersAction_remove_parser ( # noqa: N802
535- self : argparse ._SubParsersAction , # type: ignore[type-arg]
589+ self : " argparse._SubParsersAction[Cmd2ArgumentParser]" ,
536590 name : str ,
537- ) -> argparse . ArgumentParser :
591+ ) -> SubcommandRecord :
538592 """Remove a subparser from a subparsers group.
539593
540594 This function is added by cmd2 as a method called ``remove_parser()``
@@ -544,7 +598,7 @@ def _SubParsersAction_remove_parser( # noqa: N802
544598
545599 :param self: instance of the _SubParsersAction being edited
546600 :param name: name of the subcommand for the subparser to remove
547- :return: the removed parser
601+ :return: a SubcommandRecord object describing the removed parser
548602 :raises ValueError: if the subcommand doesn't exist
549603 """
550604 if name not in self ._name_parser_map :
@@ -555,22 +609,69 @@ def _SubParsersAction_remove_parser( # noqa: N802
555609 # Find all names (primary and aliases) that map to this subparser
556610 all_names = [cur_name for cur_name , cur_parser in self ._name_parser_map .items () if cur_parser is subparser ]
557611
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.
612+ # argparse inserts the primary name before the aliases in _name_parser_map
613+ primary_name = all_names [0 ]
614+ aliases = tuple (all_names [1 :])
615+
616+ # Handle Python 3.13+ deprecation
617+ deprecated : bool = False
618+ deprecated_attr = getattr (self , "_deprecated" , None )
619+ if isinstance (deprecated_attr , set ):
620+ if primary_name in deprecated_attr :
621+ deprecated = True
622+ deprecated_attr .discard (primary_name )
623+ for alias in aliases :
624+ deprecated_attr .discard (alias )
625+
626+ # Remove the help entry for this subparser.
627+ help_text = None
561628 for choice_action in self ._choices_actions :
562- if choice_action .dest in all_names :
629+ if choice_action .dest == primary_name :
630+ help_text = choice_action .help
563631 self ._choices_actions .remove (choice_action )
564632 break
565633
566634 # Remove all references to this subparser, including aliases.
567635 for cur_name in all_names :
568636 del self ._name_parser_map [cur_name ]
569637
570- return cast (argparse .ArgumentParser , subparser )
638+ return SubcommandRecord (
639+ name = primary_name ,
640+ command = "" , # To be populated by the caller
641+ help = help_text ,
642+ aliases = aliases ,
643+ deprecated = deprecated ,
644+ parser = subparser ,
645+ )
646+
647+
648+ def _SubParsersAction_remove_all_parsers ( # noqa: N802
649+ self : "argparse._SubParsersAction[Cmd2ArgumentParser]" ,
650+ ) -> list [SubcommandRecord ]:
651+ """Remove all subparsers from a subparsers group.
652+
653+ This function is added by cmd2 as a method called ``remove_all_parsers()``
654+ to ``argparse._SubParsersAction`` class.
655+
656+ To call: ``action.remove_all_parsers()``
657+
658+ :param self: instance of the _SubParsersAction being edited
659+ :return: a list of SubcommandRecord objects for the removed subparsers
660+ """
661+ records : list [SubcommandRecord ] = []
662+
663+ while self ._name_parser_map :
664+ # Get the next subcommand name. remove_parser() will remove
665+ # it and any associated aliases from _name_parser_map.
666+ name = next (iter (self ._name_parser_map ))
667+ record = self .remove_parser (name ) # type: ignore[attr-defined]
668+ records .append (record )
669+
670+ return records
571671
572672
573673argparse ._SubParsersAction .remove_parser = _SubParsersAction_remove_parser # type: ignore[attr-defined]
674+ argparse ._SubParsersAction .remove_all_parsers = _SubParsersAction_remove_all_parsers # type: ignore[attr-defined]
574675
575676
576677@dataclass
@@ -795,28 +896,27 @@ def find_parser(self, subcommand_path: Iterable[str]) -> "Cmd2ArgumentParser":
795896
796897 def attach_subcommand (
797898 self ,
798- subcommand_path : Iterable [str ],
799- subcommand : str ,
800- subcommand_parser : "Cmd2ArgumentParser" ,
801- ** add_parser_kwargs : Any ,
899+ record : SubcommandRecord ,
900+ subcommand_path : Iterable [str ] = (),
802901 ) -> None :
803902 """Attach a parser as a subcommand to a command at the specified path.
804903
904+ Note: `record.command` is not used for navigation here. It is assumed you
905+ are attaching relative to `self` using `subcommand_path`. However,
906+ `record.command` will be updated to reflect the final, absolute path
907+ of the parent parser this subcommand is attached to.
908+
909+ :param record: SubcommandRecord object describing the subcommand
805910 :param subcommand_path: sequence of subcommand names leading to the parser that will
806911 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
912+ :raises TypeError: if record.parser is not an instance of Cmd2ArgumentParser (or subclass)
813913 :raises ValueError: if the command path is invalid, doesn't support subcommands, or the
814914 subcommand already exists
815915 """
816- if not isinstance (subcommand_parser , Cmd2ArgumentParser ):
916+ if not isinstance (record . parser , Cmd2ArgumentParser ):
817917 raise TypeError (
818- f"The attached parser must be an instance of 'Cmd2ArgumentParser' (or a subclass). "
819- f"Received: '{ type (subcommand_parser ).__name__ } '."
918+ f"The attached parser must be an instance of 'Cmd2ArgumentParser' (or subclass). "
919+ f"Received: '{ type (record . parser ).__name__ } '."
820920 )
821921
822922 target_parser = self .find_parser (subcommand_path )
@@ -826,53 +926,87 @@ def attach_subcommand(
826926 # subcommand group. We use isinstance() here to allow for subclasses, providing
827927 # more flexibility than the standard add_parser() factory approach which enforces
828928 # a specific class.
829- if not isinstance (subcommand_parser , subparsers_action ._parser_class ):
929+ if not isinstance (record . parser , subparsers_action ._parser_class ):
830930 raise TypeError (
831931 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__ } '."
932+ f"(or subclass) to match the 'parser_class' configured for this subcommand group. "
933+ f"Received: '{ type (record . parser ).__name__ } '."
834934 )
835935
836936 # Do not overwrite existing subcommands or aliases
837- all_names = (subcommand , * add_parser_kwargs . get ( " aliases" , ()) )
937+ all_names = (record . name , * record . aliases )
838938 for name in all_names :
839939 if name in subparsers_action ._name_parser_map :
840940 raise ValueError (f"Subcommand '{ name } ' already exists for '{ target_parser .prog } '" )
841941
942+ # Registration kwargs
943+ kwargs : dict [str , Any ] = {"aliases" : record .aliases }
944+ if record .help is not None :
945+ kwargs ["help" ] = record .help
946+ if record .deprecated :
947+ kwargs ["deprecated" ] = record .deprecated
948+
842949 # Use add_parser to register the subcommand name and any aliases
843- placeholder_parser = subparsers_action .add_parser (subcommand , ** add_parser_kwargs )
950+ placeholder_parser = subparsers_action .add_parser (record . name , ** kwargs )
844951
845952 # To ensure accurate usage strings, recursively update 'prog' values
846953 # within the injected parser to match its new location in the command hierarchy.
847- subcommand_parser .update_prog (placeholder_parser .prog )
954+ record . parser .update_prog (placeholder_parser .prog )
848955
849956 # Replace the parser created by add_parser() with our pre-configured one
850- subparsers_action ._name_parser_map [subcommand ] = subcommand_parser
957+ subparsers_action ._name_parser_map [record . name ] = record . parser
851958
852959 # 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
960+ for alias in record . aliases :
961+ subparsers_action ._name_parser_map [alias ] = record . parser
855962
856- def detach_subcommand (self , subcommand_path : Iterable [str ], subcommand : str ) -> "Cmd2ArgumentParser" :
963+ # Update command to reflect the parent parser's absolute path
964+ record .command = target_parser .prog
965+
966+ def detach_subcommand (self , subcommand_path : Iterable [str ], subcommand : str ) -> SubcommandRecord :
857967 """Detach a subcommand from a command at the specified path.
858968
859969 :param subcommand_path: sequence of subcommand names leading to the parser hosting the
860970 subcommand to be detached. An empty sequence indicates this parser.
861971 :param subcommand: name of the subcommand to detach
862- :return: the detached parser
972+ :return: a SubcommandRecord object describing the detached subcommand
863973 :raises ValueError: if the command path is invalid or the subcommand doesn't exist
864974 """
865975 target_parser = self .find_parser (subcommand_path )
866976 subparsers_action = target_parser .get_subparsers_action ()
867977
868978 try :
869- return cast (
870- Cmd2ArgumentParser ,
979+ record = cast (
980+ SubcommandRecord ,
871981 subparsers_action .remove_parser (subcommand ), # type: ignore[attr-defined]
872982 )
873983 except ValueError :
874984 raise ValueError (f"Subcommand '{ subcommand } ' does not exist for '{ target_parser .prog } '" ) from None
875985
986+ # Update command to reflect the parent parser's absolute path
987+ record .command = target_parser .prog
988+ return record
989+
990+ def detach_all_subcommands (self , subcommand_path : Iterable [str ]) -> list [SubcommandRecord ]:
991+ """Detach all subcommands from a command at the specified path.
992+
993+ :param subcommand_path: sequence of subcommand names leading to the parser hosting the
994+ subcommands to be detached. An empty sequence indicates this parser.
995+ :return: a list of SubcommandRecord objects describing the detached subcommands
996+ :raises ValueError: if the command path is invalid or the command doesn't support subcommands
997+ """
998+ target_parser = self .find_parser (subcommand_path )
999+ subparsers_action = target_parser .get_subparsers_action ()
1000+
1001+ records = cast (
1002+ list [SubcommandRecord ],
1003+ subparsers_action .remove_all_parsers (), # type: ignore[attr-defined]
1004+ )
1005+ # Update command for each detached subcommand
1006+ for record in records :
1007+ record .command = target_parser .prog
1008+ return records
1009+
8761010 def error (self , message : str ) -> NoReturn :
8771011 """Override that applies custom formatting to the error message."""
8781012 lines = message .split ("\n " )
@@ -940,9 +1074,7 @@ def _check_value(self, action: argparse.Action, value: Any) -> None:
9401074 :param value: value from command line already run through conversion function by argparse
9411075 """
9421076 # Import gettext like argparse does
943- from gettext import (
944- gettext as _ ,
945- )
1077+ from gettext import gettext as _
9461078
9471079 if action .choices is not None and value not in action .choices :
9481080 # If any choice is a CompletionItem, then display its value property.
0 commit comments