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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ prompt is displayed.
- Replaced the global `APP_THEME` constant in `rich_utils.py` with `get_theme()` and
`set_theme()` functions to support lazy initialization and safer in-place updates of the
theme.
- Renamed `Cmd._command_parsers` to `Cmd.command_parsers`.
- Enhancements
- New `cmd2.Cmd` parameters
- **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These
Expand Down
88 changes: 61 additions & 27 deletions cmd2/argparse_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,52 @@ def _ActionsContainer_add_argument( # noqa: N802
# Overwrite _ActionsContainer.add_argument with our patch
argparse._ActionsContainer.add_argument = _ActionsContainer_add_argument # type: ignore[method-assign]

############################################################################################################
# Patch argparse._SubParsersAction by adding remove_parser() function
############################################################################################################


def _SubParsersAction_remove_parser( # noqa: N802
self: argparse._SubParsersAction, # type: ignore[type-arg]
name: str,
) -> argparse.ArgumentParser:
"""Remove a subparser from a subparsers group.

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 subparser to remove
:return: the removed parser
:raises ValueError: if the subcommand doesn't exist
"""
if name not in self._name_parser_map:
raise ValueError(f"Subcommand '{name}' not found")

subparser = self._name_parser_map[name]

# 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.
for choice_action in self._choices_actions:
if choice_action.dest in all_names:
self._choices_actions.remove(choice_action)
break

# Remove all references to this subparser, including aliases.
for cur_name in all_names:
del self._name_parser_map[cur_name]

return cast(argparse.ArgumentParser, subparser)


argparse._SubParsersAction.remove_parser = _SubParsersAction_remove_parser # type: ignore[attr-defined]


class Cmd2ArgumentParser(argparse.ArgumentParser):
"""Custom ArgumentParser class that improves error and help output."""
Expand Down Expand Up @@ -556,7 +602,7 @@ def __init__(
self.description: RenderableType | None # type: ignore[assignment]
self.epilog: RenderableType | None # type: ignore[assignment]

def _get_subparsers_action(self) -> "argparse._SubParsersAction[Cmd2ArgumentParser]":
def get_subparsers_action(self) -> "argparse._SubParsersAction[Cmd2ArgumentParser]":
Copy link
Copy Markdown
Member

@tleonhardt tleonhardt Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new APIs should probably be documented in the CHANGELOG to aid in developer discoverability.

"""Get the _SubParsersAction for this parser if it exists.

:return: the _SubParsersAction for this parser
Expand Down Expand Up @@ -619,7 +665,7 @@ def update_prog(self, prog: str) -> None:
self.prog = prog

try:
subparsers_action = self._get_subparsers_action()
subparsers_action = self.get_subparsers_action()
except ValueError:
# This parser has no subcommands
return
Expand Down Expand Up @@ -651,7 +697,7 @@ def update_prog(self, prog: str) -> None:
subcmd_parser.update_prog(subcmd_prog)
updated_parsers.add(subcmd_parser)

def _find_parser(self, subcommand_path: Iterable[str]) -> "Cmd2ArgumentParser":
def find_parser(self, subcommand_path: Iterable[str]) -> "Cmd2ArgumentParser":
"""Find a parser in the hierarchy based on a sequence of subcommand names.

:param subcommand_path: sequence of subcommand names leading to the target parser
Expand All @@ -660,7 +706,7 @@ def _find_parser(self, subcommand_path: Iterable[str]) -> "Cmd2ArgumentParser":
"""
parser = self
for name in subcommand_path:
subparsers_action = parser._get_subparsers_action()
subparsers_action = parser.get_subparsers_action()
if name not in subparsers_action.choices:
raise ValueError(f"Subcommand '{name}' not found in '{parser.prog}'")
parser = subparsers_action.choices[name]
Expand Down Expand Up @@ -691,8 +737,8 @@ def attach_subcommand(
f"Received: '{type(subcommand_parser).__name__}'."
)

target_parser = self._find_parser(subcommand_path)
subparsers_action = target_parser._get_subparsers_action()
target_parser = self.find_parser(subcommand_path)
subparsers_action = target_parser.get_subparsers_action()

# Verify the parser is compatible with the 'parser_class' configured for this
# subcommand group. We use isinstance() here to allow for subclasses, providing
Expand Down Expand Up @@ -728,28 +774,16 @@ def detach_subcommand(self, subcommand_path: Iterable[str], subcommand: str) ->
:return: the detached parser
: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()

subparser = subparsers_action._name_parser_map.get(subcommand)
if subparser is None:
raise ValueError(f"Subcommand '{subcommand}' not found in '{target_parser.prog}'")

# Remove this subcommand and all its aliases from the base command
to_remove = []
for cur_name, cur_parser in subparsers_action._name_parser_map.items():
if cur_parser is subparser:
to_remove.append(cur_name)
for cur_name in to_remove:
del subparsers_action._name_parser_map[cur_name]

# Remove this subcommand from its base command's help text
for choice_action in subparsers_action._choices_actions:
if choice_action.dest == subcommand:
subparsers_action._choices_actions.remove(choice_action)
break
target_parser = self.find_parser(subcommand_path)
subparsers_action = target_parser.get_subparsers_action()

return subparser
try:
return cast(
Cmd2ArgumentParser,
subparsers_action.remove_parser(subcommand), # type: ignore[attr-defined]
)
except ValueError:
raise ValueError(f"Subcommand '{subcommand}' not found in '{target_parser.prog}'") from None

def error(self, message: str) -> NoReturn:
"""Override that applies custom formatting to the error message."""
Expand Down
Loading
Loading