Skip to content

Commit 31af376

Browse files
committed
Removed monkey patches and improved type hints.
1 parent 13c5742 commit 31af376

4 files changed

Lines changed: 55 additions & 159 deletions

File tree

cmd2/argparse_custom.py

Lines changed: 44 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -222,19 +222,6 @@ def get_choices(self) -> Choices:
222222
223223
- ``action.get_<name>()``
224224
- ``action.set_<name>(value)``
225-
226-
**Subcommand Manipulation**
227-
228-
cmd2 has patched ``argparse._SubParsersAction`` with new functions to better facilitate the
229-
addition and removal of subcommand parsers.
230-
231-
``argparse._SubParsersAction.attach_parser`` - new function to attach
232-
an existing ArgumentParser to a subparsers action. See ``_SubParsersAction_attach_parser``
233-
for more details.
234-
235-
``argparse._SubParsersAction.detach_parser`` - new function to detach a
236-
parser from a subparsers action. See ``_SubParsersAction_detach_parser`` for
237-
more details.
238225
"""
239226

240227
import argparse
@@ -522,87 +509,6 @@ def _ActionsContainer_add_argument( # noqa: N802
522509
setattr(argparse._ActionsContainer, 'add_argument', _ActionsContainer_add_argument)
523510

524511

525-
############################################################################################################
526-
# Patch argparse._SubParsersAction to add attach_parser function
527-
############################################################################################################
528-
529-
530-
def _SubParsersAction_attach_parser( # noqa: N802
531-
self: argparse._SubParsersAction, # type: ignore[type-arg]
532-
name: str,
533-
subcmd_parser: argparse.ArgumentParser,
534-
**add_parser_kwargs: Any,
535-
) -> None:
536-
"""Attach an existing parser to a subparsers action.
537-
538-
This is useful when a parser is pre-configured (e.g. by cmd2's subcommand decorator)
539-
and needs to be attached to a parent parser.
540-
541-
This function is added by cmd2 as a method called ``attach_parser()``
542-
to ``argparse._SubParsersAction`` class.
543-
544-
To call: ``action.attach_parser(name, subcmd_parser, **add_parser_kwargs)``
545-
546-
:param self: instance of the _SubParsersAction being edited
547-
:param name: name of the subcommand to add
548-
:param subcmd_parser: the parser for this new subcommand
549-
:param add_parser_kwargs: registration-specific kwargs for add_parser()
550-
(e.g. help, aliases, deprecated [Python 3.13+])
551-
"""
552-
# Use add_parser to register the subcommand name and any aliases
553-
self.add_parser(name, **add_parser_kwargs)
554-
555-
# Replace the parser created by add_parser() with our pre-configured one
556-
self._name_parser_map[name] = subcmd_parser
557-
558-
# Remap any aliases to our pre-configured parser
559-
for alias in add_parser_kwargs.get("aliases", ()):
560-
self._name_parser_map[alias] = subcmd_parser
561-
562-
563-
setattr(argparse._SubParsersAction, 'attach_parser', _SubParsersAction_attach_parser)
564-
565-
############################################################################################################
566-
# Patch argparse._SubParsersAction to add detach_parser function
567-
############################################################################################################
568-
569-
570-
def _SubParsersAction_detach_parser( # noqa: N802
571-
self: argparse._SubParsersAction, # type: ignore[type-arg]
572-
name: str,
573-
) -> argparse.ArgumentParser | None:
574-
"""Detach a parser from a subparsers action and return it.
575-
576-
This function is added by cmd2 as a method called ``detach_parser()`` to ``argparse._SubParsersAction`` class.
577-
578-
To call: ``action.detach_parser(name)``
579-
580-
:param self: instance of the _SubParsersAction being edited
581-
:param name: name of the subcommand for the parser to detach
582-
:return: the parser which was detached or None if the subcommand doesn't exist
583-
"""
584-
subparser = self._name_parser_map.get(name)
585-
586-
if subparser is not None:
587-
# Remove this subcommand and all its aliases from the base command
588-
to_remove = []
589-
for cur_name, cur_parser in self._name_parser_map.items():
590-
if cur_parser is subparser:
591-
to_remove.append(cur_name)
592-
for cur_name in to_remove:
593-
del self._name_parser_map[cur_name]
594-
595-
# Remove this subcommand from its base command's help text
596-
for choice_action in self._choices_actions:
597-
if choice_action.dest == name:
598-
self._choices_actions.remove(choice_action)
599-
break
600-
601-
return subparser
602-
603-
604-
setattr(argparse._SubParsersAction, 'detach_parser', _SubParsersAction_detach_parser)
605-
606512
############################################################################################################
607513
# Unless otherwise noted, everything below this point are copied from Python's
608514
# argparse implementation with minor tweaks to adjust output.
@@ -865,20 +771,26 @@ def __init__(
865771

866772
self.ap_completer_type = ap_completer_type
867773

868-
def add_subparsers(self, **kwargs: Any) -> argparse._SubParsersAction: # type: ignore[type-arg]
869-
"""Add a subcommand parser.
774+
def add_subparsers( # type: ignore[override]
775+
self,
776+
**kwargs: Any,
777+
) -> "argparse._SubParsersAction[Cmd2ArgumentParser]":
778+
"""Override for improved defaults and type safety.
870779
871-
Set a default title if one was not given.
780+
This override does two things.
781+
1. Sets a default title if one was not given.
782+
2. Narrows the return type to provide better IDE autocompletion
783+
and type safety for `Cmd2ArgumentParser` instances.
872784
873785
:param kwargs: additional keyword arguments
874-
:return: argparse Subparser Action
786+
:return: _SubParsersAction which stores Cmd2ArgumentParsers
875787
"""
876788
if 'title' not in kwargs:
877789
kwargs['title'] = 'subcommands'
878790

879791
return super().add_subparsers(**kwargs)
880792

881-
def _get_subparsers_action(self) -> argparse._SubParsersAction: # type: ignore[type-arg]
793+
def _get_subparsers_action(self) -> "argparse._SubParsersAction[Cmd2ArgumentParser]":
882794
"""Get the _SubParsersAction for this parser if it exists.
883795
884796
:return: the _SubParsersAction for this parser
@@ -951,7 +863,7 @@ def _find_parser(self, subcommand_path: Iterable[str]) -> 'Cmd2ArgumentParser':
951863
subparsers_action = parser._get_subparsers_action()
952864
if name not in subparsers_action.choices:
953865
raise ValueError(f"Subcommand '{name}' not found in '{parser.prog}'")
954-
parser = cast(Cmd2ArgumentParser, subparsers_action.choices[name])
866+
parser = subparsers_action.choices[name]
955867
return parser
956868

957869
def attach_subcommand(
@@ -972,7 +884,19 @@ def attach_subcommand(
972884
"""
973885
target_parser = self._find_parser(subcommand_path)
974886
subparsers_action = target_parser._get_subparsers_action()
975-
subparsers_action.attach_parser(subcommand, parser, **add_parser_kwargs) # type: ignore[attr-defined]
887+
888+
# Use add_parser to register the subcommand name and any aliases
889+
new_parser = subparsers_action.add_parser(subcommand, **add_parser_kwargs)
890+
891+
# Ensure the parser and any nested subparsers have the correct 'prog' value.
892+
parser.update_prog(new_parser.prog)
893+
894+
# Replace the parser created by add_parser() with our pre-configured one
895+
subparsers_action._name_parser_map[subcommand] = parser
896+
897+
# Remap any aliases to our pre-configured parser
898+
for alias in add_parser_kwargs.get("aliases", ()):
899+
subparsers_action._name_parser_map[alias] = parser
976900

977901
def detach_subcommand(self, subcommand_path: Iterable[str], subcommand: str) -> 'Cmd2ArgumentParser':
978902
"""Detach a subcommand from a command at the specified path.
@@ -985,10 +909,26 @@ def detach_subcommand(self, subcommand_path: Iterable[str], subcommand: str) ->
985909
"""
986910
target_parser = self._find_parser(subcommand_path)
987911
subparsers_action = target_parser._get_subparsers_action()
988-
detached = subparsers_action.detach_parser(subcommand) # type: ignore[attr-defined]
989-
if detached is None:
912+
913+
subparser = subparsers_action._name_parser_map.get(subcommand)
914+
if subparser is None:
990915
raise ValueError(f"Subcommand '{subcommand}' not found in '{target_parser.prog}'")
991-
return cast(Cmd2ArgumentParser, detached)
916+
917+
# Remove this subcommand and all its aliases from the base command
918+
to_remove = []
919+
for cur_name, cur_parser in subparsers_action._name_parser_map.items():
920+
if cur_parser is subparser:
921+
to_remove.append(cur_name)
922+
for cur_name in to_remove:
923+
del subparsers_action._name_parser_map[cur_name]
924+
925+
# Remove this subcommand from its base command's help text
926+
for choice_action in subparsers_action._choices_actions:
927+
if choice_action.dest == subcommand:
928+
subparsers_action._choices_actions.remove(choice_action)
929+
break
930+
931+
return subparser
992932

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

cmd2/cmd2.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,10 @@ def get(self, command_method: CommandFunc) -> Cmd2ArgumentParser | None:
273273
return None
274274

275275
parent = self._cmd.find_commandset_for_command(command) or self._cmd
276-
parser = self._cmd._build_parser(parent, parser_builder, command)
276+
parser = self._cmd._build_parser(parent, parser_builder)
277+
278+
# Ensure the parser and any nested subparsers have the correct 'prog' value.
279+
parser.update_prog(command)
277280

278281
# If the description has not been set, then use the method docstring if one exists
279282
if parser.description is None and command_method.__doc__:
@@ -888,7 +891,6 @@ def _build_parser(
888891
self,
889892
parent: CmdOrSet,
890893
parser_builder: Cmd2ArgumentParser | Callable[[], Cmd2ArgumentParser] | StaticArgParseBuilder | ClassArgParseBuilder,
891-
prog: str,
892894
) -> Cmd2ArgumentParser:
893895
"""Build argument parser for a command/subcommand.
894896
@@ -897,7 +899,6 @@ def _build_parser(
897899
parent's class to it.
898900
:param parser_builder: an existing Cmd2ArgumentParser instance or a factory
899901
(callable, staticmethod, or classmethod) that returns one.
900-
:param prog: prog value to set in new parser
901902
:return: new parser
902903
:raises TypeError: if parser_builder is an invalid type or if the factory fails
903904
to return a Cmd2ArgumentParser
@@ -920,8 +921,6 @@ def _build_parser(
920921
builder_name = getattr(parser_builder, "__name__", str(parser_builder)) # type: ignore[unreachable]
921922
raise TypeError(f"The parser returned by '{builder_name}' must be a Cmd2ArgumentParser or a subclass of it")
922923

923-
parser.update_prog(prog)
924-
925924
return parser
926925

927926
def _install_command_function(self, command_func_name: str, command_method: CommandFunc, context: str = '') -> None:
@@ -1047,7 +1046,7 @@ def check_parser_uninstallable(parser: Cmd2ArgumentParser) -> None:
10471046
raise CommandSetRegistrationError(
10481047
f"Cannot uninstall CommandSet: '{subparser.prog}' is required by another CommandSet"
10491048
)
1050-
check_parser_uninstallable(cast(Cmd2ArgumentParser, subparser))
1049+
check_parser_uninstallable(subparser)
10511050

10521051
methods: list[tuple[str, Callable[..., Any]]] = inspect.getmembers(
10531052
cmdset,
@@ -1096,7 +1095,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
10961095
raise CommandSetRegistrationError(f'Subcommand {subcommand_name} is not valid: {errmsg}')
10971096

10981097
# Create the subcommand parser and configure it
1099-
subcmd_parser = self._build_parser(cmdset, subcmd_parser_builder, f'{full_command_name} {subcommand_name}')
1098+
subcmd_parser = self._build_parser(cmdset, subcmd_parser_builder)
11001099
if subcmd_parser.description is None and method.__doc__:
11011100
subcmd_parser.description = strip_doc_annotations(method.__doc__)
11021101

tests/test_argparse.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -248,15 +248,15 @@ def test_preservelist(argparse_app) -> None:
248248
def test_invalid_parser_builder(argparse_app):
249249
parser_builder = None
250250
with pytest.raises(TypeError, match="Invalid type for parser_builder"):
251-
argparse_app._build_parser(argparse_app, parser_builder, "fake_prog")
251+
argparse_app._build_parser(argparse_app, parser_builder)
252252

253253

254254
def test_invalid_parser_return_type(argparse_app):
255255
def bad_builder():
256256
return argparse.ArgumentParser()
257257

258258
with pytest.raises(TypeError, match="must be a Cmd2ArgumentParser or a subclass of it"):
259-
argparse_app._build_parser(argparse_app, bad_builder, "fake_prog")
259+
argparse_app._build_parser(argparse_app, bad_builder)
260260

261261

262262
def test_invalid_parser_return_type_staticmethod(argparse_app):
@@ -266,7 +266,7 @@ def bad_builder():
266266
sm = staticmethod(bad_builder)
267267

268268
with pytest.raises(TypeError, match="must be a Cmd2ArgumentParser or a subclass of it"):
269-
argparse_app._build_parser(argparse_app, sm, "fake_prog")
269+
argparse_app._build_parser(argparse_app, sm)
270270

271271

272272
def test_invalid_parser_return_type_classmethod(argparse_app):
@@ -276,7 +276,7 @@ def bad_builder(cls):
276276
cm = classmethod(bad_builder)
277277

278278
with pytest.raises(TypeError, match="must be a Cmd2ArgumentParser or a subclass of it"):
279-
argparse_app._build_parser(argparse_app, cm, "fake_prog")
279+
argparse_app._build_parser(argparse_app, cm)
280280

281281

282282
def test_invalid_parser_return_type_nameless_object(argparse_app):
@@ -294,7 +294,7 @@ def __call__(self):
294294
expected_msg = f"The parser returned by '{builder}' must be a Cmd2ArgumentParser"
295295

296296
with pytest.raises(TypeError, match=expected_msg):
297-
argparse_app._build_parser(argparse_app, builder, "fake_prog")
297+
argparse_app._build_parser(argparse_app, builder)
298298

299299

300300
def _build_has_subcmd_parser() -> cmd2.Cmd2ArgumentParser:

tests/test_argparse_custom.py

Lines changed: 0 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -342,49 +342,6 @@ def test_register_argparse_argument_parameter() -> None:
342342
delattr(argparse.Action, attr_name)
343343

344344

345-
def test_parser_attachment() -> None:
346-
"""Test the monkey-patched attach_parser and detach_parser methods on argparse._SubParsersAction."""
347-
# Attach a parser as a subcommand
348-
root_parser = Cmd2ArgumentParser(prog="root", description="root command")
349-
root_subparsers = root_parser.add_subparsers()
350-
351-
child_parser = Cmd2ArgumentParser(prog="child", description="child command")
352-
root_subparsers.attach_parser( # type: ignore[attr-defined]
353-
"child",
354-
child_parser,
355-
help="a child command",
356-
aliases=["child_alias"],
357-
)
358-
359-
# Verify the same parser instance was used
360-
assert root_subparsers._name_parser_map["child"] is child_parser
361-
assert root_subparsers._name_parser_map["child_alias"] is child_parser
362-
363-
# Verify an action with the help text exists
364-
child_action = None
365-
for action in root_subparsers._choices_actions:
366-
if action.dest == "child":
367-
child_action = action
368-
break
369-
assert child_action is not None
370-
assert child_action.help == "a child command"
371-
372-
# Detatch the subcommand
373-
detached_parser = root_subparsers.detach_parser("child") # type: ignore[attr-defined]
374-
375-
# Verify subcommand and its aliases were removed
376-
assert detached_parser is child_parser
377-
assert "child" not in root_subparsers._name_parser_map
378-
assert "child_alias" not in root_subparsers._name_parser_map
379-
380-
# Verify the help text action was removed
381-
choices_actions = [action.dest for action in root_subparsers._choices_actions]
382-
assert "child" not in choices_actions
383-
384-
# Verify it returns None when subcommand does not exist
385-
assert root_subparsers.detach_parser("fake") is None # type: ignore[attr-defined]
386-
387-
388345
def test_subcommand_attachment() -> None:
389346
"""Test Cmd2ArgumentParser convenience methods for attaching and detaching subcommands."""
390347

0 commit comments

Comments
 (0)