Skip to content

Commit 744311b

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 744311b

File tree

6 files changed

+202
-75
lines changed

6 files changed

+202
-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

@@ -659,6 +677,48 @@ def register_command_set(self, cmdset: CommandSet) -> None:
659677
cmdset.on_unregistered()
660678
raise
661679

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

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

744+
self._register_command_parser(command, command_wrapper)
745+
684746
setattr(self, cmd_func_name, command_wrapper)
685747

686748
def _install_completer_function(self, cmd_name: str, cmd_completer: CompleterFunc) -> None:
@@ -727,6 +789,8 @@ def unregister_command_set(self, cmdset: CommandSet) -> None:
727789
del self._cmd_to_command_sets[cmd_name]
728790

729791
delattr(self, COMMAND_FUNC_PREFIX + cmd_name)
792+
if cmd_name in self._command_parsers:
793+
del self._command_parsers[cmd_name]
730794

731795
if hasattr(self, COMPLETER_FUNC_PREFIX + cmd_name):
732796
delattr(self, COMPLETER_FUNC_PREFIX + cmd_name)
@@ -746,14 +810,7 @@ def _check_uninstallable(self, cmdset: CommandSet) -> None:
746810

747811
for method in methods:
748812
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))
813+
command_parser = self._command_parsers.get(command_name, None)
757814

758815
def check_parser_uninstallable(parser: argparse.ArgumentParser) -> None:
759816
for action in parser._actions:
@@ -792,7 +849,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
792849
for method_name, method in methods:
793850
subcommand_name: str = getattr(method, constants.SUBCMD_ATTR_NAME)
794851
full_command_name: str = getattr(method, constants.SUBCMD_ATTR_COMMAND)
795-
subcmd_parser = getattr(method, constants.CMD_ATTR_ARGPARSER)
852+
subcmd_parser_builder = getattr(method, constants.CMD_ATTR_ARGPARSER)
796853

797854
subcommand_valid, errmsg = self.statement_parser.is_valid_command(subcommand_name, is_subcommand=True)
798855
if not subcommand_valid:
@@ -812,7 +869,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None:
812869
raise CommandSetRegistrationError(
813870
f"Could not find command '{command_name}' needed by subcommand: {str(method)}"
814871
)
815-
command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None)
872+
command_parser = self._command_parsers.get(command_name, None)
816873
if command_parser is None:
817874
raise CommandSetRegistrationError(
818875
f"Could not find argparser for command '{command_name}' needed by subcommand: {str(method)}"
@@ -832,16 +889,17 @@ def find_subcommand(action: argparse.ArgumentParser, subcmd_names: List[str]) ->
832889

833890
target_parser = find_subcommand(command_parser, subcommand_names)
834891

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

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

20392097
if func is not None and argparser is not None:
20402098
# Get arguments for complete()
@@ -3259,14 +3317,19 @@ def _cmdloop(self) -> None:
32593317
#############################################################
32603318

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

32683331
# Preserve quotes since we are passing strings to other commands
3269-
@with_argparser(alias_parser, preserve_quotes=True)
3332+
@with_argparser(_build_alias_parser, preserve_quotes=True)
32703333
def do_alias(self, args: argparse.Namespace) -> None:
32713334
"""Manage aliases"""
32723335
# Call handler for whatever subcommand was selected
@@ -3681,7 +3744,7 @@ def complete_help_subcommands(
36813744

36823745
# Check if this command uses argparse
36833746
func = self.cmd_func(command)
3684-
argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
3747+
argparser = self._command_parsers.get(command, None)
36853748
if func is None or argparser is None:
36863749
return []
36873750

@@ -3717,7 +3780,7 @@ def do_help(self, args: argparse.Namespace) -> None:
37173780
# Getting help for a specific command
37183781
func = self.cmd_func(args.command)
37193782
help_func = getattr(self, constants.HELP_FUNC_PREFIX + args.command, None)
3720-
argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
3783+
argparser = self._command_parsers.get(args.command, None)
37213784

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

38553918
# Non-argparse commands can have help_functions for their documentation
3856-
if not hasattr(func, constants.CMD_ATTR_ARGPARSER):
3919+
if command not in self._command_parsers:
38573920
has_help_func = True
38583921

38593922
if hasattr(func, constants.CMD_ATTR_HELP_CATEGORY):
@@ -3899,7 +3962,7 @@ def _print_topics(self, header: str, cmds: List[str], verbose: bool) -> None:
38993962
doc: Optional[str]
39003963

39013964
# Non-argparse commands can have help_functions for their documentation
3902-
if not hasattr(cmd_func, constants.CMD_ATTR_ARGPARSER) and command in topics:
3965+
if command not in self._command_parsers and command in topics:
39033966
help_func = getattr(self, constants.HELP_FUNC_PREFIX + command)
39043967
result = io.StringIO()
39053968

0 commit comments

Comments
 (0)