Skip to content

Commit 8d88c35

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 27f3666 commit 8d88c35

File tree

5 files changed

+198
-75
lines changed

5 files changed

+198
-75
lines changed

cmd2/cmd2.py

Lines changed: 96 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
@@ -55,6 +56,7 @@
5556
)
5657
from typing import (
5758
IO,
59+
TYPE_CHECKING,
5860
Any,
5961
Callable,
6062
Dict,
@@ -99,6 +101,7 @@
99101
HELP_FUNC_PREFIX,
100102
)
101103
from .decorators import (
104+
CommandParent,
102105
as_subcommand_to,
103106
with_argparser,
104107
)
@@ -197,6 +200,14 @@ def __init__(self) -> None:
197200
DisabledCommand = namedtuple('DisabledCommand', ['command_function', 'help_function', 'completer_function'])
198201

199202

203+
if TYPE_CHECKING: # pragma: no cover
204+
StaticArgParseBuilder = staticmethod[[], argparse.ArgumentParser]
205+
ClassArgParseBuilder = classmethod[Union['Cmd', CommandSet], [], argparse.ArgumentParser]
206+
else:
207+
StaticArgParseBuilder = staticmethod
208+
ClassArgParseBuilder = classmethod
209+
210+
200211
class Cmd(cmd.Cmd):
201212
"""An easy but powerful framework for writing line-oriented command interpreters.
202213
@@ -510,6 +521,16 @@ def __init__(
510521
# This does not affect self.formatted_completions.
511522
self.matches_sorted = False
512523

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

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

@@ -662,6 +680,48 @@ def register_command_set(self, cmdset: CommandSet) -> None:
662680
cmdset.on_unregistered()
663681
raise
664682

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

@@ -684,6 +744,8 @@ def _install_command_function(self, command: str, command_wrapper: Callable[...,
684744
self.pwarning(f"Deleting macro '{command}' because it shares its name with a new command")
685745
del self.macros[command]
686746

747+
self._register_command_parser(command, command_wrapper)
748+
687749
setattr(self, cmd_func_name, command_wrapper)
688750

689751
def _install_completer_function(self, cmd_name: str, cmd_completer: CompleterFunc) -> None:
@@ -730,6 +792,8 @@ def unregister_command_set(self, cmdset: CommandSet) -> None:
730792
del self._cmd_to_command_sets[cmd_name]
731793

732794
delattr(self, COMMAND_FUNC_PREFIX + cmd_name)
795+
if cmd_name in self._command_parsers:
796+
del self._command_parsers[cmd_name]
733797

734798
if hasattr(self, COMPLETER_FUNC_PREFIX + cmd_name):
735799
delattr(self, COMPLETER_FUNC_PREFIX + cmd_name)
@@ -749,14 +813,7 @@ def _check_uninstallable(self, cmdset: CommandSet) -> None:
749813

750814
for method in methods:
751815
command_name = method[0][len(COMMAND_FUNC_PREFIX) :]
752-
753-
# Search for the base command function and verify it has an argparser defined
754-
if command_name in self.disabled_commands:
755-
command_func = self.disabled_commands[command_name].command_function
756-
else:
757-
command_func = self.cmd_func(command_name)
758-
759-
command_parser = cast(argparse.ArgumentParser, getattr(command_func, constants.CMD_ATTR_ARGPARSER, None))
816+
command_parser = self._command_parsers.get(command_name, None)
760817

761818
def check_parser_uninstallable(parser: argparse.ArgumentParser) -> None:
762819
for action in parser._actions:
@@ -795,7 +852,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
795852
for method_name, method in methods:
796853
subcommand_name: str = getattr(method, constants.SUBCMD_ATTR_NAME)
797854
full_command_name: str = getattr(method, constants.SUBCMD_ATTR_COMMAND)
798-
subcmd_parser = getattr(method, constants.CMD_ATTR_ARGPARSER)
855+
subcmd_parser_builder = getattr(method, constants.CMD_ATTR_ARGPARSER)
799856

800857
subcommand_valid, errmsg = self.statement_parser.is_valid_command(subcommand_name, is_subcommand=True)
801858
if not subcommand_valid:
@@ -815,7 +872,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
815872
raise CommandSetRegistrationError(
816873
f"Could not find command '{command_name}' needed by subcommand: {str(method)}"
817874
)
818-
command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None)
875+
command_parser = self._command_parsers.get(command_name, None)
819876
if command_parser is None:
820877
raise CommandSetRegistrationError(
821878
f"Could not find argparser for command '{command_name}' needed by subcommand: {str(method)}"
@@ -835,16 +892,17 @@ def find_subcommand(action: argparse.ArgumentParser, subcmd_names: List[str]) ->
835892

836893
target_parser = find_subcommand(command_parser, subcommand_names)
837894

895+
subcmd_parser = cast(argparse.ArgumentParser, self._build_parser(cmdset, subcmd_parser_builder))
896+
from .decorators import (
897+
_set_parser_prog,
898+
)
899+
900+
_set_parser_prog(subcmd_parser, f'{command_name} {subcommand_name}')
901+
if subcmd_parser.description is None and method.__doc__:
902+
subcmd_parser.description = strip_doc_annotations(method.__doc__)
903+
838904
for action in target_parser._actions:
839905
if isinstance(action, argparse._SubParsersAction):
840-
# Temporary workaround for avoiding subcommand help text repeatedly getting added to
841-
# action._choices_actions. Until we have instance-specific parser objects, we will remove
842-
# any existing subcommand which has the same name before replacing it. This problem is
843-
# exercised when more than one cmd2.Cmd-based object is created and the same subcommands
844-
# get added each time. Argparse overwrites the previous subcommand but keeps growing the help
845-
# text which is shown by running something like 'alias -h'.
846-
action.remove_parser(subcommand_name) # type: ignore[arg-type,attr-defined]
847-
848906
# Get the kwargs for add_parser()
849907
add_parser_kwargs = getattr(method, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, {})
850908

@@ -916,7 +974,7 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
916974
raise CommandSetRegistrationError(
917975
f"Could not find command '{command_name}' needed by subcommand: {str(method)}"
918976
)
919-
command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None)
977+
command_parser = self._command_parsers.get(command_name, None)
920978
if command_parser is None: # pragma: no cover
921979
# This really shouldn't be possible since _register_subcommands would prevent this from happening
922980
# but keeping in case it does for some strange reason
@@ -2037,7 +2095,7 @@ def _perform_completion(
20372095
else:
20382096
# There's no completer function, next see if the command uses argparse
20392097
func = self.cmd_func(command)
2040-
argparser: Optional[argparse.ArgumentParser] = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
2098+
argparser = self._command_parsers.get(command, None)
20412099

20422100
if func is not None and argparser is not None:
20432101
# Get arguments for complete()
@@ -3272,14 +3330,19 @@ def _cmdloop(self) -> None:
32723330
#############################################################
32733331

32743332
# Top-level parser for alias
3275-
alias_description = "Manage aliases\n" "\n" "An alias is a command that enables replacement of a word by another string."
3276-
alias_epilog = "See also:\n" " macro"
3277-
alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description, epilog=alias_epilog)
3278-
alias_subparsers = alias_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND')
3279-
alias_subparsers.required = True
3333+
@staticmethod
3334+
def _build_alias_parser() -> argparse.ArgumentParser:
3335+
alias_description = (
3336+
"Manage aliases\n" "\n" "An alias is a command that enables replacement of a word by another string."
3337+
)
3338+
alias_epilog = "See also:\n" " macro"
3339+
alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description, epilog=alias_epilog)
3340+
alias_subparsers = alias_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND')
3341+
alias_subparsers.required = True
3342+
return alias_parser
32803343

32813344
# Preserve quotes since we are passing strings to other commands
3282-
@with_argparser(alias_parser, preserve_quotes=True)
3345+
@with_argparser(_build_alias_parser, preserve_quotes=True)
32833346
def do_alias(self, args: argparse.Namespace) -> None:
32843347
"""Manage aliases"""
32853348
# Call handler for whatever subcommand was selected
@@ -3694,7 +3757,7 @@ def complete_help_subcommands(
36943757

36953758
# Check if this command uses argparse
36963759
func = self.cmd_func(command)
3697-
argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
3760+
argparser = self._command_parsers.get(command, None)
36983761
if func is None or argparser is None:
36993762
return []
37003763

@@ -3730,7 +3793,7 @@ def do_help(self, args: argparse.Namespace) -> None:
37303793
# Getting help for a specific command
37313794
func = self.cmd_func(args.command)
37323795
help_func = getattr(self, constants.HELP_FUNC_PREFIX + args.command, None)
3733-
argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
3796+
argparser = self._command_parsers.get(args.command, None)
37343797

37353798
# If the command function uses argparse, then use argparse's help
37363799
if func is not None and argparser is not None:
@@ -3866,7 +3929,7 @@ def _build_command_info(self) -> Tuple[Dict[str, List[str]], List[str], List[str
38663929
help_topics.remove(command)
38673930

38683931
# Non-argparse commands can have help_functions for their documentation
3869-
if not hasattr(func, constants.CMD_ATTR_ARGPARSER):
3932+
if command not in self._command_parsers:
38703933
has_help_func = True
38713934

38723935
if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY):
@@ -3912,7 +3975,7 @@ def _print_topics(self, header: str, cmds: List[str], verbose: bool) -> None:
39123975
doc: Optional[str]
39133976

39143977
# Non-argparse commands can have help_functions for their documentation
3915-
if not hasattr(cmd_func, constants.CMD_ATTR_ARGPARSER) and command in topics:
3978+
if command not in self._command_parsers and command in topics:
39163979
help_func = getattr(self, constants.HELP_FUNC_PREFIX + command)
39173980
result = io.StringIO()
39183981

0 commit comments

Comments
 (0)