Skip to content

Commit 41eef55

Browse files
authored
Validate subparser class and remove default subcommand title (#1636)
* Improved attach_subcommand() by validating that attached parsers are Cmd2ArgumentParser instances and match the subparsers group's parser_class. * Removed default subcommands title from add_subparsers() to align with standard argparse behavior.
1 parent b2b888c commit 41eef55

5 files changed

Lines changed: 70 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,9 @@ prompt is displayed.
7878
the Enhancements section below for details).
7979
- Removed `Cmd.undoc_header` since all commands are now considered categorized.
8080
- Renamed `Cmd.cmd_func()` to `Cmd.get_command_func()`.
81+
- `cmd2` no longer sets a default title for a subparsers group. If you desire a title, you will
82+
need to pass one in like this `parser.add_subparsers(title="subcommands")`. This is standard
83+
`argparse` behavior.
8184
- Enhancements
8285
- New `cmd2.Cmd` parameters
8386
- **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These

cmd2/argparse_custom.py

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -772,25 +772,6 @@ def __init__(
772772
self.description: RenderableType | None # type: ignore[assignment]
773773
self.epilog: RenderableType | None # type: ignore[assignment]
774774

775-
def add_subparsers( # type: ignore[override]
776-
self,
777-
**kwargs: Any,
778-
) -> "argparse._SubParsersAction[Cmd2ArgumentParser]":
779-
"""Override for improved defaults and type safety.
780-
781-
This override does two things.
782-
1. Sets a default title if one was not given.
783-
2. Narrows the return type to provide better IDE autocompletion
784-
and type safety for `Cmd2ArgumentParser` instances.
785-
786-
:param kwargs: additional keyword arguments
787-
:return: _SubParsersAction which stores Cmd2ArgumentParsers
788-
"""
789-
if 'title' not in kwargs:
790-
kwargs['title'] = 'subcommands'
791-
792-
return super().add_subparsers(**kwargs)
793-
794775
def _get_subparsers_action(self) -> "argparse._SubParsersAction[Cmd2ArgumentParser]":
795776
"""Get the _SubParsersAction for this parser if it exists.
796777
@@ -890,7 +871,7 @@ def _find_parser(self, subcommand_path: Iterable[str]) -> 'Cmd2ArgumentParser':
890871
"""Find a parser in the hierarchy based on a sequence of subcommand names.
891872
892873
:param subcommand_path: sequence of subcommand names leading to the target parser
893-
:return: the discovered Cmd2ArgumentParser
874+
:return: the discovered parser
894875
:raises ValueError: if any subcommand in the path is not found or a level doesn't support subcommands
895876
"""
896877
parser = self
@@ -905,34 +886,54 @@ def attach_subcommand(
905886
self,
906887
subcommand_path: Iterable[str],
907888
subcommand: str,
908-
parser: 'Cmd2ArgumentParser',
889+
subcommand_parser: 'Cmd2ArgumentParser',
909890
**add_parser_kwargs: Any,
910891
) -> None:
911892
"""Attach a parser as a subcommand to a command at the specified path.
912893
913894
:param subcommand_path: sequence of subcommand names leading to the parser that will
914895
host the new subcommand. An empty sequence indicates this parser.
915896
:param subcommand: name of the new subcommand
916-
:param parser: the parser to attach
897+
:param subcommand_parser: the parser to attach
917898
:param add_parser_kwargs: additional arguments for the subparser registration (e.g. help, aliases)
899+
:raises TypeError: if subcommand_parser is not an instance of the following or their subclasses:
900+
1. Cmd2ArgumentParser
901+
2. The parser_class configured for the target subcommand group
918902
:raises ValueError: if the command path is invalid or doesn't support subcommands
919903
"""
904+
if not isinstance(subcommand_parser, Cmd2ArgumentParser):
905+
raise TypeError(
906+
f"The attached parser must be an instance of 'Cmd2ArgumentParser' (or a subclass). "
907+
f"Received: '{type(subcommand_parser).__name__}'."
908+
)
909+
920910
target_parser = self._find_parser(subcommand_path)
921911
subparsers_action = target_parser._get_subparsers_action()
922912

913+
# Verify the parser is compatible with the 'parser_class' configured for this
914+
# subcommand group. We use isinstance() here to allow for subclasses, providing
915+
# more flexibility than the standard add_parser() factory approach which enforces
916+
# a specific class.
917+
if not isinstance(subcommand_parser, subparsers_action._parser_class):
918+
raise TypeError(
919+
f"The attached parser must be an instance of '{subparsers_action._parser_class.__name__}' "
920+
f"(or a subclass) to match the 'parser_class' configured for this subcommand group. "
921+
f"Received: '{type(subcommand_parser).__name__}'."
922+
)
923+
923924
# Use add_parser to register the subcommand name and any aliases
924-
new_parser = subparsers_action.add_parser(subcommand, **add_parser_kwargs)
925+
placeholder_parser = subparsers_action.add_parser(subcommand, **add_parser_kwargs)
925926

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

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

933934
# Remap any aliases to our pre-configured parser
934935
for alias in add_parser_kwargs.get("aliases", ()):
935-
subparsers_action._name_parser_map[alias] = parser
936+
subparsers_action._name_parser_map[alias] = subcommand_parser
936937

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

cmd2/cmd2.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1179,20 +1179,23 @@ 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 subcommand_parser is not an instance of the following or their subclasses:
1193+
1. Cmd2ArgumentParser
1194+
2. The parser_class configured for the target subcommand group
11921195
:raises ValueError: if the command path is invalid or doesn't support subcommands
11931196
"""
11941197
root_parser, subcommand_path = self._get_root_parser_and_subcmd_path(command)
1195-
root_parser.attach_subcommand(subcommand_path, subcommand, parser, **add_parser_kwargs)
1198+
root_parser.attach_subcommand(subcommand_path, subcommand, subcommand_parser, **add_parser_kwargs)
11961199

11971200
def detach_subcommand(self, command: str, subcommand: str) -> Cmd2ArgumentParser:
11981201
"""Detach a subcommand from a command at the specified path.
@@ -3726,7 +3729,7 @@ def _build_alias_parser() -> Cmd2ArgumentParser:
37263729
"See Also",
37273730
"macro",
37283731
)
3729-
alias_parser.add_subparsers(metavar='SUBCOMMAND', required=True)
3732+
alias_parser.add_subparsers(title="subcommands", metavar="SUBCOMMAND", required=True)
37303733

37313734
return alias_parser
37323735

@@ -3942,7 +3945,7 @@ def _build_macro_parser() -> Cmd2ArgumentParser:
39423945
"See Also",
39433946
"alias",
39443947
)
3945-
macro_parser.add_subparsers(metavar='SUBCOMMAND', required=True)
3948+
macro_parser.add_subparsers(title="subcommands", metavar="SUBCOMMAND", required=True)
39463949

39473950
return macro_parser
39483951

cmd2/decorators.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ def as_subcommand_to(
370370
Example:
371371
```py
372372
base_parser = cmd2.Cmd2ArgumentParser()
373-
base_parser.add_subparsers(metavar='SUBCOMMAND', required=True)
373+
base_parser.add_subparsers(title="subcommands", metavar="SUBCOMMAND", required=True)
374374
sub_parser = cmd2.Cmd2ArgumentParser()
375375
376376
class MyApp(cmd2.Cmd):

tests/test_argparse_custom.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,37 @@ 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 non-Cmd2ArgumentParser type
429+
ap_parser = argparse.ArgumentParser(prog="non-cmd2-parser")
430+
with pytest.raises(TypeError, match=r"must be an instance of 'Cmd2ArgumentParser' \(or a subclass\)"):
431+
root_parser.attach_subcommand([], "sub", ap_parser) # type: ignore[arg-type]
432+
433+
434+
def test_subcommand_attachment_parser_class_override() -> None:
435+
class MyParser(Cmd2ArgumentParser):
436+
pass
437+
438+
class MySubParser(MyParser):
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 MySubParser instance should also succeed (isinstance check)
451+
my_sub_parser = MySubParser(prog="sub2")
452+
root_parser.attach_subcommand([], "sub2", my_sub_parser)
453+
454+
# Attaching a standard Cmd2ArgumentParser instance should fail
455+
standard_parser = Cmd2ArgumentParser(prog="standard")
456+
with pytest.raises(TypeError, match=r"must be an instance of 'MyParser' \(or a subclass\)"):
457+
root_parser.attach_subcommand([], "fail", standard_parser)
458+
428459

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

0 commit comments

Comments
 (0)