diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 623da8308..68f970cfa 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -222,42 +222,43 @@ def get_choices(self) -> Choices: more details on these arguments. ``argparse.ArgumentParser._get_nargs_pattern`` - adds support for nargs ranges. -See _get_nargs_pattern_wrapper for more details. +See ``_get_nargs_pattern_wrapper`` for more details. ``argparse.ArgumentParser._match_argument`` - adds support for nargs ranges. -See _match_argument_wrapper for more details. - -``argparse._SubParsersAction.remove_parser`` - new function which removes a -sub-parser from a sub-parsers group. See _SubParsersAction_remove_parser for -more details. +See ``_match_argument_wrapper`` for more details. **Added accessor methods** cmd2 has patched ``argparse.Action`` to include the following accessor methods for cases in which you need to manually access the cmd2-specific attributes. -- ``argparse.Action.get_choices_callable()`` - See `action_get_choices_callable` for more details. -- ``argparse.Action.set_choices_provider()`` - See `_action_set_choices_provider` for more details. -- ``argparse.Action.set_completer()`` - See `_action_set_completer` for more details. -- ``argparse.Action.get_table_columns()`` - See `_action_get_table_columns` for more details. -- ``argparse.Action.set_table_columns()`` - See `_action_set_table_columns` for more details. -- ``argparse.Action.get_nargs_range()`` - See `_action_get_nargs_range` for more details. -- ``argparse.Action.set_nargs_range()`` - See `_action_set_nargs_range` for more details. -- ``argparse.Action.get_suppress_tab_hint()`` - See `_action_get_suppress_tab_hint` for more details. -- ``argparse.Action.set_suppress_tab_hint()`` - See `_action_set_suppress_tab_hint` for more details. +- ``argparse.Action.get_choices_callable()`` - See ``action_get_choices_callable`` for more details. +- ``argparse.Action.set_choices_provider()`` - See ``_action_set_choices_provider`` for more details. +- ``argparse.Action.set_completer()`` - See ``_action_set_completer`` for more details. +- ``argparse.Action.get_table_columns()`` - See ``_action_get_table_columns`` for more details. +- ``argparse.Action.set_table_columns()`` - See ``_action_set_table_columns`` for more details. +- ``argparse.Action.get_nargs_range()`` - See ``_action_get_nargs_range`` for more details. +- ``argparse.Action.set_nargs_range()`` - See ``_action_set_nargs_range`` for more details. +- ``argparse.Action.get_suppress_tab_hint()`` - See ``_action_get_suppress_tab_hint`` for more details. +- ``argparse.Action.set_suppress_tab_hint()`` - See ``_action_set_suppress_tab_hint`` for more details. cmd2 has patched ``argparse.ArgumentParser`` to include the following accessor methods -- ``argparse.ArgumentParser.get_ap_completer_type()`` - See `_ArgumentParser_get_ap_completer_type` for more details. -- ``argparse.Action.set_ap_completer_type()`` - See `_ArgumentParser_set_ap_completer_type` for more details. +- ``argparse.ArgumentParser.get_ap_completer_type()`` - See ``_ArgumentParser_get_ap_completer_type`` for more details. +- ``argparse.Action.set_ap_completer_type()`` - See ``_ArgumentParser_set_ap_completer_type`` for more details. + +**Subcommand Manipulation** -**Subcommand removal** +cmd2 has patched ``argparse._SubParsersAction`` with new functions to better facilitate the +addition and removal of subcommand parsers. -cmd2 has patched ``argparse._SubParsersAction`` to include a ``remove_parser()`` -method which can be used to remove a subcommand. +``argparse._SubParsersAction.attach_parser`` - new function to attach +an existing ArgumentParser to a subparsers action. See ``_SubParsersAction_attach_parser`` +for more details. -``argparse._SubParsersAction.remove_parser`` - new function which removes a -sub-parser from a sub-parsers group. See _SubParsersAction_remove_parser` for more details. +``argparse._SubParsersAction.detach_parser`` - new function to detach a +parser from a subparsers action. See ``_SubParsersAction_detach_parser`` for +more details. """ import argparse @@ -944,29 +945,68 @@ def _ArgumentParser_check_value(_self: argparse.ArgumentParser, action: argparse ############################################################################################################ -# Patch argparse._SubParsersAction to add remove_parser function +# Patch argparse._SubParsersAction to add attach_parser function ############################################################################################################ -def _SubParsersAction_remove_parser(self: argparse._SubParsersAction, name: str) -> None: # type: ignore[type-arg] # noqa: N802 - """Remove a sub-parser from a sub-parsers group. Used to remove subcommands from a parser. +def _SubParsersAction_attach_parser( # noqa: N802 + self: argparse._SubParsersAction, # type: ignore[type-arg] + name: str, + subcmd_parser: argparse.ArgumentParser, + **add_parser_kwargs: Any, +) -> None: + """Attach an existing ArgumentParser to a subparsers action. + + This is useful when a parser is pre-configured (e.g. by cmd2's subcommand decorator) + and needs to be attached to a parent parser. - This function is added by cmd2 as a method called ``remove_parser()`` to ``argparse._SubParsersAction`` class. + This function is added by cmd2 as a method called ``attach_parser()`` + to ``argparse._SubParsersAction`` class. - To call: ``action.remove_parser(name)`` + To call: ``action.attach_parser(name, subcmd_parser, **add_parser_kwargs)`` :param self: instance of the _SubParsersAction being edited - :param name: name of the subcommand for the sub-parser to remove + :param name: name of the subcommand to add + :param subcmd_parser: the parser for this new subcommand + :param add_parser_kwargs: registration-specific kwargs for add_parser() + (e.g. help, aliases, deprecated [Python 3.13+]) """ - # Remove this subcommand from its base command's help text - for choice_action in self._choices_actions: - if choice_action.dest == name: - self._choices_actions.remove(choice_action) - break + # Use add_parser to register the subcommand name and any aliases + self.add_parser(name, **add_parser_kwargs) + + # Replace the parser created by add_parser() with our pre-configured one + self._name_parser_map[name] = subcmd_parser + + # Remap any aliases to our pre-configured parser + for alias in add_parser_kwargs.get("aliases", ()): + self._name_parser_map[alias] = subcmd_parser + + +setattr(argparse._SubParsersAction, 'attach_parser', _SubParsersAction_attach_parser) - # Remove this subcommand and all its aliases from the base command +############################################################################################################ +# Patch argparse._SubParsersAction to add detach_parser function +############################################################################################################ + + +def _SubParsersAction_detach_parser( # noqa: N802 + self: argparse._SubParsersAction, # type: ignore[type-arg] + name: str, +) -> argparse.ArgumentParser | None: + """Detach a parser from a subparsers action and return it. + + This function is added by cmd2 as a method called ``detach_parser()`` to ``argparse._SubParsersAction`` class. + + To call: ``action.detach_parser(name)`` + + :param self: instance of the _SubParsersAction being edited + :param name: name of the subcommand for the parser to detach + :return: the parser which was detached or None if the subcommand doesn't exist + """ subparser = self._name_parser_map.get(name) + if subparser is not None: + # Remove this subcommand and all its aliases from the base command to_remove = [] for cur_name, cur_parser in self._name_parser_map.items(): if cur_parser is subparser: @@ -974,9 +1014,16 @@ def _SubParsersAction_remove_parser(self: argparse._SubParsersAction, name: str) for cur_name in to_remove: del self._name_parser_map[cur_name] + # Remove this subcommand from its base command's help text + for choice_action in self._choices_actions: + if choice_action.dest == name: + self._choices_actions.remove(choice_action) + break + + return subparser -setattr(argparse._SubParsersAction, 'remove_parser', _SubParsersAction_remove_parser) +setattr(argparse._SubParsersAction, 'detach_parser', _SubParsersAction_detach_parser) ############################################################################################################ # Unless otherwise noted, everything below this point are copied from Python's diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 43cc0f3ed..786417814 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1129,19 +1129,15 @@ def find_subcommand( # Find the argparse action that handles subcommands for action in target_parser._actions: if isinstance(action, argparse._SubParsersAction): - # Get the kwargs for add_parser() + # Get add_parser() kwargs (aliases, help, etc.) defined by the decorator add_parser_kwargs = getattr(method, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, {}) - # Use add_parser to register the subcommand name and any aliases - action.add_parser(subcommand_name, **add_parser_kwargs) - - # Replace the parser created by add_parser() with our pre-configured one - action._name_parser_map[subcommand_name] = subcmd_parser - - # Also remap any aliases to our pre-configured parser - for alias in add_parser_kwargs.get("aliases", []): - action._name_parser_map[alias] = subcmd_parser - + # Attach existing parser as a subcommand + action.attach_parser( # type: ignore[attr-defined] + subcommand_name, + subcmd_parser, + **add_parser_kwargs, + ) break def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: @@ -1188,7 +1184,7 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: for action in command_parser._actions: if isinstance(action, argparse._SubParsersAction): - action.remove_parser(subcommand_name) # type: ignore[attr-defined] + action.detach_parser(subcommand_name) # type: ignore[attr-defined] break @property diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 3c8bc9ed6..5054d91f6 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -353,6 +353,7 @@ def as_subcommand_to( *, help: str | None = None, # noqa: A002 aliases: Sequence[str] | None = None, + **add_parser_kwargs: Any, ) -> Callable[[ArgparseCommandFunc[CmdOrSet]], ArgparseCommandFunc[CmdOrSet]]: """Tag this method as a subcommand to an existing argparse decorated command. @@ -363,6 +364,8 @@ def as_subcommand_to( This is passed as the help argument to subparsers.add_parser(). :param aliases: Alternative names for this subcommand. This is passed as the alias argument to subparsers.add_parser(). + :param add_parser_kwargs: other registration-specific kwargs for add_parser() + (e.g. deprecated [Python 3.13+]) :return: Wrapper function that can receive an argparse.Namespace """ @@ -373,13 +376,13 @@ def arg_decorator(func: ArgparseCommandFunc[CmdOrSet]) -> ArgparseCommandFunc[Cm setattr(func, constants.SUBCMD_ATTR_NAME, subcommand) # Keyword arguments for subparsers.add_parser() - add_parser_kwargs: dict[str, Any] = {} + final_kwargs: dict[str, Any] = dict(add_parser_kwargs) if help is not None: - add_parser_kwargs['help'] = help + final_kwargs['help'] = help if aliases: - add_parser_kwargs['aliases'] = aliases[:] + final_kwargs['aliases'] = tuple(aliases) - setattr(func, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, add_parser_kwargs) + setattr(func, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, final_kwargs) return func diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index 1b063643b..f5967ee90 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -308,6 +308,48 @@ def test_cmd2_attribute_wrapper() -> None: assert wrapper.get() == new_val +def test_parser_attachment() -> None: + # Attach a parser as a subcommand + root_parser = Cmd2ArgumentParser(description="root command") + root_subparsers = root_parser.add_subparsers() + + child_parser = Cmd2ArgumentParser(description="child command") + root_subparsers.attach_parser( # type: ignore[attr-defined] + "child", + child_parser, + help="a child command", + aliases=["child_alias"], + ) + + # Verify the same parser instance was used + assert root_subparsers._name_parser_map["child"] is child_parser + assert root_subparsers._name_parser_map["child_alias"] is child_parser + + # Verify an action with the help text exists + child_action = None + for action in root_subparsers._choices_actions: + if action.dest == "child": + child_action = action + break + assert child_action is not None + assert child_action.help == "a child command" + + # Detatch the subcommand + detached_parser = root_subparsers.detach_parser("child") # type: ignore[attr-defined] + + # Verify subcommand and its aliases were removed + assert detached_parser is child_parser + assert "child" not in root_subparsers._name_parser_map + assert "child_alias" not in root_subparsers._name_parser_map + + # Verify the help text action was removed + choices_actions = [action.dest for action in root_subparsers._choices_actions] + assert "child" not in choices_actions + + # Verify it returns None when subcommand does not exist + assert root_subparsers.detach_parser("fake") is None # type: ignore[attr-defined] + + def test_completion_items_as_choices(capsys) -> None: """Test cmd2's patch to Argparse._check_value() which supports CompletionItems as choices. Choices are compared to CompletionItems.orig_value instead of the CompletionItem instance.