Skip to content

Commit 3dec00d

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 3dec00d

File tree

6 files changed

+202
-76
lines changed

6 files changed

+202
-76
lines changed

cmd2/cmd2.py

Lines changed: 96 additions & 34 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
@@ -69,6 +70,7 @@
6970
TypeVar,
7071
Union,
7172
cast,
73+
TYPE_CHECKING,
7274
)
7375

7476
from . import (
@@ -100,7 +102,7 @@
100102
)
101103
from .decorators import (
102104
as_subcommand_to,
103-
with_argparser,
105+
with_argparser, CommandParent,
104106
)
105107
from .exceptions import (
106108
Cmd2ShlexError,
@@ -197,6 +199,14 @@ def __init__(self) -> None:
197199
DisabledCommand = namedtuple('DisabledCommand', ['command_function', 'help_function', 'completer_function'])
198200

199201

202+
if TYPE_CHECKING: # pragma: no cover
203+
StaticArgParseBuilder = staticmethod[[], argparse.ArgumentParser]
204+
ClassArgParseBuilder = classmethod[Union['Cmd', CommandSet], [], argparse.ArgumentParser]
205+
else:
206+
StaticArgParseBuilder = staticmethod
207+
ClassArgParseBuilder = classmethod
208+
209+
200210
class Cmd(cmd.Cmd):
201211
"""An easy but powerful framework for writing line-oriented command interpreters.
202212
@@ -510,6 +520,16 @@ def __init__(
510520
# This does not affect self.formatted_completions.
511521
self.matches_sorted = False
512522

523+
# Command parsers for this Cmd instance.
524+
self._command_parsers: Dict[str, argparse.ArgumentParser] = {}
525+
526+
# Locates the command parser template or factory and creates an instance-specific parser
527+
for command in self.get_all_commands():
528+
self._register_command_parser(command, self.cmd_func(command)) # type: ignore[arg-type]
529+
530+
# Add functions decorated to be subcommands
531+
self._register_subcommands(self)
532+
513533
############################################################################################################
514534
# The following code block loads CommandSets, verifies command names, and registers subcommands.
515535
# This block should appear after all attributes have been created since the registration code
@@ -529,9 +549,6 @@ def __init__(
529549
if not valid:
530550
raise ValueError(f"Invalid command name '{cur_cmd}': {errmsg}")
531551

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

@@ -659,6 +676,48 @@ def register_command_set(self, cmdset: CommandSet) -> None:
659676
cmdset.on_unregistered()
660677
raise
661678

679+
def _build_parser(self,
680+
parent: CommandParent,
681+
parser_builder: Optional[Union[argparse.ArgumentParser,
682+
Callable[[], argparse.ArgumentParser],
683+
StaticArgParseBuilder,
684+
ClassArgParseBuilder]]
685+
) -> Optional[argparse.ArgumentParser]:
686+
parser: Optional[argparse.ArgumentParser] = None
687+
if isinstance(parser_builder, staticmethod):
688+
parser = parser_builder.__func__()
689+
elif isinstance(parser_builder, classmethod):
690+
parser = parser_builder.__func__(parent if not None else self)
691+
elif callable(parser_builder):
692+
parser = parser_builder()
693+
elif isinstance(parser_builder, argparse.ArgumentParser):
694+
if sys.version_info >= (3, 6, 4):
695+
parser = copy.deepcopy(parser_builder)
696+
else: # pragma: no cover
697+
parser = parser_builder
698+
return parser
699+
700+
def _register_command_parser(self, command: str, command_method: Callable[..., Any]) -> None:
701+
if command not in self._command_parsers:
702+
parser_builder = getattr(command_method, constants.CMD_ATTR_ARGPARSER, None)
703+
parent = self.find_commandset_for_command(command) or self
704+
parser = self._build_parser(parent, parser_builder)
705+
if parser is None:
706+
return
707+
708+
# argparser defaults the program name to sys.argv[0], but we want it to be the name of our command
709+
from .decorators import (
710+
_set_parser_prog,
711+
)
712+
713+
_set_parser_prog(parser, command)
714+
715+
# If the description has not been set, then use the method docstring if one exists
716+
if parser.description is None and hasattr(command_method, '__wrapped__') and command_method.__wrapped__.__doc__:
717+
parser.description = strip_doc_annotations(command_method.__wrapped__.__doc__)
718+
719+
self._command_parsers[command] = parser
720+
662721
def _install_command_function(self, command: str, command_wrapper: Callable[..., Any], context: str = '') -> None:
663722
cmd_func_name = COMMAND_FUNC_PREFIX + command
664723

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

743+
self._register_command_parser(command, command_wrapper)
744+
684745
setattr(self, cmd_func_name, command_wrapper)
685746

686747
def _install_completer_function(self, cmd_name: str, cmd_completer: CompleterFunc) -> None:
@@ -727,6 +788,8 @@ def unregister_command_set(self, cmdset: CommandSet) -> None:
727788
del self._cmd_to_command_sets[cmd_name]
728789

729790
delattr(self, COMMAND_FUNC_PREFIX + cmd_name)
791+
if cmd_name in self._command_parsers:
792+
del self._command_parsers[cmd_name]
730793

731794
if hasattr(self, COMPLETER_FUNC_PREFIX + cmd_name):
732795
delattr(self, COMPLETER_FUNC_PREFIX + cmd_name)
@@ -746,14 +809,7 @@ def _check_uninstallable(self, cmdset: CommandSet) -> None:
746809

747810
for method in methods:
748811
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))
812+
command_parser = self._command_parsers.get(command_name, None)
757813

758814
def check_parser_uninstallable(parser: argparse.ArgumentParser) -> None:
759815
for action in parser._actions:
@@ -792,7 +848,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
792848
for method_name, method in methods:
793849
subcommand_name: str = getattr(method, constants.SUBCMD_ATTR_NAME)
794850
full_command_name: str = getattr(method, constants.SUBCMD_ATTR_COMMAND)
795-
subcmd_parser = getattr(method, constants.CMD_ATTR_ARGPARSER)
851+
subcmd_parser_builder = getattr(method, constants.CMD_ATTR_ARGPARSER)
796852

797853
subcommand_valid, errmsg = self.statement_parser.is_valid_command(subcommand_name, is_subcommand=True)
798854
if not subcommand_valid:
@@ -812,7 +868,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
812868
raise CommandSetRegistrationError(
813869
f"Could not find command '{command_name}' needed by subcommand: {str(method)}"
814870
)
815-
command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None)
871+
command_parser = self._command_parsers.get(command_name, None)
816872
if command_parser is None:
817873
raise CommandSetRegistrationError(
818874
f"Could not find argparser for command '{command_name}' needed by subcommand: {str(method)}"
@@ -832,16 +888,17 @@ def find_subcommand(action: argparse.ArgumentParser, subcmd_names: List[str]) ->
832888

833889
target_parser = find_subcommand(command_parser, subcommand_names)
834890

891+
subcmd_parser = cast(argparse.ArgumentParser, self._build_parser(cmdset, subcmd_parser_builder))
892+
from .decorators import (
893+
_set_parser_prog,
894+
)
895+
896+
_set_parser_prog(subcmd_parser, f'{command_name} {subcommand_name}')
897+
if subcmd_parser.description is None and method.__doc__:
898+
subcmd_parser.description = strip_doc_annotations(method.__doc__)
899+
835900
for action in target_parser._actions:
836901
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-
845902
# Get the kwargs for add_parser()
846903
add_parser_kwargs = getattr(method, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, {})
847904

@@ -913,7 +970,7 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
913970
raise CommandSetRegistrationError(
914971
f"Could not find command '{command_name}' needed by subcommand: {str(method)}"
915972
)
916-
command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None)
973+
command_parser = self._command_parsers.get(command_name, None)
917974
if command_parser is None: # pragma: no cover
918975
# This really shouldn't be possible since _register_subcommands would prevent this from happening
919976
# but keeping in case it does for some strange reason
@@ -2034,7 +2091,7 @@ def _perform_completion(
20342091
else:
20352092
# There's no completer function, next see if the command uses argparse
20362093
func = self.cmd_func(command)
2037-
argparser: Optional[argparse.ArgumentParser] = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
2094+
argparser = self._command_parsers.get(command, None)
20382095

20392096
if func is not None and argparser is not None:
20402097
# Get arguments for complete()
@@ -3259,14 +3316,19 @@ def _cmdloop(self) -> None:
32593316
#############################################################
32603317

32613318
# 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
3319+
@staticmethod
3320+
def _build_alias_parser() -> argparse.ArgumentParser:
3321+
alias_description = (
3322+
"Manage aliases\n" "\n" "An alias is a command that enables replacement of a word by another string."
3323+
)
3324+
alias_epilog = "See also:\n" " macro"
3325+
alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description, epilog=alias_epilog)
3326+
alias_subparsers = alias_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND')
3327+
alias_subparsers.required = True
3328+
return alias_parser
32673329

32683330
# Preserve quotes since we are passing strings to other commands
3269-
@with_argparser(alias_parser, preserve_quotes=True)
3331+
@with_argparser(_build_alias_parser, preserve_quotes=True)
32703332
def do_alias(self, args: argparse.Namespace) -> None:
32713333
"""Manage aliases"""
32723334
# Call handler for whatever subcommand was selected
@@ -3681,7 +3743,7 @@ def complete_help_subcommands(
36813743

36823744
# Check if this command uses argparse
36833745
func = self.cmd_func(command)
3684-
argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
3746+
argparser = self._command_parsers.get(command, None)
36853747
if func is None or argparser is None:
36863748
return []
36873749

@@ -3717,7 +3779,7 @@ def do_help(self, args: argparse.Namespace) -> None:
37173779
# Getting help for a specific command
37183780
func = self.cmd_func(args.command)
37193781
help_func = getattr(self, constants.HELP_FUNC_PREFIX + args.command, None)
3720-
argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
3782+
argparser = self._command_parsers.get(args.command, None)
37213783

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

38553917
# Non-argparse commands can have help_functions for their documentation
3856-
if not hasattr(func, constants.CMD_ATTR_ARGPARSER):
3918+
if command not in self._command_parsers:
38573919
has_help_func = True
38583920

38593921
if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY):
@@ -3899,7 +3961,7 @@ def _print_topics(self, header: str, cmds: List[str], verbose: bool) -> None:
38993961
doc: Optional[str]
39003962

39013963
# Non-argparse commands can have help_functions for their documentation
3902-
if not hasattr(cmd_func, constants.CMD_ATTR_ARGPARSER) and command in topics:
3964+
if command not in self._command_parsers and command in topics:
39033965
help_func = getattr(self, constants.HELP_FUNC_PREFIX + command)
39043966
result = io.StringIO()
39053967

0 commit comments

Comments
 (0)