From f22013ed5ae7c81fc6447362e634dd429324804b Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Thu, 11 Jun 2020 18:00:07 -0400 Subject: [PATCH 01/14] Initial implementation of modular command loading Issue #943 New class CommandSet can be used to tag a class as a command class. If the constructor is simple, the object will automatically be instantiated and loaded. New register_command decorator to tag any arbitrary function as a command. --- cmd2/cmd2.py | 74 +++++++++- cmd2/command_definition.py | 122 +++++++++++++++++ examples/modular_commands/__init__.py | 0 examples/modular_commands/commandset_basic.py | 105 +++++++++++++++ .../modular_commands/commandset_custominit.py | 33 +++++ examples/modular_commands_main.py | 127 ++++++++++++++++++ 6 files changed, 455 insertions(+), 6 deletions(-) create mode 100644 cmd2/command_definition.py create mode 100644 examples/modular_commands/__init__.py create mode 100644 examples/modular_commands/commandset_basic.py create mode 100644 examples/modular_commands/commandset_custominit.py create mode 100644 examples/modular_commands_main.py diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index cdee35232..a2df3c465 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -37,14 +37,17 @@ import re import sys import threading +import types from code import InteractiveConsole from collections import namedtuple from contextlib import redirect_stdout -from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Tuple, Type, Union +from typing import Any, AnyStr, Callable, Dict, Iterable, List, Mapping, Optional, Tuple, Type, Union from . import ansi, constants, plugin, utils from .argparse_custom import DEFAULT_ARGUMENT_PARSER, CompletionItem from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer +from .command_definition import _UNBOUND_COMMANDS, CommandSet, _PartialPassthru +from .constants import COMMAND_FUNC_PREFIX, COMPLETER_FUNC_PREFIX, HELP_FUNC_PREFIX from .decorators import with_argparser from .exceptions import Cmd2ShlexError, EmbeddedConsoleExit, EmptyStatement, RedirectionError, SkipPostcommandHooks from .history import History, HistoryItem @@ -130,7 +133,8 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, startup_script: str = '', use_ipython: bool = False, allow_cli_args: bool = True, transcript_files: Optional[List[str]] = None, allow_redirection: bool = True, multiline_commands: Optional[List[str]] = None, - terminators: Optional[List[str]] = None, shortcuts: Optional[Dict[str, str]] = None) -> None: + terminators: Optional[List[str]] = None, shortcuts: Optional[Dict[str, str]] = None, + command_sets: Optional[Iterable[CommandSet]] = None) -> None: """An easy but powerful framework for writing line-oriented command interpreters. Extends Python's cmd package. @@ -200,7 +204,7 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, self.max_completion_items = 50 # A dictionary mapping settable names to their Settable instance - self.settables = dict() + self.settables = dict() # type: Dict[str, Settable] self.build_settables() # Use as prompt for multiline commands on the 2nd+ line of input @@ -220,7 +224,7 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, self.exclude_from_history = ['eof', 'history'] # Dictionary of macro names and their values - self.macros = dict() + self.macros = dict() # type: Dict[str, Macro] # Keeps track of typed command history in the Python shell self._py_history = [] @@ -249,7 +253,7 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, self.last_result = None # Used by run_script command to store current script dir as a LIFO queue to support _relative_run_script command - self._script_dir = [] + self._script_dir = [] # type: List[AnyStr] # Context manager used to protect critical sections in the main thread from stopping due to a KeyboardInterrupt self.sigint_protection = utils.ContextFlag() @@ -333,7 +337,7 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, # Commands that have been disabled from use. This is to support commands that are only available # during specific states of the application. This dictionary's keys are the command names and its # values are DisabledCommand objects. - self.disabled_commands = dict() + self.disabled_commands = dict() # type: Dict[str, DisabledCommand] # If any command has been categorized, then all other commands that haven't been categorized # will display under this section in the help output. @@ -381,6 +385,64 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, # If False, then complete() will sort the matches using self.default_sort_key before they are displayed. self.matches_sorted = False + # Load modular commands + self._command_sets = command_sets if command_sets is not None else [] + self._load_modular_commands() + + def _load_modular_commands(self) -> None: + """ + Load modular command definitions. + :return: None + """ + + # start by loading registered functions as commands + for cmd_name, cmd_func, cmd_completer, cmd_help in _UNBOUND_COMMANDS: + assert getattr(self, cmd_func.__name__, None) is None, 'Duplicate command function registered: ' + cmd_name + setattr(self, cmd_func.__name__, types.MethodType(cmd_func, self)) + if cmd_completer is not None: + assert getattr(self, cmd_completer.__name__, None) is None, \ + 'Duplicate command completer registered: ' + cmd_completer.__name__ + setattr(self, cmd_completer.__name__, types.MethodType(cmd_completer, self)) + if cmd_help is not None: + assert getattr(self, cmd_help.__name__, None) is None, \ + 'Duplicate command help registered: ' + cmd_help.__name__ + setattr(self, cmd_help.__name__, types.MethodType(cmd_help, self)) + + # Search for all subclasses of CommandSet, instantiate them if they weren't provided in the constructor + all_commandset_defs = CommandSet.__subclasses__() + existing_commandset_types = [type(command_set) for command_set in self._command_sets] + for cmdset_type in all_commandset_defs: + init_sig = inspect.signature(cmdset_type.__init__) + if cmdset_type in existing_commandset_types or len(init_sig.parameters) != 1 or 'self' not in init_sig.parameters: + continue + cmdset = cmdset_type() + self._command_sets.append(cmdset) + + # initialize each CommandSet and register all matching functions as command, helper, completer functions + for cmdset in self._command_sets: + cmdset.on_register(self) + methods = inspect.getmembers(cmdset, predicate=lambda meth: inspect.ismethod( + meth) and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) + + for method in methods: + assert getattr(self, method[0], None) is None, \ + 'In {}: Duplicate command function: {}'.format(cmdset_type.__name__, method[0]) + + command_wrapper = _PartialPassthru(method[1], self) + setattr(self, method[0], command_wrapper) + + command = method[0][len(COMMAND_FUNC_PREFIX):] + + completer_func_name = COMPLETER_FUNC_PREFIX + command + cmd_completer = getattr(cmdset, completer_func_name, None) + if cmd_completer and not getattr(self, completer_func_name, None): + completer_wrapper = _PartialPassthru(cmd_completer, self) + setattr(self, completer_func_name, completer_wrapper) + cmd_help = getattr(cmdset, HELP_FUNC_PREFIX + command, None) + if cmd_help and not getattr(self, HELP_FUNC_PREFIX + command, None): + help_wrapper = _PartialPassthru(cmd_help, self) + setattr(self, HELP_FUNC_PREFIX + command, help_wrapper) + def add_settable(self, settable: Settable) -> None: """ Convenience method to add a settable parameter to ``self.settables`` diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py new file mode 100644 index 000000000..f08040bb3 --- /dev/null +++ b/cmd2/command_definition.py @@ -0,0 +1,122 @@ +# coding=utf-8 +""" +Supports the definition of commands in separate classes to be composed into cmd2.Cmd +""" +import functools +from typing import ( + Callable, + Iterable, + List, + Optional, + Tuple, + Type, + Union, +) +from .constants import COMMAND_FUNC_PREFIX, HELP_FUNC_PREFIX, COMPLETER_FUNC_PREFIX + +# Allows IDEs to resolve types without impacting imports at runtime, breaking circular dependency issues +try: + from typing import TYPE_CHECKING + if TYPE_CHECKING: + from .cmd2 import Cmd, Statement + import argparse +except ImportError: + pass + +_UNBOUND_COMMANDS = [] # type: List[Tuple[str, Callable, Optional[Callable], Optional[Callable]]] +""" +Registered command tuples. (command, do_ function, complete_ function, help_ function +""" + + +class _PartialPassthru(functools.partial): + """ + Wrapper around partial function that passes through getattr, setattr, and dir to the wrapped function. + This allows for CommandSet functions to be wrapped while maintaining the decorated properties + """ + def __getattr__(self, item): + return getattr(self.func, item) + + def __setattr__(self, key, value): + return setattr(self.func, key, value) + + def __dir__(self) -> Iterable[str]: + return dir(self.func) + + +def register_command(cmd_func: Callable[['Cmd', Union['Statement', 'argparse.Namespace']], None]): + """ + Decorator that allows an arbitrary function to be automatically registered as a command. + If there is a help_ or complete_ function that matches this command, that will also be registered. + + :param cmd_func: Function to register as a cmd2 command + :return: + """ + assert cmd_func.__name__.startswith(COMMAND_FUNC_PREFIX), 'Command functions must start with `do_`' + + import inspect + + cmd_name = cmd_func.__name__[len(COMMAND_FUNC_PREFIX):] + cmd_completer = None + cmd_help = None + + module = inspect.getmodule(cmd_func) + + module_funcs = [mf for mf in inspect.getmembers(module) if inspect.isfunction(mf[1])] + for mf in module_funcs: + if mf[0] == COMPLETER_FUNC_PREFIX + cmd_name: + cmd_completer = mf[1] + elif mf[0] == HELP_FUNC_PREFIX + cmd_name: + cmd_help = mf[1] + if cmd_completer is not None and cmd_help is not None: + break + + _UNBOUND_COMMANDS.append((cmd_name, cmd_func, cmd_completer, cmd_help)) + + +def with_default_category(category: str): + """ + Decorator that applies a category to all ``do_*`` command methods in a class that do not already + have a category specified. + + :param category: category to put all uncategorized commands in + :return: decorator function + """ + + def decorate_class(cls: Type[CommandSet]): + from .constants import CMD_ATTR_HELP_CATEGORY + import inspect + from .decorators import with_category + methods = inspect.getmembers( + cls, + predicate=lambda meth: inspect.isfunction(meth) and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) + category_decorator = with_category(category) + for method in methods: + if not hasattr(method[1], CMD_ATTR_HELP_CATEGORY): + setattr(cls, method[0], category_decorator(method[1])) + return cls + return decorate_class + + +class CommandSet(object): + """ + Base class for defining sets of commands to load in cmd2. + + ``with_default_category`` can be used to apply a default category to all commands in the CommandSet. + + ``do_``, ``help_``, and ``complete_`` functions differ only in that they're now required to accept + a reference to ``cmd2.Cmd`` as the first argument after self. + """ + + def __init__(self): + self._cmd = None # type: Optional[Cmd] + + def on_register(self, cmd: 'Cmd'): + """ + Called by cmd2.Cmd when a CommandSet is registered. Subclasses can override this + to perform an initialization requiring access to the Cmd object. + + :param cmd: The cmd2 main application + :return: None + """ + self._cmd = cmd diff --git a/examples/modular_commands/__init__.py b/examples/modular_commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/modular_commands/commandset_basic.py b/examples/modular_commands/commandset_basic.py new file mode 100644 index 000000000..5ad26d97b --- /dev/null +++ b/examples/modular_commands/commandset_basic.py @@ -0,0 +1,105 @@ +# coding=utf-8 +""" +A simple example demonstrating a loadable command set +""" +from typing import List + +from cmd2 import Cmd, Statement, with_category +from cmd2.command_definition import CommandSet, with_default_category, register_command +from cmd2.utils import CompletionError + + +@register_command +@with_category("AAA") +def do_unbound(cmd: Cmd, statement: Statement): + """ + This is an example of registering an unbound function + :param cmd: + :param statement: + :return: + """ + cmd.poutput('Unbound Command: {}'.format(statement.args)) + + +@with_default_category('Basic Completion') +class BasicCompletionCommandSet(CommandSet): + # List of strings used with completion functions + food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato'] + sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] + + # This data is used to demonstrate delimiter_complete + file_strs = \ + [ + '/home/user/file.db', + '/home/user/file space.db', + '/home/user/another.db', + '/home/other user/maps.db', + '/home/other user/tests.db' + ] + + def do_flag_based(self, cmd: Cmd, statement: Statement): + """Tab completes arguments based on a preceding flag using flag_based_complete + -f, --food [completes food items] + -s, --sport [completes sports] + -p, --path [completes local file system paths] + """ + cmd.poutput("Args: {}".format(statement.args)) + + def complete_flag_based(self, cmd: Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """Completion function for do_flag_based""" + flag_dict = \ + { + # Tab complete food items after -f and --food flags in command line + '-f': self.food_item_strs, + '--food': self.food_item_strs, + + # Tab complete sport items after -s and --sport flags in command line + '-s': self.sport_item_strs, + '--sport': self.sport_item_strs, + + # Tab complete using path_complete function after -p and --path flags in command line + '-p': cmd.path_complete, + '--path': cmd.path_complete, + } + + return cmd.flag_based_complete(text, line, begidx, endidx, flag_dict=flag_dict) + + def do_index_based(self, cmd: Cmd, statement: Statement): + """Tab completes first 3 arguments using index_based_complete""" + cmd.poutput("Args: {}".format(statement.args)) + + def complete_index_based(self, cmd: Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """Completion function for do_index_based""" + index_dict = \ + { + 1: self.food_item_strs, # Tab complete food items at index 1 in command line + 2: self.sport_item_strs, # Tab complete sport items at index 2 in command line + 3: cmd.path_complete, # Tab complete using path_complete function at index 3 in command line + } + + return cmd.index_based_complete(text, line, begidx, endidx, index_dict=index_dict) + + def do_delimiter_complete(self, cmd: Cmd, statement: Statement): + """Tab completes files from a list using delimiter_complete""" + cmd.poutput("Args: {}".format(statement.args)) + + def complete_delimiter_complete(self, cmd: Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: + return cmd.delimiter_complete(text, line, begidx, endidx, match_against=self.file_strs, delimiter='/') + + def do_raise_error(self, cmd: Cmd, statement: Statement): + """Demonstrates effect of raising CompletionError""" + cmd.poutput("Args: {}".format(statement.args)) + + def complete_raise_error(self, cmd: Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """ + CompletionErrors can be raised if an error occurs while tab completing. + + Example use cases + - Reading a database to retrieve a tab completion data set failed + - A previous command line argument that determines the data set being completed is invalid + """ + raise CompletionError("This is how a CompletionError behaves") + + @with_category('Not Basic Completion') + def do_custom_category(self, cmd: Cmd, statement: Statement): + cmd.poutput('Demonstrates a command that bypasses the default category') diff --git a/examples/modular_commands/commandset_custominit.py b/examples/modular_commands/commandset_custominit.py new file mode 100644 index 000000000..440db8509 --- /dev/null +++ b/examples/modular_commands/commandset_custominit.py @@ -0,0 +1,33 @@ +# coding=utf-8 +""" +A simple example demonstrating a loadable command set +""" +from cmd2 import Cmd, Statement, with_category +from cmd2.command_definition import CommandSet, with_default_category, register_command + + +@register_command +@with_category("AAA") +def do_another_command(cmd: Cmd, statement: Statement): + """ + This is an example of registering an unbound function + :param cmd: + :param statement: + :return: + """ + cmd.poutput('Another Unbound Command: {}'.format(statement.args)) + + +@with_default_category('Custom Init') +class CustomInitCommandSet(CommandSet): + def __init__(self, arg1, arg2): + super().__init__() + + self._arg1 = arg1 + self._arg2 = arg2 + + def do_show_arg1(self, cmd: Cmd, _: Statement): + cmd.poutput('Arg1: ' + self._arg1) + + def do_show_arg2(self, cmd: Cmd, _: Statement): + cmd.poutput('Arg2: ' + self._arg2) diff --git a/examples/modular_commands_main.py b/examples/modular_commands_main.py new file mode 100644 index 000000000..93dc79ea8 --- /dev/null +++ b/examples/modular_commands_main.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python +# coding=utf-8 +""" +A simple example demonstrating how to integrate tab completion with argparse-based commands. +""" +import argparse +from typing import Dict, List + +from cmd2 import Cmd, Cmd2ArgumentParser, CompletionItem, with_argparser +from cmd2.utils import CompletionError, basic_complete +from modular_commands.commandset_basic import BasicCompletionCommandSet # noqa: F401 +from modular_commands.commandset_custominit import CustomInitCommandSet + +# Data source for argparse.choices +food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato'] + + +def choices_function() -> List[str]: + """Choices functions are useful when the choice list is dynamically generated (e.g. from data in a database)""" + return ['a', 'dynamic', 'list', 'goes', 'here'] + + +def completer_function(text: str, line: str, begidx: int, endidx: int) -> List[str]: + """ + A tab completion function not dependent on instance data. Since custom tab completion operations commonly + need to modify cmd2's instance variables related to tab completion, it will be rare to need a completer + function. completer_method should be used in those cases. + """ + match_against = ['a', 'dynamic', 'list', 'goes', 'here'] + return basic_complete(text, line, begidx, endidx, match_against) + + +def choices_completion_item() -> List[CompletionItem]: + """Return CompletionItem instead of strings. These give more context to what's being tab completed.""" + items = \ + { + 1: "My item", + 2: "Another item", + 3: "Yet another item" + } + return [CompletionItem(item_id, description) for item_id, description in items.items()] + + +def choices_arg_tokens(arg_tokens: Dict[str, List[str]]) -> List[str]: + """ + If a choices or completer function/method takes a value called arg_tokens, then it will be + passed a dictionary that maps the command line tokens up through the one being completed + to their argparse argument name. All values of the arg_tokens dictionary are lists, even if + a particular argument expects only 1 token. + """ + # Check if choices_function flag has appeared + values = ['choices_function', 'flag'] + if 'choices_function' in arg_tokens: + values.append('is {}'.format(arg_tokens['choices_function'][0])) + else: + values.append('not supplied') + return values + + +class WithCommandSets(Cmd): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] + + def choices_method(self) -> List[str]: + """Choices methods are useful when the choice list is based on instance data of your application""" + return self.sport_item_strs + + def choices_completion_error(self) -> List[str]: + """ + CompletionErrors can be raised if an error occurs while tab completing. + + Example use cases + - Reading a database to retrieve a tab completion data set failed + - A previous command line argument that determines the data set being completed is invalid + """ + if self.debug: + return self.sport_item_strs + raise CompletionError("debug must be true") + + # Parser for example command + example_parser = Cmd2ArgumentParser(description="Command demonstrating tab completion with argparse\n" + "Notice even the flags of this command tab complete") + + # Tab complete from a list using argparse choices. Set metavar if you don't + # want the entire choices list showing in the usage text for this command. + example_parser.add_argument('--choices', choices=food_item_strs, metavar="CHOICE", + help="tab complete using choices") + + # Tab complete from choices provided by a choices function and choices method + example_parser.add_argument('--choices_function', choices_function=choices_function, + help="tab complete using a choices_function") + example_parser.add_argument('--choices_method', choices_method=choices_method, + help="tab complete using a choices_method") + + # Tab complete using a completer function and completer method + example_parser.add_argument('--completer_function', completer_function=completer_function, + help="tab complete using a completer_function") + example_parser.add_argument('--completer_method', completer_method=Cmd.path_complete, + help="tab complete using a completer_method") + + # Demonstrate raising a CompletionError while tab completing + example_parser.add_argument('--completion_error', choices_method=choices_completion_error, + help="raise a CompletionError while tab completing if debug is False") + + # Demonstrate returning CompletionItems instead of strings + example_parser.add_argument('--completion_item', choices_function=choices_completion_item, metavar="ITEM_ID", + descriptive_header="Description", + help="demonstrate use of CompletionItems") + + # Demonstrate use of arg_tokens dictionary + example_parser.add_argument('--arg_tokens', choices_function=choices_arg_tokens, + help="demonstrate use of arg_tokens dictionary") + + @with_argparser(example_parser) + def do_example(self, _: argparse.Namespace) -> None: + """The example command""" + self.poutput("I do nothing") + + +if __name__ == '__main__': + import sys + + print("Starting") + command_sets = [CustomInitCommandSet('First argument', 'Second argument')] + app = WithCommandSets(command_sets=command_sets) + sys.exit(app.cmdloop()) From 744cfbf3c4d6de78ab8c103dde064513778120f8 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Fri, 12 Jun 2020 20:44:10 -0400 Subject: [PATCH 02/14] Some minor cleanup of how imports work. Fixed issue with help documentation for CommandSet commands. Issue #943 --- cmd2/__init__.py | 1 + cmd2/cmd2.py | 45 ++++++++++--------- cmd2/command_definition.py | 21 +++++++-- examples/modular_commands/commandset_basic.py | 3 +- .../modular_commands/commandset_custominit.py | 3 +- tests/conftest.py | 12 +++-- 6 files changed, 53 insertions(+), 32 deletions(-) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 8c07fb808..d96c4a2d6 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -28,6 +28,7 @@ # Get the current value for argparse_custom.DEFAULT_ARGUMENT_PARSER from .argparse_custom import DEFAULT_ARGUMENT_PARSER from .cmd2 import Cmd +from .command_definition import CommandSet, with_default_category, register_command from .constants import COMMAND_NAME, DEFAULT_SHORTCUTS from .decorators import with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category from .exceptions import Cmd2ArgparseError, SkipPostcommandHooks diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index a2df3c465..d51ffc322 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -46,7 +46,7 @@ from . import ansi, constants, plugin, utils from .argparse_custom import DEFAULT_ARGUMENT_PARSER, CompletionItem from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer -from .command_definition import _UNBOUND_COMMANDS, CommandSet, _PartialPassthru +from .command_definition import _UNBOUND_COMMANDS, CommandSet, _partial_passthru from .constants import COMMAND_FUNC_PREFIX, COMPLETER_FUNC_PREFIX, HELP_FUNC_PREFIX from .decorators import with_argparser from .exceptions import Cmd2ShlexError, EmbeddedConsoleExit, EmptyStatement, RedirectionError, SkipPostcommandHooks @@ -428,7 +428,7 @@ def _load_modular_commands(self) -> None: assert getattr(self, method[0], None) is None, \ 'In {}: Duplicate command function: {}'.format(cmdset_type.__name__, method[0]) - command_wrapper = _PartialPassthru(method[1], self) + command_wrapper = _partial_passthru(method[1], self) setattr(self, method[0], command_wrapper) command = method[0][len(COMMAND_FUNC_PREFIX):] @@ -436,11 +436,11 @@ def _load_modular_commands(self) -> None: completer_func_name = COMPLETER_FUNC_PREFIX + command cmd_completer = getattr(cmdset, completer_func_name, None) if cmd_completer and not getattr(self, completer_func_name, None): - completer_wrapper = _PartialPassthru(cmd_completer, self) + completer_wrapper = _partial_passthru(cmd_completer, self) setattr(self, completer_func_name, completer_wrapper) cmd_help = getattr(cmdset, HELP_FUNC_PREFIX + command, None) if cmd_help and not getattr(self, HELP_FUNC_PREFIX + command, None): - help_wrapper = _PartialPassthru(cmd_help, self) + help_wrapper = _partial_passthru(cmd_help, self) setattr(self, HELP_FUNC_PREFIX + command, help_wrapper) def add_settable(self, settable: Settable) -> None: @@ -2756,16 +2756,31 @@ def do_help(self, args: argparse.Namespace) -> None: def _help_menu(self, verbose: bool = False) -> None: """Show a list of commands which help can be displayed for""" + cmds_cats, cmds_doc, cmds_undoc, help_topics = self._build_command_info() + + if len(cmds_cats) == 0: + # No categories found, fall back to standard behavior + self.poutput("{}".format(str(self.doc_leader))) + self._print_topics(self.doc_header, cmds_doc, verbose) + else: + # Categories found, Organize all commands by category + self.poutput('{}'.format(str(self.doc_leader))) + self.poutput('{}'.format(str(self.doc_header)), end="\n\n") + for category in sorted(cmds_cats.keys(), key=self.default_sort_key): + self._print_topics(category, cmds_cats[category], verbose) + self._print_topics(self.default_category, cmds_doc, verbose) + + self.print_topics(self.misc_header, help_topics, 15, 80) + self.print_topics(self.undoc_header, cmds_undoc, 15, 80) + + def _build_command_info(self): # Get a sorted list of help topics help_topics = sorted(self.get_help_topics(), key=self.default_sort_key) - # Get a sorted list of visible command names visible_commands = sorted(self.get_visible_commands(), key=self.default_sort_key) - cmds_doc = [] cmds_undoc = [] cmds_cats = {} - for command in visible_commands: func = self.cmd_func(command) has_help_func = False @@ -2786,21 +2801,7 @@ def _help_menu(self, verbose: bool = False) -> None: cmds_doc.append(command) else: cmds_undoc.append(command) - - if len(cmds_cats) == 0: - # No categories found, fall back to standard behavior - self.poutput("{}".format(str(self.doc_leader))) - self._print_topics(self.doc_header, cmds_doc, verbose) - else: - # Categories found, Organize all commands by category - self.poutput('{}'.format(str(self.doc_leader))) - self.poutput('{}'.format(str(self.doc_header)), end="\n\n") - for category in sorted(cmds_cats.keys(), key=self.default_sort_key): - self._print_topics(category, cmds_cats[category], verbose) - self._print_topics(self.default_category, cmds_doc, verbose) - - self.print_topics(self.misc_header, help_topics, 15, 80) - self.print_topics(self.undoc_header, cmds_undoc, 15, 80) + return cmds_cats, cmds_doc, cmds_undoc, help_topics def _print_topics(self, header: str, cmds: List[str], verbose: bool) -> None: """Customized version of print_topics that can switch between verbose or traditional output""" diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py index f08040bb3..a235525d4 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -29,10 +29,15 @@ """ -class _PartialPassthru(functools.partial): +def _partial_passthru(func: Callable, *args, **kwargs) -> functools.partial: """ - Wrapper around partial function that passes through getattr, setattr, and dir to the wrapped function. - This allows for CommandSet functions to be wrapped while maintaining the decorated properties + Constructs a partial function that passes arguments through to the wrapped function. + Must construct a new type every time so that each wrapped function's __doc__ can be copied correctly. + + :param func: wrapped function + :param args: positional arguments + :param kwargs: keyword arguments + :return: partial function that exposes attributes of wrapped function """ def __getattr__(self, item): return getattr(self.func, item) @@ -43,6 +48,16 @@ def __setattr__(self, key, value): def __dir__(self) -> Iterable[str]: return dir(self.func) + passthru_type = type('PassthruPartial' + func.__name__, + (functools.partial,), + { + '__getattr__': __getattr__, + '__setattr__': __setattr__, + '__dir__': __dir__, + }) + passthru_type.__doc__ = func.__doc__ + return passthru_type(func, *args, **kwargs) + def register_command(cmd_func: Callable[['Cmd', Union['Statement', 'argparse.Namespace']], None]): """ diff --git a/examples/modular_commands/commandset_basic.py b/examples/modular_commands/commandset_basic.py index 5ad26d97b..8b51b7e48 100644 --- a/examples/modular_commands/commandset_basic.py +++ b/examples/modular_commands/commandset_basic.py @@ -4,8 +4,7 @@ """ from typing import List -from cmd2 import Cmd, Statement, with_category -from cmd2.command_definition import CommandSet, with_default_category, register_command +from cmd2 import Cmd, Statement, with_category, CommandSet, with_default_category, register_command from cmd2.utils import CompletionError diff --git a/examples/modular_commands/commandset_custominit.py b/examples/modular_commands/commandset_custominit.py index 440db8509..ce49876a4 100644 --- a/examples/modular_commands/commandset_custominit.py +++ b/examples/modular_commands/commandset_custominit.py @@ -2,8 +2,7 @@ """ A simple example demonstrating a loadable command set """ -from cmd2 import Cmd, Statement, with_category -from cmd2.command_definition import CommandSet, with_default_category, register_command +from cmd2 import Cmd, Statement, with_category, CommandSet, with_default_category, register_command @register_command diff --git a/tests/conftest.py b/tests/conftest.py index 60074f5cb..c07f7083f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,13 +4,14 @@ """ import sys from contextlib import redirect_stderr, redirect_stdout -from typing import List, Optional, Union +from typing import Dict, List, Optional, Union from unittest import mock from pytest import fixture import cmd2 from cmd2.utils import StdSim +from cmd2.constants import COMMAND_FUNC_PREFIX, CMD_ATTR_HELP_CATEGORY # Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit) try: @@ -25,11 +26,14 @@ pass -def verify_help_text(cmd2_app: cmd2.Cmd, help_output: Union[str, List[str]]) -> None: +def verify_help_text(cmd2_app: cmd2.Cmd, + help_output: Union[str, List[str]], + verbose_strings: Optional[List[str]] = None) -> None: """This function verifies that all expected commands are present in the help text. :param cmd2_app: instance of cmd2.Cmd :param help_output: output of help, either as a string or list of strings + :param verbose_strings: optional list of verbose strings to search for """ if isinstance(help_output, str): help_text = help_output @@ -39,7 +43,9 @@ def verify_help_text(cmd2_app: cmd2.Cmd, help_output: Union[str, List[str]]) -> for command in commands: assert command in help_text - # TODO: Consider adding checks for categories and for verbose history + if verbose_strings: + for verbose_string in verbose_strings: + assert verbose_string in help_text # Help text for the history command From 8300fa15fb40658589fdf2bb9643ea636fc38043 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Fri, 12 Jun 2020 22:22:11 -0400 Subject: [PATCH 03/14] Added new constructor parameter to flag whether commands should autoload. Added unit tests. Moved installing commands into separate functions that can be called Issue #943 --- cmd2/cmd2.py | 114 ++++++++++++++++++++++--------- tests/conftest.py | 2 +- tests/test_argparse.py | 4 +- tests/test_argparse_completer.py | 2 +- tests/test_argparse_custom.py | 2 +- tests/test_cmd2.py | 26 +++++-- tests/test_commandset.py | 86 +++++++++++++++++++++++ tests/test_completion.py | 2 +- tests/test_parsing.py | 2 +- tests/test_plugin.py | 2 +- tests/test_transcript.py | 2 +- 11 files changed, 200 insertions(+), 44 deletions(-) create mode 100644 tests/test_commandset.py diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index d51ffc322..94cf4c45c 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -134,7 +134,8 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, allow_cli_args: bool = True, transcript_files: Optional[List[str]] = None, allow_redirection: bool = True, multiline_commands: Optional[List[str]] = None, terminators: Optional[List[str]] = None, shortcuts: Optional[Dict[str, str]] = None, - command_sets: Optional[Iterable[CommandSet]] = None) -> None: + command_sets: Optional[Iterable[CommandSet]] = None, + auto_load_commands: bool = True) -> None: """An easy but powerful framework for writing line-oriented command interpreters. Extends Python's cmd package. @@ -242,6 +243,16 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, multiline_commands=multiline_commands, shortcuts=shortcuts) + # Load modular commands + self._installed_functions: List[str] = [] + self._installed_command_sets: List[CommandSet] = [] + if command_sets: + for command_set in command_sets: + self.install_command_set(command_set) + + if auto_load_commands: + self._autoload_commands() + # Verify commands don't have invalid names (like starting with a shortcut) for cur_cmd in self.get_all_commands(): valid, errmsg = self.statement_parser.is_valid_command(cur_cmd) @@ -385,11 +396,7 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, # If False, then complete() will sort the matches using self.default_sort_key before they are displayed. self.matches_sorted = False - # Load modular commands - self._command_sets = command_sets if command_sets is not None else [] - self._load_modular_commands() - - def _load_modular_commands(self) -> None: + def _autoload_commands(self) -> None: """ Load modular command definitions. :return: None @@ -397,51 +404,96 @@ def _load_modular_commands(self) -> None: # start by loading registered functions as commands for cmd_name, cmd_func, cmd_completer, cmd_help in _UNBOUND_COMMANDS: - assert getattr(self, cmd_func.__name__, None) is None, 'Duplicate command function registered: ' + cmd_name - setattr(self, cmd_func.__name__, types.MethodType(cmd_func, self)) - if cmd_completer is not None: - assert getattr(self, cmd_completer.__name__, None) is None, \ - 'Duplicate command completer registered: ' + cmd_completer.__name__ - setattr(self, cmd_completer.__name__, types.MethodType(cmd_completer, self)) - if cmd_help is not None: - assert getattr(self, cmd_help.__name__, None) is None, \ - 'Duplicate command help registered: ' + cmd_help.__name__ - setattr(self, cmd_help.__name__, types.MethodType(cmd_help, self)) + self.install_command_function(cmd_name, cmd_func, cmd_completer, cmd_help) # Search for all subclasses of CommandSet, instantiate them if they weren't provided in the constructor all_commandset_defs = CommandSet.__subclasses__() - existing_commandset_types = [type(command_set) for command_set in self._command_sets] + existing_commandset_types = [type(command_set) for command_set in self._installed_command_sets] for cmdset_type in all_commandset_defs: init_sig = inspect.signature(cmdset_type.__init__) - if cmdset_type in existing_commandset_types or len(init_sig.parameters) != 1 or 'self' not in init_sig.parameters: + if cmdset_type in existing_commandset_types or \ + len(init_sig.parameters) != 1 or \ + 'self' not in init_sig.parameters: continue cmdset = cmdset_type() - self._command_sets.append(cmdset) + self.install_command_set(cmdset) - # initialize each CommandSet and register all matching functions as command, helper, completer functions - for cmdset in self._command_sets: - cmdset.on_register(self) - methods = inspect.getmembers(cmdset, predicate=lambda meth: inspect.ismethod( - meth) and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) + def install_command_set(self, cmdset: CommandSet): + """ + Installs a CommandSet, loading all commands defined in the CommandSet + :param cmdset: CommandSet to load + :return: None + """ + existing_commandset_types = [type(command_set) for command_set in self._installed_command_sets] + if type(cmdset) in existing_commandset_types: + raise ValueError('CommandSet ' + type(cmdset).__name__ + ' is already installed') + + cmdset.on_register(self) + methods = inspect.getmembers( + cmdset, + predicate=lambda meth: inspect.ismethod(meth) and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) + + installed_attributes = [] + try: for method in methods: - assert getattr(self, method[0], None) is None, \ - 'In {}: Duplicate command function: {}'.format(cmdset_type.__name__, method[0]) + command = method[0][len(COMMAND_FUNC_PREFIX):] + + valid, errmsg = self.statement_parser.is_valid_command(command) + if not valid: + raise ValueError("Invalid command name {!r}: {}".format(command, errmsg)) + + assert getattr(self, COMMAND_FUNC_PREFIX + command, None) is None, \ + 'In {}: Duplicate command function: {}'.format(type(cmdset).__name__, method[0]) command_wrapper = _partial_passthru(method[1], self) setattr(self, method[0], command_wrapper) - - command = method[0][len(COMMAND_FUNC_PREFIX):] + installed_attributes.append(method[0]) completer_func_name = COMPLETER_FUNC_PREFIX + command cmd_completer = getattr(cmdset, completer_func_name, None) if cmd_completer and not getattr(self, completer_func_name, None): completer_wrapper = _partial_passthru(cmd_completer, self) setattr(self, completer_func_name, completer_wrapper) - cmd_help = getattr(cmdset, HELP_FUNC_PREFIX + command, None) - if cmd_help and not getattr(self, HELP_FUNC_PREFIX + command, None): + installed_attributes.append(completer_func_name) + + help_func_name = HELP_FUNC_PREFIX + command + cmd_help = getattr(cmdset, help_func_name, None) + if cmd_help and not getattr(self, help_func_name, None): help_wrapper = _partial_passthru(cmd_help, self) - setattr(self, HELP_FUNC_PREFIX + command, help_wrapper) + setattr(self, help_func_name, help_wrapper) + installed_attributes.append(help_func_name) + self._installed_command_sets.append(cmdset) + except Exception: + for attrib in installed_attributes: + delattr(self, attrib) + raise + + def install_command_function(self, cmd_name: str, cmd_func: Callable, cmd_completer: Callable, cmd_help: Callable): + """ + Installs a command by passing in functions for the command, completion, and help + + :param cmd_name: name of the command to install + :param cmd_func: function to handle the command + :param cmd_completer: completion function for the command + :param cmd_help: help generator for the command + :return: None + """ + valid, errmsg = self.statement_parser.is_valid_command(cmd_name) + if not valid: + raise ValueError("Invalid command name {!r}: {}".format(cmd_name, errmsg)) + + assert getattr(self, COMMAND_FUNC_PREFIX + cmd_name, None) is None, 'Duplicate command function registered: ' + cmd_name + setattr(self, COMMAND_FUNC_PREFIX + cmd_name, types.MethodType(cmd_func, self)) + self._installed_functions.append(cmd_name) + if cmd_completer is not None: + assert getattr(self, COMPLETER_FUNC_PREFIX + cmd_name, None) is None, \ + 'Duplicate command completer registered: ' + COMPLETER_FUNC_PREFIX + cmd_name + setattr(self, COMPLETER_FUNC_PREFIX + cmd_name, types.MethodType(cmd_completer, self)) + if cmd_help is not None: + assert getattr(self, HELP_FUNC_PREFIX + cmd_name, None) is None, \ + 'Duplicate command help registered: ' + HELP_FUNC_PREFIX + cmd_name + setattr(self, HELP_FUNC_PREFIX + cmd_name, types.MethodType(cmd_help, self)) def add_settable(self, settable: Settable) -> None: """ diff --git a/tests/conftest.py b/tests/conftest.py index c07f7083f..67a330e77 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -153,7 +153,7 @@ def run_cmd(app, cmd): @fixture def base_app(): - return cmd2.Cmd() + return cmd2.Cmd(auto_load_commands=False) # These are odd file names for testing quoting of them diff --git a/tests/test_argparse.py b/tests/test_argparse.py index daf434978..4f5dcf35e 100644 --- a/tests/test_argparse.py +++ b/tests/test_argparse.py @@ -28,7 +28,7 @@ class ArgparseApp(cmd2.Cmd): def __init__(self): self.maxrepeats = 3 - cmd2.Cmd.__init__(self) + cmd2.Cmd.__init__(self, auto_load_commands=False) def namespace_provider(self) -> argparse.Namespace: ns = argparse.Namespace() @@ -223,7 +223,7 @@ class SubcommandApp(cmd2.Cmd): """ Example cmd2 application where we a base command which has a couple subcommands.""" def __init__(self): - cmd2.Cmd.__init__(self) + cmd2.Cmd.__init__(self, auto_load_commands=False) # subcommand functions for the base command def base_foo(self, args): diff --git a/tests/test_argparse_completer.py b/tests/test_argparse_completer.py index 4313647b0..cc692b7c7 100644 --- a/tests/test_argparse_completer.py +++ b/tests/test_argparse_completer.py @@ -58,7 +58,7 @@ def completer_takes_arg_tokens(text: str, line: str, begidx: int, endidx: int, class AutoCompleteTester(cmd2.Cmd): """Cmd2 app that exercises ArgparseCompleter class""" def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + super().__init__(*args, auto_load_commands=False, **kwargs) ############################################################################################################ # Begin code related to help and command name completion diff --git a/tests/test_argparse_custom.py b/tests/test_argparse_custom.py index f4db12b6d..1ad1a4223 100644 --- a/tests/test_argparse_custom.py +++ b/tests/test_argparse_custom.py @@ -33,7 +33,7 @@ def do_range(self, _): @pytest.fixture def cust_app(): - return ApCustomTestApp() + return ApCustomTestApp(auto_load_commands=False) def fake_func(): diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index bc0e0a94f..85dcd3bf5 100755 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -36,7 +36,7 @@ def CreateOutsimApp(): - c = cmd2.Cmd() + c = cmd2.Cmd(auto_load_commands=False) c.stdout = utils.StdSim(c.stdout) return c @@ -925,7 +925,7 @@ def test_ansi_prompt_not_esacped(base_app): def test_ansi_prompt_escaped(): from cmd2.rl_utils import rl_make_safe_prompt - app = cmd2.Cmd() + app = cmd2.Cmd(auto_load_commands=False) color = 'cyan' prompt = 'InColor' color_prompt = ansi.style(prompt, fg=color) @@ -1025,14 +1025,32 @@ def helpcat_app(): app = HelpCategoriesApp() return app + def test_help_cat_base(helpcat_app): out, err = run_cmd(helpcat_app, 'help') verify_help_text(helpcat_app, out) + cmds_cats, cmds_doc, cmds_undoc, help_topics = helpcat_app._build_command_info() + assert 'Some Category' in cmds_cats + assert 'diddly' in cmds_cats['Some Category'] + assert 'cat_nodoc' in cmds_cats['Some Category'] + assert 'Custom Category' in cmds_cats + assert 'squat' in cmds_cats['Custom Category'] + assert 'edit' in cmds_cats['Custom Category'] + assert 'undoc' in cmds_undoc + assert 'Fake Category' not in cmds_cats + + help_text = ''.join(out) + assert 'This command does diddly squat...' not in help_text + + def test_help_cat_verbose(helpcat_app): out, err = run_cmd(helpcat_app, 'help --verbose') verify_help_text(helpcat_app, out) + help_text = ''.join(out) + assert 'This command does diddly squat...' in help_text + class SelectApp(cmd2.Cmd): def do_eat(self, arg): @@ -1396,7 +1414,7 @@ def test_eof(base_app): assert base_app.do_eof('') def test_echo(capsys): - app = cmd2.Cmd() + app = cmd2.Cmd(auto_load_commands=False) app.echo = True commands = ['help history'] @@ -1409,7 +1427,7 @@ def test_read_input_rawinput_true(capsys, monkeypatch): prompt_str = 'the_prompt' input_str = 'some input' - app = cmd2.Cmd() + app = cmd2.Cmd(auto_load_commands=False) app.use_rawinput = True # Mock out input() to return input_str diff --git a/tests/test_commandset.py b/tests/test_commandset.py new file mode 100644 index 000000000..acdb58b32 --- /dev/null +++ b/tests/test_commandset.py @@ -0,0 +1,86 @@ +# coding=utf-8 +# flake8: noqa E302 +""" +Test CommandSet +""" + +from typing import List +import pytest + +import cmd2 +from cmd2 import utils + + +# Python 3.5 had some regressions in the unitest.mock module, so use 3rd party mock if available +try: + import mock +except ImportError: + from unittest import mock + + +@cmd2.register_command +@cmd2.with_category("AAA") +def do_unbound(cmd: cmd2.Cmd, statement: cmd2.Statement): + """ + This is an example of registering an unbound function + + :param cmd: + :param statement: + :return: + """ + cmd.poutput('Unbound Command: {}'.format(statement.args)) + + +@cmd2.with_default_category('Command Set') +class TestCommandSet(cmd2.CommandSet): + def do_apple(self, cmd: cmd2.Cmd, statement: cmd2.Statement): + cmd.poutput('Apple!') + + def do_banana(self, cmd: cmd2.Cmd, statement: cmd2.Statement): + """Banana Command""" + cmd.poutput('Banana!!') + + def do_cranberry(self, cmd: cmd2.Cmd, statement: cmd2.Statement): + cmd.poutput('Cranberry!!') + + def help_cranberry(self, cmd: cmd2.Cmd): + cmd.stdout.write('This command does diddly squat...\n') + + def do_durian(self, cmd: cmd2.Cmd, statement: cmd2.Statement): + """Durian Command""" + cmd.poutput('Durian!!') + + def complete_durian(self, cmd: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: + return utils.basic_complete(text, line, begidx, endidx, ['stinks', 'smells', 'disgusting']) + + @cmd2.with_category('Alone') + def do_elderberry(self, cmd: cmd2.Cmd, statement: cmd2.Statement): + cmd.poutput('Elderberry!!') + + +class WithCommandSets(cmd2.Cmd): + """Class for testing custom help_* methods which override docstring help.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +@pytest.fixture +def command_sets_app(): + app = WithCommandSets() + return app + + +def test_autoload_commands(command_sets_app): + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_app._build_command_info() + + assert 'AAA' in cmds_cats + assert 'unbound' in cmds_cats['AAA'] + + assert 'Alone' in cmds_cats + assert 'elderberry' in cmds_cats['Alone'] + + assert 'Command Set' in cmds_cats + assert 'cranberry' in cmds_cats['Command Set'] + + + diff --git a/tests/test_completion.py b/tests/test_completion.py index a380d43af..9a611fc02 100755 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -1098,7 +1098,7 @@ class SubcommandsWithUnknownExample(cmd2.Cmd): """ def __init__(self): - cmd2.Cmd.__init__(self) + cmd2.Cmd.__init__(self, auto_load_commands=False) # subcommand functions for the base command def base_foo(self, args): diff --git a/tests/test_parsing.py b/tests/test_parsing.py index c2c242fe3..3d6495166 100755 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -588,7 +588,7 @@ def test_parse_unclosed_quotes(parser): _ = parser.tokenize("command with 'unclosed quotes") def test_empty_statement_raises_exception(): - app = cmd2.Cmd() + app = cmd2.Cmd(auto_load_commands=False) with pytest.raises(exceptions.EmptyStatement): app._complete_statement('') diff --git a/tests/test_plugin.py b/tests/test_plugin.py index e49cbbfc3..6e6a4a427 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -263,7 +263,7 @@ def cmdfinalization_hook_wrong_return_annotation(self, data: plugin.CommandFinal class PluggedApp(Plugin, cmd2.Cmd): """A sample app with a plugin mixed in""" def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + super().__init__(*args, auto_load_commands=False, **kwargs) def do_say(self, statement): """Repeat back the arguments""" diff --git a/tests/test_transcript.py b/tests/test_transcript.py index 69389b7f2..284e9c370 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -29,7 +29,7 @@ class CmdLineApp(cmd2.Cmd): def __init__(self, *args, **kwargs): self.maxrepeats = 3 - super().__init__(*args, multiline_commands=['orate'], **kwargs) + super().__init__(*args, multiline_commands=['orate'], auto_load_commands=False, **kwargs) # Make maxrepeats settable at runtime self.add_settable(Settable('maxrepeats', int, 'Max number of `--repeat`s allowed')) From a92e26197620d42d8ca1e432f2a5725f4922a802 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Sat, 13 Jun 2020 12:30:33 -0400 Subject: [PATCH 04/14] add ability to remove commands and commandsets Issue #943 --- cmd2/cmd2.py | 50 ++++++++++++++++++-- cmd2/command_definition.py | 9 +++- tests/conftest.py | 3 +- tests/test_commandset.py | 94 +++++++++++++++++++++++++++++++++++++- 4 files changed, 148 insertions(+), 8 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 94cf4c45c..c83c3893a 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -244,8 +244,8 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, shortcuts=shortcuts) # Load modular commands - self._installed_functions: List[str] = [] - self._installed_command_sets: List[CommandSet] = [] + self._installed_functions = [] # type: List[str] + self._installed_command_sets = [] # type: List[CommandSet] if command_sets: for command_set in command_sets: self.install_command_set(command_set) @@ -469,7 +469,34 @@ def install_command_set(self, cmdset: CommandSet): delattr(self, attrib) raise - def install_command_function(self, cmd_name: str, cmd_func: Callable, cmd_completer: Callable, cmd_help: Callable): + def uninstall_command_set(self, cmdset: CommandSet): + """ + Uninstalls an CommandSet and unloads all associated commands + :param cmdset: CommandSet to uninstall + """ + if cmdset in self._installed_command_sets: + methods = inspect.getmembers( + cmdset, + predicate=lambda meth: inspect.ismethod(meth) and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) + + for method in methods: + cmd_name = method[0][len(COMMAND_FUNC_PREFIX):] + + delattr(self, COMMAND_FUNC_PREFIX + cmd_name) + + if hasattr(self, COMPLETER_FUNC_PREFIX + cmd_name): + delattr(self, COMPLETER_FUNC_PREFIX + cmd_name) + if hasattr(self, HELP_FUNC_PREFIX + cmd_name): + delattr(self, HELP_FUNC_PREFIX + cmd_name) + + cmdset.on_unregister(self) + self._installed_command_sets.remove(cmdset) + + def install_command_function(self, + cmd_name: str, + cmd_func: Callable, + cmd_completer: Optional[Callable], + cmd_help: Optional[Callable]): """ Installs a command by passing in functions for the command, completion, and help @@ -483,7 +510,8 @@ def install_command_function(self, cmd_name: str, cmd_func: Callable, cmd_comple if not valid: raise ValueError("Invalid command name {!r}: {}".format(cmd_name, errmsg)) - assert getattr(self, COMMAND_FUNC_PREFIX + cmd_name, None) is None, 'Duplicate command function registered: ' + cmd_name + assert getattr(self, COMMAND_FUNC_PREFIX + cmd_name, None) is None,\ + 'Duplicate command function registered: ' + cmd_name setattr(self, COMMAND_FUNC_PREFIX + cmd_name, types.MethodType(cmd_func, self)) self._installed_functions.append(cmd_name) if cmd_completer is not None: @@ -495,6 +523,20 @@ def install_command_function(self, cmd_name: str, cmd_func: Callable, cmd_comple 'Duplicate command help registered: ' + HELP_FUNC_PREFIX + cmd_name setattr(self, HELP_FUNC_PREFIX + cmd_name, types.MethodType(cmd_help, self)) + def uninstall_command(self, cmd_name: str): + """ + Uninstall an installed command and any associated completer or help functions + :param cmd_name: Command to uninstall + """ + if cmd_name in self._installed_functions: + delattr(self, COMMAND_FUNC_PREFIX + cmd_name) + + if hasattr(self, COMPLETER_FUNC_PREFIX + cmd_name): + delattr(self, COMPLETER_FUNC_PREFIX + cmd_name) + if hasattr(self, HELP_FUNC_PREFIX + cmd_name): + delattr(self, HELP_FUNC_PREFIX + cmd_name) + self._installed_functions.remove(cmd_name) + def add_settable(self, settable: Settable) -> None: """ Convenience method to add a settable parameter to ``self.settables`` diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py index a235525d4..115cef642 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -87,6 +87,7 @@ def register_command(cmd_func: Callable[['Cmd', Union['Statement', 'argparse.Nam break _UNBOUND_COMMANDS.append((cmd_name, cmd_func, cmd_completer, cmd_help)) + return cmd_func def with_default_category(category: str): @@ -132,6 +133,12 @@ def on_register(self, cmd: 'Cmd'): to perform an initialization requiring access to the Cmd object. :param cmd: The cmd2 main application - :return: None """ self._cmd = cmd + + def on_unregister(self, cmd: 'Cmd'): + """ + Called by ``cmd2.Cmd`` when a CommandSet is unregistered and removed. + :param cmd: + """ + self._cmd = None diff --git a/tests/conftest.py b/tests/conftest.py index 67a330e77..adaba062d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,14 +4,13 @@ """ import sys from contextlib import redirect_stderr, redirect_stdout -from typing import Dict, List, Optional, Union +from typing import List, Optional, Union from unittest import mock from pytest import fixture import cmd2 from cmd2.utils import StdSim -from cmd2.constants import COMMAND_FUNC_PREFIX, CMD_ATTR_HELP_CATEGORY # Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit) try: diff --git a/tests/test_commandset.py b/tests/test_commandset.py index acdb58b32..02fff7b21 100644 --- a/tests/test_commandset.py +++ b/tests/test_commandset.py @@ -31,8 +31,40 @@ def do_unbound(cmd: cmd2.Cmd, statement: cmd2.Statement): cmd.poutput('Unbound Command: {}'.format(statement.args)) +@cmd2.register_command +@cmd2.with_category("AAA") +def do_command_with_support(cmd: cmd2.Cmd, statement: cmd2.Statement): + """ + This is an example of registering an unbound function + + :param cmd: + :param statement: + :return: + """ + cmd.poutput('Unbound Command: {}'.format(statement.args)) + + +def help_command_with_support(cmd: cmd2.Cmd): + cmd.poutput('Help for command_with_support') + + +def complete_command_with_support(self, cmd: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: + """Completion function for do_index_based""" + food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato'] + sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] + + index_dict = \ + { + 1: food_item_strs, # Tab complete food items at index 1 in command line + 2: sport_item_strs, # Tab complete sport items at index 2 in command line + 3: cmd.path_complete, # Tab complete using path_complete function at index 3 in command line + } + + return cmd.index_based_complete(text, line, begidx, endidx, index_dict=index_dict) + + @cmd2.with_default_category('Command Set') -class TestCommandSet(cmd2.CommandSet): +class CommandSetA(cmd2.CommandSet): def do_apple(self, cmd: cmd2.Cmd, statement: cmd2.Statement): cmd.poutput('Apple!') @@ -69,6 +101,11 @@ def command_sets_app(): app = WithCommandSets() return app +@pytest.fixture() +def command_sets_manual(): + app = WithCommandSets(auto_load_commands=False) + return app + def test_autoload_commands(command_sets_app): cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_app._build_command_info() @@ -83,4 +120,59 @@ def test_autoload_commands(command_sets_app): assert 'cranberry' in cmds_cats['Command Set'] +def test_load_commands(command_sets_manual): + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + + assert 'AAA' not in cmds_cats + + assert 'Alone' not in cmds_cats + + assert 'Command Set' not in cmds_cats + + command_sets_manual.install_command_function('unbound', do_unbound, None, None) + + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + + assert 'AAA' in cmds_cats + assert 'unbound' in cmds_cats['AAA'] + + assert 'Alone' not in cmds_cats + + assert 'Command Set' not in cmds_cats + + cmd_set = CommandSetA() + + command_sets_manual.install_command_set(cmd_set) + + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + + assert 'AAA' in cmds_cats + assert 'unbound' in cmds_cats['AAA'] + + assert 'Alone' in cmds_cats + assert 'elderberry' in cmds_cats['Alone'] + + assert 'Command Set' in cmds_cats + assert 'cranberry' in cmds_cats['Command Set'] + + command_sets_manual.uninstall_command('unbound') + + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + + assert 'AAA' not in cmds_cats + + assert 'Alone' in cmds_cats + assert 'elderberry' in cmds_cats['Alone'] + + assert 'Command Set' in cmds_cats + assert 'cranberry' in cmds_cats['Command Set'] + + command_sets_manual.uninstall_command_set(cmd_set) + + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + + assert 'AAA' not in cmds_cats + + assert 'Alone' not in cmds_cats + assert 'Command Set' not in cmds_cats From 8a551930c5e601fd74582e2a3f6b2352672bac54 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Sun, 14 Jun 2020 11:05:24 -0400 Subject: [PATCH 05/14] Fixes issue with locating help_ annd complete_ functions when autoloading command functions Adds handling of some edge cases. More thorough test coverage. --- cmd2/cmd2.py | 32 +++- cmd2/command_definition.py | 29 +--- examples/modular_commands/commandset_basic.py | 17 ++ tests/test_commandset.py | 146 ++++++++++++++++-- 4 files changed, 188 insertions(+), 36 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index c83c3893a..5279fbf6f 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -46,7 +46,7 @@ from . import ansi, constants, plugin, utils from .argparse_custom import DEFAULT_ARGUMENT_PARSER, CompletionItem from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer -from .command_definition import _UNBOUND_COMMANDS, CommandSet, _partial_passthru +from .command_definition import _REGISTERED_COMMANDS, CommandSet, _partial_passthru from .constants import COMMAND_FUNC_PREFIX, COMPLETER_FUNC_PREFIX, HELP_FUNC_PREFIX from .decorators import with_argparser from .exceptions import Cmd2ShlexError, EmbeddedConsoleExit, EmptyStatement, RedirectionError, SkipPostcommandHooks @@ -403,8 +403,8 @@ def _autoload_commands(self) -> None: """ # start by loading registered functions as commands - for cmd_name, cmd_func, cmd_completer, cmd_help in _UNBOUND_COMMANDS: - self.install_command_function(cmd_name, cmd_func, cmd_completer, cmd_help) + for cmd_name in _REGISTERED_COMMANDS.keys(): + self.install_registered_command(cmd_name) # Search for all subclasses of CommandSet, instantiate them if they weren't provided in the constructor all_commandset_defs = CommandSet.__subclasses__() @@ -492,6 +492,28 @@ def uninstall_command_set(self, cmdset: CommandSet): cmdset.on_unregister(self) self._installed_command_sets.remove(cmdset) + def install_registered_command(self, cmd_name: str): + cmd_completer = None + cmd_help = None + + if cmd_name not in _REGISTERED_COMMANDS: + raise KeyError('Command ' + cmd_name + ' has not been registered') + + cmd_func = _REGISTERED_COMMANDS[cmd_name] + + module = inspect.getmodule(cmd_func) + + module_funcs = [mf for mf in inspect.getmembers(module) if inspect.isfunction(mf[1])] + for mf in module_funcs: + if mf[0] == COMPLETER_FUNC_PREFIX + cmd_name: + cmd_completer = mf[1] + elif mf[0] == HELP_FUNC_PREFIX + cmd_name: + cmd_help = mf[1] + if cmd_completer is not None and cmd_help is not None: + break + + self.install_command_function(cmd_name, cmd_func, cmd_completer, cmd_help) + def install_command_function(self, cmd_name: str, cmd_func: Callable, @@ -510,8 +532,8 @@ def install_command_function(self, if not valid: raise ValueError("Invalid command name {!r}: {}".format(cmd_name, errmsg)) - assert getattr(self, COMMAND_FUNC_PREFIX + cmd_name, None) is None,\ - 'Duplicate command function registered: ' + cmd_name + if getattr(self, COMMAND_FUNC_PREFIX + cmd_name, None) is not None: + raise KeyError('Duplicate command function registered: ' + cmd_name) setattr(self, COMMAND_FUNC_PREFIX + cmd_name, types.MethodType(cmd_func, self)) self._installed_functions.append(cmd_name) if cmd_completer is not None: diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py index 115cef642..6996bd9d0 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -5,17 +5,16 @@ import functools from typing import ( Callable, + Dict, Iterable, - List, Optional, - Tuple, Type, Union, ) -from .constants import COMMAND_FUNC_PREFIX, HELP_FUNC_PREFIX, COMPLETER_FUNC_PREFIX +from .constants import COMMAND_FUNC_PREFIX # Allows IDEs to resolve types without impacting imports at runtime, breaking circular dependency issues -try: +try: # pragma: no cover from typing import TYPE_CHECKING if TYPE_CHECKING: from .cmd2 import Cmd, Statement @@ -23,7 +22,7 @@ except ImportError: pass -_UNBOUND_COMMANDS = [] # type: List[Tuple[str, Callable, Optional[Callable], Optional[Callable]]] +_REGISTERED_COMMANDS = {} # type: Dict[str, Callable] """ Registered command tuples. (command, do_ function, complete_ function, help_ function """ @@ -69,24 +68,12 @@ def register_command(cmd_func: Callable[['Cmd', Union['Statement', 'argparse.Nam """ assert cmd_func.__name__.startswith(COMMAND_FUNC_PREFIX), 'Command functions must start with `do_`' - import inspect - cmd_name = cmd_func.__name__[len(COMMAND_FUNC_PREFIX):] - cmd_completer = None - cmd_help = None - - module = inspect.getmodule(cmd_func) - - module_funcs = [mf for mf in inspect.getmembers(module) if inspect.isfunction(mf[1])] - for mf in module_funcs: - if mf[0] == COMPLETER_FUNC_PREFIX + cmd_name: - cmd_completer = mf[1] - elif mf[0] == HELP_FUNC_PREFIX + cmd_name: - cmd_help = mf[1] - if cmd_completer is not None and cmd_help is not None: - break - _UNBOUND_COMMANDS.append((cmd_name, cmd_func, cmd_completer, cmd_help)) + if cmd_name not in _REGISTERED_COMMANDS: + _REGISTERED_COMMANDS[cmd_name] = cmd_func + else: + raise KeyError('Command ' + cmd_name + ' is already registered') return cmd_func diff --git a/examples/modular_commands/commandset_basic.py b/examples/modular_commands/commandset_basic.py index 8b51b7e48..01ce1b394 100644 --- a/examples/modular_commands/commandset_basic.py +++ b/examples/modular_commands/commandset_basic.py @@ -11,8 +11,21 @@ @register_command @with_category("AAA") def do_unbound(cmd: Cmd, statement: Statement): + """This is an example of registering an unbound function + + :param cmd: + :param statement: + :return: + """ + cmd.poutput('Unbound Command: {}'.format(statement.args)) + + +@register_command +@with_category("AAA") +def do_func_with_help(cmd: Cmd, statement: Statement): """ This is an example of registering an unbound function + :param cmd: :param statement: :return: @@ -20,6 +33,10 @@ def do_unbound(cmd: Cmd, statement: Statement): cmd.poutput('Unbound Command: {}'.format(statement.args)) +def help_func_with_help(cmd: Cmd): + cmd.poutput('Help for func_with_help') + + @with_default_category('Basic Completion') class BasicCompletionCommandSet(CommandSet): # List of strings used with completion functions diff --git a/tests/test_commandset.py b/tests/test_commandset.py index 02fff7b21..269c5de95 100644 --- a/tests/test_commandset.py +++ b/tests/test_commandset.py @@ -10,6 +10,11 @@ import cmd2 from cmd2 import utils +from .conftest import ( + complete_tester, + run_cmd, +) + # Python 3.5 had some regressions in the unitest.mock module, so use 3rd party mock if available try: @@ -41,14 +46,14 @@ def do_command_with_support(cmd: cmd2.Cmd, statement: cmd2.Statement): :param statement: :return: """ - cmd.poutput('Unbound Command: {}'.format(statement.args)) + cmd.poutput('Command with support functions: {}'.format(statement.args)) def help_command_with_support(cmd: cmd2.Cmd): cmd.poutput('Help for command_with_support') -def complete_command_with_support(self, cmd: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: +def complete_command_with_support(cmd: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: """Completion function for do_index_based""" food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato'] sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] @@ -96,11 +101,29 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) +@cmd2.with_default_category('Command Set B') +class CommandSetB(cmd2.CommandSet): + def __init__(self, arg1): + super().__init__() + self._arg1 = arg1 + + def do_aardvark(self, cmd: cmd2.Cmd, statement: cmd2.Statement): + cmd.poutput('Aardvark!') + + def do_bat(self, cmd: cmd2.Cmd, statement: cmd2.Statement): + """Banana Command""" + cmd.poutput('Bat!!') + + def do_crocodile(self, cmd: cmd2.Cmd, statement: cmd2.Statement): + cmd.poutput('Crocodile!!') + + @pytest.fixture def command_sets_app(): app = WithCommandSets() return app + @pytest.fixture() def command_sets_manual(): app = WithCommandSets(auto_load_commands=False) @@ -120,28 +143,55 @@ def test_autoload_commands(command_sets_app): assert 'cranberry' in cmds_cats['Command Set'] +def test_custom_construct_commandsets(): + command_set = CommandSetB('foo') + app = WithCommandSets(command_sets=[command_set]) + + cmds_cats, cmds_doc, cmds_undoc, help_topics = app._build_command_info() + assert 'Command Set B' in cmds_cats + + command_set_2 = CommandSetB('bar') + with pytest.raises(ValueError): + assert app.install_command_set(command_set_2) + + def test_load_commands(command_sets_manual): cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() - assert 'AAA' not in cmds_cats + # start by verifying none of the installable commands are present + assert 'AAA' not in cmds_cats assert 'Alone' not in cmds_cats - assert 'Command Set' not in cmds_cats - command_sets_manual.install_command_function('unbound', do_unbound, None, None) + # install the `unbound` command + command_sets_manual.install_registered_command('unbound') + with pytest.raises(KeyError): + assert command_sets_manual.install_registered_command('unbound') + + with pytest.raises(KeyError): + assert command_sets_manual.install_registered_command('nonexistent_command') + + def do_unbound(cmd: cmd2.Cmd, statement: cmd2.Statement): + """ + This function duplicates an existing command + """ + cmd.poutput('Unbound Command: {}'.format(statement.args)) + + with pytest.raises(KeyError): + assert cmd2.register_command(do_unbound) + + # verify only the `unbound` command was installed cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() assert 'AAA' in cmds_cats assert 'unbound' in cmds_cats['AAA'] - assert 'Alone' not in cmds_cats - assert 'Command Set' not in cmds_cats + # now install a command set and verify the commands are now present cmd_set = CommandSetA() - command_sets_manual.install_command_set(cmd_set) cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() @@ -155,6 +205,7 @@ def test_load_commands(command_sets_manual): assert 'Command Set' in cmds_cats assert 'cranberry' in cmds_cats['Command Set'] + # uninstall the `unbound` command and verify only it was uninstalled command_sets_manual.uninstall_command('unbound') cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() @@ -167,12 +218,87 @@ def test_load_commands(command_sets_manual): assert 'Command Set' in cmds_cats assert 'cranberry' in cmds_cats['Command Set'] + # uninstall the command set and verify it is now also no longer accessible command_sets_manual.uninstall_command_set(cmd_set) cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() assert 'AAA' not in cmds_cats - assert 'Alone' not in cmds_cats - assert 'Command Set' not in cmds_cats + + # reinstall the command set and verifyt is accessible but the `unbound` command isn't + command_sets_manual.install_command_set(cmd_set) + + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + + assert 'AAA' not in cmds_cats + + assert 'Alone' in cmds_cats + assert 'elderberry' in cmds_cats['Alone'] + + assert 'Command Set' in cmds_cats + assert 'cranberry' in cmds_cats['Command Set'] + + +def test_command_functions(command_sets_manual): + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'AAA' not in cmds_cats + + out, err = run_cmd(command_sets_manual, 'command_with_support') + assert 'is not a recognized command, alias, or macro' in err[0] + + out, err = run_cmd(command_sets_manual, 'help command_with_support') + assert 'No help on command_with_support' in err[0] + + text = '' + line = 'command_with_support' + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + assert first_match is None + + command_sets_manual.install_registered_command('command_with_support') + + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'AAA' in cmds_cats + assert 'command_with_support' in cmds_cats['AAA'] + + out, err = run_cmd(command_sets_manual, 'command_with_support') + assert 'Command with support functions' in out[0] + + out, err = run_cmd(command_sets_manual, 'help command_with_support') + assert 'Help for command_with_support' in out[0] + + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + assert first_match == 'Ham' + + text = '' + line = 'command_with_support Ham' + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + + assert first_match == 'Basket' + + command_sets_manual.uninstall_command('command_with_support') + + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'AAA' not in cmds_cats + + out, err = run_cmd(command_sets_manual, 'command_with_support') + assert 'is not a recognized command, alias, or macro' in err[0] + + out, err = run_cmd(command_sets_manual, 'help command_with_support') + assert 'No help on command_with_support' in err[0] + + text = '' + line = 'command_with_support' + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + assert first_match is None + From 1490e9dd49ed349933ddb25f64b691dcecc5911d Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 16 Jun 2020 09:40:40 -0400 Subject: [PATCH 06/14] Fixes to sphinx generation --- cmd2/cmd2.py | 2 +- cmd2/command_definition.py | 2 +- docs/api/command_definition.rst | 5 +++++ docs/api/index.rst | 1 + 4 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 docs/api/command_definition.rst diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 5279fbf6f..e16c0fb41 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -471,7 +471,7 @@ def install_command_set(self, cmdset: CommandSet): def uninstall_command_set(self, cmdset: CommandSet): """ - Uninstalls an CommandSet and unloads all associated commands + Uninstalls a CommandSet and unloads all associated commands :param cmdset: CommandSet to uninstall """ if cmdset in self._installed_command_sets: diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py index 6996bd9d0..1768d86e0 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -61,7 +61,7 @@ def __dir__(self) -> Iterable[str]: def register_command(cmd_func: Callable[['Cmd', Union['Statement', 'argparse.Namespace']], None]): """ Decorator that allows an arbitrary function to be automatically registered as a command. - If there is a help_ or complete_ function that matches this command, that will also be registered. + If there is a ``help_`` or ``complete_`` function that matches this command, that will also be registered. :param cmd_func: Function to register as a cmd2 command :return: diff --git a/docs/api/command_definition.rst b/docs/api/command_definition.rst new file mode 100644 index 000000000..cfb7082ed --- /dev/null +++ b/docs/api/command_definition.rst @@ -0,0 +1,5 @@ +cmd2.command_definition +======================= + +.. automodule:: cmd2.command_definition + :members: diff --git a/docs/api/index.rst b/docs/api/index.rst index cc899ba1c..17a259072 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -23,6 +23,7 @@ This documentation is for ``cmd2`` version |version|. argparse_completer argparse_custom constants + command_definition decorators exceptions history From 564ffdfabd219052afabb3ff0d89e90dac059b08 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 16 Jun 2020 10:07:46 -0400 Subject: [PATCH 07/14] Added explicit tests for dir and setattr. Minor type hinting changes --- cmd2/cmd2.py | 10 +++++----- tests/test_commandset.py | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index e16c0fb41..40748d517 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -264,14 +264,14 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, self.last_result = None # Used by run_script command to store current script dir as a LIFO queue to support _relative_run_script command - self._script_dir = [] # type: List[AnyStr] + self._script_dir = [] # type: List[str] # Context manager used to protect critical sections in the main thread from stopping due to a KeyboardInterrupt self.sigint_protection = utils.ContextFlag() # If the current command created a process to pipe to, then this will be a ProcReader object. # Otherwise it will be None. It's used to know when a pipe process can be killed and/or waited upon. - self._cur_pipe_proc_reader = None + self._cur_pipe_proc_reader = None # type: Optional[utils.ProcReader] # Used to keep track of whether we are redirecting or piping output self._redirecting = False @@ -295,7 +295,7 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, self.broken_pipe_warning = '' # Commands that will run at the beginning of the command loop - self._startup_commands = [] + self._startup_commands = [] # type: List[str] # If a startup script is provided and exists, then execute it in the startup commands if startup_script: @@ -304,7 +304,7 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, self._startup_commands.append("run_script {}".format(utils.quote_string(startup_script))) # Transcript files to run instead of interactive command loop - self._transcript_files = None + self._transcript_files = None # type: Optional[List[str]] # Check for command line args if allow_cli_args: @@ -2088,7 +2088,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: self._cur_pipe_proc_reader, self._redirecting) # The ProcReader for this command - cmd_pipe_proc_reader = None + cmd_pipe_proc_reader = None # type: Optional[utils.ProcReader] if not self.allow_redirection: # Don't return since we set some state variables at the end of the function diff --git a/tests/test_commandset.py b/tests/test_commandset.py index 269c5de95..a4207f99a 100644 --- a/tests/test_commandset.py +++ b/tests/test_commandset.py @@ -302,3 +302,27 @@ def test_command_functions(command_sets_manual): first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) assert first_match is None + +def test_partial_with_passthru(): + + def test_func(arg1, arg2): + """Documentation Comment""" + print('Do stuff {} - {}'.format(arg1, arg2)) + + my_partial = cmd2.command_definition._partial_passthru(test_func, 1) + + setattr(test_func, 'Foo', 5) + + assert hasattr(my_partial, 'Foo') + + assert getattr(my_partial, 'Foo', None) == 5 + + a = dir(test_func) + b = dir(my_partial) + assert a == b + + assert not hasattr(test_func, 'Bar') + setattr(my_partial, 'Bar', 6) + assert hasattr(test_func, 'Bar') + + assert getattr(test_func, 'Bar', None) == 6 From 35c37b8dbf80496fa2ca49db5d7614b8ce7b7360 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 16 Jun 2020 14:41:25 -0400 Subject: [PATCH 08/14] Added more command validation. Moved some common behavior into private functions. --- cmd2/cmd2.py | 75 +++++++++++++++++++++++++------------- cmd2/command_definition.py | 2 +- tests/test_commandset.py | 60 +++++++++++++++++++++++++----- 3 files changed, 101 insertions(+), 36 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 40748d517..25251bef8 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -41,7 +41,7 @@ from code import InteractiveConsole from collections import namedtuple from contextlib import redirect_stdout -from typing import Any, AnyStr, Callable, Dict, Iterable, List, Mapping, Optional, Tuple, Type, Union +from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Tuple, Type, Union from . import ansi, constants, plugin, utils from .argparse_custom import DEFAULT_ARGUMENT_PARSER, CompletionItem @@ -438,37 +438,69 @@ def install_command_set(self, cmdset: CommandSet): try: for method in methods: command = method[0][len(COMMAND_FUNC_PREFIX):] - - valid, errmsg = self.statement_parser.is_valid_command(command) - if not valid: - raise ValueError("Invalid command name {!r}: {}".format(command, errmsg)) - - assert getattr(self, COMMAND_FUNC_PREFIX + command, None) is None, \ - 'In {}: Duplicate command function: {}'.format(type(cmdset).__name__, method[0]) - command_wrapper = _partial_passthru(method[1], self) - setattr(self, method[0], command_wrapper) + + self.__install_command_function(command, command_wrapper, type(cmdset).__name__) installed_attributes.append(method[0]) completer_func_name = COMPLETER_FUNC_PREFIX + command cmd_completer = getattr(cmdset, completer_func_name, None) - if cmd_completer and not getattr(self, completer_func_name, None): + if cmd_completer is not None: completer_wrapper = _partial_passthru(cmd_completer, self) - setattr(self, completer_func_name, completer_wrapper) + self.__install_completer_function(command, completer_wrapper) installed_attributes.append(completer_func_name) help_func_name = HELP_FUNC_PREFIX + command cmd_help = getattr(cmdset, help_func_name, None) - if cmd_help and not getattr(self, help_func_name, None): + if cmd_help is not None: help_wrapper = _partial_passthru(cmd_help, self) - setattr(self, help_func_name, help_wrapper) + self.__install_help_function(command, help_wrapper) installed_attributes.append(help_func_name) + self._installed_command_sets.append(cmdset) except Exception: for attrib in installed_attributes: delattr(self, attrib) raise + def __install_command_function(self, command, command_wrapper, context=''): + cmd_func_name = COMMAND_FUNC_PREFIX + command + + # Make sure command function doesn't share naem with existing attribute + if hasattr(self, cmd_func_name): + raise ValueError('Attribute already exists: {} ({})'.format(cmd_func_name, context)) + + # Check if command has an invalid name + valid, errmsg = self.statement_parser.is_valid_command(command) + if not valid: + raise ValueError("Invalid command name {!r}: {}".format(command, errmsg)) + + # Check if command shares a name with an alias + if command in self.aliases: + self.pwarning("Deleting alias '{}' because it shares its name with a new command".format(command)) + del self.aliases[command] + + # Check if command shares a name with a macro + if command in self.macros: + self.pwarning("Deleting macro '{}' because it shares its name with a new command".format(command)) + del self.macros[command] + + setattr(self, cmd_func_name, command_wrapper) + + def __install_completer_function(self, cmd_name, cmd_completer): + completer_func_name = COMPLETER_FUNC_PREFIX + cmd_name + + if hasattr(self, completer_func_name): + raise ValueError('Attribute already exists: {}'.format(completer_func_name)) + setattr(self, completer_func_name, cmd_completer) + + def __install_help_function(self, cmd_name, cmd_completer): + help_func_name = HELP_FUNC_PREFIX + cmd_name + + if hasattr(self, help_func_name): + raise ValueError('Attribute already exists: {}'.format(help_func_name)) + setattr(self, help_func_name, cmd_completer) + def uninstall_command_set(self, cmdset: CommandSet): """ Uninstalls a CommandSet and unloads all associated commands @@ -528,22 +560,13 @@ def install_command_function(self, :param cmd_help: help generator for the command :return: None """ - valid, errmsg = self.statement_parser.is_valid_command(cmd_name) - if not valid: - raise ValueError("Invalid command name {!r}: {}".format(cmd_name, errmsg)) + self.__install_command_function(cmd_name, types.MethodType(cmd_func, self)) - if getattr(self, COMMAND_FUNC_PREFIX + cmd_name, None) is not None: - raise KeyError('Duplicate command function registered: ' + cmd_name) - setattr(self, COMMAND_FUNC_PREFIX + cmd_name, types.MethodType(cmd_func, self)) self._installed_functions.append(cmd_name) if cmd_completer is not None: - assert getattr(self, COMPLETER_FUNC_PREFIX + cmd_name, None) is None, \ - 'Duplicate command completer registered: ' + COMPLETER_FUNC_PREFIX + cmd_name - setattr(self, COMPLETER_FUNC_PREFIX + cmd_name, types.MethodType(cmd_completer, self)) + self.__install_completer_function(cmd_name, types.MethodType(cmd_completer, self)) if cmd_help is not None: - assert getattr(self, HELP_FUNC_PREFIX + cmd_name, None) is None, \ - 'Duplicate command help registered: ' + HELP_FUNC_PREFIX + cmd_name - setattr(self, HELP_FUNC_PREFIX + cmd_name, types.MethodType(cmd_help, self)) + self.__install_help_function(cmd_name, types.MethodType(cmd_help, self)) def uninstall_command(self, cmd_name: str): """ diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py index 1768d86e0..b5c9fbca7 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -19,7 +19,7 @@ if TYPE_CHECKING: from .cmd2 import Cmd, Statement import argparse -except ImportError: +except ImportError: # pragma: no cover pass _REGISTERED_COMMANDS = {} # type: Dict[str, Callable] diff --git a/tests/test_commandset.py b/tests/test_commandset.py index a4207f99a..bed570b94 100644 --- a/tests/test_commandset.py +++ b/tests/test_commandset.py @@ -12,17 +12,11 @@ from .conftest import ( complete_tester, + normalize, run_cmd, ) -# Python 3.5 had some regressions in the unitest.mock module, so use 3rd party mock if available -try: - import mock -except ImportError: - from unittest import mock - - @cmd2.register_command @cmd2.with_category("AAA") def do_unbound(cmd: cmd2.Cmd, statement: cmd2.Statement): @@ -131,6 +125,8 @@ def command_sets_manual(): def test_autoload_commands(command_sets_app): + # verifies that, when autoload is enabled, CommandSets and registered functions all show up + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_app._build_command_info() assert 'AAA' in cmds_cats @@ -144,6 +140,7 @@ def test_autoload_commands(command_sets_app): def test_custom_construct_commandsets(): + # Verifies that a custom initialized CommandSet loads correctly when passed into the constructor command_set = CommandSetB('foo') app = WithCommandSets(command_sets=[command_set]) @@ -159,7 +156,6 @@ def test_load_commands(command_sets_manual): cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() # start by verifying none of the installable commands are present - assert 'AAA' not in cmds_cats assert 'Alone' not in cmds_cats assert 'Command Set' not in cmds_cats @@ -167,12 +163,15 @@ def test_load_commands(command_sets_manual): # install the `unbound` command command_sets_manual.install_registered_command('unbound') - with pytest.raises(KeyError): + # verify that the same registered command can't be installed twice + with pytest.raises(ValueError): assert command_sets_manual.install_registered_command('unbound') + # verifies detection of unregistered commands with pytest.raises(KeyError): assert command_sets_manual.install_registered_command('nonexistent_command') + # verifies that a duplicate function name is detected def do_unbound(cmd: cmd2.Cmd, statement: cmd2.Statement): """ This function duplicates an existing command @@ -259,6 +258,17 @@ def test_command_functions(command_sets_manual): first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) assert first_match is None + # A bad command name gets rejected with an exception + with pytest.raises(ValueError): + assert command_sets_manual.install_command_function('>"', + do_command_with_support, + complete_command_with_support, + help_command_with_support) + + # create an alias to verify that it gets removed when the command is created + out, err = run_cmd(command_sets_manual, 'alias create command_with_support run_pyscript') + assert out == normalize("Alias 'command_with_support' created") + command_sets_manual.install_registered_command('command_with_support') cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() @@ -302,6 +312,38 @@ def test_command_functions(command_sets_manual): first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) assert first_match is None + # create an alias to verify that it gets removed when the command is created + out, err = run_cmd(command_sets_manual, 'macro create command_with_support run_pyscript') + assert out == normalize("Macro 'command_with_support' created") + + command_sets_manual.install_command_function('command_with_support', + do_command_with_support, + complete_command_with_support, + help_command_with_support) + + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'AAA' in cmds_cats + assert 'command_with_support' in cmds_cats['AAA'] + + out, err = run_cmd(command_sets_manual, 'command_with_support') + assert 'Command with support functions' in out[0] + + out, err = run_cmd(command_sets_manual, 'help command_with_support') + assert 'Help for command_with_support' in out[0] + + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + assert first_match == 'Ham' + + text = '' + line = 'command_with_support Ham' + endidx = len(line) + begidx = endidx - len(text) + + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + + assert first_match == 'Basket' + + def test_partial_with_passthru(): From 07b2ec16458d733f0e1df2b90c48ff663cda69f4 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 16 Jun 2020 16:52:08 -0400 Subject: [PATCH 09/14] Appears to be a type hinting olution that works for flake, sphinx, and PyCharm --- cmd2/command_definition.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py index b5c9fbca7..64e02fc87 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -9,7 +9,6 @@ Iterable, Optional, Type, - Union, ) from .constants import COMMAND_FUNC_PREFIX @@ -17,14 +16,14 @@ try: # pragma: no cover from typing import TYPE_CHECKING if TYPE_CHECKING: - from .cmd2 import Cmd, Statement - import argparse + import cmd2 + except ImportError: # pragma: no cover pass _REGISTERED_COMMANDS = {} # type: Dict[str, Callable] """ -Registered command tuples. (command, do_ function, complete_ function, help_ function +Registered command tuples. (command, ``do_`` function) """ @@ -58,12 +57,13 @@ def __dir__(self) -> Iterable[str]: return passthru_type(func, *args, **kwargs) -def register_command(cmd_func: Callable[['Cmd', Union['Statement', 'argparse.Namespace']], None]): +def register_command(cmd_func: Callable): """ Decorator that allows an arbitrary function to be automatically registered as a command. If there is a ``help_`` or ``complete_`` function that matches this command, that will also be registered. :param cmd_func: Function to register as a cmd2 command + :type cmd_func: Callable[[cmd2.Cmd, Union[Statement, argparse.Namespace]], None] :return: """ assert cmd_func.__name__.startswith(COMMAND_FUNC_PREFIX), 'Command functions must start with `do_`' @@ -112,20 +112,23 @@ class CommandSet(object): """ def __init__(self): - self._cmd = None # type: Optional[Cmd] + self._cmd = None # type: Optional[cmd2.Cmd] - def on_register(self, cmd: 'Cmd'): + def on_register(self, cmd): """ Called by cmd2.Cmd when a CommandSet is registered. Subclasses can override this to perform an initialization requiring access to the Cmd object. :param cmd: The cmd2 main application + :type cmd: cmd2.Cmd """ self._cmd = cmd - def on_unregister(self, cmd: 'Cmd'): + def on_unregister(self, cmd): """ Called by ``cmd2.Cmd`` when a CommandSet is unregistered and removed. + :param cmd: + :type cmd: cmd2.Cmd """ self._cmd = None From be70feb013ad5e05a133de8996ac435e61eaeadf Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Wed, 17 Jun 2020 21:09:43 -0400 Subject: [PATCH 10/14] Sort imports using isort --- cmd2/command_definition.py | 9 ++------- examples/modular_commands/commandset_basic.py | 2 +- examples/modular_commands/commandset_custominit.py | 2 +- tests/test_commandset.py | 7 ++----- 4 files changed, 6 insertions(+), 14 deletions(-) diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py index 64e02fc87..d99259694 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -3,13 +3,8 @@ Supports the definition of commands in separate classes to be composed into cmd2.Cmd """ import functools -from typing import ( - Callable, - Dict, - Iterable, - Optional, - Type, -) +from typing import Callable, Dict, Iterable, Optional, Type + from .constants import COMMAND_FUNC_PREFIX # Allows IDEs to resolve types without impacting imports at runtime, breaking circular dependency issues diff --git a/examples/modular_commands/commandset_basic.py b/examples/modular_commands/commandset_basic.py index 01ce1b394..25ba976d6 100644 --- a/examples/modular_commands/commandset_basic.py +++ b/examples/modular_commands/commandset_basic.py @@ -4,7 +4,7 @@ """ from typing import List -from cmd2 import Cmd, Statement, with_category, CommandSet, with_default_category, register_command +from cmd2 import Cmd, CommandSet, Statement, register_command, with_category, with_default_category from cmd2.utils import CompletionError diff --git a/examples/modular_commands/commandset_custominit.py b/examples/modular_commands/commandset_custominit.py index ce49876a4..d96c5f1c6 100644 --- a/examples/modular_commands/commandset_custominit.py +++ b/examples/modular_commands/commandset_custominit.py @@ -2,7 +2,7 @@ """ A simple example demonstrating a loadable command set """ -from cmd2 import Cmd, Statement, with_category, CommandSet, with_default_category, register_command +from cmd2 import Cmd, CommandSet, Statement, register_command, with_category, with_default_category @register_command diff --git a/tests/test_commandset.py b/tests/test_commandset.py index bed570b94..eedf51de4 100644 --- a/tests/test_commandset.py +++ b/tests/test_commandset.py @@ -5,16 +5,13 @@ """ from typing import List + import pytest import cmd2 from cmd2 import utils -from .conftest import ( - complete_tester, - normalize, - run_cmd, -) +from .conftest import complete_tester, normalize, run_cmd @cmd2.register_command From ba7f85d1d1f599a0fa4c30d703e8ba8c3a723084 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 7 Jul 2020 13:20:54 -0400 Subject: [PATCH 11/14] cleanup --- examples/modular_commands_main.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/modular_commands_main.py b/examples/modular_commands_main.py index 93dc79ea8..9e7f79ccd 100644 --- a/examples/modular_commands_main.py +++ b/examples/modular_commands_main.py @@ -4,12 +4,12 @@ A simple example demonstrating how to integrate tab completion with argparse-based commands. """ import argparse -from typing import Dict, List +from typing import Dict, Iterable, List, Optional -from cmd2 import Cmd, Cmd2ArgumentParser, CompletionItem, with_argparser +from cmd2 import Cmd, Cmd2ArgumentParser, CommandSet, CompletionItem, with_argparser from cmd2.utils import CompletionError, basic_complete from modular_commands.commandset_basic import BasicCompletionCommandSet # noqa: F401 -from modular_commands.commandset_custominit import CustomInitCommandSet +from modular_commands.commandset_custominit import CustomInitCommandSet # noqa: F401 # Data source for argparse.choices food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato'] @@ -58,8 +58,8 @@ def choices_arg_tokens(arg_tokens: Dict[str, List[str]]) -> List[str]: class WithCommandSets(Cmd): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, command_sets: Optional[Iterable[CommandSet]] = None): + super().__init__(command_sets=command_sets) self.sport_item_strs = ['Bat', 'Basket', 'Basketball', 'Football', 'Space Ball'] def choices_method(self) -> List[str]: @@ -122,6 +122,6 @@ def do_example(self, _: argparse.Namespace) -> None: import sys print("Starting") - command_sets = [CustomInitCommandSet('First argument', 'Second argument')] - app = WithCommandSets(command_sets=command_sets) + my_sets = [CustomInitCommandSet('First argument', 'Second argument')] + app = WithCommandSets(command_sets=my_sets) sys.exit(app.cmdloop()) From 1cd01c285172ce4f9843ff9d2cbbd254984d3957 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Fri, 17 Jul 2020 18:49:32 -0400 Subject: [PATCH 12/14] Adjusted decorators to accept variable positional parameters --- cmd2/decorators.py | 59 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/cmd2/decorators.py b/cmd2/decorators.py index d2fdf9c7e..cd35ad63f 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -1,7 +1,7 @@ # coding=utf-8 """Decorators for ``cmd2`` commands""" import argparse -from typing import Any, Callable, Dict, List, Optional, Union +from typing import Any, Callable, Dict, List, Optional, Tuple, Union from . import constants from .exceptions import Cmd2ArgparseError @@ -31,6 +31,21 @@ def cat_decorator(func): return cat_decorator +def _parse_positionals(args: Tuple) -> Tuple['cmd2.Cmd', Union[Statement, str]]: + for pos, arg in enumerate(args): + from cmd2 import Cmd + if isinstance(arg, Cmd): + return arg, args[pos + 1] + return None, None + + +def _arg_swap(args: Union[Tuple[Any], List[Any]], search_arg: Any, *replace_arg: Any) -> List[Any]: + index = args.index(search_arg) + args_list = list(args) + args_list[index:index + 1] = replace_arg + return args_list + + def with_argument_list(*args: List[Callable], preserve_quotes: bool = False) -> Callable[[List], Optional[bool]]: """ A decorator to alter the arguments passed to a ``do_*`` method. Default @@ -53,20 +68,22 @@ def with_argument_list(*args: List[Callable], preserve_quotes: bool = False) -> def arg_decorator(func: Callable): @functools.wraps(func) - def cmd_wrapper(cmd2_app, statement: Union[Statement, str], **kwargs: Dict[str, Any]) -> Optional[bool]: + def cmd_wrapper(*args, **kwargs: Dict[str, Any]) -> Optional[bool]: """ Command function wrapper which translates command line into an argument list and calls actual command function - :param cmd2_app: CLI instance passed as self parameter to command function - :param statement: command line string or already generated Statement + :param args: All positional arguments to this function. We're expecting there to be: + cmd2_app, statement: Union[Statement, str] + contiguously somewhere in the list :param kwargs: any keyword arguments being passed to command function :return: return value of command function """ + cmd2_app, statement = _parse_positionals(args) _, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(command_name, statement, preserve_quotes) - - return func(cmd2_app, parsed_arglist, **kwargs) + args_list = _arg_swap(args, statement, parsed_arglist) + return func(*args_list, **kwargs) command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX):] cmd_wrapper.__doc__ = func.__doc__ @@ -159,17 +176,19 @@ def with_argparser_and_unknown_args(parser: argparse.ArgumentParser, *, def arg_decorator(func: Callable): @functools.wraps(func) - def cmd_wrapper(cmd2_app, statement: Union[Statement, str], **kwargs: Dict[str, Any]) -> Optional[bool]: + def cmd_wrapper(*args: Tuple[Any, ...], **kwargs: Dict[str, Any]) -> Optional[bool]: """ Command function wrapper which translates command line into argparse Namespace and calls actual command function - :param cmd2_app: CLI instance passed as self parameter to command function - :param statement: command line string or already generated Statement + :param args: All positional arguments to this function. We're expecting there to be: + cmd2_app, statement: Union[Statement, str] + contiguously somewhere in the list :param kwargs: any keyword arguments being passed to command function :return: return value of command function :raises: Cmd2ArgparseError if argparse has error parsing command line """ + cmd2_app, statement = _parse_positionals(args) statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(command_name, statement, preserve_quotes) @@ -180,12 +199,13 @@ def cmd_wrapper(cmd2_app, statement: Union[Statement, str], **kwargs: Dict[str, namespace = ns_provider(cmd2_app) try: - args, unknown = parser.parse_known_args(parsed_arglist, namespace) + ns, unknown = parser.parse_known_args(parsed_arglist, namespace) except SystemExit: raise Cmd2ArgparseError else: - setattr(args, '__statement__', statement) - return func(cmd2_app, args, unknown, **kwargs) + setattr(ns, '__statement__', statement) + args_list = _arg_swap(args, statement, ns, unknown) + return func(*args_list, **kwargs) # argparser defaults the program name to sys.argv[0], but we want it to be the name of our command command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX):] @@ -241,17 +261,19 @@ def with_argparser(parser: argparse.ArgumentParser, *, def arg_decorator(func: Callable): @functools.wraps(func) - def cmd_wrapper(cmd2_app, statement: Union[Statement, str], **kwargs: Dict[str, Any]) -> Optional[bool]: + def cmd_wrapper(*args: Any, **kwargs: Dict[str, Any]) -> Optional[bool]: """ Command function wrapper which translates command line into argparse Namespace and calls actual command function - :param cmd2_app: CLI instance passed as self parameter to command function - :param statement: command line string or already generated Statement + :param args: All positional arguments to this function. We're expecting there to be: + cmd2_app, statement: Union[Statement, str] + contiguously somewhere in the list :param kwargs: any keyword arguments being passed to command function :return: return value of command function :raises: Cmd2ArgparseError if argparse has error parsing command line """ + cmd2_app, statement = _parse_positionals(args) statement, parsed_arglist = cmd2_app.statement_parser.get_command_arg_list(command_name, statement, preserve_quotes) @@ -262,12 +284,13 @@ def cmd_wrapper(cmd2_app, statement: Union[Statement, str], **kwargs: Dict[str, namespace = ns_provider(cmd2_app) try: - args = parser.parse_args(parsed_arglist, namespace) + ns = parser.parse_args(parsed_arglist, namespace) except SystemExit: raise Cmd2ArgparseError else: - setattr(args, '__statement__', statement) - return func(cmd2_app, args, **kwargs) + setattr(ns, '__statement__', statement) + args_list = _arg_swap(args, statement, ns) + return func(*args_list, **kwargs) # argparser defaults the program name to sys.argv[0], but we want it to be the name of our command command_name = func.__name__[len(constants.COMMAND_FUNC_PREFIX):] From 91a9ed51e2a8b8149c0b9203ad757bfaea468594 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Sat, 18 Jul 2020 16:40:21 -0400 Subject: [PATCH 13/14] Added an additional check for isinstance(method, Callable) since there are scenarios where inspect.ismethod() fails for some reason --- cmd2/cmd2.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 25251bef8..2215f8188 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -432,7 +432,8 @@ def install_command_set(self, cmdset: CommandSet): cmdset.on_register(self) methods = inspect.getmembers( cmdset, - predicate=lambda meth: inspect.ismethod(meth) and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) + predicate=lambda meth: (inspect.ismethod(meth) or isinstance(meth, Callable)) and + meth.__name__.startswith(COMMAND_FUNC_PREFIX)) installed_attributes = [] try: From c794059fe6b6db2c1cb4d06e7eda293bbcd7128c Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Sat, 18 Jul 2020 17:09:06 -0400 Subject: [PATCH 14/14] added additional documentation for new decorator behavior --- cmd2/decorators.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/cmd2/decorators.py b/cmd2/decorators.py index cd35ad63f..aad44ac4b 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -30,16 +30,37 @@ def cat_decorator(func): return func return cat_decorator +########################## +# The _parse_positionals and _swap_args decorators allow for additional positional args to be preserved +# in cmd2 command functions/callables. As long as the 2-ple of arguments we expect to be there can be +# found we can swap out the statement with each decorator's specific parameters +########################## + def _parse_positionals(args: Tuple) -> Tuple['cmd2.Cmd', Union[Statement, str]]: + """ + Helper function for cmd2 decorators to inspect the positional arguments until the cmd2.Cmd argument is found + Assumes that we will find cmd2.Cmd followed by the command statement object or string. + :arg args: The positional arguments to inspect + :return: The cmd2.Cmd reference and the command line statement + """ for pos, arg in enumerate(args): from cmd2 import Cmd - if isinstance(arg, Cmd): - return arg, args[pos + 1] - return None, None + if isinstance(arg, Cmd) and len(args) > pos: + next_arg = args[pos + 1] + if isinstance(next_arg, (Statement, str)): + return arg, args[pos + 1] + raise TypeError('Expected arguments: cmd: cmd2.Cmd, statement: Union[Statement, str] Not found') def _arg_swap(args: Union[Tuple[Any], List[Any]], search_arg: Any, *replace_arg: Any) -> List[Any]: + """ + Helper function for cmd2 decorators to swap the Statement parameter with one or more decorator-specific parameters + :param args: The original positional arguments + :param search_arg: The argument to search for (usually the Statement) + :param replace_arg: The arguments to substitute in + :return: The new set of arguments to pass to the command function + """ index = args.index(search_arg) args_list = list(args) args_list[index:index + 1] = replace_arg