Skip to content

Commit 039f92b

Browse files
committed
Changed with_argparser() and as_subcommand_to() to accept either an ArgumentParser or a
factory callable that returns an ArgumentParser. Changed Cmd constructor to construct an instance-specific ArgumentParser using either the factory callable or by deep-copying the provided ArgumentParser. With this change a new argparse instance should be created for each instance of Cmd. Addresses #1002
1 parent 9886b82 commit 039f92b

File tree

6 files changed

+179
-70
lines changed

6 files changed

+179
-70
lines changed

cmd2/cmd2.py

Lines changed: 82 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
# setting is True
3131
import argparse
3232
import cmd
33+
import copy
3334
import functools
3435
import glob
3536
import inspect
@@ -140,6 +141,7 @@
140141
from .utils import (
141142
Settable,
142143
get_defining_class,
144+
is_callable,
143145
strip_doc_annotations,
144146
suggest_similar,
145147
)
@@ -510,6 +512,16 @@ def __init__(
510512
# This does not affect self.formatted_completions.
511513
self.matches_sorted = False
512514

515+
# Command parsers for this Cmd instance.
516+
self._command_parsers: Dict[str, argparse.ArgumentParser] = {}
517+
518+
# Locates the command parser template or factory and creates an instance-specific parser
519+
for command in self.get_all_commands():
520+
self._register_command_parser(command, self.cmd_func(command)) # type: ignore[arg-type]
521+
522+
# Add functions decorated to be subcommands
523+
self._register_subcommands(self)
524+
513525
############################################################################################################
514526
# The following code block loads CommandSets, verifies command names, and registers subcommands.
515527
# This block should appear after all attributes have been created since the registration code
@@ -529,9 +541,6 @@ def __init__(
529541
if not valid:
530542
raise ValueError(f"Invalid command name '{cur_cmd}': {errmsg}")
531543

532-
# Add functions decorated to be subcommands
533-
self._register_subcommands(self)
534-
535544
self.suggest_similar_command = suggest_similar_command
536545
self.default_suggestion_message = "Did you mean {}?"
537546

@@ -659,6 +668,43 @@ def register_command_set(self, cmdset: CommandSet) -> None:
659668
cmdset.on_unregistered()
660669
raise
661670

671+
def _build_parser(self, parent: Union['Cmd', CommandSet], parser_builder: Any) -> Optional[argparse.ArgumentParser]:
672+
parser: Optional[argparse.ArgumentParser] = None
673+
if is_callable(parser_builder):
674+
if isinstance(parser_builder, staticmethod):
675+
parser = cast(argparse.ArgumentParser, parser_builder.__func__())
676+
elif isinstance(parser_builder, classmethod):
677+
parser = cast(argparse.ArgumentParser, parser_builder.__func__(parent if not None else self))
678+
else:
679+
parser = cast(argparse.ArgumentParser, parser_builder()) # type: ignore[misc]
680+
elif isinstance(parser_builder, argparse.ArgumentParser):
681+
if sys.version_info >= (3, 6, 4):
682+
parser = copy.deepcopy(parser_builder)
683+
else: # pragma: no cover
684+
parser = parser_builder
685+
return parser
686+
687+
def _register_command_parser(self, command: str, command_method: Callable[..., Any]) -> None:
688+
if command not in self._command_parsers:
689+
parser_builder = getattr(command_method, constants.CMD_ATTR_ARGPARSER, None)
690+
parent = self.find_commandset_for_command(command) or self
691+
parser = self._build_parser(parent, parser_builder)
692+
if parser is None:
693+
return
694+
695+
# argparser defaults the program name to sys.argv[0], but we want it to be the name of our command
696+
from .decorators import (
697+
_set_parser_prog,
698+
)
699+
700+
_set_parser_prog(parser, command)
701+
702+
# If the description has not been set, then use the method docstring if one exists
703+
if parser.description is None and hasattr(command_method, '__wrapped__') and command_method.__wrapped__.__doc__:
704+
parser.description = strip_doc_annotations(command_method.__wrapped__.__doc__)
705+
706+
self._command_parsers[command] = parser
707+
662708
def _install_command_function(self, command: str, command_wrapper: Callable[..., Any], context: str = '') -> None:
663709
cmd_func_name = COMMAND_FUNC_PREFIX + command
664710

@@ -681,6 +727,8 @@ def _install_command_function(self, command: str, command_wrapper: Callable[...,
681727
self.pwarning(f"Deleting macro '{command}' because it shares its name with a new command")
682728
del self.macros[command]
683729

730+
self._register_command_parser(command, command_wrapper)
731+
684732
setattr(self, cmd_func_name, command_wrapper)
685733

686734
def _install_completer_function(self, cmd_name: str, cmd_completer: CompleterFunc) -> None:
@@ -727,6 +775,8 @@ def unregister_command_set(self, cmdset: CommandSet) -> None:
727775
del self._cmd_to_command_sets[cmd_name]
728776

729777
delattr(self, COMMAND_FUNC_PREFIX + cmd_name)
778+
if cmd_name in self._command_parsers:
779+
del self._command_parsers[cmd_name]
730780

731781
if hasattr(self, COMPLETER_FUNC_PREFIX + cmd_name):
732782
delattr(self, COMPLETER_FUNC_PREFIX + cmd_name)
@@ -746,14 +796,7 @@ def _check_uninstallable(self, cmdset: CommandSet) -> None:
746796

747797
for method in methods:
748798
command_name = method[0][len(COMMAND_FUNC_PREFIX) :]
749-
750-
# Search for the base command function and verify it has an argparser defined
751-
if command_name in self.disabled_commands:
752-
command_func = self.disabled_commands[command_name].command_function
753-
else:
754-
command_func = self.cmd_func(command_name)
755-
756-
command_parser = cast(argparse.ArgumentParser, getattr(command_func, constants.CMD_ATTR_ARGPARSER, None))
799+
command_parser = self._command_parsers.get(command_name, None)
757800

758801
def check_parser_uninstallable(parser: argparse.ArgumentParser) -> None:
759802
for action in parser._actions:
@@ -792,7 +835,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
792835
for method_name, method in methods:
793836
subcommand_name: str = getattr(method, constants.SUBCMD_ATTR_NAME)
794837
full_command_name: str = getattr(method, constants.SUBCMD_ATTR_COMMAND)
795-
subcmd_parser = getattr(method, constants.CMD_ATTR_ARGPARSER)
838+
subcmd_parser_builder = getattr(method, constants.CMD_ATTR_ARGPARSER)
796839

797840
subcommand_valid, errmsg = self.statement_parser.is_valid_command(subcommand_name, is_subcommand=True)
798841
if not subcommand_valid:
@@ -812,7 +855,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
812855
raise CommandSetRegistrationError(
813856
f"Could not find command '{command_name}' needed by subcommand: {str(method)}"
814857
)
815-
command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None)
858+
command_parser = self._command_parsers.get(command_name, None)
816859
if command_parser is None:
817860
raise CommandSetRegistrationError(
818861
f"Could not find argparser for command '{command_name}' needed by subcommand: {str(method)}"
@@ -832,16 +875,17 @@ def find_subcommand(action: argparse.ArgumentParser, subcmd_names: List[str]) ->
832875

833876
target_parser = find_subcommand(command_parser, subcommand_names)
834877

878+
subcmd_parser = cast(argparse.ArgumentParser, self._build_parser(cmdset, subcmd_parser_builder))
879+
from .decorators import (
880+
_set_parser_prog,
881+
)
882+
883+
_set_parser_prog(subcmd_parser, f'{command_name} {subcommand_name}')
884+
if subcmd_parser.description is None and method.__doc__:
885+
subcmd_parser.description = strip_doc_annotations(method.__doc__)
886+
835887
for action in target_parser._actions:
836888
if isinstance(action, argparse._SubParsersAction):
837-
# Temporary workaround for avoiding subcommand help text repeatedly getting added to
838-
# action._choices_actions. Until we have instance-specific parser objects, we will remove
839-
# any existing subcommand which has the same name before replacing it. This problem is
840-
# exercised when more than one cmd2.Cmd-based object is created and the same subcommands
841-
# get added each time. Argparse overwrites the previous subcommand but keeps growing the help
842-
# text which is shown by running something like 'alias -h'.
843-
action.remove_parser(subcommand_name) # type: ignore[arg-type,attr-defined]
844-
845889
# Get the kwargs for add_parser()
846890
add_parser_kwargs = getattr(method, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, {})
847891

@@ -913,7 +957,7 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
913957
raise CommandSetRegistrationError(
914958
f"Could not find command '{command_name}' needed by subcommand: {str(method)}"
915959
)
916-
command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None)
960+
command_parser = self._command_parsers.get(command_name, None)
917961
if command_parser is None: # pragma: no cover
918962
# This really shouldn't be possible since _register_subcommands would prevent this from happening
919963
# but keeping in case it does for some strange reason
@@ -2034,7 +2078,7 @@ def _perform_completion(
20342078
else:
20352079
# There's no completer function, next see if the command uses argparse
20362080
func = self.cmd_func(command)
2037-
argparser: Optional[argparse.ArgumentParser] = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
2081+
argparser = self._command_parsers.get(command, None)
20382082

20392083
if func is not None and argparser is not None:
20402084
# Get arguments for complete()
@@ -3259,14 +3303,19 @@ def _cmdloop(self) -> None:
32593303
#############################################################
32603304

32613305
# Top-level parser for alias
3262-
alias_description = "Manage aliases\n" "\n" "An alias is a command that enables replacement of a word by another string."
3263-
alias_epilog = "See also:\n" " macro"
3264-
alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description, epilog=alias_epilog)
3265-
alias_subparsers = alias_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND')
3266-
alias_subparsers.required = True
3306+
@staticmethod
3307+
def _build_alias_parser() -> argparse.ArgumentParser:
3308+
alias_description = (
3309+
"Manage aliases\n" "\n" "An alias is a command that enables replacement of a word by another string."
3310+
)
3311+
alias_epilog = "See also:\n" " macro"
3312+
alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description, epilog=alias_epilog)
3313+
alias_subparsers = alias_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND')
3314+
alias_subparsers.required = True
3315+
return alias_parser
32673316

32683317
# Preserve quotes since we are passing strings to other commands
3269-
@with_argparser(alias_parser, preserve_quotes=True)
3318+
@with_argparser(_build_alias_parser, preserve_quotes=True)
32703319
def do_alias(self, args: argparse.Namespace) -> None:
32713320
"""Manage aliases"""
32723321
# Call handler for whatever subcommand was selected
@@ -3681,7 +3730,7 @@ def complete_help_subcommands(
36813730

36823731
# Check if this command uses argparse
36833732
func = self.cmd_func(command)
3684-
argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
3733+
argparser = self._command_parsers.get(command, None)
36853734
if func is None or argparser is None:
36863735
return []
36873736

@@ -3717,7 +3766,7 @@ def do_help(self, args: argparse.Namespace) -> None:
37173766
# Getting help for a specific command
37183767
func = self.cmd_func(args.command)
37193768
help_func = getattr(self, constants.HELP_FUNC_PREFIX + args.command, None)
3720-
argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
3769+
argparser = self._command_parsers.get(args.command, None)
37213770

37223771
# If the command function uses argparse, then use argparse's help
37233772
if func is not None and argparser is not None:
@@ -3853,7 +3902,7 @@ def _build_command_info(self) -> Tuple[Dict[str, List[str]], List[str], List[str
38533902
help_topics.remove(command)
38543903

38553904
# Non-argparse commands can have help_functions for their documentation
3856-
if not hasattr(func, constants.CMD_ATTR_ARGPARSER):
3905+
if command not in self._command_parsers:
38573906
has_help_func = True
38583907

38593908
if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY):
@@ -3899,7 +3948,7 @@ def _print_topics(self, header: str, cmds: List[str], verbose: bool) -> None:
38993948
doc: Optional[str]
39003949

39013950
# Non-argparse commands can have help_functions for their documentation
3902-
if not hasattr(cmd_func, constants.CMD_ATTR_ARGPARSER) and command in topics:
3951+
if command not in self._command_parsers and command in topics:
39033952
help_func = getattr(self, constants.HELP_FUNC_PREFIX + command)
39043953
result = io.StringIO()
39053954

cmd2/decorators.py

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
Tuple,
1313
TypeVar,
1414
Union,
15+
overload,
1516
)
1617

1718
from . import (
@@ -30,9 +31,6 @@
3031
from .parsing import (
3132
Statement,
3233
)
33-
from .utils import (
34-
strip_doc_annotations,
35-
)
3634

3735
if TYPE_CHECKING: # pragma: no cover
3836
import cmd2
@@ -261,17 +259,39 @@ def _set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None:
261259
]
262260

263261

262+
@overload
264263
def with_argparser(
265264
parser: argparse.ArgumentParser,
266265
*,
267266
ns_provider: Optional[Callable[..., argparse.Namespace]] = None,
268267
preserve_quotes: bool = False,
269268
with_unknown_args: bool = False,
269+
) -> Callable[[ArgparseCommandFunc[CommandParent]], RawCommandFuncOptionalBoolReturn[CommandParent]]:
270+
... # pragma: no cover
271+
272+
273+
@overload
274+
def with_argparser(
275+
parser: Callable[[], argparse.ArgumentParser],
276+
*,
277+
ns_provider: Optional[Callable[..., argparse.Namespace]] = None,
278+
preserve_quotes: bool = False,
279+
with_unknown_args: bool = False,
280+
) -> Callable[[ArgparseCommandFunc[CommandParent]], RawCommandFuncOptionalBoolReturn[CommandParent]]:
281+
... # pragma: no cover
282+
283+
284+
def with_argparser(
285+
parser: Union[argparse.ArgumentParser, Callable[[], argparse.ArgumentParser]],
286+
*,
287+
ns_provider: Optional[Callable[..., argparse.Namespace]] = None,
288+
preserve_quotes: bool = False,
289+
with_unknown_args: bool = False,
270290
) -> Callable[[ArgparseCommandFunc[CommandParent]], RawCommandFuncOptionalBoolReturn[CommandParent]]:
271291
"""A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments
272292
with the given instance of argparse.ArgumentParser.
273293
274-
:param parser: unique instance of ArgumentParser
294+
:param parser: unique instance of ArgumentParser or a callable that returns an ArgumentParser
275295
:param ns_provider: An optional function that accepts a cmd2.Cmd or cmd2.CommandSet object as an argument and returns an
276296
argparse.Namespace. This is useful if the Namespace needs to be prepopulated with state data that
277297
affects parsing.
@@ -339,6 +359,9 @@ def cmd_wrapper(*args: Any, **kwargs: Dict[str, Any]) -> Optional[bool]:
339359
statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(
340360
command_name, statement_arg, preserve_quotes
341361
)
362+
arg_parser = cmd2_app._command_parsers.get(command_name, None)
363+
if arg_parser is None:
364+
raise ValueError(f'No argument parser found for {command_name}')
342365

343366
if ns_provider is None:
344367
namespace = None
@@ -352,9 +375,9 @@ def cmd_wrapper(*args: Any, **kwargs: Dict[str, Any]) -> Optional[bool]:
352375
try:
353376
new_args: Union[Tuple[argparse.Namespace], Tuple[argparse.Namespace, List[str]]]
354377
if with_unknown_args:
355-
new_args = parser.parse_known_args(parsed_arglist, namespace)
378+
new_args = arg_parser.parse_known_args(parsed_arglist, namespace)
356379
else:
357-
new_args = (parser.parse_args(parsed_arglist, namespace),)
380+
new_args = (arg_parser.parse_args(parsed_arglist, namespace),)
358381
ns = new_args[0]
359382
except SystemExit:
360383
raise Cmd2ArgparseError
@@ -374,16 +397,7 @@ def cmd_wrapper(*args: Any, **kwargs: Dict[str, Any]) -> Optional[bool]:
374397
args_list = _arg_swap(args, statement_arg, *new_args)
375398
return func(*args_list, **kwargs) # type: ignore[call-arg]
376399

377-
# argparser defaults the program name to sys.argv[0], but we want it to be the name of our command
378400
command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX) :]
379-
_set_parser_prog(parser, command_name)
380-
381-
# If the description has not been set, then use the method docstring if one exists
382-
if parser.description is None and func.__doc__:
383-
parser.description = strip_doc_annotations(func.__doc__)
384-
385-
# Set the command's help text as argparser.description (which can be None)
386-
cmd_wrapper.__doc__ = parser.description
387401

388402
# Set some custom attributes for this command
389403
setattr(cmd_wrapper, constants.CMD_ATTR_ARGPARSER, parser)
@@ -395,13 +409,37 @@ def cmd_wrapper(*args: Any, **kwargs: Dict[str, Any]) -> Optional[bool]:
395409
return arg_decorator
396410

397411

412+
@overload
398413
def as_subcommand_to(
399414
command: str,
400415
subcommand: str,
401416
parser: argparse.ArgumentParser,
402417
*,
403418
help: Optional[str] = None,
404419
aliases: Optional[List[str]] = None,
420+
) -> Callable[[ArgparseCommandFunc[CommandParent]], ArgparseCommandFunc[CommandParent]]:
421+
... # pragma: no cover
422+
423+
424+
@overload
425+
def as_subcommand_to(
426+
command: str,
427+
subcommand: str,
428+
parser: Callable[[], argparse.ArgumentParser],
429+
*,
430+
help: Optional[str] = None,
431+
aliases: Optional[List[str]] = None,
432+
) -> Callable[[ArgparseCommandFunc[CommandParent]], ArgparseCommandFunc[CommandParent]]:
433+
... # pragma: no cover
434+
435+
436+
def as_subcommand_to(
437+
command: str,
438+
subcommand: str,
439+
parser: Union[argparse.ArgumentParser, Callable[[], argparse.ArgumentParser]],
440+
*,
441+
help: Optional[str] = None,
442+
aliases: Optional[List[str]] = None,
405443
) -> Callable[[ArgparseCommandFunc[CommandParent]], ArgparseCommandFunc[CommandParent]]:
406444
"""
407445
Tag this method as a subcommand to an existing argparse decorated command.
@@ -417,12 +455,6 @@ def as_subcommand_to(
417455
"""
418456

419457
def arg_decorator(func: ArgparseCommandFunc[CommandParent]) -> ArgparseCommandFunc[CommandParent]:
420-
_set_parser_prog(parser, command + ' ' + subcommand)
421-
422-
# If the description has not been set, then use the method docstring if one exists
423-
if parser.description is None and func.__doc__:
424-
parser.description = func.__doc__
425-
426458
# Set some custom attributes for this command
427459
setattr(func, constants.SUBCMD_ATTR_COMMAND, command)
428460
setattr(func, constants.CMD_ATTR_ARGPARSER, parser)

0 commit comments

Comments
 (0)