Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 82 additions & 35 deletions cmd2/argparse_custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -944,39 +945,85 @@ 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:
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, '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
Expand Down
20 changes: 8 additions & 12 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions cmd2/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
"""

Expand All @@ -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

Expand Down
42 changes: 42 additions & 0 deletions tests/test_argparse_custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading