From 822bf1a3d6de8273e425730999f41e991ee16452 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 28 Mar 2026 11:26:22 -0400 Subject: [PATCH 1/5] Added add_existing_parser() method to argparse._SubParsersAction. --- cmd2/argparse_custom.py | 45 ++++++++++++++++++++++++++++++++++++++++- cmd2/cmd2.py | 18 +++++++---------- cmd2/decorators.py | 10 +++++---- 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 623da8308..809ec90f4 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -231,6 +231,10 @@ def get_choices(self) -> Choices: sub-parser from a sub-parsers group. See _SubParsersAction_remove_parser for more details. +``argparse._SubParsersAction.add_existing_parser`` - new function which allows you to attach +an existing ArgumentParser to a sub-parsers group. See _SubParsersAction_add_existing_parser +for more details. + **Added accessor methods** cmd2 has patched ``argparse.Action`` to include the following accessor methods @@ -948,7 +952,10 @@ def _ArgumentParser_check_value(_self: argparse.ArgumentParser, action: argparse ############################################################################################################ -def _SubParsersAction_remove_parser(self: argparse._SubParsersAction, name: str) -> None: # type: ignore[type-arg] # noqa: N802 +def _SubParsersAction_remove_parser( # noqa: N802 + self: argparse._SubParsersAction, # type: ignore[type-arg] + name: str, +) -> None: """Remove a sub-parser from a sub-parsers group. Used to remove subcommands from a parser. This function is added by cmd2 as a method called ``remove_parser()`` to ``argparse._SubParsersAction`` class. @@ -977,6 +984,42 @@ def _SubParsersAction_remove_parser(self: argparse._SubParsersAction, name: str) setattr(argparse._SubParsersAction, 'remove_parser', _SubParsersAction_remove_parser) +############################################################################################################ +# Patch argparse._SubParsersAction to add add_existing_parser function +############################################################################################################ + + +def _SubParsersAction_add_existing_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 sub-parsers group. + + 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 ``add_existing_parser()`` + to ``argparse._SubParsersAction`` class. + + :param self: instance of the _SubParsersAction being edited + :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) + """ + # 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, 'add_existing_parser', _SubParsersAction_add_existing_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..096aa70ac 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 - + # Add the existing parser as a subcommand + action.add_existing_parser( # type: ignore[attr-defined] + subcommand_name, + subcmd_parser, + **add_parser_kwargs, + ) break def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 3c8bc9ed6..22290af5f 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,7 @@ 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) :return: Wrapper function that can receive an argparse.Namespace """ @@ -373,13 +375,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 From 693d700aa91f1694e619a62bddd3a11a487276dc Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 28 Mar 2026 11:31:59 -0400 Subject: [PATCH 2/5] Updated comment. --- cmd2/argparse_custom.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 809ec90f4..957ada682 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -1003,6 +1003,8 @@ def _SubParsersAction_add_existing_parser( # noqa: N802 This function is added by cmd2 as a method called ``add_existing_parser()`` to ``argparse._SubParsersAction`` class. + To call: ``action.add_existing_parser(name, subcmd_parser, **add_parser_kwargs)`` + :param self: instance of the _SubParsersAction being edited :param name: name of the subcommand to add :param subcmd_parser: the parser for this new subcommand From 727b80d27be48472b295f5000bc60699e289379c Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 28 Mar 2026 16:34:11 -0400 Subject: [PATCH 3/5] Updated docs. --- cmd2/argparse_custom.py | 49 +++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 957ada682..3afa11853 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -222,46 +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. - -``argparse._SubParsersAction.add_existing_parser`` - new function which allows you to attach -an existing ArgumentParser to a sub-parsers group. See _SubParsersAction_add_existing_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 removal** +**Subcommand Manipulation** -cmd2 has patched ``argparse._SubParsersAction`` to include a ``remove_parser()`` -method which can be used to remove a subcommand. +cmd2 has patched ``argparse._SubParsersAction`` with new functions to better facilitate the +addition and removal of subcommand parsers. ``argparse._SubParsersAction.remove_parser`` - new function which removes a -sub-parser from a sub-parsers group. See _SubParsersAction_remove_parser` for more details. +sub-parser from a sub-parsers group. See ``_SubParsersAction_remove_parser`` for +more details. + +``argparse._SubParsersAction.add_existing_parser`` - new function which allows you to attach +an existing ArgumentParser to a sub-parsers group. See ``_SubParsersAction_add_existing_parser`` +for more details. """ import argparse @@ -1017,7 +1014,7 @@ def _SubParsersAction_add_existing_parser( # noqa: N802 self._name_parser_map[name] = subcmd_parser # Remap any aliases to our pre-configured parser - for alias in add_parser_kwargs.get("aliases", []): + for alias in add_parser_kwargs.get("aliases", ()): self._name_parser_map[alias] = subcmd_parser From f9157fd9ab1022ad33e737d4aa8c350f09cd0567 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Sat, 28 Mar 2026 16:46:44 -0400 Subject: [PATCH 4/5] Updated docstrings. --- cmd2/argparse_custom.py | 3 ++- cmd2/decorators.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 3afa11853..638bd1c6a 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -1005,7 +1005,8 @@ def _SubParsersAction_add_existing_parser( # noqa: N802 :param self: instance of the _SubParsersAction being edited :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) + :param add_parser_kwargs: registration-specific kwargs for add_parser() + (e.g. help, aliases, deprecated [Python 3.13+]) """ # Use add_parser to register the subcommand name and any aliases self.add_parser(name, **add_parser_kwargs) diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 22290af5f..5054d91f6 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -364,7 +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) + :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 """ From b8ce4fe3df4d127c11012bf4140fe5cb6cc42f55 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Mon, 30 Mar 2026 13:44:30 -0400 Subject: [PATCH 5/5] Renamed add_existing_parser() to attach_parser(). Renamed remove_parser() to detach_parser(). --- cmd2/argparse_custom.py | 102 ++++++++++++++++++---------------- cmd2/cmd2.py | 6 +- tests/test_argparse_custom.py | 42 ++++++++++++++ 3 files changed, 98 insertions(+), 52 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 638bd1c6a..68f970cfa 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -252,13 +252,13 @@ def get_choices(self) -> Choices: cmd2 has patched ``argparse._SubParsersAction`` with new functions to better facilitate the addition and removal of subcommand parsers. -``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.add_existing_parser`` - new function which allows you to attach -an existing ArgumentParser to a sub-parsers group. See ``_SubParsersAction_add_existing_parser`` +``argparse._SubParsersAction.attach_parser`` - new function to attach +an existing ArgumentParser to a subparsers action. See ``_SubParsersAction_attach_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 @@ -945,62 +945,25 @@ def _ArgumentParser_check_value(_self: argparse.ArgumentParser, action: argparse ############################################################################################################ -# Patch argparse._SubParsersAction to add remove_parser function -############################################################################################################ - - -def _SubParsersAction_remove_parser( # noqa: N802 - self: argparse._SubParsersAction, # type: ignore[type-arg] - name: str, -) -> None: - """Remove a sub-parser from a sub-parsers group. Used to remove subcommands from a parser. - - This function is added by cmd2 as a method called ``remove_parser()`` to ``argparse._SubParsersAction`` class. - - To call: ``action.remove_parser(name)`` - - :param self: instance of the _SubParsersAction being edited - :param name: name of the subcommand for the sub-parser to remove - """ - # 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 - - # Remove this subcommand and all its aliases from the base command - subparser = self._name_parser_map.get(name) - if subparser is not None: - to_remove = [] - for cur_name, cur_parser in self._name_parser_map.items(): - if cur_parser is subparser: - to_remove.append(cur_name) - for cur_name in to_remove: - del self._name_parser_map[cur_name] - - -setattr(argparse._SubParsersAction, 'remove_parser', _SubParsersAction_remove_parser) - -############################################################################################################ -# Patch argparse._SubParsersAction to add add_existing_parser function +# Patch argparse._SubParsersAction to add attach_parser function ############################################################################################################ -def _SubParsersAction_add_existing_parser( # noqa: N802 +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 sub-parsers group. + """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 ``add_existing_parser()`` + This function is added by cmd2 as a method called ``attach_parser()`` to ``argparse._SubParsersAction`` class. - To call: ``action.add_existing_parser(name, subcmd_parser, **add_parser_kwargs)`` + 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 to add @@ -1019,7 +982,48 @@ def _SubParsersAction_add_existing_parser( # noqa: N802 self._name_parser_map[alias] = subcmd_parser -setattr(argparse._SubParsersAction, 'add_existing_parser', _SubParsersAction_add_existing_parser) +setattr(argparse._SubParsersAction, 'attach_parser', _SubParsersAction_attach_parser) + +############################################################################################################ +# 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: + to_remove.append(cur_name) + 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, '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 096aa70ac..786417814 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1132,8 +1132,8 @@ def find_subcommand( # Get add_parser() kwargs (aliases, help, etc.) defined by the decorator add_parser_kwargs = getattr(method, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, {}) - # Add the existing parser as a subcommand - action.add_existing_parser( # type: ignore[attr-defined] + # Attach existing parser as a subcommand + action.attach_parser( # type: ignore[attr-defined] subcommand_name, subcmd_parser, **add_parser_kwargs, @@ -1184,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/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.