Skip to content

Commit 2a002fb

Browse files
committed
Making more of the subcommand management API public.
1 parent 14d91a7 commit 2a002fb

5 files changed

Lines changed: 110 additions & 35 deletions

File tree

cmd2/argparse_utils.py

Lines changed: 61 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,52 @@ def _ActionsContainer_add_argument( # noqa: N802
494494
# Overwrite _ActionsContainer.add_argument with our patch
495495
argparse._ActionsContainer.add_argument = _ActionsContainer_add_argument # type: ignore[method-assign]
496496

497+
############################################################################################################
498+
# Patch argparse._SubParsersAction by adding remove_parser() function
499+
############################################################################################################
500+
501+
502+
def _SubParsersAction_remove_parser( # noqa: N802
503+
self: argparse._SubParsersAction, # type: ignore[type-arg]
504+
name: str,
505+
) -> argparse.ArgumentParser:
506+
"""Remove a subparser from a subparsers group.
507+
508+
This function is added by cmd2 as a method called ``remove_parser()``
509+
to ``argparse._SubParsersAction`` class.
510+
511+
To call: ``action.remove_parser(name)``
512+
513+
:param self: instance of the _SubParsersAction being edited
514+
:param name: name of the subcommand for the subparser to remove
515+
:return: the removed parser
516+
:raises ValueError: if the subcommand doesn't exist
517+
"""
518+
if name not in self._name_parser_map:
519+
raise ValueError(f"Subcommand '{name}' not found")
520+
521+
subparser = self._name_parser_map[name]
522+
523+
# Find all names (primary and aliases) that map to this subparser
524+
all_names = [cur_name for cur_name, cur_parser in self._name_parser_map.items() if cur_parser is subparser]
525+
526+
# Remove the help entry for this subparser. To handle the case where
527+
# name is an alias, we remove the action whose 'dest' matches any of
528+
# the names mapped to this subparser.
529+
for choice_action in self._choices_actions:
530+
if choice_action.dest in all_names:
531+
self._choices_actions.remove(choice_action)
532+
break
533+
534+
# Remove all references to this subparser, including aliases.
535+
for cur_name in all_names:
536+
del self._name_parser_map[cur_name]
537+
538+
return cast(argparse.ArgumentParser, subparser)
539+
540+
541+
argparse._SubParsersAction.remove_parser = _SubParsersAction_remove_parser # type: ignore[attr-defined]
542+
497543

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

559-
def _get_subparsers_action(self) -> "argparse._SubParsersAction[Cmd2ArgumentParser]":
605+
def get_subparsers_action(self) -> "argparse._SubParsersAction[Cmd2ArgumentParser]":
560606
"""Get the _SubParsersAction for this parser if it exists.
561607
562608
:return: the _SubParsersAction for this parser
@@ -619,7 +665,7 @@ def update_prog(self, prog: str) -> None:
619665
self.prog = prog
620666

621667
try:
622-
subparsers_action = self._get_subparsers_action()
668+
subparsers_action = self.get_subparsers_action()
623669
except ValueError:
624670
# This parser has no subcommands
625671
return
@@ -651,7 +697,7 @@ def update_prog(self, prog: str) -> None:
651697
subcmd_parser.update_prog(subcmd_prog)
652698
updated_parsers.add(subcmd_parser)
653699

654-
def _find_parser(self, subcommand_path: Iterable[str]) -> "Cmd2ArgumentParser":
700+
def find_parser(self, subcommand_path: Iterable[str]) -> "Cmd2ArgumentParser":
655701
"""Find a parser in the hierarchy based on a sequence of subcommand names.
656702
657703
:param subcommand_path: sequence of subcommand names leading to the target parser
@@ -660,7 +706,7 @@ def _find_parser(self, subcommand_path: Iterable[str]) -> "Cmd2ArgumentParser":
660706
"""
661707
parser = self
662708
for name in subcommand_path:
663-
subparsers_action = parser._get_subparsers_action()
709+
subparsers_action = parser.get_subparsers_action()
664710
if name not in subparsers_action.choices:
665711
raise ValueError(f"Subcommand '{name}' not found in '{parser.prog}'")
666712
parser = subparsers_action.choices[name]
@@ -691,8 +737,8 @@ def attach_subcommand(
691737
f"Received: '{type(subcommand_parser).__name__}'."
692738
)
693739

694-
target_parser = self._find_parser(subcommand_path)
695-
subparsers_action = target_parser._get_subparsers_action()
740+
target_parser = self.find_parser(subcommand_path)
741+
subparsers_action = target_parser.get_subparsers_action()
696742

697743
# Verify the parser is compatible with the 'parser_class' configured for this
698744
# subcommand group. We use isinstance() here to allow for subclasses, providing
@@ -728,28 +774,16 @@ def detach_subcommand(self, subcommand_path: Iterable[str], subcommand: str) ->
728774
:return: the detached parser
729775
:raises ValueError: if the command path is invalid or the subcommand doesn't exist
730776
"""
731-
target_parser = self._find_parser(subcommand_path)
732-
subparsers_action = target_parser._get_subparsers_action()
733-
734-
subparser = subparsers_action._name_parser_map.get(subcommand)
735-
if subparser is None:
736-
raise ValueError(f"Subcommand '{subcommand}' not found in '{target_parser.prog}'")
737-
738-
# Remove this subcommand and all its aliases from the base command
739-
to_remove = []
740-
for cur_name, cur_parser in subparsers_action._name_parser_map.items():
741-
if cur_parser is subparser:
742-
to_remove.append(cur_name)
743-
for cur_name in to_remove:
744-
del subparsers_action._name_parser_map[cur_name]
745-
746-
# Remove this subcommand from its base command's help text
747-
for choice_action in subparsers_action._choices_actions:
748-
if choice_action.dest == subcommand:
749-
subparsers_action._choices_actions.remove(choice_action)
750-
break
777+
target_parser = self.find_parser(subcommand_path)
778+
subparsers_action = target_parser.get_subparsers_action()
751779

752-
return subparser
780+
try:
781+
return cast(
782+
Cmd2ArgumentParser,
783+
subparsers_action.remove_parser(subcommand), # type: ignore[attr-defined]
784+
)
785+
except ValueError:
786+
raise ValueError(f"Subcommand '{subcommand}' not found in '{target_parser.prog}'") from None
753787

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

cmd2/cmd2.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1026,7 +1026,7 @@ def _check_uninstallable(self, cmdset: CommandSet[Any]) -> None:
10261026

10271027
def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None:
10281028
try:
1029-
subparsers_action = parser._get_subparsers_action()
1029+
subparsers_action = parser.get_subparsers_action()
10301030
except ValueError:
10311031
# No subcommands to check
10321032
return

examples/scripts/save_help_text.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
def get_sub_commands(parser: Cmd2ArgumentParser) -> list[str]:
1515
"""Get a list of subcommands for a Cmd2ArgumentParser."""
1616
try:
17-
subparsers_action = parser._get_subparsers_action()
17+
subparsers_action = parser.get_subparsers_action()
1818
except ValueError:
1919
# No subcommands
2020
return []

tests/test_argparse_utils.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -367,9 +367,9 @@ def test_subcommand_attachment() -> None:
367367
# Verify hierarchy navigation
368368
###############################
369369

370-
assert root_parser._find_parser(["child", "grandchild"]) is grandchild_parser
371-
assert root_parser._find_parser(["child"]) is child_parser
372-
assert root_parser._find_parser([]) is root_parser
370+
assert root_parser.find_parser(["child", "grandchild"]) is grandchild_parser
371+
assert root_parser.find_parser(["child"]) is child_parser
372+
assert root_parser.find_parser([]) is root_parser
373373

374374
###############################
375375
# Verify attachments
@@ -382,6 +382,10 @@ def test_subcommand_attachment() -> None:
382382
# Verify grandchild attachment
383383
assert child_subparsers._name_parser_map["grandchild"] is grandchild_parser
384384

385+
# Verify help entries are present
386+
assert any(action.dest == "child" for action in root_subparsers._choices_actions)
387+
assert any(action.dest == "grandchild" for action in child_subparsers._choices_actions)
388+
385389
###############################
386390
# Detach subcommands
387391
###############################
@@ -390,12 +394,49 @@ def test_subcommand_attachment() -> None:
390394
detached_grandchild = root_parser.detach_subcommand(["child"], "grandchild")
391395
assert detached_grandchild is grandchild_parser
392396
assert "grandchild" not in child_subparsers._name_parser_map
397+
assert not any(action.dest == "grandchild" for action in child_subparsers._choices_actions)
393398

394399
# Detach child from root
395400
detached_child = root_parser.detach_subcommand([], "child")
396401
assert detached_child is child_parser
397402
assert "child" not in root_subparsers._name_parser_map
398403
assert "child_alias" not in root_subparsers._name_parser_map
404+
assert not any(action.dest == "child" for action in root_subparsers._choices_actions)
405+
406+
407+
def test_detach_subcommand_by_alias() -> None:
408+
"""Test detaching a subcommand using one of its alias names."""
409+
root_parser = Cmd2ArgumentParser(prog="root")
410+
root_subparsers = root_parser.add_subparsers()
411+
412+
child_parser = Cmd2ArgumentParser(prog="child")
413+
root_parser.attach_subcommand(
414+
[],
415+
"child",
416+
child_parser,
417+
help="a child command",
418+
aliases=["alias1", "alias2"],
419+
)
420+
421+
# Verify all names map to the parser
422+
assert root_subparsers._name_parser_map["child"] is child_parser
423+
assert root_subparsers._name_parser_map["alias1"] is child_parser
424+
assert root_subparsers._name_parser_map["alias2"] is child_parser
425+
426+
# Verify help entry is present
427+
assert any(action.dest == "child" for action in root_subparsers._choices_actions)
428+
429+
# Detach using an alias
430+
detached = root_parser.detach_subcommand([], "alias1")
431+
assert detached is child_parser
432+
433+
# Verify all names are gone
434+
assert "child" not in root_subparsers._name_parser_map
435+
assert "alias1" not in root_subparsers._name_parser_map
436+
assert "alias2" not in root_subparsers._name_parser_map
437+
438+
# Verify help entry is gone
439+
assert not any(action.dest == "child" for action in root_subparsers._choices_actions)
399440

400441

401442
def test_subcommand_attachment_errors() -> None:
@@ -411,7 +452,7 @@ def test_subcommand_attachment_errors() -> None:
411452
# Allow subcommands for the next tests
412453
root_parser.add_subparsers()
413454

414-
# Verify ValueError when path is invalid (_find_parser() fails)
455+
# Verify ValueError when path is invalid (find_parser() fails)
415456
with pytest.raises(ValueError, match="Subcommand 'nonexistent' not found"):
416457
root_parser.attach_subcommand(["nonexistent"], "anything", child_parser)
417458
with pytest.raises(ValueError, match="Subcommand 'nonexistent' not found"):

tests/test_cmd2.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4490,7 +4490,7 @@ def do_root(self, _args: argparse.Namespace) -> None:
44904490
app.attach_subcommand("root", "child", child_parser, help="child help")
44914491

44924492
# Verify child was attached
4493-
root_subparsers_action = root_parser._get_subparsers_action()
4493+
root_subparsers_action = root_parser.get_subparsers_action()
44944494
assert "child" in root_subparsers_action._name_parser_map
44954495
assert root_subparsers_action._name_parser_map["child"] is child_parser
44964496

@@ -4499,7 +4499,7 @@ def do_root(self, _args: argparse.Namespace) -> None:
44994499
app.attach_subcommand("root child", "grandchild", grandchild_parser)
45004500

45014501
# Verify grandchild was attached
4502-
child_subparsers_action = child_parser._get_subparsers_action()
4502+
child_subparsers_action = child_parser.get_subparsers_action()
45034503
assert "grandchild" in child_subparsers_action._name_parser_map
45044504

45054505
# Detach grandchild

0 commit comments

Comments
 (0)