Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Changed with_argparser() and as_subcommand_to() to accept either an A… #1278

Merged
merged 1 commit into from
Oct 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 96 additions & 33 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
# setting is True
import argparse
import cmd
import copy
import functools
import glob
import inspect
Expand All @@ -55,6 +56,7 @@
)
from typing import (
IO,
TYPE_CHECKING,
Any,
Callable,
Dict,
Expand Down Expand Up @@ -99,6 +101,7 @@
HELP_FUNC_PREFIX,
)
from .decorators import (
CommandParent,
as_subcommand_to,
with_argparser,
)
Expand Down Expand Up @@ -197,6 +200,14 @@ def __init__(self) -> None:
DisabledCommand = namedtuple('DisabledCommand', ['command_function', 'help_function', 'completer_function'])


if TYPE_CHECKING: # pragma: no cover
StaticArgParseBuilder = staticmethod[[], argparse.ArgumentParser]
ClassArgParseBuilder = classmethod[Union['Cmd', CommandSet], [], argparse.ArgumentParser]
else:
StaticArgParseBuilder = staticmethod
ClassArgParseBuilder = classmethod


class Cmd(cmd.Cmd):
"""An easy but powerful framework for writing line-oriented command interpreters.

Expand Down Expand Up @@ -510,6 +521,16 @@ def __init__(
# This does not affect self.formatted_completions.
self.matches_sorted = False

# Command parsers for this Cmd instance.
self._command_parsers: Dict[str, argparse.ArgumentParser] = {}

# Locates the command parser template or factory and creates an instance-specific parser
for command in self.get_all_commands():
self._register_command_parser(command, self.cmd_func(command)) # type: ignore[arg-type]

# Add functions decorated to be subcommands
self._register_subcommands(self)

############################################################################################################
# The following code block loads CommandSets, verifies command names, and registers subcommands.
# This block should appear after all attributes have been created since the registration code
Expand All @@ -529,9 +550,6 @@ def __init__(
if not valid:
raise ValueError(f"Invalid command name '{cur_cmd}': {errmsg}")

# Add functions decorated to be subcommands
self._register_subcommands(self)

self.suggest_similar_command = suggest_similar_command
self.default_suggestion_message = "Did you mean {}?"

Expand Down Expand Up @@ -659,6 +677,48 @@ def register_command_set(self, cmdset: CommandSet) -> None:
cmdset.on_unregistered()
raise

def _build_parser(
self,
parent: CommandParent,
parser_builder: Optional[
Union[argparse.ArgumentParser, Callable[[], argparse.ArgumentParser], StaticArgParseBuilder, ClassArgParseBuilder]
],
) -> Optional[argparse.ArgumentParser]:
parser: Optional[argparse.ArgumentParser] = None
if isinstance(parser_builder, staticmethod):
parser = parser_builder.__func__()
elif isinstance(parser_builder, classmethod):
parser = parser_builder.__func__(parent if not None else self)
elif callable(parser_builder):
parser = parser_builder()
elif isinstance(parser_builder, argparse.ArgumentParser):
if sys.version_info >= (3, 6, 4):
parser = copy.deepcopy(parser_builder)
else: # pragma: no cover
parser = parser_builder
return parser

def _register_command_parser(self, command: str, command_method: Callable[..., Any]) -> None:
if command not in self._command_parsers:
parser_builder = getattr(command_method, constants.CMD_ATTR_ARGPARSER, None)
parent = self.find_commandset_for_command(command) or self
parser = self._build_parser(parent, parser_builder)
if parser is None:
return

# argparser defaults the program name to sys.argv[0], but we want it to be the name of our command
from .decorators import (
_set_parser_prog,
)

_set_parser_prog(parser, command)

# If the description has not been set, then use the method docstring if one exists
if parser.description is None and hasattr(command_method, '__wrapped__') and command_method.__wrapped__.__doc__:
parser.description = strip_doc_annotations(command_method.__wrapped__.__doc__)

self._command_parsers[command] = parser

def _install_command_function(self, command: str, command_wrapper: Callable[..., Any], context: str = '') -> None:
cmd_func_name = COMMAND_FUNC_PREFIX + command

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

self._register_command_parser(command, command_wrapper)

setattr(self, cmd_func_name, command_wrapper)

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

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

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

for method in methods:
command_name = method[0][len(COMMAND_FUNC_PREFIX) :]

# Search for the base command function and verify it has an argparser defined
if command_name in self.disabled_commands:
command_func = self.disabled_commands[command_name].command_function
else:
command_func = self.cmd_func(command_name)

command_parser = cast(argparse.ArgumentParser, getattr(command_func, constants.CMD_ATTR_ARGPARSER, None))
command_parser = self._command_parsers.get(command_name, None)

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

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

target_parser = find_subcommand(command_parser, subcommand_names)

subcmd_parser = cast(argparse.ArgumentParser, self._build_parser(cmdset, subcmd_parser_builder))
from .decorators import (
_set_parser_prog,
)

_set_parser_prog(subcmd_parser, f'{command_name} {subcommand_name}')
if subcmd_parser.description is None and method.__doc__:
subcmd_parser.description = strip_doc_annotations(method.__doc__)

for action in target_parser._actions:
if isinstance(action, argparse._SubParsersAction):
# Temporary workaround for avoiding subcommand help text repeatedly getting added to
# action._choices_actions. Until we have instance-specific parser objects, we will remove
# any existing subcommand which has the same name before replacing it. This problem is
# exercised when more than one cmd2.Cmd-based object is created and the same subcommands
# get added each time. Argparse overwrites the previous subcommand but keeps growing the help
# text which is shown by running something like 'alias -h'.
action.remove_parser(subcommand_name) # type: ignore[arg-type,attr-defined]

# Get the kwargs for add_parser()
add_parser_kwargs = getattr(method, constants.SUBCMD_ATTR_ADD_PARSER_KWARGS, {})

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

if func is not None and argparser is not None:
# Get arguments for complete()
Expand Down Expand Up @@ -3259,14 +3317,19 @@ def _cmdloop(self) -> None:
#############################################################

# Top-level parser for alias
alias_description = "Manage aliases\n" "\n" "An alias is a command that enables replacement of a word by another string."
alias_epilog = "See also:\n" " macro"
alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description, epilog=alias_epilog)
alias_subparsers = alias_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND')
alias_subparsers.required = True
@staticmethod
def _build_alias_parser() -> argparse.ArgumentParser:
alias_description = (
"Manage aliases\n" "\n" "An alias is a command that enables replacement of a word by another string."
)
alias_epilog = "See also:\n" " macro"
alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description, epilog=alias_epilog)
alias_subparsers = alias_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND')
alias_subparsers.required = True
return alias_parser

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

# Check if this command uses argparse
func = self.cmd_func(command)
argparser = getattr(func, constants.CMD_ATTR_ARGPARSER, None)
argparser = self._command_parsers.get(command, None)
if func is None or argparser is None:
return []

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

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

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

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

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

Expand Down
Loading