From f61a80ba10abd16a8ef3173894c17812db0ca3f3 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 14 May 2026 11:51:33 -0400 Subject: [PATCH 1/3] Expanded subcommand API. 1. Detaching a subcommand returns metadata for easily reattaching later 2. Added ability to remove all subcommands of a given command --- cmd2/__init__.py | 2 + cmd2/argparse_utils.py | 206 ++++++++++++++++++++++++++++------- cmd2/cmd2.py | 171 +++++++++++++++-------------- cmd2/constants.py | 18 +-- cmd2/decorators.py | 134 +++++++++++++++++------ examples/async_commands.py | 2 +- tests/test_argparse.py | 42 ++++--- tests/test_argparse_utils.py | 179 ++++++++++++++++++++++++------ tests/test_cmd2.py | 62 +++++++++-- 9 files changed, 591 insertions(+), 225 deletions(-) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 059707711..4d40974b4 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -14,6 +14,7 @@ from .argparse_completer import set_default_ap_completer_type from .argparse_utils import ( Cmd2ArgumentParser, + SubcommandRecord, register_argparse_argument_parameter, set_default_argument_parser_type, ) @@ -70,6 +71,7 @@ "DEFAULT_SHORTCUTS", # Argparse Exports "Cmd2ArgumentParser", + "SubcommandRecord", "register_argparse_argument_parameter", "set_default_ap_completer_type", "set_default_argument_parser_type", diff --git a/cmd2/argparse_utils.py b/cmd2/argparse_utils.py index 3c6b076a0..77045e673 100644 --- a/cmd2/argparse_utils.py +++ b/cmd2/argparse_utils.py @@ -243,6 +243,8 @@ def get_choices(self) -> Choices: Any, ClassVar, NoReturn, + TypeAlias, + Union, cast, ) @@ -264,9 +266,59 @@ def get_choices(self) -> Choices: if TYPE_CHECKING: # pragma: no cover from .argparse_completer import ArgparseCompleter + _StaticParserFactory = staticmethod[[], "Cmd2ArgumentParser"] + _ClassParserFactory = classmethod[CmdOrSetT, [], "Cmd2ArgumentParser"] +else: + _StaticParserFactory = staticmethod + _ClassParserFactory = classmethod + +# Represents a parser factory with no arguments (including staticmethod) +NoParamParserFactory: TypeAlias = Callable[[], "Cmd2ArgumentParser"] | _StaticParserFactory + +# Represents a parser factory with a class argument (including classmethod) +ClassParamParserFactory: TypeAlias = Union[ + Callable[[type[CmdOrSetT]], "Cmd2ArgumentParser"], + "_ClassParserFactory[CmdOrSetT]", +] + +# Represents the various types from which cmd2 can build a parser +ParserSource: TypeAlias = Union[ + "Cmd2ArgumentParser", + NoParamParserFactory, + ClassParamParserFactory[CmdOrSetT], +] + + +@dataclass(kw_only=True) +class _SubcommandBase: + """Base metadata shared by all subcommand representations.""" + + name: str + command: str # The full parent command path (e.g., 'foo bar') + help: str | None = None + aliases: tuple[str, ...] = () + deprecated: bool = False + + +@dataclass(kw_only=True) +class SubcommandSpec(_SubcommandBase): + """Metadata used to build and register a subcommand.""" + + parser_source: ParserSource[Any] + + +@dataclass(kw_only=True) +class SubcommandRecord(_SubcommandBase): + """A record of a subcommand's configuration and parser. + + Used primarily for attaching and detaching subcommands. + """ + + parser: "Cmd2ArgumentParser" + def build_range_error(range_min: int, range_max: float) -> str: - """Build an error message when the the number of arguments provided is not within the expected range.""" + """Build an error message when the number of arguments provided is not within the expected range.""" err_msg = "expected " if range_max == constants.INFINITY: @@ -532,9 +584,9 @@ def _ActionsContainer_add_argument( # noqa: N802 def _SubParsersAction_remove_parser( # noqa: N802 - self: argparse._SubParsersAction, # type: ignore[type-arg] + self: "argparse._SubParsersAction[Cmd2ArgumentParser]", name: str, -) -> argparse.ArgumentParser: +) -> SubcommandRecord: """Remove a subparser from a subparsers group. This function is added by cmd2 as a method called ``remove_parser()`` @@ -544,7 +596,7 @@ def _SubParsersAction_remove_parser( # noqa: N802 :param self: instance of the _SubParsersAction being edited :param name: name of the subcommand for the subparser to remove - :return: the removed parser + :return: a SubcommandRecord object describing the removed parser :raises ValueError: if the subcommand doesn't exist """ if name not in self._name_parser_map: @@ -555,11 +607,25 @@ def _SubParsersAction_remove_parser( # noqa: N802 # Find all names (primary and aliases) that map to this subparser all_names = [cur_name for cur_name, cur_parser in self._name_parser_map.items() if cur_parser is subparser] - # Remove the help entry for this subparser. To handle the case where - # name is an alias, we remove the action whose 'dest' matches any of - # the names mapped to this subparser. + # argparse inserts the primary name before the aliases in _name_parser_map + primary_name = all_names[0] + aliases = tuple(all_names[1:]) + + # Handle Python 3.13+ deprecation + deprecated: bool = False + deprecated_attr = getattr(self, "_deprecated", None) + if isinstance(deprecated_attr, set): + if primary_name in deprecated_attr: + deprecated = True + deprecated_attr.discard(primary_name) + for alias in aliases: + deprecated_attr.discard(alias) + + # Remove the help entry for this subparser. + help_text = None for choice_action in self._choices_actions: - if choice_action.dest in all_names: + if choice_action.dest == primary_name: + help_text = choice_action.help self._choices_actions.remove(choice_action) break @@ -567,10 +633,43 @@ def _SubParsersAction_remove_parser( # noqa: N802 for cur_name in all_names: del self._name_parser_map[cur_name] - return cast(argparse.ArgumentParser, subparser) + return SubcommandRecord( + name=primary_name, + command="", # To be populated by the caller + help=help_text, + aliases=aliases, + deprecated=deprecated, + parser=subparser, + ) + + +def _SubParsersAction_remove_all_parsers( # noqa: N802 + self: "argparse._SubParsersAction[Cmd2ArgumentParser]", +) -> list[SubcommandRecord]: + """Remove all subparsers from a subparsers group. + + This function is added by cmd2 as a method called ``remove_all_parsers()`` + to ``argparse._SubParsersAction`` class. + + To call: ``action.remove_all_parsers()`` + + :param self: instance of the _SubParsersAction being edited + :return: a list of SubcommandRecord objects for the removed subparsers + """ + records: list[SubcommandRecord] = [] + + while self._name_parser_map: + # Get the next subcommand name. remove_parser() will remove + # it and any associated aliases from _name_parser_map. + name = next(iter(self._name_parser_map)) + record = self.remove_parser(name) # type: ignore[attr-defined] + records.append(record) + + return records argparse._SubParsersAction.remove_parser = _SubParsersAction_remove_parser # type: ignore[attr-defined] +argparse._SubParsersAction.remove_all_parsers = _SubParsersAction_remove_all_parsers # type: ignore[attr-defined] @dataclass @@ -795,28 +894,27 @@ def find_parser(self, subcommand_path: Iterable[str]) -> "Cmd2ArgumentParser": def attach_subcommand( self, - subcommand_path: Iterable[str], - subcommand: str, - subcommand_parser: "Cmd2ArgumentParser", - **add_parser_kwargs: Any, + record: SubcommandRecord, + subcommand_path: Iterable[str] = (), ) -> None: """Attach a parser as a subcommand to a command at the specified path. + Note: `record.command` is not used for navigation here. It is assumed you + are attaching relative to `self` using `subcommand_path`. However, + `record.command` will be updated to reflect the final, absolute path + of the parent parser this subcommand is attached to. + + :param record: SubcommandRecord object describing the subcommand :param subcommand_path: sequence of subcommand names leading to the parser that will host the new subcommand. An empty sequence indicates this parser. - :param subcommand: name of the new subcommand - :param subcommand_parser: the parser to attach - :param add_parser_kwargs: additional arguments for the subparser registration (e.g. help, aliases) - :raises TypeError: if subcommand_parser is not an instance of the following or their subclasses: - 1. Cmd2ArgumentParser - 2. The parser_class configured for the target subcommand group + :raises TypeError: if record.parser is not an instance of Cmd2ArgumentParser (or subclass) :raises ValueError: if the command path is invalid, doesn't support subcommands, or the subcommand already exists """ - if not isinstance(subcommand_parser, Cmd2ArgumentParser): + if not isinstance(record.parser, Cmd2ArgumentParser): raise TypeError( - f"The attached parser must be an instance of 'Cmd2ArgumentParser' (or a subclass). " - f"Received: '{type(subcommand_parser).__name__}'." + f"The attached parser must be an instance of 'Cmd2ArgumentParser' (or subclass). " + f"Received: '{type(record.parser).__name__}'." ) target_parser = self.find_parser(subcommand_path) @@ -826,53 +924,87 @@ def attach_subcommand( # subcommand group. We use isinstance() here to allow for subclasses, providing # more flexibility than the standard add_parser() factory approach which enforces # a specific class. - if not isinstance(subcommand_parser, subparsers_action._parser_class): + if not isinstance(record.parser, subparsers_action._parser_class): raise TypeError( f"The attached parser must be an instance of '{subparsers_action._parser_class.__name__}' " - f"(or a subclass) to match the 'parser_class' configured for this subcommand group. " - f"Received: '{type(subcommand_parser).__name__}'." + f"(or subclass) to match the 'parser_class' configured for this subcommand group. " + f"Received: '{type(record.parser).__name__}'." ) # Do not overwrite existing subcommands or aliases - all_names = (subcommand, *add_parser_kwargs.get("aliases", ())) + all_names = (record.name, *record.aliases) for name in all_names: if name in subparsers_action._name_parser_map: raise ValueError(f"Subcommand '{name}' already exists for '{target_parser.prog}'") + # Registration kwargs + kwargs: dict[str, Any] = {"aliases": record.aliases} + if record.help is not None: + kwargs["help"] = record.help + if record.deprecated: + kwargs["deprecated"] = record.deprecated + # Use add_parser to register the subcommand name and any aliases - placeholder_parser = subparsers_action.add_parser(subcommand, **add_parser_kwargs) + placeholder_parser = subparsers_action.add_parser(record.name, **kwargs) # To ensure accurate usage strings, recursively update 'prog' values # within the injected parser to match its new location in the command hierarchy. - subcommand_parser.update_prog(placeholder_parser.prog) + record.parser.update_prog(placeholder_parser.prog) # Replace the parser created by add_parser() with our pre-configured one - subparsers_action._name_parser_map[subcommand] = subcommand_parser + subparsers_action._name_parser_map[record.name] = record.parser # Remap any aliases to our pre-configured parser - for alias in add_parser_kwargs.get("aliases", ()): - subparsers_action._name_parser_map[alias] = subcommand_parser + for alias in record.aliases: + subparsers_action._name_parser_map[alias] = record.parser - def detach_subcommand(self, subcommand_path: Iterable[str], subcommand: str) -> "Cmd2ArgumentParser": + # Update command to reflect the parent parser's absolute path + record.command = target_parser.prog + + def detach_subcommand(self, subcommand_path: Iterable[str], subcommand: str) -> SubcommandRecord: """Detach a subcommand from a command at the specified path. :param subcommand_path: sequence of subcommand names leading to the parser hosting the subcommand to be detached. An empty sequence indicates this parser. :param subcommand: name of the subcommand to detach - :return: the detached parser + :return: a SubcommandRecord object describing the detached subcommand :raises ValueError: if the command path is invalid or the subcommand doesn't exist """ target_parser = self.find_parser(subcommand_path) subparsers_action = target_parser.get_subparsers_action() try: - return cast( - Cmd2ArgumentParser, + record = cast( + SubcommandRecord, subparsers_action.remove_parser(subcommand), # type: ignore[attr-defined] ) except ValueError: raise ValueError(f"Subcommand '{subcommand}' does not exist for '{target_parser.prog}'") from None + # Update command to reflect the parent parser's absolute path + record.command = target_parser.prog + return record + + def detach_all_subcommands(self, subcommand_path: Iterable[str]) -> list[SubcommandRecord]: + """Detach all subcommands from a command at the specified path. + + :param subcommand_path: sequence of subcommand names leading to the parser hosting the + subcommands to be detached. An empty sequence indicates this parser. + :return: a list of SubcommandRecord objects describing the detached subcommands + :raises ValueError: if the command path is invalid or the command doesn't support subcommands + """ + target_parser = self.find_parser(subcommand_path) + subparsers_action = target_parser.get_subparsers_action() + + records = cast( + list[SubcommandRecord], + subparsers_action.remove_all_parsers(), # type: ignore[attr-defined] + ) + # Update command for each detached subcommand + for record in records: + record.command = target_parser.prog + return records + def error(self, message: str) -> NoReturn: """Override that applies custom formatting to the error message.""" lines = message.split("\n") @@ -940,9 +1072,7 @@ def _check_value(self, action: argparse.Action, value: Any) -> None: :param value: value from command line already run through conversion function by argparse """ # Import gettext like argparse does - from gettext import ( - gettext as _, - ) + from gettext import gettext as _ if action.choices is not None and value not in action.choices: # If any choice is a CompletionItem, then display its value property. diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 2485448a6..2aede4daf 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -110,7 +110,12 @@ ) from . import rich_utils as ru from . import string_utils as su -from .argparse_utils import Cmd2ArgumentParser +from .argparse_utils import ( + Cmd2ArgumentParser, + ParserSource, + SubcommandRecord, + SubcommandSpec, +) from .clipboard import ( get_paste_buffer, write_to_paste_buffer, @@ -205,12 +210,7 @@ def __init__(self, msg: str = "") -> None: ) if TYPE_CHECKING: # pragma: no cover - StaticArgParseBuilder = staticmethod[[], Cmd2ArgumentParser] - ClassArgParseBuilder = classmethod[CmdOrSet, [], Cmd2ArgumentParser] from prompt_toolkit.buffer import Buffer -else: - StaticArgParseBuilder = staticmethod - ClassArgParseBuilder = classmethod class _SavedCmd2Env: @@ -280,12 +280,12 @@ def get(self, command_method: BoundCommandFunc) -> Cmd2ArgumentParser | None: return None command = command_method.__name__[len(COMMAND_FUNC_PREFIX) :] - parser_builder = getattr(command_method, constants.CMD_ATTR_ARGPARSER, None) - if parser_builder is None: + parser_source = getattr(command_method, constants.CMD_ATTR_PARSER_SOURCE, None) + if parser_source is None: return None - parent = self._cmd_app.find_commandset_for_command(command) or self._cmd_app - parser = self._cmd_app._build_parser(parent, parser_builder) + owner = self._cmd_app.find_commandset_for_command(command) or self._cmd_app + parser = self._cmd_app._build_parser(owner, parser_source) # To ensure accurate usage strings, recursively update 'prog' values # within the parser to match the command name. @@ -916,37 +916,48 @@ def register_command_set(self, cmdset: CommandSet[Any]) -> None: def _build_parser( self, - parent: CmdOrSet, - parser_builder: Cmd2ArgumentParser | Callable[[], Cmd2ArgumentParser] | StaticArgParseBuilder | ClassArgParseBuilder, + owner: CmdOrSet, + parser_source: ParserSource[Any], ) -> Cmd2ArgumentParser: """Build argument parser for a command/subcommand. - :param parent: object which owns the command using the parser. - When parser_builder is a classmethod, this function passes - parent's class to it. - :param parser_builder: an existing Cmd2ArgumentParser instance or a factory - (callable, staticmethod, or classmethod) that returns one. + :param owner: the object that owns the command. If parser_source requires + a class argument (like a classmethod), this object's class is passed. + :param parser_source: an existing Cmd2ArgumentParser instance or a factory + (callable, staticmethod, or classmethod) that returns one. :return: new parser - :raises TypeError: if parser_builder is an invalid type or if the factory fails + :raises TypeError: if parser_source is an invalid type or if the factory fails to return a Cmd2ArgumentParser """ - if isinstance(parser_builder, Cmd2ArgumentParser): - parser = copy.deepcopy(parser_builder) + # Handle existing parser + if isinstance(parser_source, argparse.ArgumentParser): + if not isinstance(parser_source, Cmd2ArgumentParser): + raise TypeError( + f"The parser must be an instance of 'Cmd2ArgumentParser' (or subclass). " + f"Received: '{type(parser_source).__name__}'." + ) + return copy.deepcopy(parser_source) + + # Handle factories + if isinstance(parser_source, staticmethod): + parser = parser_source.__func__() + elif isinstance(parser_source, classmethod): + parser = parser_source.__func__(owner.__class__) else: - # Try to build the parser with a factory - if isinstance(parser_builder, staticmethod): - parser = parser_builder.__func__() - elif isinstance(parser_builder, classmethod): - parser = parser_builder.__func__(parent.__class__) - elif callable(parser_builder): - parser = parser_builder() + # Inspect the signature to determine if this factory expects a class argument. + builder_sig = inspect.signature(parser_source) + + if builder_sig.parameters: + parser = cast(Callable[[Any], Cmd2ArgumentParser], parser_source)(owner.__class__) else: - raise TypeError(f"Invalid type for parser_builder: {type(parser_builder)}") + parser = cast(Callable[[], Cmd2ArgumentParser], parser_source)() - # Verify the factory returned the required type - if not isinstance(parser, Cmd2ArgumentParser): - builder_name = getattr(parser_builder, "__name__", str(parser_builder)) # type: ignore[unreachable] - raise TypeError(f"The parser returned by '{builder_name}' must be a Cmd2ArgumentParser or a subclass of it") + # Verify the factory returned the required type + if not isinstance(parser, Cmd2ArgumentParser): + builder_name = getattr(parser_source, "__name__", str(parser_source)) # type: ignore[unreachable] + raise TypeError( + f"'{builder_name}' must return a 'Cmd2ArgumentParser' (or subclass). Received: '{type(parser).__name__}'." + ) return parser @@ -1068,7 +1079,7 @@ def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None: continue checked_parsers.add(subparser) - attached_cmdset_id = getattr(subparser, constants.PARSER_ATTR_COMMANDSET_ID, None) + attached_cmdset_id = getattr(subparser, constants.PARSER_ATTR_OWNER_ID, None) if attached_cmdset_id is not None and attached_cmdset_id != cmdset_id: raise CommandSetRegistrationError( f"Cannot uninstall CommandSet: '{subparser.prog}' is required by another CommandSet" @@ -1092,37 +1103,33 @@ def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None: if command_parser is not None: check_parser_uninstallable(command_parser) - def _register_subcommands(self, cmdset: CmdOrSet) -> None: + def _register_subcommands(self, owner: CmdOrSet) -> None: """Register subcommands with their base command. - :param cmdset: CommandSet or cmd2.Cmd subclass containing subcommands + :param owner: Cmd or CommandSet which owns the subcommand functions """ - if not (cmdset is self or cmdset in self._installed_command_sets): + if not (owner is self or owner in self._installed_command_sets): raise CommandSetRegistrationError("Cannot register subcommands with an unregistered CommandSet") # find methods that have the required attributes necessary to be recognized as a sub-command methods = inspect.getmembers( - cmdset, + owner, predicate=lambda meth: ( isinstance(meth, Callable) # type: ignore[arg-type] - and hasattr(meth, constants.SUBCMD_ATTR_NAME) - and hasattr(meth, constants.SUBCMD_ATTR_COMMAND) - and hasattr(meth, constants.CMD_ATTR_ARGPARSER) + and hasattr(meth, constants.SUBCMD_ATTR_SPEC) ), ) # iterate through all matching methods for _method_name, method in methods: - subcommand_name: str = getattr(method, constants.SUBCMD_ATTR_NAME) - full_command_name: str = getattr(method, constants.SUBCMD_ATTR_COMMAND) - subcmd_parser_builder = getattr(method, constants.CMD_ATTR_ARGPARSER) + spec: SubcommandSpec = getattr(method, constants.SUBCMD_ATTR_SPEC) - subcommand_valid, errmsg = self.statement_parser.is_valid_command(subcommand_name, is_subcommand=True) + subcommand_valid, errmsg = self.statement_parser.is_valid_command(spec.name, is_subcommand=True) if not subcommand_valid: - raise CommandSetRegistrationError(f"Subcommand {subcommand_name} is not valid: {errmsg}") + raise CommandSetRegistrationError(f"Subcommand {spec.name} is not valid: {errmsg}") # Create the subcommand parser and configure it - subcmd_parser = self._build_parser(cmdset, subcmd_parser_builder) + subcmd_parser = self._build_parser(owner, spec.parser_source) if subcmd_parser.description is None and method.__doc__: subcmd_parser.description = strip_doc_annotations(method.__doc__) @@ -1131,43 +1138,46 @@ def _register_subcommands(self, cmdset: CmdOrSet) -> None: subcmd_parser.set_defaults(**defaults) # Set what instance the handler is bound to - setattr(subcmd_parser, constants.PARSER_ATTR_COMMANDSET_ID, id(cmdset)) - - # Get add_parser() kwargs (aliases, help, etc.) defined by the decorator - add_parser_kwargs = getattr(method, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, {}) + setattr(subcmd_parser, constants.PARSER_ATTR_OWNER_ID, id(owner)) + + # Attach this subcommand + record = SubcommandRecord( + name=spec.name, + command=spec.command, + help=spec.help, + aliases=spec.aliases, + deprecated=spec.deprecated, + parser=subcmd_parser, + ) - # Attach existing parser as a subcommand try: - self.attach_subcommand(full_command_name, subcommand_name, subcmd_parser, **add_parser_kwargs) + self.attach_subcommand(record) except ValueError as ex: raise CommandSetRegistrationError(str(ex)) from ex - def _unregister_subcommands(self, cmdset: CmdOrSet) -> None: + def _unregister_subcommands(self, owner: CmdOrSet) -> None: """Unregister subcommands from their base command. - :param cmdset: CommandSet containing subcommands + :param owner: Cmd or CommandSet which owns the subcommand functions """ - if not (cmdset is self or cmdset in self._installed_command_sets): + if not (owner is self or owner in self._installed_command_sets): raise CommandSetRegistrationError("Cannot unregister subcommands with an unregistered CommandSet") # find methods that have the required attributes necessary to be recognized as a sub-command methods = inspect.getmembers( - cmdset, + owner, predicate=lambda meth: ( isinstance(meth, Callable) # type: ignore[arg-type] - and hasattr(meth, constants.SUBCMD_ATTR_NAME) - and hasattr(meth, constants.SUBCMD_ATTR_COMMAND) - and hasattr(meth, constants.CMD_ATTR_ARGPARSER) + and hasattr(meth, constants.SUBCMD_ATTR_SPEC) ), ) # iterate through all matching methods for _method_name, method in methods: - subcommand_name = getattr(method, constants.SUBCMD_ATTR_NAME) - full_command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND) + spec: SubcommandSpec = getattr(method, constants.SUBCMD_ATTR_SPEC) with contextlib.suppress(ValueError): - self.detach_subcommand(full_command_name, subcommand_name) + self.detach_subcommand(spec.command, spec.name) def get_root_parser_and_subcmd_path(self, command: str) -> tuple[Cmd2ArgumentParser, list[str]]: """Tokenize a command string and resolve the associated root parser and relative subcommand path. @@ -1200,41 +1210,40 @@ def get_root_parser_and_subcmd_path(self, command: str) -> tuple[Cmd2ArgumentPar return root_parser, subcommand_path - def attach_subcommand( - self, - command: str, - subcommand: str, - subcommand_parser: Cmd2ArgumentParser, - **add_parser_kwargs: Any, - ) -> None: + def attach_subcommand(self, record: SubcommandRecord) -> None: """Attach a parser as a subcommand to a command at the specified path. - :param command: full command path (space-delimited) leading to the parser that will - host the new subcommand (e.g. 'foo bar') - :param subcommand: name of the new subcommand - :param subcommand_parser: the parser to attach - :param add_parser_kwargs: additional arguments for the subparser registration (e.g. help, aliases) - :raises TypeError: if subcommand_parser is not an instance of the following or their subclasses: - 1. Cmd2ArgumentParser - 2. The parser_class configured for the target subcommand group + :param record: SubcommandRecord object describing the subcommand + :raises TypeError: if record.parser is not an instance of Cmd2ArgumentParser (or subclass) :raises ValueError: if the command path is invalid, doesn't support subcommands, or the subcommand already exists """ - root_parser, subcommand_path = self.get_root_parser_and_subcmd_path(command) - root_parser.attach_subcommand(subcommand_path, subcommand, subcommand_parser, **add_parser_kwargs) + root_parser, subcommand_path = self.get_root_parser_and_subcmd_path(record.command) + root_parser.attach_subcommand(record, subcommand_path=subcommand_path) - def detach_subcommand(self, command: str, subcommand: str) -> Cmd2ArgumentParser: + def detach_subcommand(self, command: str, subcommand: str) -> SubcommandRecord: """Detach a subcommand from a command at the specified path. :param command: full command path (space-delimited) leading to the parser hosting the subcommand to be detached (e.g. 'foo bar') :param subcommand: name of the subcommand to detach - :return: the detached parser + :return: a SubcommandRecord object describing the detached subcommand :raises ValueError: if the command path is invalid or the subcommand doesn't exist """ root_parser, subcommand_path = self.get_root_parser_and_subcmd_path(command) return root_parser.detach_subcommand(subcommand_path, subcommand) + def detach_all_subcommands(self, command: str) -> list[SubcommandRecord]: + """Detach all subcommands from a command at the specified path. + + :param command: full command path (space-delimited) leading to the parser hosting the + subcommands to be detached (e.g. 'foo bar') + :return: a list of SubcommandRecord objects describing the detached subcommands + :raises ValueError: if the command path is invalid or the command doesn't support subcommands + """ + root_parser, subcommand_path = self.get_root_parser_and_subcmd_path(command) + return root_parser.detach_all_subcommands(subcommand_path) + @property def always_prefix_settables(self) -> bool: """Flags whether CommandSet settable values should always be prefixed. diff --git a/cmd2/constants.py b/cmd2/constants.py index 34f927f74..71a222144 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -69,8 +69,8 @@ def cmd2_public_attr_name(name: str) -> str: # --- Private Internal Attributes --- -# Attached to a command function; defines its argument parser -CMD_ATTR_ARGPARSER = cmd2_private_attr_name("argparser") +# Attached to a command function; defines the source from which its parser is built +CMD_ATTR_PARSER_SOURCE = cmd2_private_attr_name("parser_source") # Attached to a command function; defines its help section category CMD_ATTR_HELP_CATEGORY = cmd2_private_attr_name("help_category") @@ -78,17 +78,11 @@ def cmd2_public_attr_name(name: str) -> str: # Attached to a command function; defines whether tokens are unquoted before reaching argparse CMD_ATTR_PRESERVE_QUOTES = cmd2_private_attr_name("preserve_quotes") -# Attached to a subcommand function; defines the full command path to the parent (e.g., "foo" or "foo bar") -SUBCMD_ATTR_COMMAND = cmd2_private_attr_name("parent_command") +# Attached to a subcommand function; defines its SubcommandSpec instance +SUBCMD_ATTR_SPEC = cmd2_private_attr_name("subcommand_spec") -# Attached to a subcommand function; defines the name of this specific subcommand (e.g., "bar") -SUBCMD_ATTR_NAME = cmd2_private_attr_name("subcommand_name") - -# Attached to a subcommand function; specifies kwargs passed to add_parser() -SUBCMD_ATTR_ADD_PARSER_KWARGS = cmd2_private_attr_name("subcommand_add_parser_kwargs") - -# Attached to an argparse parser; identifies the CommandSet instance it belongs to -PARSER_ATTR_COMMANDSET_ID = cmd2_private_attr_name("command_set_id") +# Attached to an argparse parser; identifies the Cmd or CommandSet instance it belongs to +PARSER_ATTR_OWNER_ID = cmd2_private_attr_name("owner_id") # --- Public Developer Attributes --- diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 975d35088..153f9c116 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -15,12 +15,17 @@ ) from . import constants -from .argparse_utils import Cmd2ArgumentParser +from .argparse_utils import ( + ClassParamParserFactory, + Cmd2ArgumentParser, + NoParamParserFactory, + ParserSource, + SubcommandSpec, +) from .command_set import CommandSet from .exceptions import Cmd2ArgparseError from .parsing import Statement from .types import ( - CmdOrSetClassT, CmdOrSetT, UnboundCommandFunc, ) @@ -206,10 +211,41 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: ) +# Overload for: existing parser instance +@overload +def with_argparser( + parser_source: Cmd2ArgumentParser, + *, + ns_provider: Callable[..., argparse.Namespace] | None = None, + preserve_quotes: bool = False, + with_unknown_args: bool = False, +) -> Callable[[ArgparseCommandFunc[CmdOrSetT]], RawCommandFunc[CmdOrSetT]]: ... + + +# Overload for: factory with no arguments (including staticmethod) +@overload +def with_argparser( + parser_source: NoParamParserFactory, + *, + ns_provider: Callable[..., argparse.Namespace] | None = None, + preserve_quotes: bool = False, + with_unknown_args: bool = False, +) -> Callable[[ArgparseCommandFunc[CmdOrSetT]], RawCommandFunc[CmdOrSetT]]: ... + + +# Overload for: factory with a class argument (including classmethod) +@overload +def with_argparser( + parser_source: ClassParamParserFactory[CmdOrSetT], + *, + ns_provider: Callable[..., argparse.Namespace] | None = None, + preserve_quotes: bool = False, + with_unknown_args: bool = False, +) -> Callable[[ArgparseCommandFunc[CmdOrSetT]], RawCommandFunc[CmdOrSetT]]: ... + + def with_argparser( - parser: Cmd2ArgumentParser # existing parser - | Callable[[], Cmd2ArgumentParser] # function or staticmethod - | Callable[[CmdOrSetClassT], Cmd2ArgumentParser], # Cmd or CommandSet classmethod + parser_source: ParserSource[CmdOrSetT], *, ns_provider: Callable[..., argparse.Namespace] | None = None, preserve_quotes: bool = False, @@ -217,7 +253,8 @@ def with_argparser( ) -> Callable[[ArgparseCommandFunc[CmdOrSetT]], RawCommandFunc[CmdOrSetT]]: """Decorate a ``do_*`` command function to populate its ``args`` argument with a Cmd2ArgumentParser. - :param parser: instance of Cmd2ArgumentParser or a callable that returns a Cmd2ArgumentParser for this command + :param parser_source: an existing Cmd2ArgumentParser instance or a factory + (callable, staticmethod, or classmethod) that returns one. :param ns_provider: An optional function that accepts a cmd2.Cmd or cmd2.CommandSet object as an argument and returns an argparse.Namespace. This is useful if the Namespace needs to be prepopulated with state data that affects parsing. @@ -327,7 +364,7 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX) :] # Set some custom attributes for this command - setattr(cmd_wrapper, constants.CMD_ATTR_ARGPARSER, parser) + setattr(cmd_wrapper, constants.CMD_ATTR_PARSER_SOURCE, parser_source) setattr(cmd_wrapper, constants.CMD_ATTR_PRESERVE_QUOTES, preserve_quotes) return cmd_wrapper @@ -335,16 +372,53 @@ def cmd_wrapper(*args: Any, **kwargs: Any) -> bool | None: return arg_decorator +# Overload for: existing parser instance +@overload def as_subcommand_to( command: str, subcommand: str, - parser: Cmd2ArgumentParser # existing parser - | Callable[[], Cmd2ArgumentParser] # function or staticmethod - | Callable[[CmdOrSetClassT], Cmd2ArgumentParser], # Cmd or CommandSet classmethod + parser_source: Cmd2ArgumentParser, + *, + help: str | None = None, + aliases: Sequence[str] = (), + deprecated: bool = False, +) -> Callable[[F], F]: ... + + +# Overload for: factory with no arguments (including staticmethod) +@overload +def as_subcommand_to( + command: str, + subcommand: str, + parser_source: NoParamParserFactory, + *, + help: str | None = None, + aliases: Sequence[str] = (), + deprecated: bool = False, +) -> Callable[[F], F]: ... + + +# Overload for: factory with a class argument (including classmethod) +@overload +def as_subcommand_to( + command: str, + subcommand: str, + parser_source: ClassParamParserFactory[CmdOrSetT], + *, + help: str | None = None, + aliases: Sequence[str] = (), + deprecated: bool = False, +) -> Callable[[F], F]: ... + + +def as_subcommand_to( + command: str, + subcommand: str, + parser_source: ParserSource[CmdOrSetT], *, help: str | None = None, # noqa: A002 - aliases: Sequence[str] | None = None, - **add_parser_kwargs: Any, + aliases: Sequence[str] = (), + deprecated: bool = False, ) -> Callable[[F], F]: """Tag a function as a subcommand to an existing argparse decorated command. @@ -359,13 +433,12 @@ def as_subcommand_to( :param command: Command Name. Space-delimited subcommands may optionally be specified :param subcommand: Subcommand name - :param parser: instance of Cmd2ArgumentParser or a callable that returns a Cmd2ArgumentParser for this subcommand - :param help: Help message for this subcommand which displays in the list of subcommands of the command we are adding to. - If not None, this is passed as the 'help' argument to subparsers.add_parser(). - :param aliases: Alternative names for this subcommand. If a non-empty sequence is provided, it is passed - as the 'aliases' argument to subparsers.add_parser(). - :param add_parser_kwargs: other registration-specific kwargs for add_parser() - (e.g. deprecated [Python 3.13+]) + :param parser_source: an existing Cmd2ArgumentParser instance or a factory + (callable, staticmethod, or classmethod) that returns one. + :param help: optional help message for this subcommand which displays in the list of subcommands + of the command we are adding to. + :param aliases: optional alternative names for this subcommand. + :param deprecated: whether this subcommand is deprecated (requires Python 3.13+). :return: a decorator which configures the target function to be a subcommand handler Example: @@ -387,20 +460,15 @@ def sub_handler(self, args: argparse.Namespace) -> None: """ def arg_decorator(func: F) -> F: - # Set some custom attributes for this command - setattr(func, constants.SUBCMD_ATTR_COMMAND, command) - setattr(func, constants.CMD_ATTR_ARGPARSER, parser) - setattr(func, constants.SUBCMD_ATTR_NAME, subcommand) - - # Keyword arguments for subparsers.add_parser() - final_kwargs: dict[str, Any] = dict(add_parser_kwargs) - if help is not None: - final_kwargs["help"] = help - if aliases: - final_kwargs["aliases"] = tuple(aliases) - - setattr(func, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, final_kwargs) - + spec = SubcommandSpec( + name=subcommand, + command=command, + help=help, + aliases=tuple(aliases), + deprecated=deprecated, + parser_source=parser_source, + ) + setattr(func, constants.SUBCMD_ATTR_SPEC, spec) return func return arg_decorator diff --git a/examples/async_commands.py b/examples/async_commands.py index 5e18214de..f80027661 100755 --- a/examples/async_commands.py +++ b/examples/async_commands.py @@ -128,7 +128,7 @@ def handle_control_t(self, _event) -> None: padding_size = random.randint(0, extra_width) padding = " " * padding_size - # Use rich to generate the the overall text to print out + # Use rich to generate the overall text to print out text = Text() text.append(padding) text.append(word, style=f"rgb({r},{g},{b})") diff --git a/tests/test_argparse.py b/tests/test_argparse.py index 8e0c44459..eb062af6d 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -245,40 +245,48 @@ def test_preservelist(argparse_app) -> None: assert out[0] == "['foo', '\"bar baz\"']" -def test_invalid_parser_builder(argparse_app): - parser_builder = None - with pytest.raises(TypeError, match="Invalid type for parser_builder"): - argparse_app._build_parser(argparse_app, parser_builder) +def test_invalid_parser_type(argparse_app): + parser_source = argparse.ArgumentParser() + with pytest.raises(TypeError, match="The parser must be an instance of 'Cmd2ArgumentParser'"): + argparse_app._build_parser(argparse_app, parser_source) -def test_invalid_parser_return_type(argparse_app): - def bad_builder(): - return argparse.ArgumentParser() - - with pytest.raises(TypeError, match="must be a Cmd2ArgumentParser or a subclass of it"): - argparse_app._build_parser(argparse_app, bad_builder) - - -def test_invalid_parser_return_type_staticmethod(argparse_app): +def test_invalid_return_staticmethod(argparse_app): def bad_builder(): return argparse.ArgumentParser() sm = staticmethod(bad_builder) - with pytest.raises(TypeError, match="must be a Cmd2ArgumentParser or a subclass of it"): + with pytest.raises(TypeError, match="must return a 'Cmd2ArgumentParser'"): argparse_app._build_parser(argparse_app, sm) -def test_invalid_parser_return_type_classmethod(argparse_app): +def test_invalid_return_classmethod(argparse_app): def bad_builder(cls): return argparse.ArgumentParser() cm = classmethod(bad_builder) - with pytest.raises(TypeError, match="must be a Cmd2ArgumentParser or a subclass of it"): + with pytest.raises(TypeError, match="must return a 'Cmd2ArgumentParser'"): argparse_app._build_parser(argparse_app, cm) +def test_invalid_return_class_arg(argparse_app): + def bad_builder(cls): + return argparse.ArgumentParser() + + with pytest.raises(TypeError, match="must return a 'Cmd2ArgumentParser'"): + argparse_app._build_parser(argparse_app, bad_builder) + + +def test_invalid_return_no_arg(argparse_app): + def bad_builder(): + return argparse.ArgumentParser() + + with pytest.raises(TypeError, match="must return a 'Cmd2ArgumentParser'"): + argparse_app._build_parser(argparse_app, bad_builder) + + def test_invalid_parser_return_type_nameless_object(argparse_app): # A class that is callable but has no __name__ attribute class NamelessBuilder: @@ -291,7 +299,7 @@ def __call__(self): assert not hasattr(builder, "__name__") # The error message should now contain the string representation of the object - expected_msg = f"The parser returned by '{builder}' must be a Cmd2ArgumentParser" + expected_msg = f"'{builder}' must return a 'Cmd2ArgumentParser'" with pytest.raises(TypeError, match=expected_msg): argparse_app._build_parser(argparse_app, builder) diff --git a/tests/test_argparse_utils.py b/tests/test_argparse_utils.py index 3f3f3a83c..c5d09a5cf 100644 --- a/tests/test_argparse_utils.py +++ b/tests/test_argparse_utils.py @@ -10,6 +10,7 @@ from cmd2 import ( Choices, Cmd2ArgumentParser, + SubcommandRecord, argparse_utils, constants, ) @@ -353,21 +354,23 @@ def test_subcommand_attachment() -> None: ############################### # Attach child to root - root_parser.attach_subcommand( - [], - "child", - child_parser, + child_record = SubcommandRecord( + name="child", + command="root", + parser=child_parser, help="a child command", - aliases=["child_alias"], + aliases=("child_alias",), ) + root_parser.attach_subcommand(child_record) # Attach grandchild to child - root_parser.attach_subcommand( - ["child"], - "grandchild", - grandchild_parser, + grandchild_record = SubcommandRecord( + name="grandchild", + command="root", + parser=grandchild_parser, help="a grandchild command", ) + root_parser.attach_subcommand(grandchild_record, subcommand_path=["child"]) ############################### # Verify hierarchy navigation @@ -397,14 +400,16 @@ def test_subcommand_attachment() -> None: ############################### # Detach grandchild from child - detached_grandchild = root_parser.detach_subcommand(["child"], "grandchild") - assert detached_grandchild is grandchild_parser + detached_grandchild_info = root_parser.detach_subcommand(["child"], "grandchild") + assert detached_grandchild_info.parser is grandchild_parser + assert detached_grandchild_info.name == "grandchild" assert "grandchild" not in child_subparsers._name_parser_map assert not any(action.dest == "grandchild" for action in child_subparsers._choices_actions) # Detach child from root - detached_child = root_parser.detach_subcommand([], "child") - assert detached_child is child_parser + detached_child_info = root_parser.detach_subcommand([], "child") + assert detached_child_info.parser is child_parser + assert detached_child_info.name == "child" assert "child" not in root_subparsers._name_parser_map assert "child_alias" not in root_subparsers._name_parser_map assert not any(action.dest == "child" for action in root_subparsers._choices_actions) @@ -416,13 +421,14 @@ def test_detach_subcommand_by_alias() -> None: root_subparsers = root_parser.add_subparsers() child_parser = Cmd2ArgumentParser(prog="child") - root_parser.attach_subcommand( - [], - "child", - child_parser, + child_record = SubcommandRecord( + name="child", + command="root", + parser=child_parser, help="a child command", - aliases=["alias1", "alias2"], + aliases=("alias1", "alias2"), ) + root_parser.attach_subcommand(child_record) # Verify all names map to the parser assert root_subparsers._name_parser_map["child"] is child_parser @@ -433,8 +439,9 @@ def test_detach_subcommand_by_alias() -> None: assert any(action.dest == "child" for action in root_subparsers._choices_actions) # Detach using an alias - detached = root_parser.detach_subcommand([], "alias1") - assert detached is child_parser + detached_info = root_parser.detach_subcommand([], "alias1") + assert detached_info.parser is child_parser + assert detached_info.name == "child" # Verify all names are gone assert "child" not in root_subparsers._name_parser_map @@ -448,21 +455,22 @@ def test_detach_subcommand_by_alias() -> None: def test_subcommand_attachment_errors() -> None: root_parser = Cmd2ArgumentParser(prog="root", description="root command") child_parser = Cmd2ArgumentParser(prog="child", description="child command") + child_record = SubcommandRecord(name="child", command="root", parser=child_parser) # Verify ValueError when subcommands are not supported with pytest.raises(ValueError, match="Command 'root' does not support subcommands"): - root_parser.attach_subcommand([], "anything", child_parser) + root_parser.attach_subcommand(child_record) with pytest.raises(ValueError, match="Command 'root' does not support subcommands"): - root_parser.detach_subcommand([], "anything") + root_parser.detach_subcommand([], "child") # Allow subcommands for the next tests root_parser.add_subparsers() # Verify ValueError when path is invalid (find_parser() fails) with pytest.raises(ValueError, match="Subcommand 'nonexistent' does not exist for 'root'"): - root_parser.attach_subcommand(["nonexistent"], "anything", child_parser) + root_parser.attach_subcommand(child_record, subcommand_path=["nonexistent"]) with pytest.raises(ValueError, match="Subcommand 'nonexistent' does not exist for 'root'"): - root_parser.detach_subcommand(["nonexistent"], "anything") + root_parser.detach_subcommand(["nonexistent"], "child") # Verify ValueError when path is valid but subcommand name is wrong with pytest.raises(ValueError, match="Subcommand 'fake' does not exist for 'root'"): @@ -470,14 +478,16 @@ def test_subcommand_attachment_errors() -> None: # Verify TypeError when attaching a non-Cmd2ArgumentParser type ap_parser = argparse.ArgumentParser(prog="non-cmd2-parser") - with pytest.raises(TypeError, match=r"must be an instance of 'Cmd2ArgumentParser' \(or a subclass\)"): - root_parser.attach_subcommand([], "sub", ap_parser) # type: ignore[arg-type] + ap_record = SubcommandRecord(name="sub", command="root", parser=ap_parser) # type: ignore[arg-type] + with pytest.raises(TypeError, match=r"must be an instance of 'Cmd2ArgumentParser'"): + root_parser.attach_subcommand(ap_record) # Verify ValueError when subcommand name already exists sub_parser = Cmd2ArgumentParser(prog="sub") - root_parser.attach_subcommand([], "sub", sub_parser) + sub_record = SubcommandRecord(name="sub", command="root", parser=sub_parser) + root_parser.attach_subcommand(sub_record) with pytest.raises(ValueError, match="Subcommand 'sub' already exists for 'root'"): - root_parser.attach_subcommand([], "sub", sub_parser) + root_parser.attach_subcommand(sub_record) def test_subcommand_attachment_parser_class_override() -> None: @@ -494,16 +504,19 @@ class MySubParser(MyParser): # Attaching a MyParser instance should succeed my_parser = MyParser(prog="sub") - root_parser.attach_subcommand([], "sub", my_parser) + my_record = SubcommandRecord(name="sub", command="root", parser=my_parser) + root_parser.attach_subcommand(my_record) # Attaching a MySubParser instance should also succeed (isinstance check) my_sub_parser = MySubParser(prog="sub2") - root_parser.attach_subcommand([], "sub2", my_sub_parser) + my_sub_record = SubcommandRecord(name="sub2", command="root", parser=my_sub_parser) + root_parser.attach_subcommand(my_sub_record) # Attaching a standard Cmd2ArgumentParser instance should fail standard_parser = Cmd2ArgumentParser(prog="standard") - with pytest.raises(TypeError, match=r"must be an instance of 'MyParser' \(or a subclass\)"): - root_parser.attach_subcommand([], "fail", standard_parser) + standard_record = SubcommandRecord(name="fail", command="root", parser=standard_parser) + with pytest.raises(TypeError, match=r"must be an instance of 'MyParser'"): + root_parser.attach_subcommand(standard_record) def test_completion_items_as_choices(capsys) -> None: @@ -750,6 +763,108 @@ def test_argparse_output_capture(base_app: cmd2.Cmd) -> None: assert su.strip_style("\n".join(styled_help_out)) == "\n".join(unstyled_help_out) +def test_detach_all_subcommands() -> None: + root_parser = Cmd2ArgumentParser(prog="root") + root_parser.add_subparsers() + + child1 = Cmd2ArgumentParser(prog="child1") + child2 = Cmd2ArgumentParser(prog="child2") + + child1_record = SubcommandRecord( + name="child1", + command="root", + parser=child1, + help="help1", + aliases=("alias1",), + ) + child2_record = SubcommandRecord( + name="child2", + command="root", + parser=child2, + help="help2", + ) + + root_parser.attach_subcommand(child1_record) + root_parser.attach_subcommand(child2_record) + + removed = root_parser.detach_all_subcommands([]) + assert len(removed) == 2 + + # Sort by name for consistent comparison + removed.sort(key=lambda x: x.name) + + assert removed[0].name == "child1" + assert removed[0].parser is child1 + assert removed[0].help == "help1" + assert removed[0].aliases == ("alias1",) + + assert removed[1].name == "child2" + assert removed[1].parser is child2 + assert removed[1].help == "help2" + assert removed[1].aliases == () + + # Verify they are gone + subparsers_action = root_parser.get_subparsers_action() + assert not subparsers_action._name_parser_map + assert not subparsers_action._choices_actions + + +def test_subcommand_move() -> None: + root = Cmd2ArgumentParser(prog="root") + root.add_subparsers() + + other = Cmd2ArgumentParser(prog="other") + other.add_subparsers() + + child = Cmd2ArgumentParser(prog="child") + child_record = SubcommandRecord(name="child", command="root", parser=child) + + # Attach to root + root.attach_subcommand(child_record) + assert child_record.command == "root" + + # Detach from root + detached_info = root.detach_subcommand([], "child") + assert detached_info.command == "root" + + # Attach to other (Move) + other.attach_subcommand(detached_info) + assert detached_info.command == "other" + assert child.prog == "other child" + + +@pytest.mark.skipif( + sys.version_info < (3, 13), + reason="deprecated subcommands require Python 3.13+", +) +def test_deprecated_subcommand() -> None: + root = Cmd2ArgumentParser(prog="root") + root.add_subparsers() + + child = Cmd2ArgumentParser(prog="child") + child_record = SubcommandRecord( + name="old", + command="root", + parser=child, + deprecated=True, + aliases=("old_alias",), + ) + + root.attach_subcommand(child_record) + + subparsers_action = root.get_subparsers_action() + assert "old" in subparsers_action._deprecated # type: ignore[attr-defined] + assert "old_alias" in subparsers_action._deprecated # type: ignore[attr-defined] + + # Detach and verify info captures it + detached_info = root.detach_subcommand([], "old") + assert detached_info.deprecated is True + + # Verify it was removed from _deprecated set + assert "old" not in subparsers_action._deprecated # type: ignore[attr-defined] + assert "old_alias" not in subparsers_action._deprecated # type: ignore[attr-defined] + + @pytest.mark.skipif( sys.version_info < (3, 15), reason="_ColorlessTheme only exists in 3.15+", diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 976801b7a..e502f7137 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -27,6 +27,7 @@ Color, CommandSet, Completions, + SubcommandRecord, clipboard, constants, exceptions, @@ -4496,7 +4497,8 @@ def do_root(self, _args: argparse.Namespace) -> None: # Attach child to root child_parser = cmd2.Cmd2ArgumentParser(prog="child") child_parser.add_subparsers() - app.attach_subcommand("root", "child", child_parser, help="child help") + child_record = SubcommandRecord(name="child", command="root", parser=child_parser, help="child help") + app.attach_subcommand(child_record) # Verify child was attached root_subparsers_action = root_parser.get_subparsers_action() @@ -4505,20 +4507,21 @@ def do_root(self, _args: argparse.Namespace) -> None: # Attach grandchild to child grandchild_parser = cmd2.Cmd2ArgumentParser(prog="grandchild") - app.attach_subcommand("root child", "grandchild", grandchild_parser) + grandchild_record = SubcommandRecord(name="grandchild", command="root child", parser=grandchild_parser) + app.attach_subcommand(grandchild_record) # Verify grandchild was attached child_subparsers_action = child_parser.get_subparsers_action() assert "grandchild" in child_subparsers_action._name_parser_map # Detach grandchild - detached_grandchild = app.detach_subcommand("root child", "grandchild") - assert detached_grandchild is grandchild_parser + detached_grandchild_info = app.detach_subcommand("root child", "grandchild") + assert detached_grandchild_info.parser is grandchild_parser assert "grandchild" not in child_subparsers_action._name_parser_map # Detach child - detached_child = app.detach_subcommand("root", "child") - assert detached_child is child_parser + detached_child_info = app.detach_subcommand("root", "child") + assert detached_child_info.parser is child_parser assert "child" not in root_subparsers_action._name_parser_map @@ -4540,18 +4543,55 @@ def do_no_argparse(self, _statement: cmd2.Statement) -> None: app = SubcmdErrorApp() # Test empty command + sub_record = SubcommandRecord(name="sub", command="", parser=cmd2.Cmd2ArgumentParser()) with pytest.raises(ValueError, match="Command path cannot be empty"): - app.attach_subcommand("", "sub", cmd2.Cmd2ArgumentParser()) + app.attach_subcommand(sub_record) # Test non-existent command + nonexistent_record = SubcommandRecord(name="sub", command="fake", parser=cmd2.Cmd2ArgumentParser()) with pytest.raises(ValueError, match="Root command 'fake' does not exist"): - app.attach_subcommand("fake", "sub", cmd2.Cmd2ArgumentParser()) + app.attach_subcommand(nonexistent_record) # Test command that doesn't use argparse + no_argparse_record = SubcommandRecord(name="sub", command="no_argparse", parser=cmd2.Cmd2ArgumentParser()) with pytest.raises(ValueError, match="Command 'no_argparse' does not use argparse"): - app.attach_subcommand("no_argparse", "sub", cmd2.Cmd2ArgumentParser()) + app.attach_subcommand(no_argparse_record) # Test duplicate subcommand - app.attach_subcommand("test", "sub", cmd2.Cmd2ArgumentParser()) + duplicate_record = SubcommandRecord(name="sub", command="test", parser=cmd2.Cmd2ArgumentParser()) + app.attach_subcommand(duplicate_record) with pytest.raises(ValueError, match="Subcommand 'sub' already exists for 'test'"): - app.attach_subcommand("test", "sub", cmd2.Cmd2ArgumentParser()) + app.attach_subcommand(duplicate_record) + + +def test_detach_all_subcommands() -> None: + import argparse + + class RefactorApp(cmd2.Cmd): + def __init__(self) -> None: + super().__init__() + + base_parser = cmd2.Cmd2ArgumentParser() + base_parser.add_subparsers() + + @cmd2.with_argparser(base_parser) + def do_base(self, _: argparse.Namespace) -> None: + pass + + app = RefactorApp() + + child1 = cmd2.Cmd2ArgumentParser() + child2 = cmd2.Cmd2ArgumentParser() + + child1_record = SubcommandRecord(name="child1", command="base", parser=child1) + child2_record = SubcommandRecord(name="child2", command="base", parser=child2) + + app.attach_subcommand(child1_record) + app.attach_subcommand(child2_record) + + removed = app.detach_all_subcommands("base") + assert len(removed) == 2 + + root_parser = cast(cmd2.Cmd2ArgumentParser, app.command_parsers.get(app.do_base)) + subparsers_action = root_parser.get_subparsers_action() + assert not subparsers_action._name_parser_map From 72ca7bd8c57baacf2142a5ff9cf10a54910117b9 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 14 May 2026 19:32:20 -0400 Subject: [PATCH 2/3] Updated comment. --- cmd2/cmd2.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index dceae6b61..5c24a798d 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -944,7 +944,8 @@ def _build_parser( elif isinstance(parser_source, classmethod): parser = parser_source.__func__(owner.__class__) else: - # Inspect the signature to determine if this factory expects a class argument. + # Following the ParserSource definition, any function with parameters + # is assumed to be a one-argument factory expecting the owner's class. builder_sig = inspect.signature(parser_source) if builder_sig.parameters: From ced8042a5d9e3a4d854b099202afe8db552339c2 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 14 May 2026 19:52:26 -0400 Subject: [PATCH 3/3] Added comment. --- cmd2/argparse_utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd2/argparse_utils.py b/cmd2/argparse_utils.py index 77045e673..e1f0968a0 100644 --- a/cmd2/argparse_utils.py +++ b/cmd2/argparse_utils.py @@ -266,6 +266,8 @@ def get_choices(self) -> Choices: if TYPE_CHECKING: # pragma: no cover from .argparse_completer import ArgparseCompleter + # In Python 3.14+, move these definitions outside the TYPE_CHECKING + # block as staticmethod/classmethod become subscriptable at runtime. _StaticParserFactory = staticmethod[[], "Cmd2ArgumentParser"] _ClassParserFactory = classmethod[CmdOrSetT, [], "Cmd2ArgumentParser"] else: