Skip to content

Commit f61a80b

Browse files
committed
Expanded subcommand API.
1. Detaching a subcommand returns metadata for easily reattaching later 2. Added ability to remove all subcommands of a given command
1 parent 74803c1 commit f61a80b

9 files changed

Lines changed: 591 additions & 225 deletions

File tree

cmd2/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .argparse_completer import set_default_ap_completer_type
1515
from .argparse_utils import (
1616
Cmd2ArgumentParser,
17+
SubcommandRecord,
1718
register_argparse_argument_parameter,
1819
set_default_argument_parser_type,
1920
)
@@ -70,6 +71,7 @@
7071
"DEFAULT_SHORTCUTS",
7172
# Argparse Exports
7273
"Cmd2ArgumentParser",
74+
"SubcommandRecord",
7375
"register_argparse_argument_parameter",
7476
"set_default_ap_completer_type",
7577
"set_default_argument_parser_type",

cmd2/argparse_utils.py

Lines changed: 168 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -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:
264266
if 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

268320
def 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

534586
def _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

573671
argparse._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

Comments
 (0)