Skip to content

Commit 6e0ee38

Browse files
committed
Merge branch 'main' into drop-3.10
2 parents bd5dd0d + 211a3b1 commit 6e0ee38

9 files changed

Lines changed: 594 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
)
@@ -71,6 +72,7 @@
7172
"DEFAULT_SHORTCUTS",
7273
# Argparse Exports
7374
"Cmd2ArgumentParser",
75+
"SubcommandRecord",
7476
"register_argparse_argument_parameter",
7577
"set_default_ap_completer_type",
7678
"set_default_argument_parser_type",

cmd2/argparse_utils.py

Lines changed: 170 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,61 @@ def get_choices(self) -> Choices:
264266
if 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

268322
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."""
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

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

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

Comments
 (0)