Skip to content

Commit 03db5e4

Browse files
authored
Raise ValueError in attach_subcommand() when the subcommand already exists. (#1645)
1 parent 47c492b commit 03db5e4

5 files changed

Lines changed: 44 additions & 18 deletions

File tree

cmd2/argparse_utils.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -516,7 +516,7 @@ def _SubParsersAction_remove_parser( # noqa: N802
516516
:raises ValueError: if the subcommand doesn't exist
517517
"""
518518
if name not in self._name_parser_map:
519-
raise ValueError(f"Subcommand '{name}' not found")
519+
raise ValueError(f"Subcommand '{name}' does not exist")
520520

521521
subparser = self._name_parser_map[name]
522522

@@ -684,12 +684,12 @@ def update_prog(self, prog: str) -> None:
684684
# add_parser() will have the correct prog value.
685685
subparsers_action._prog_prefix = self._build_subparsers_prog_prefix(positionals)
686686

687-
# subparsers_action.choices includes aliases. Since primary names are inserted first,
688-
# we skip already updated parsers to ensure primary names are used in 'prog'.
687+
# subparsers_action._name_parser_map includes aliases. Since primary names are inserted
688+
# first, we skip already updated parsers to ensure primary names are used in 'prog'.
689689
updated_parsers: set[Cmd2ArgumentParser] = set()
690690

691691
# Set the prog value for each subcommand's parser
692-
for subcmd_name, subcmd_parser in subparsers_action.choices.items():
692+
for subcmd_name, subcmd_parser in subparsers_action._name_parser_map.items():
693693
if subcmd_parser in updated_parsers:
694694
continue
695695

@@ -707,9 +707,9 @@ def find_parser(self, subcommand_path: Iterable[str]) -> "Cmd2ArgumentParser":
707707
parser = self
708708
for name in subcommand_path:
709709
subparsers_action = parser.get_subparsers_action()
710-
if name not in subparsers_action.choices:
711-
raise ValueError(f"Subcommand '{name}' not found in '{parser.prog}'")
712-
parser = subparsers_action.choices[name]
710+
if name not in subparsers_action._name_parser_map:
711+
raise ValueError(f"Subcommand '{name}' does not exist for '{parser.prog}'")
712+
parser = subparsers_action._name_parser_map[name]
713713
return parser
714714

715715
def attach_subcommand(
@@ -729,7 +729,8 @@ def attach_subcommand(
729729
:raises TypeError: if subcommand_parser is not an instance of the following or their subclasses:
730730
1. Cmd2ArgumentParser
731731
2. The parser_class configured for the target subcommand group
732-
:raises ValueError: if the command path is invalid or doesn't support subcommands
732+
:raises ValueError: if the command path is invalid, doesn't support subcommands, or the
733+
subcommand already exists
733734
"""
734735
if not isinstance(subcommand_parser, Cmd2ArgumentParser):
735736
raise TypeError(
@@ -751,6 +752,12 @@ def attach_subcommand(
751752
f"Received: '{type(subcommand_parser).__name__}'."
752753
)
753754

755+
# Do not overwrite existing subcommands or aliases
756+
all_names = (subcommand, *add_parser_kwargs.get("aliases", ()))
757+
for name in all_names:
758+
if name in subparsers_action._name_parser_map:
759+
raise ValueError(f"Subcommand '{name}' already exists for '{target_parser.prog}'")
760+
754761
# Use add_parser to register the subcommand name and any aliases
755762
placeholder_parser = subparsers_action.add_parser(subcommand, **add_parser_kwargs)
756763

@@ -783,7 +790,7 @@ def detach_subcommand(self, subcommand_path: Iterable[str], subcommand: str) ->
783790
subparsers_action.remove_parser(subcommand), # type: ignore[attr-defined]
784791
)
785792
except ValueError:
786-
raise ValueError(f"Subcommand '{subcommand}' not found in '{target_parser.prog}'") from None
793+
raise ValueError(f"Subcommand '{subcommand}' does not exist for '{target_parser.prog}'") from None
787794

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

cmd2/cmd2.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,7 +1174,7 @@ def get_root_parser_and_subcmd_path(self, command: str) -> tuple[Cmd2ArgumentPar
11741174
# Search for the base command function and verify it has an argparser defined
11751175
command_func = self.get_command_func(root_command)
11761176
if command_func is None:
1177-
raise ValueError(f"Root command '{root_command}' not found")
1177+
raise ValueError(f"Root command '{root_command}' does not exist")
11781178

11791179
root_parser = self.command_parsers.get(command_func)
11801180
if root_parser is None:
@@ -1199,7 +1199,8 @@ def attach_subcommand(
11991199
:raises TypeError: if subcommand_parser is not an instance of the following or their subclasses:
12001200
1. Cmd2ArgumentParser
12011201
2. The parser_class configured for the target subcommand group
1202-
:raises ValueError: if the command path is invalid or doesn't support subcommands
1202+
:raises ValueError: if the command path is invalid, doesn't support subcommands, or the
1203+
subcommand already exists
12031204
"""
12041205
root_parser, subcommand_path = self.get_root_parser_and_subcmd_path(command)
12051206
root_parser.attach_subcommand(subcommand_path, subcommand, subcommand_parser, **add_parser_kwargs)

cmd2/decorators.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -360,9 +360,9 @@ def as_subcommand_to(
360360
:param subcommand: Subcommand name
361361
:param parser: instance of Cmd2ArgumentParser or a callable that returns a Cmd2ArgumentParser for this subcommand
362362
:param help: Help message for this subcommand which displays in the list of subcommands of the command we are adding to.
363-
This is passed as the help argument to subparsers.add_parser().
364-
:param aliases: Alternative names for this subcommand. This is passed as the alias argument to
365-
subparsers.add_parser().
363+
If not None, this is passed as the 'help' argument to subparsers.add_parser().
364+
:param aliases: Alternative names for this subcommand. If a non-empty sequence is provided, it is passed
365+
as the 'aliases' argument to subparsers.add_parser().
366366
:param add_parser_kwargs: other registration-specific kwargs for add_parser()
367367
(e.g. deprecated [Python 3.13+])
368368
:return: a decorator which configures the target function to be a subcommand handler

tests/test_argparse_utils.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -453,20 +453,26 @@ def test_subcommand_attachment_errors() -> None:
453453
root_parser.add_subparsers()
454454

455455
# Verify ValueError when path is invalid (find_parser() fails)
456-
with pytest.raises(ValueError, match="Subcommand 'nonexistent' not found"):
456+
with pytest.raises(ValueError, match="Subcommand 'nonexistent' does not exist for 'root'"):
457457
root_parser.attach_subcommand(["nonexistent"], "anything", child_parser)
458-
with pytest.raises(ValueError, match="Subcommand 'nonexistent' not found"):
458+
with pytest.raises(ValueError, match="Subcommand 'nonexistent' does not exist for 'root'"):
459459
root_parser.detach_subcommand(["nonexistent"], "anything")
460460

461461
# Verify ValueError when path is valid but subcommand name is wrong
462-
with pytest.raises(ValueError, match="Subcommand 'fake' not found in 'root'"):
462+
with pytest.raises(ValueError, match="Subcommand 'fake' does not exist for 'root'"):
463463
root_parser.detach_subcommand([], "fake")
464464

465465
# Verify TypeError when attaching a non-Cmd2ArgumentParser type
466466
ap_parser = argparse.ArgumentParser(prog="non-cmd2-parser")
467467
with pytest.raises(TypeError, match=r"must be an instance of 'Cmd2ArgumentParser' \(or a subclass\)"):
468468
root_parser.attach_subcommand([], "sub", ap_parser) # type: ignore[arg-type]
469469

470+
# Verify ValueError when subcommand name already exists
471+
sub_parser = Cmd2ArgumentParser(prog="sub")
472+
root_parser.attach_subcommand([], "sub", sub_parser)
473+
with pytest.raises(ValueError, match="Subcommand 'sub' already exists for 'root'"):
474+
root_parser.attach_subcommand([], "sub", sub_parser)
475+
470476

471477
def test_subcommand_attachment_parser_class_override() -> None:
472478
class MyParser(Cmd2ArgumentParser):

tests/test_cmd2.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4596,6 +4596,13 @@ class SubcmdErrorApp(cmd2.Cmd):
45964596
def __init__(self) -> None:
45974597
super().__init__()
45984598

4599+
test_parser = cmd2.Cmd2ArgumentParser()
4600+
test_parser.add_subparsers(required=True)
4601+
4602+
@cmd2.with_argparser(test_parser)
4603+
def do_test(self, _statement: cmd2.Statement) -> None:
4604+
pass
4605+
45994606
def do_no_argparse(self, _statement: cmd2.Statement) -> None:
46004607
pass
46014608

@@ -4606,9 +4613,14 @@ def do_no_argparse(self, _statement: cmd2.Statement) -> None:
46064613
app.attach_subcommand("", "sub", cmd2.Cmd2ArgumentParser())
46074614

46084615
# Test non-existent command
4609-
with pytest.raises(ValueError, match="Root command 'fake' not found"):
4616+
with pytest.raises(ValueError, match="Root command 'fake' does not exist"):
46104617
app.attach_subcommand("fake", "sub", cmd2.Cmd2ArgumentParser())
46114618

46124619
# Test command that doesn't use argparse
46134620
with pytest.raises(ValueError, match="Command 'no_argparse' does not use argparse"):
46144621
app.attach_subcommand("no_argparse", "sub", cmd2.Cmd2ArgumentParser())
4622+
4623+
# Test duplicate subcommand
4624+
app.attach_subcommand("test", "sub", cmd2.Cmd2ArgumentParser())
4625+
with pytest.raises(ValueError, match="Subcommand 'sub' already exists for 'test'"):
4626+
app.attach_subcommand("test", "sub", cmd2.Cmd2ArgumentParser())

0 commit comments

Comments
 (0)