Skip to content

Commit 40bf34a

Browse files
committed
Enforcing that a subparser's type match the _SubparsersAction's parser class.
1 parent b2b888c commit 40bf34a

4 files changed

Lines changed: 54 additions & 10 deletions

File tree

cmd2/argparse_custom.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -890,7 +890,7 @@ def _find_parser(self, subcommand_path: Iterable[str]) -> 'Cmd2ArgumentParser':
890890
"""Find a parser in the hierarchy based on a sequence of subcommand names.
891891
892892
:param subcommand_path: sequence of subcommand names leading to the target parser
893-
:return: the discovered Cmd2ArgumentParser
893+
:return: the discovered parser
894894
:raises ValueError: if any subcommand in the path is not found or a level doesn't support subcommands
895895
"""
896896
parser = self
@@ -905,34 +905,43 @@ def attach_subcommand(
905905
self,
906906
subcommand_path: Iterable[str],
907907
subcommand: str,
908-
parser: 'Cmd2ArgumentParser',
908+
subcommand_parser: 'Cmd2ArgumentParser',
909909
**add_parser_kwargs: Any,
910910
) -> None:
911911
"""Attach a parser as a subcommand to a command at the specified path.
912912
913913
:param subcommand_path: sequence of subcommand names leading to the parser that will
914914
host the new subcommand. An empty sequence indicates this parser.
915915
:param subcommand: name of the new subcommand
916-
:param parser: the parser to attach
916+
:param subcommand_parser: the parser to attach
917917
:param add_parser_kwargs: additional arguments for the subparser registration (e.g. help, aliases)
918+
:raises TypeError: if the subcommand parser's type does not match the 'parser_class' configured
919+
for the target subcommand group.
918920
:raises ValueError: if the command path is invalid or doesn't support subcommands
919921
"""
920922
target_parser = self._find_parser(subcommand_path)
921923
subparsers_action = target_parser._get_subparsers_action()
922924

925+
if type(subcommand_parser) is not subparsers_action._parser_class:
926+
raise TypeError(
927+
f"The attached parser must be of type '{subparsers_action._parser_class.__name__}' "
928+
f"to match the 'parser_class' configured for this subparsers action. "
929+
f"Received '{type(subcommand_parser).__name__}'."
930+
)
931+
923932
# Use add_parser to register the subcommand name and any aliases
924-
new_parser = subparsers_action.add_parser(subcommand, **add_parser_kwargs)
933+
placeholder_parser = subparsers_action.add_parser(subcommand, **add_parser_kwargs)
925934

926935
# To ensure accurate usage strings, recursively update 'prog' values
927936
# within the injected parser to match its new location in the command hierarchy.
928-
parser.update_prog(new_parser.prog)
937+
subcommand_parser.update_prog(placeholder_parser.prog)
929938

930939
# Replace the parser created by add_parser() with our pre-configured one
931-
subparsers_action._name_parser_map[subcommand] = parser
940+
subparsers_action._name_parser_map[subcommand] = subcommand_parser
932941

933942
# Remap any aliases to our pre-configured parser
934943
for alias in add_parser_kwargs.get("aliases", ()):
935-
subparsers_action._name_parser_map[alias] = parser
944+
subparsers_action._name_parser_map[alias] = subcommand_parser
936945

937946
def detach_subcommand(self, subcommand_path: Iterable[str], subcommand: str) -> 'Cmd2ArgumentParser':
938947
"""Detach a subcommand from a command at the specified path.

cmd2/cmd2.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,20 +1179,22 @@ def attach_subcommand(
11791179
self,
11801180
command: str,
11811181
subcommand: str,
1182-
parser: Cmd2ArgumentParser,
1182+
subcommand_parser: Cmd2ArgumentParser,
11831183
**add_parser_kwargs: Any,
11841184
) -> None:
11851185
"""Attach a parser as a subcommand to a command at the specified path.
11861186
11871187
:param command: full command path (space-delimited) leading to the parser that will
11881188
host the new subcommand (e.g. 'foo bar')
11891189
:param subcommand: name of the new subcommand
1190-
:param parser: the parser to attach
1190+
:param subcommand_parser: the parser to attach
11911191
:param add_parser_kwargs: additional arguments for the subparser registration (e.g. help, aliases)
1192+
:raises TypeError: if the subcommand parser's type does not match the 'parser_class' configured
1193+
for the target subcommand group.
11921194
:raises ValueError: if the command path is invalid or doesn't support subcommands
11931195
"""
11941196
root_parser, subcommand_path = self._get_root_parser_and_subcmd_path(command)
1195-
root_parser.attach_subcommand(subcommand_path, subcommand, parser, **add_parser_kwargs)
1197+
root_parser.attach_subcommand(subcommand_path, subcommand, subcommand_parser, **add_parser_kwargs)
11961198

11971199
def detach_subcommand(self, command: str, subcommand: str) -> Cmd2ArgumentParser:
11981200
"""Detach a subcommand from a command at the specified path.

tests/test_argparse_custom.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,33 @@ def test_subcommand_attachment_errors() -> None:
425425
with pytest.raises(ValueError, match="Subcommand 'fake' not found in 'root'"):
426426
root_parser.detach_subcommand([], "fake")
427427

428+
# Verify TypeError when attaching a parser of a different type
429+
class SubParser(Cmd2ArgumentParser):
430+
pass
431+
432+
subclass_parser = SubParser(prog="sub")
433+
with pytest.raises(TypeError, match="The attached parser must be of type 'Cmd2ArgumentParser'"):
434+
root_parser.attach_subcommand([], "sub", subclass_parser)
435+
436+
437+
def test_subcommand_attachment_parser_class_override() -> None:
438+
class MyParser(Cmd2ArgumentParser):
439+
pass
440+
441+
root_parser = Cmd2ArgumentParser(prog="root")
442+
443+
# Explicitly override parser_class for this subparsers action
444+
root_parser.add_subparsers(parser_class=MyParser)
445+
446+
# Attaching a MyParser instance should succeed
447+
my_parser = MyParser(prog="sub")
448+
root_parser.attach_subcommand([], "sub", my_parser)
449+
450+
# Attaching a standard Cmd2ArgumentParser instance should fail
451+
standard_parser = Cmd2ArgumentParser(prog="standard")
452+
with pytest.raises(TypeError, match="The attached parser must be of type 'MyParser'"):
453+
root_parser.attach_subcommand([], "fail", standard_parser)
454+
428455

429456
def test_completion_items_as_choices(capsys) -> None:
430457
"""Test cmd2's patch to Argparse._check_value() which supports CompletionItems as choices.

tests/test_cmd2.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4534,3 +4534,9 @@ def do_no_argparse(self, _statement: cmd2.Statement) -> None:
45344534
# Test command that doesn't use argparse
45354535
with pytest.raises(ValueError, match="Command 'no_argparse' does not use argparse"):
45364536
app.attach_subcommand("no_argparse", "sub", cmd2.Cmd2ArgumentParser())
4537+
4538+
# Test type mismatch
4539+
import argparse
4540+
4541+
with pytest.raises(TypeError, match="to match the 'parser_class' configured for this subparsers action"):
4542+
app.attach_subcommand("alias", "sub", argparse.ArgumentParser()) # type: ignore[arg-type]

0 commit comments

Comments
 (0)