From 1314c97066dc90ab2fb17d92a9714eed8ab94f40 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Thu, 11 Jun 2020 18:00:07 -0400 Subject: [PATCH 01/35] 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 | 66 ++++++++- 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, 451 insertions(+), 2 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 70ec508c2..9a98b5505 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. @@ -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 91a7eec2e6c94ddc57600dcdd9f4500357df22b8 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Fri, 12 Jun 2020 20:44:10 -0400 Subject: [PATCH 02/35] Some minor cleanup of how imports work. Fixed issue with help documentation for CommandSet commands. Issue #943 --- cmd2/__init__.py | 1 + cmd2/cmd2.py | 8 +++---- 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, 34 insertions(+), 14 deletions(-) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index c3c1f87e7..70a52f700 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 9a98b5505..4100ec086 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: 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 050feee8da46bf730eb78acb373ca097e90cbc51 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Fri, 12 Jun 2020 22:22:11 -0400 Subject: [PATCH 03/35] 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/test_commandset.py | 86 +++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 31 deletions(-) create mode 100644 tests/test_commandset.py diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 4100ec086..edf2a6431 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/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'] + + + From 97f2135d9ccb37baacfe862da4b9e63e18011642 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Sat, 13 Jun 2020 12:30:33 -0400 Subject: [PATCH 04/35] 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 edf2a6431..ef273d15d 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 c07f7083f..5b1a6f05b 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 2964e14ec9ab44f7ad6adfebcc60d064d7ef60ef Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Sun, 14 Jun 2020 11:05:24 -0400 Subject: [PATCH 05/35] 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 ef273d15d..310ad32fb 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 2a5b7d8a8fb88997a1379eedb3e5da963cc191db Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 16 Jun 2020 09:40:40 -0400 Subject: [PATCH 06/35] 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 310ad32fb..40748d517 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 bd7d247fb22c618cf14531a6ec0fcbe66ea91b9b Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 16 Jun 2020 10:07:46 -0400 Subject: [PATCH 07/35] Added explicit tests for dir and setattr. Minor type hinting changes --- tests/test_commandset.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) 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 0a564b7bd14687954c578d75a63c7105048ce871 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 16 Jun 2020 14:41:25 -0400 Subject: [PATCH 08/35] 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 08afb7697484ff65549b06caff65e5723f395560 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 16 Jun 2020 16:52:08 -0400 Subject: [PATCH 09/35] 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 706682a79c72455b92322691daeb22deacb0108d Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Wed, 17 Jun 2020 21:09:43 -0400 Subject: [PATCH 10/35] 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 86c494971b5c64bc42e10ece52f095a96cdadabe Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 7 Jul 2020 13:20:54 -0400 Subject: [PATCH 11/35] 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 fdcd13384437d4eb0ff8bf27c143fafe1bc65910 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Fri, 17 Jul 2020 18:49:32 -0400 Subject: [PATCH 12/35] 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 546fd97c30c23bb3a865ea7026d492f12e52e2df Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Sat, 18 Jul 2020 16:40:21 -0400 Subject: [PATCH 13/35] 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 91e5f9a13d23b618855bcb7bfe51fbe083b5611c Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Sat, 18 Jul 2020 17:09:06 -0400 Subject: [PATCH 14/35] 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 From 28517e5b6cb3b2e5b54e1c3b4651f0f0ea98a5c8 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 21 Jul 2020 16:36:31 -0400 Subject: [PATCH 15/35] Moved commandset tests into an isolated test --- isolated_tests/test_commandset/__init__.py | 3 + isolated_tests/test_commandset/conftest.py | 196 ++++++++++++++++++ .../test_commandset}/test_commandset.py | 0 tasks.py | 11 +- 4 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 isolated_tests/test_commandset/__init__.py create mode 100644 isolated_tests/test_commandset/conftest.py rename {tests => isolated_tests/test_commandset}/test_commandset.py (100%) diff --git a/isolated_tests/test_commandset/__init__.py b/isolated_tests/test_commandset/__init__.py new file mode 100644 index 000000000..037f3866e --- /dev/null +++ b/isolated_tests/test_commandset/__init__.py @@ -0,0 +1,3 @@ +# +# -*- coding: utf-8 -*- +# diff --git a/isolated_tests/test_commandset/conftest.py b/isolated_tests/test_commandset/conftest.py new file mode 100644 index 000000000..5b1a6f05b --- /dev/null +++ b/isolated_tests/test_commandset/conftest.py @@ -0,0 +1,196 @@ +# coding=utf-8 +""" +Cmd2 unit/functional testing +""" +import sys +from contextlib import redirect_stderr, redirect_stdout +from typing import List, Optional, Union +from unittest import mock + +from pytest import fixture + +import cmd2 +from cmd2.utils import StdSim + +# Prefer statically linked gnureadline if available (for macOS compatibility due to issues with libedit) +try: + import gnureadline as readline +except ImportError: + # Try to import readline, but allow failure for convenience in Windows unit testing + # Note: If this actually fails, you should install readline on Linux or Mac or pyreadline on Windows + try: + # noinspection PyUnresolvedReferences + import readline + except ImportError: + pass + + +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 + else: + help_text = ''.join(help_output) + commands = cmd2_app.get_visible_commands() + for command in commands: + assert command in help_text + + if verbose_strings: + for verbose_string in verbose_strings: + assert verbose_string in help_text + + +# Help text for the history command +HELP_HISTORY = """Usage: history [-h] [-r | -e | -o FILE | -t TRANSCRIPT_FILE | -c] [-s] [-x] + [-v] [-a] + [arg] + +View, run, edit, save, or clear previously entered commands + +positional arguments: + arg empty all history items + a one history item by number + a..b, a:b, a:, ..b items by indices (inclusive) + string items containing string + /regex/ items matching regular expression + +optional arguments: + -h, --help show this help message and exit + -r, --run run selected history items + -e, --edit edit and then run selected history items + -o, --output_file FILE + output commands to a script file, implies -s + -t, --transcript TRANSCRIPT_FILE + output commands and results to a transcript file, + implies -s + -c, --clear clear all history + +formatting: + -s, --script output commands in script format, i.e. without command + numbers + -x, --expanded output fully parsed commands with any aliases and + macros expanded, instead of typed commands + -v, --verbose display history and include expanded commands if they + differ from the typed command + -a, --all display all commands, including ones persisted from + previous sessions +""" + +# Output from the shortcuts command with default built-in shortcuts +SHORTCUTS_TXT = """Shortcuts for other commands: +!: shell +?: help +@: run_script +@@: _relative_run_script +""" + +# Output from the show command with default settings +SHOW_TXT = """allow_style: 'Terminal' +debug: False +echo: False +editor: 'vim' +feedback_to_output: False +max_completion_items: 50 +quiet: False +timing: False +""" + +SHOW_LONG = """ +allow_style: 'Terminal' # Allow ANSI text style sequences in output (valid values: Terminal, Always, Never) +debug: False # Show full traceback on exception +echo: False # Echo command issued into output +editor: 'vim' # Program used by 'edit' +feedback_to_output: False # Include nonessentials in '|', '>' results +max_completion_items: 50 # Maximum number of CompletionItems to display during tab completion +quiet: False # Don't print nonessential feedback +timing: False # Report execution times +""" + + +def normalize(block): + """ Normalize a block of text to perform comparison. + + Strip newlines from the very beginning and very end Then split into separate lines and strip trailing whitespace + from each line. + """ + assert isinstance(block, str) + block = block.strip('\n') + return [line.rstrip() for line in block.splitlines()] + + +def run_cmd(app, cmd): + """ Clear out and err StdSim buffers, run the command, and return out and err """ + saved_sysout = sys.stdout + sys.stdout = app.stdout + + # This will be used to capture app.stdout and sys.stdout + copy_cmd_stdout = StdSim(app.stdout) + + # This will be used to capture sys.stderr + copy_stderr = StdSim(sys.stderr) + + try: + app.stdout = copy_cmd_stdout + with redirect_stdout(copy_cmd_stdout): + with redirect_stderr(copy_stderr): + app.onecmd_plus_hooks(cmd) + finally: + app.stdout = copy_cmd_stdout.inner_stream + sys.stdout = saved_sysout + + out = copy_cmd_stdout.getvalue() + err = copy_stderr.getvalue() + return normalize(out), normalize(err) + + +@fixture +def base_app(): + return cmd2.Cmd() + + +# These are odd file names for testing quoting of them +odd_file_names = [ + 'nothingweird', + 'has spaces', + '"is_double_quoted"', + "'is_single_quoted'" +] + + +def complete_tester(text: str, line: str, begidx: int, endidx: int, app) -> Optional[str]: + """ + This is a convenience function to test cmd2.complete() since + in a unit test environment there is no actual console readline + is monitoring. Therefore we use mock to provide readline data + to complete(). + + :param text: the string prefix we are attempting to match + :param line: the current input line with leading whitespace removed + :param begidx: the beginning index of the prefix text + :param endidx: the ending index of the prefix text + :param app: the cmd2 app that will run completions + :return: The first matched string or None if there are no matches + Matches are stored in app.completion_matches + These matches also have been sorted by complete() + """ + def get_line(): + return line + + def get_begidx(): + return begidx + + def get_endidx(): + return endidx + + # Run the readline tab completion function with readline mocks in place + with mock.patch.object(readline, 'get_line_buffer', get_line): + with mock.patch.object(readline, 'get_begidx', get_begidx): + with mock.patch.object(readline, 'get_endidx', get_endidx): + return app.complete(text, 0) diff --git a/tests/test_commandset.py b/isolated_tests/test_commandset/test_commandset.py similarity index 100% rename from tests/test_commandset.py rename to isolated_tests/test_commandset/test_commandset.py diff --git a/tasks.py b/tasks.py index e5f6e7cba..811e32457 100644 --- a/tasks.py +++ b/tasks.py @@ -58,8 +58,15 @@ def pytest(context, junit=False, pty=True): command_str = 'pytest --cov=cmd2 --cov-report=term --cov-report=html ' if junit: command_str += ' --junitxml=junit/test-results.xml ' - command_str += ' tests' - context.run(command_str, pty=pty) + tests_cmd = command_str + ' tests' + context.run(tests_cmd, pty=pty) + + command_str += ' --cov-append' + + for root, dirnames, _ in os.walk(TASK_ROOT/'isolated_tests'): + for dir in dirnames: + if dir.startswith('test_'): + context.run(command_str + ' isolated_tests/' + dir) namespace.add_task(pytest) From 475d53ee0231d1033cbb12212a075dee797cc6e2 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 21 Jul 2020 17:18:38 -0400 Subject: [PATCH 16/35] Removed support for functions outside of CommandSets --- cmd2/__init__.py | 2 +- cmd2/cmd2.py | 85 +------ cmd2/command_definition.py | 25 --- .../test_commandset/test_commandset.py | 209 +----------------- 4 files changed, 13 insertions(+), 308 deletions(-) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 70a52f700..1fb01b163 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -28,7 +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 .command_definition import CommandSet, with_default_category 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 2215f8188..affd395f8 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 _REGISTERED_COMMANDS, CommandSet, _partial_passthru +from .command_definition import 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 @@ -90,6 +90,7 @@ class _SavedReadlineSettings: """readline settings that are backed up when switching between readline environments""" + def __init__(self): self.completer = None self.delims = '' @@ -98,6 +99,7 @@ def __init__(self): class _SavedCmd2Env: """cmd2 environment settings that are backed up when entering an interactive Python shell""" + def __init__(self): self.readline_settings = _SavedReadlineSettings() self.readline_module = None @@ -397,15 +399,7 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, self.matches_sorted = False def _autoload_commands(self) -> None: - """ - Load modular command definitions. - :return: None - """ - - # start by loading registered functions as commands - for cmd_name in _REGISTERED_COMMANDS.keys(): - self.install_registered_command(cmd_name) - + """Load modular command definitions.""" # 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._installed_command_sets] @@ -418,12 +412,11 @@ def _autoload_commands(self) -> None: cmdset = cmdset_type() self.install_command_set(cmdset) - def install_command_set(self, cmdset: CommandSet): + def install_command_set(self, cmdset: CommandSet) -> None: """ 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: @@ -525,64 +518,6 @@ 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, - cmd_completer: Optional[Callable], - cmd_help: Optional[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 - """ - self.__install_command_function(cmd_name, types.MethodType(cmd_func, self)) - - self._installed_functions.append(cmd_name) - if cmd_completer is not None: - self.__install_completer_function(cmd_name, types.MethodType(cmd_completer, self)) - if cmd_help is not None: - self.__install_help_function(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`` @@ -2156,7 +2091,8 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: if proc.returncode is not None: subproc_stdin.close() new_stdout.close() - raise RedirectionError('Pipe process exited with code {} before command could run'.format(proc.returncode)) + raise RedirectionError( + 'Pipe process exited with code {} before command could run'.format(proc.returncode)) else: redir_saved_state.redirecting = True cmd_pipe_proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr) @@ -2165,7 +2101,8 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: elif statement.output: import tempfile if (not statement.output_to) and (not self._can_clip): - raise RedirectionError("Cannot redirect to paste buffer; missing 'pyperclip' and/or pyperclip dependencies") + raise RedirectionError( + "Cannot redirect to paste buffer; missing 'pyperclip' and/or pyperclip dependencies") # Redirecting to a file elif statement.output_to: @@ -2271,7 +2208,6 @@ def onecmd(self, statement: Union[Statement, str], *, add_to_history: bool = Tru # Check to see if this command should be stored in history if statement.command not in self.exclude_from_history and \ statement.command not in self.disabled_commands and add_to_history: - self.history.append(statement) stop = func(statement) @@ -3358,7 +3294,7 @@ def _restore_cmd2_env(self, cmd2_env: _SavedCmd2Env) -> None: if 'gnureadline' in sys.modules: # Restore what the readline module pointed to if cmd2_env.readline_module is None: - del(sys.modules['readline']) + del (sys.modules['readline']) else: sys.modules['readline'] = cmd2_env.readline_module @@ -3387,6 +3323,7 @@ def do_py(self, args: argparse.Namespace, *, pyscript: Optional[str] = None) -> other arguments. (Defaults to None) :return: True if running of commands should stop """ + def py_quit(): """Function callable from the interactive Python console to exit that environment""" raise EmbeddedConsoleExit diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py index d99259694..0645de2ac 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -16,11 +16,6 @@ except ImportError: # pragma: no cover pass -_REGISTERED_COMMANDS = {} # type: Dict[str, Callable] -""" -Registered command tuples. (command, ``do_`` function) -""" - def _partial_passthru(func: Callable, *args, **kwargs) -> functools.partial: """ @@ -52,26 +47,6 @@ def __dir__(self) -> Iterable[str]: return passthru_type(func, *args, **kwargs) -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_`' - - cmd_name = cmd_func.__name__[len(COMMAND_FUNC_PREFIX):] - - 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 - - def with_default_category(category: str): """ Decorator that applies a category to all ``do_*`` command methods in a class that do not already diff --git a/isolated_tests/test_commandset/test_commandset.py b/isolated_tests/test_commandset/test_commandset.py index eedf51de4..8de2d3b0d 100644 --- a/isolated_tests/test_commandset/test_commandset.py +++ b/isolated_tests/test_commandset/test_commandset.py @@ -14,51 +14,6 @@ from .conftest import complete_tester, normalize, run_cmd -@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.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('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(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 CommandSetA(cmd2.CommandSet): def do_apple(self, cmd: cmd2.Cmd, statement: cmd2.Statement): @@ -126,9 +81,6 @@ 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'] @@ -150,41 +102,6 @@ def test_custom_construct_commandsets(): 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 - - # install the `unbound` command - command_sets_manual.install_registered_command('unbound') - - # 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 - """ - 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() @@ -192,22 +109,6 @@ def do_unbound(cmd: cmd2.Cmd, statement: cmd2.Statement): 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'] - - # 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() - - assert 'AAA' not in cmds_cats - assert 'Alone' in cmds_cats assert 'elderberry' in cmds_cats['Alone'] @@ -219,17 +120,14 @@ def do_unbound(cmd: cmd2.Cmd, statement: cmd2.Statement): 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 + # reinstall the command set and verify it is accessible 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'] @@ -237,111 +135,6 @@ def do_unbound(cmd: cmd2.Cmd, statement: cmd2.Statement): 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 - - # 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() - 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 - - # 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(): def test_func(arg1, arg2): From 2abe20a1d36c19b938fff3bb8065a0784a6da02d Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Fri, 24 Jul 2020 12:03:59 -0400 Subject: [PATCH 17/35] Updates the example to remove usage of the now remove ability to register arbitrary functions as commands. Added example that demonstrates use of each of the command decorators with CommandSets. Adds unit test that verifies that CommandSets containing decorators load and process commands correctly. Updated the constructor declaration for Cmd2ArgumentParser to explicitly re-declare argparse constructor parameters. --- cmd2/argparse_custom.py | 31 +++++-- examples/modular_commands/commandset_basic.py | 31 +------ .../modular_commands/commandset_complex.py | 53 +++++++++++ .../modular_commands/commandset_custominit.py | 14 +-- examples/modular_commands_main.py | 1 + .../test_commandset/test_commandset.py | 90 +++++++++++++++---- 6 files changed, 154 insertions(+), 66 deletions(-) create mode 100644 examples/modular_commands/commandset_complex.py diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 485f65c2a..74bddfc79 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -728,11 +728,32 @@ def _format_args(self, action, default_metavar) -> str: class Cmd2ArgumentParser(argparse.ArgumentParser): """Custom ArgumentParser class that improves error and help output""" - def __init__(self, *args, **kwargs) -> None: - if 'formatter_class' not in kwargs: - kwargs['formatter_class'] = Cmd2HelpFormatter - - super().__init__(*args, **kwargs) + def __init__(self, + prog=None, + usage=None, + description=None, + epilog=None, + parents=None, + formatter_class=Cmd2HelpFormatter, + prefix_chars='-', + fromfile_prefix_chars=None, + argument_default=None, + conflict_handler='error', + add_help=True, + allow_abbrev=True) -> None: + super(Cmd2ArgumentParser, self).__init__( + prog=prog, + usage=usage, + description=description, + epilog=epilog, + parents=parents if parents else [], + formatter_class=formatter_class, + prefix_chars=prefix_chars, + fromfile_prefix_chars=fromfile_prefix_chars, + argument_default=argument_default, + conflict_handler=conflict_handler, + add_help=add_help, + allow_abbrev=allow_abbrev) def add_subparsers(self, **kwargs): """Custom override. Sets a default title if one was not given.""" diff --git a/examples/modular_commands/commandset_basic.py b/examples/modular_commands/commandset_basic.py index 25ba976d6..105530e89 100644 --- a/examples/modular_commands/commandset_basic.py +++ b/examples/modular_commands/commandset_basic.py @@ -4,39 +4,10 @@ """ from typing import List -from cmd2 import Cmd, CommandSet, Statement, register_command, with_category, with_default_category +from cmd2 import Cmd, CommandSet, Statement, with_category, with_default_category 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)) - - -@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: - """ - 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/examples/modular_commands/commandset_complex.py b/examples/modular_commands/commandset_complex.py new file mode 100644 index 000000000..5a031bd06 --- /dev/null +++ b/examples/modular_commands/commandset_complex.py @@ -0,0 +1,53 @@ +# coding=utf-8 +# flake8: noqa E302 +""" +Test CommandSet +""" + +import argparse +from typing import List + +import cmd2 +from cmd2 import utils + + +@cmd2.with_default_category('Fruits') +class CommandSetA(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!!') + + cranberry_parser = cmd2.Cmd2ArgumentParser('cranberry') + cranberry_parser.add_argument('arg1', choices=['lemonade', 'juice', 'sauce']) + + @cmd2.with_argparser_and_unknown_args(cranberry_parser) + def do_cranberry(self, cmd: cmd2.Cmd, ns: argparse.Namespace, unknown: List[str]): + cmd.poutput('Cranberry {}!!'.format(ns.arg1)) + if unknown and len(unknown): + cmd.poutput('Unknown: ' + ', '.join(['{}']*len(unknown)).format(*unknown)) + cmd.last_result = {'arg1': ns.arg1, + 'unknown': unknown} + + def help_cranberry(self, cmd: cmd2.Cmd): + cmd.stdout.write('This command does diddly squat...\n') + + @cmd2.with_argument_list + @cmd2.with_category('Also Alone') + def do_durian(self, cmd: cmd2.Cmd, args: List[str]): + """Durian Command""" + cmd.poutput('{} Arguments: '.format(len(args))) + cmd.poutput(', '.join(['{}']*len(args)).format(*args)) + + 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']) + + elderberry_parser = cmd2.Cmd2ArgumentParser('elderberry') + elderberry_parser.add_argument('arg1') + + @cmd2.with_category('Alone') + @cmd2.with_argparser(elderberry_parser) + def do_elderberry(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + cmd.poutput('Elderberry {}!!'.format(ns.arg1)) diff --git a/examples/modular_commands/commandset_custominit.py b/examples/modular_commands/commandset_custominit.py index d96c5f1c6..fa26644be 100644 --- a/examples/modular_commands/commandset_custominit.py +++ b/examples/modular_commands/commandset_custominit.py @@ -2,19 +2,7 @@ """ A simple example demonstrating a loadable command set """ -from cmd2 import Cmd, CommandSet, Statement, register_command, with_category, with_default_category - - -@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)) +from cmd2 import Cmd, CommandSet, Statement, with_category, with_default_category @with_default_category('Custom Init') diff --git a/examples/modular_commands_main.py b/examples/modular_commands_main.py index 9e7f79ccd..fd10d8d35 100644 --- a/examples/modular_commands_main.py +++ b/examples/modular_commands_main.py @@ -10,6 +10,7 @@ from cmd2.utils import CompletionError, basic_complete from modular_commands.commandset_basic import BasicCompletionCommandSet # noqa: F401 from modular_commands.commandset_custominit import CustomInitCommandSet # noqa: F401 +from modular_commands.commandset_complex import CommandSetA # noqa: F401 # Data source for argparse.choices food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato'] diff --git a/isolated_tests/test_commandset/test_commandset.py b/isolated_tests/test_commandset/test_commandset.py index 8de2d3b0d..023ea30d7 100644 --- a/isolated_tests/test_commandset/test_commandset.py +++ b/isolated_tests/test_commandset/test_commandset.py @@ -4,17 +4,17 @@ Test CommandSet """ +import argparse from typing import List import pytest import cmd2 from cmd2 import utils +from cmd2_ext_test import ExternalTestMixin -from .conftest import complete_tester, normalize, run_cmd - -@cmd2.with_default_category('Command Set') +@cmd2.with_default_category('Fruits') class CommandSetA(cmd2.CommandSet): def do_apple(self, cmd: cmd2.Cmd, statement: cmd2.Statement): cmd.poutput('Apple!') @@ -23,28 +23,45 @@ 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!!') + cranberry_parser = cmd2.Cmd2ArgumentParser('cranberry') + cranberry_parser.add_argument('arg1', choices=['lemonade', 'juice', 'sauce']) + + @cmd2.with_argparser_and_unknown_args(cranberry_parser) + def do_cranberry(self, cmd: cmd2.Cmd, ns: argparse.Namespace, unknown: List[str]): + cmd.poutput('Cranberry {}!!'.format(ns.arg1)) + if unknown and len(unknown): + cmd.poutput('Unknown: ' + ', '.join(['{}']*len(unknown)).format(*unknown)) + cmd.last_result = {'arg1': ns.arg1, + 'unknown': unknown} 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): + @cmd2.with_argument_list + @cmd2.with_category('Also Alone') + def do_durian(self, cmd: cmd2.Cmd, args: List[str]): """Durian Command""" - cmd.poutput('Durian!!') + cmd.poutput('{} Arguments: '.format(len(args))) + cmd.poutput(', '.join(['{}']*len(args)).format(*args)) + cmd.last_result = {'args': args} 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']) + elderberry_parser = cmd2.Cmd2ArgumentParser('elderberry') + elderberry_parser.add_argument('arg1') + @cmd2.with_category('Alone') - def do_elderberry(self, cmd: cmd2.Cmd, statement: cmd2.Statement): - cmd.poutput('Elderberry!!') + @cmd2.with_argparser(elderberry_parser) + def do_elderberry(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + cmd.poutput('Elderberry {}!!'.format(ns.arg1)) + cmd.last_result = {'arg1': ns.arg1} -class WithCommandSets(cmd2.Cmd): +class WithCommandSets(ExternalTestMixin, cmd2.Cmd): """Class for testing custom help_* methods which override docstring help.""" def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + super(WithCommandSets, self).__init__(*args, **kwargs) @cmd2.with_default_category('Command Set B') @@ -84,8 +101,11 @@ def test_autoload_commands(command_sets_app): assert 'Alone' in cmds_cats assert 'elderberry' in cmds_cats['Alone'] - assert 'Command Set' in cmds_cats - assert 'cranberry' in cmds_cats['Command Set'] + assert 'Also Alone' in cmds_cats + assert 'durian' in cmds_cats['Also Alone'] + + assert 'Fruits' in cmds_cats + assert 'cranberry' in cmds_cats['Fruits'] def test_custom_construct_commandsets(): @@ -96,6 +116,7 @@ def test_custom_construct_commandsets(): cmds_cats, cmds_doc, cmds_undoc, help_topics = app._build_command_info() assert 'Command Set B' in cmds_cats + # Verifies that the same CommandSet can not be loaded twice command_set_2 = CommandSetB('bar') with pytest.raises(ValueError): assert app.install_command_set(command_set_2) @@ -112,8 +133,8 @@ def test_load_commands(command_sets_manual): assert 'Alone' in cmds_cats assert 'elderberry' in cmds_cats['Alone'] - assert 'Command Set' in cmds_cats - assert 'cranberry' in cmds_cats['Command Set'] + assert 'Fruits' in cmds_cats + assert 'cranberry' in cmds_cats['Fruits'] # uninstall the command set and verify it is now also no longer accessible command_sets_manual.uninstall_command_set(cmd_set) @@ -121,7 +142,7 @@ def test_load_commands(command_sets_manual): cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() assert 'Alone' not in cmds_cats - assert 'Command Set' not in cmds_cats + assert 'Fruits' not in cmds_cats # reinstall the command set and verify it is accessible command_sets_manual.install_command_set(cmd_set) @@ -131,8 +152,8 @@ def test_load_commands(command_sets_manual): assert 'Alone' in cmds_cats assert 'elderberry' in cmds_cats['Alone'] - assert 'Command Set' in cmds_cats - assert 'cranberry' in cmds_cats['Command Set'] + assert 'Fruits' in cmds_cats + assert 'cranberry' in cmds_cats['Fruits'] def test_partial_with_passthru(): @@ -158,3 +179,36 @@ def test_func(arg1, arg2): assert hasattr(test_func, 'Bar') assert getattr(test_func, 'Bar', None) == 6 + + +def test_commandset_decorators(command_sets_app): + result = command_sets_app.app_cmd('cranberry juice extra1 extra2') + assert len(result.data['unknown']) == 2 + assert 'extra1' in result.data['unknown'] + assert 'extra2' in result.data['unknown'] + assert result.data['arg1'] == 'juice' + assert result.stderr is None + + result = command_sets_app.app_cmd('durian juice extra1 extra2') + assert len(result.data['args']) == 3 + assert 'juice' in result.data['args'] + assert 'extra1' in result.data['args'] + assert 'extra2' in result.data['args'] + assert result.stderr is None + + result = command_sets_app.app_cmd('durian') + assert len(result.data['args']) == 0 + assert result.stderr is None + + result = command_sets_app.app_cmd('elderberry') + assert result.stderr is not None + assert len(result.stderr) > 0 + assert 'arguments are required' in result.stderr + assert result.data is None + + result = command_sets_app.app_cmd('elderberry a b') + assert result.stderr is not None + assert len(result.stderr) > 0 + assert 'unrecognized arguments' in result.stderr + assert result.data is None + From ef8eef4184970f7a9fa8baeea31fcde610080815 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Fri, 24 Jul 2020 12:21:43 -0400 Subject: [PATCH 18/35] updated imports Added additional documentation --- cmd2/cmd2.py | 5 +- cmd2/command_definition.py | 2 +- cmd2/decorators.py | 5 +- docs/features/commands.rst | 12 ++ docs/features/index.rst | 1 + docs/features/modular_commands.rst | 201 ++++++++++++++++++ .../modular_commands/commandset_custominit.py | 2 +- examples/modular_commands_basic.py | 37 ++++ examples/modular_commands_dynamic.py | 86 ++++++++ examples/modular_commands_main.py | 5 +- examples/plumbum_colors.py | 3 +- isolated_tests/__init__.py | 0 .../test_commandset/test_commandset.py | 1 - noxfile.py | 4 +- .../ext_test/cmd2_ext_test/cmd2_ext_test.py | 2 +- plugins/template/cmd2_myplugin/__init__.py | 2 +- plugins/template/cmd2_myplugin/myplugin.py | 2 +- plugins/template/setup.py | 1 + plugins/template/tasks.py | 1 - plugins/template/tests/test_myplugin.py | 2 +- tasks.py | 27 ++- tox.ini | 21 -- 22 files changed, 374 insertions(+), 48 deletions(-) create mode 100644 docs/features/modular_commands.rst create mode 100644 examples/modular_commands_basic.py create mode 100644 examples/modular_commands_dynamic.py create mode 100644 isolated_tests/__init__.py delete mode 100644 tox.ini diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index affd395f8..ca60a461f 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -37,7 +37,6 @@ import re import sys import threading -import types from code import InteractiveConsole from collections import namedtuple from contextlib import redirect_stdout @@ -425,8 +424,8 @@ def install_command_set(self, cmdset: CommandSet) -> None: cmdset.on_register(self) methods = inspect.getmembers( cmdset, - predicate=lambda meth: (inspect.ismethod(meth) or isinstance(meth, Callable)) 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: diff --git a/cmd2/command_definition.py b/cmd2/command_definition.py index 0645de2ac..1858c80b0 100644 --- a/cmd2/command_definition.py +++ b/cmd2/command_definition.py @@ -3,7 +3,7 @@ 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, Iterable, Optional, Type from .constants import COMMAND_FUNC_PREFIX diff --git a/cmd2/decorators.py b/cmd2/decorators.py index aad44ac4b..8c3739f1e 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -1,12 +1,15 @@ # coding=utf-8 """Decorators for ``cmd2`` commands""" import argparse -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union from . import constants from .exceptions import Cmd2ArgparseError from .parsing import Statement +if TYPE_CHECKING: + import cmd2 + def with_category(category: str) -> Callable: """A decorator to apply a category to a ``do_*`` command method. diff --git a/docs/features/commands.rst b/docs/features/commands.rst index 13a4ac1fc..8e61a4721 100644 --- a/docs/features/commands.rst +++ b/docs/features/commands.rst @@ -209,3 +209,15 @@ to: - remove commands included in ``cmd2`` - hide commands from the help menu - disable and re-enable commands at runtime + + +Modular Commands and Loading/Unloading Commands +----------------------------------------------- + +See :ref:`features/modular_commands:Modular Commands` for details of how +to: + +- Define commands in separate CommandSet modules +- Load or unload commands at runtime + + diff --git a/docs/features/index.rst b/docs/features/index.rst index efc0fe671..48590b6ad 100644 --- a/docs/features/index.rst +++ b/docs/features/index.rst @@ -17,6 +17,7 @@ Features hooks initialization misc + modular_commands multiline_commands os packaging diff --git a/docs/features/modular_commands.rst b/docs/features/modular_commands.rst new file mode 100644 index 000000000..d94e225aa --- /dev/null +++ b/docs/features/modular_commands.rst @@ -0,0 +1,201 @@ +Modular Commands +================ + +Overview +-------- + +Cmd2 also enables developers to modularize their command definitions into Command Sets. Command sets represent +a logical grouping of commands within an cmd2 application. By default, all CommandSets will be discovered and loaded +automatically when the cmd2.Cmd class is instantiated with this mixin. This also enables the developer to +dynamically add/remove commands from the cmd2 application. This could be useful for loadable plugins that +add additional capabilities. + +Features +~~~~~~~~ + +* Modular Command Sets - Commands can be broken into separate modules rather than in one god class holding all commands. +* Automatic Command Discovery - In your application, merely defining and importing a CommandSet is sufficient for + cmd2 to discover and load your command. No manual registration is necessary. +* Dynamically Loadable/Unloadable Commands - Command functions and CommandSets can both be loaded and unloaded + dynamically during application execution. This can enable features such as dynamically loaded modules that + add additional commands. + +See the examples for more details: https://github.com/python-cmd2/cmd2/tree/master/plugins/command_sets/examples + + +Defining Commands +----------------- + +Command Sets +~~~~~~~~~~~~~ + +CommandSets group multiple commands together. The plugin will inspect functions within a ``CommandSet`` +using the same rules as when they're defined in ``cmd2.Cmd``. Commands must be prefixed with ``do_``, help +functions with ``help_``, and completer functions with ``complete_``. + +A new decorator ``with_default_category`` is provided to categorize all commands within a CommandSet in the +same command category. Individual commands in a CommandSet may be override the default category by specifying a +specific category with ``cmd.with_category``. + +CommandSet methods will always expect ``self``, and ``cmd2.Cmd`` as the first two parameters. The parameters that +follow will depend on the specific command decorator being used. + +CommandSets will only be auto-loaded if the constructor takes no arguments. +If you need to provide constructor arguments, see :ref:`features/modular_commands:Manual CommandSet Construction` + +.. code-block:: python + + import cmd2 + from cmd2 import CommandSet, with_default_category + + @with_default_category('My Category') + class AutoLoadCommandSet(CommandSet): + def __init__(self): + super().__init__() + + def do_hello(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Hello') + + def do_world(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('World') + + class ExampleApp(cmd2.Cmd): + """ + CommandSets are automatically loaded. Nothing needs to be done. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def do_something(self, arg): + self.poutput('this is the something command') + + +Manual CommandSet Construction +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If a CommandSet class requires parameters to be provided to the constructor, you man manually construct +CommandSets and pass in the constructor to Cmd2. + +.. code-block:: python + + import cmd2 + from cmd2 import CommandSet, with_default_category + + @with_default_category('My Category') + class CustomInitCommandSet(CommandSet): + def __init__(self, arg1, arg2): + super().__init__() + + self._arg1 = arg1 + self._arg2 = arg2 + + def do_show_arg1(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Arg1: ' + self._arg1) + + def do_show_arg2(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Arg2: ' + self._arg2) + + class ExampleApp(cmd2.Cmd): + """ + CommandSets with constructor parameters are provided in the constructor + """ + def __init__(self, *args, **kwargs): + # gotta have this or neither the plugin or cmd2 will initialize + super().__init__(*args, **kwargs) + + def do_something(self, arg): + self.last_result = 5 + self.poutput('this is the something command') + + + def main(): + my_commands = CustomInitCommandSet(1, 2) + app = ExampleApp(command_sets=[my_commands]) + app.cmdloop() + + +Dynamic Commands +~~~~~~~~~~~~~~~~ + +You man also dynamically load and unload commands by installing and removing CommandSets at runtime. For example, +if you could support runtime loadable plugins or add/remove commands based on your state. + +You may need to disable command auto-loading if you need dynamically load commands at runtime. + +.. code-block:: python + + import argparse + import cmd2 + from cmd2 import CommandSet, with_argparser, with_category, with_default_category + + + @with_default_category('Fruits') + class LoadableFruits(CommandSet): + def __init__(self): + super().__init__() + + def do_apple(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Apple') + + def do_banana(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Banana') + + + @with_default_category('Vegetables') + class LoadableVegetables(CommandSet): + def __init__(self): + super().__init__() + + def do_arugula(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Arugula') + + def do_bokchoy(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Bok Choy') + + + class ExampleApp(cmd2.Cmd): + """ + CommandSets are loaded via the `load` and `unload` commands + """ + + def __init__(self, *args, **kwargs): + # gotta have this or neither the plugin or cmd2 will initialize + super().__init__(*args, auto_load_commands=False, **kwargs) + + self._fruits = LoadableFruits() + self._vegetables = LoadableVegetables() + + load_parser = cmd2.Cmd2ArgumentParser('load') + load_parser.add_argument('cmds', choices=['fruits', 'vegetables']) + + @with_argparser(load_parser) + @with_category('Command Loading') + def do_load(self, ns: argparse.Namespace): + if ns.cmds == 'fruits': + try: + self.install_command_set(self._fruits) + self.poutput('Fruits loaded') + except ValueError: + self.poutput('Fruits already loaded') + + if ns.cmds == 'vegetables': + try: + self.install_command_set(self._vegetables) + self.poutput('Vegetables loaded') + except ValueError: + self.poutput('Vegetables already loaded') + + @with_argparser(load_parser) + def do_unload(self, ns: argparse.Namespace): + if ns.cmds == 'fruits': + self.uninstall_command_set(self._fruits) + self.poutput('Fruits unloaded') + + if ns.cmds == 'vegetables': + self.uninstall_command_set(self._vegetables) + self.poutput('Vegetables unloaded') + + + if __name__ == '__main__': + app = ExampleApp() + app.cmdloop() diff --git a/examples/modular_commands/commandset_custominit.py b/examples/modular_commands/commandset_custominit.py index fa26644be..5a574a594 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, CommandSet, Statement, with_category, with_default_category +from cmd2 import Cmd, CommandSet, Statement, with_default_category @with_default_category('Custom Init') diff --git a/examples/modular_commands_basic.py b/examples/modular_commands_basic.py new file mode 100644 index 000000000..9f4a0bd2b --- /dev/null +++ b/examples/modular_commands_basic.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# coding=utf-8 +""" +Simple example demonstrating basic CommandSet usage. +""" + +import cmd2 +from cmd2 import CommandSet, with_default_category + + +@with_default_category('My Category') +class AutoLoadCommandSet(CommandSet): + def __init__(self): + super().__init__() + + def do_hello(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Hello') + + def do_world(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('World') + + +class ExampleApp(cmd2.Cmd): + """ + CommandSets are automatically loaded. Nothing needs to be done. + """ + + def __init__(self): + super(ExampleApp, self).__init__() + + def do_something(self, arg): + self.poutput('this is the something command') + + +if __name__ == '__main__': + app = ExampleApp() + app.cmdloop() diff --git a/examples/modular_commands_dynamic.py b/examples/modular_commands_dynamic.py new file mode 100644 index 000000000..81dbad824 --- /dev/null +++ b/examples/modular_commands_dynamic.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# coding=utf-8 +""" +Simple example demonstrating dynamic CommandSet loading and unloading. + +There are 2 CommandSets defined. ExampleApp sets the `auto_load_commands` flag to false. + +The `load` and `unload` commands will load and unload the CommandSets. The available commands will change depending +on which CommandSets are loaded +""" + +import argparse +import cmd2 +from cmd2 import CommandSet, with_argparser, with_category, with_default_category + + +@with_default_category('Fruits') +class LoadableFruits(CommandSet): + def __init__(self): + super().__init__() + + def do_apple(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Apple') + + def do_banana(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Banana') + + +@with_default_category('Vegetables') +class LoadableVegetables(CommandSet): + def __init__(self): + super().__init__() + + def do_arugula(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Arugula') + + def do_bokchoy(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Bok Choy') + + +class ExampleApp(cmd2.Cmd): + """ + CommandSets are loaded via the `load` and `unload` commands + """ + + def __init__(self, *args, **kwargs): + # gotta have this or neither the plugin or cmd2 will initialize + super().__init__(*args, auto_load_commands=False, **kwargs) + + self._fruits = LoadableFruits() + self._vegetables = LoadableVegetables() + + load_parser = cmd2.Cmd2ArgumentParser('load') + load_parser.add_argument('cmds', choices=['fruits', 'vegetables']) + + @with_argparser(load_parser) + @with_category('Command Loading') + def do_load(self, ns: argparse.Namespace): + if ns.cmds == 'fruits': + try: + self.install_command_set(self._fruits) + self.poutput('Fruits loaded') + except ValueError: + self.poutput('Fruits already loaded') + + if ns.cmds == 'vegetables': + try: + self.install_command_set(self._vegetables) + self.poutput('Vegetables loaded') + except ValueError: + self.poutput('Vegetables already loaded') + + @with_argparser(load_parser) + def do_unload(self, ns: argparse.Namespace): + if ns.cmds == 'fruits': + self.uninstall_command_set(self._fruits) + self.poutput('Fruits unloaded') + + if ns.cmds == 'vegetables': + self.uninstall_command_set(self._vegetables) + self.poutput('Vegetables unloaded') + + +if __name__ == '__main__': + app = ExampleApp() + app.cmdloop() diff --git a/examples/modular_commands_main.py b/examples/modular_commands_main.py index fd10d8d35..b698e00fb 100644 --- a/examples/modular_commands_main.py +++ b/examples/modular_commands_main.py @@ -1,7 +1,8 @@ #!/usr/bin/env python # coding=utf-8 """ -A simple example demonstrating how to integrate tab completion with argparse-based commands. +A complex example demonstrating a variety of methods to load CommandSets using a mix of command decorators +with examples of how to integrate tab completion with argparse-based commands. """ import argparse from typing import Dict, Iterable, List, Optional @@ -9,8 +10,8 @@ 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 # noqa: F401 from modular_commands.commandset_complex import CommandSetA # noqa: F401 +from modular_commands.commandset_custominit import CustomInitCommandSet # noqa: F401 # Data source for argparse.choices food_item_strs = ['Pizza', 'Ham', 'Ham Sandwich', 'Potato'] diff --git a/examples/plumbum_colors.py b/examples/plumbum_colors.py index ed65f245c..a30e4c706 100755 --- a/examples/plumbum_colors.py +++ b/examples/plumbum_colors.py @@ -27,10 +27,9 @@ """ import argparse -from plumbum.colors import bg, fg - import cmd2 from cmd2 import ansi +from plumbum.colors import bg, fg class FgColors(ansi.ColorBase): diff --git a/isolated_tests/__init__.py b/isolated_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/isolated_tests/test_commandset/test_commandset.py b/isolated_tests/test_commandset/test_commandset.py index 023ea30d7..c94c6690e 100644 --- a/isolated_tests/test_commandset/test_commandset.py +++ b/isolated_tests/test_commandset/test_commandset.py @@ -211,4 +211,3 @@ def test_commandset_decorators(command_sets_app): assert len(result.stderr) > 0 assert 'unrecognized arguments' in result.stderr assert result.data is None - diff --git a/noxfile.py b/noxfile.py index df4e97aa3..ec8a16e27 100644 --- a/noxfile.py +++ b/noxfile.py @@ -22,7 +22,9 @@ def docs(session): def tests(session, plugin): if plugin is None: session.install('invoke', './[test]') - session.run('invoke', 'pytest', '--junit', '--no-pty') + session.run('invoke', 'pytest', '--junit', '--no-pty', '--base') + session.install('./plugins/ext_test/') + session.run('invoke', 'pytest', '--junit', '--no-pty', '--isolated') elif plugin == 'coverage': session.install('invoke', 'codecov', 'coverage') session.run('codecov') diff --git a/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py b/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py index df54e1129..b1827f025 100644 --- a/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py +++ b/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py @@ -2,7 +2,7 @@ # coding=utf-8 """External test interface plugin""" -from typing import Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, Optional import cmd2 diff --git a/plugins/template/cmd2_myplugin/__init__.py b/plugins/template/cmd2_myplugin/__init__.py index e66b62cdb..838d828a3 100644 --- a/plugins/template/cmd2_myplugin/__init__.py +++ b/plugins/template/cmd2_myplugin/__init__.py @@ -5,7 +5,7 @@ An overview of what myplugin does. """ -from .myplugin import empty_decorator, MyPluginMixin # noqa: F401 +from .myplugin import MyPluginMixin, empty_decorator # noqa: F401 try: # For python 3.8 and later diff --git a/plugins/template/cmd2_myplugin/myplugin.py b/plugins/template/cmd2_myplugin/myplugin.py index 4f1ff0e99..816198b0b 100644 --- a/plugins/template/cmd2_myplugin/myplugin.py +++ b/plugins/template/cmd2_myplugin/myplugin.py @@ -3,7 +3,7 @@ """An example cmd2 plugin""" import functools -from typing import Callable, TYPE_CHECKING +from typing import TYPE_CHECKING, Callable import cmd2 diff --git a/plugins/template/setup.py b/plugins/template/setup.py index 17d06fa89..cb1dfd8ef 100644 --- a/plugins/template/setup.py +++ b/plugins/template/setup.py @@ -2,6 +2,7 @@ # coding=utf-8 import os + import setuptools # diff --git a/plugins/template/tasks.py b/plugins/template/tasks.py index 3fcb4cbfb..dcde18047 100644 --- a/plugins/template/tasks.py +++ b/plugins/template/tasks.py @@ -8,7 +8,6 @@ import invoke - TASK_ROOT = pathlib.Path(__file__).resolve().parent TASK_ROOT_STR = str(TASK_ROOT) diff --git a/plugins/template/tests/test_myplugin.py b/plugins/template/tests/test_myplugin.py index 4149f7df3..d61181a6e 100644 --- a/plugins/template/tests/test_myplugin.py +++ b/plugins/template/tests/test_myplugin.py @@ -1,8 +1,8 @@ # # coding=utf-8 -from cmd2 import cmd2 import cmd2_myplugin +from cmd2 import cmd2 ###### # diff --git a/tasks.py b/tasks.py index 811e32457..eef310eca 100644 --- a/tasks.py +++ b/tasks.py @@ -52,21 +52,28 @@ def rmrf(items, verbose=True): @invoke.task() -def pytest(context, junit=False, pty=True): +def pytest(context, junit=False, pty=True, base=False, isolated=False): """Run tests and code coverage using pytest""" with context.cd(TASK_ROOT_STR): - command_str = 'pytest --cov=cmd2 --cov-report=term --cov-report=html ' + command_str = 'pytest ' + command_str += ' --cov=cmd2 ' + command_str += ' --cov-append --cov-report=term --cov-report=html ' + + if not base and not isolated: + base = True + isolated = True + if junit: command_str += ' --junitxml=junit/test-results.xml ' - tests_cmd = command_str + ' tests' - context.run(tests_cmd, pty=pty) - - command_str += ' --cov-append' - for root, dirnames, _ in os.walk(TASK_ROOT/'isolated_tests'): - for dir in dirnames: - if dir.startswith('test_'): - context.run(command_str + ' isolated_tests/' + dir) + if base: + tests_cmd = command_str + ' tests' + context.run(tests_cmd, pty=pty) + if isolated: + for root, dirnames, _ in os.walk(str(TASK_ROOT/'isolated_tests')): + for dir in dirnames: + if dir.startswith('test_'): + context.run(command_str + ' isolated_tests/' + dir) namespace.add_task(pytest) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index ec8ccbc72..000000000 --- a/tox.ini +++ /dev/null @@ -1,21 +0,0 @@ -[tox] -envlist = docs,py35,py36,py37,py38,py39 - -[pytest] -testpaths = tests - -[testenv] -passenv = CI TRAVIS TRAVIS_* APPVEYOR* -setenv = PYTHONPATH={toxinidir} -extras = test -commands = - py.test {posargs} --cov --junitxml=junit/test-results.xml - codecov - -[testenv:docs] -basepython = python3.7 -deps = - sphinx - sphinx-rtd-theme -changedir = docs -commands = sphinx-build -a -W -T -b html -d {envtmpdir}/doctrees . {envtmpdir}/html From cc4485a27a7668a9f266c60902baef40229bc318 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Mon, 27 Jul 2020 11:43:16 -0400 Subject: [PATCH 19/35] Adds support for injectable subcommands as part of CommandSet load/unload. Updated examples and documentation to include discussion of injectable sub-commands. --- cmd2/__init__.py | 2 +- cmd2/argparse_custom.py | 32 +++++++- cmd2/cmd2.py | 99 +++++++++++++++++++++-- cmd2/constants.py | 4 + cmd2/decorators.py | 31 +++++++ docs/features/modular_commands.rst | 126 +++++++++++++++++++++++++++++ examples/modular_subcommands.py | 110 +++++++++++++++++++++++++ 7 files changed, 396 insertions(+), 8 deletions(-) create mode 100644 examples/modular_subcommands.py diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 1fb01b163..19e620bed 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -30,7 +30,7 @@ from .cmd2 import Cmd from .command_definition import CommandSet, with_default_category from .constants import COMMAND_NAME, DEFAULT_SHORTCUTS -from .decorators import with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category +from .decorators import with_argument_list, with_argparser, with_argparser_and_unknown_args, with_category, as_subcommand_to from .exceptions import Cmd2ArgparseError, SkipPostcommandHooks from . import plugin from .parsing import Statement diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 74bddfc79..689c1db75 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -724,6 +724,24 @@ def _format_args(self, action, default_metavar) -> str: return result +class _UnloadableSubParsersAction(argparse._SubParsersAction): + """Extends the argparse internal SubParsers action to allow sub-parsers to be removed dynamically""" + def remove_parser(self, name): + """Removes a sub-parser from the sub-parsers group""" + for choice_action in self._choices_actions: + if choice_action.dest == name: + self._choices_actions.remove(choice_action) + break + + subparser = self._name_parser_map[name] + to_remove = [] + for name, parser in self._name_parser_map.items(): + if parser is subparser: + to_remove.append(name) + for name in to_remove: + del self._name_parser_map[name] + + # noinspection PyCompatibility class Cmd2ArgumentParser(argparse.ArgumentParser): """Custom ArgumentParser class that improves error and help output""" @@ -754,12 +772,22 @@ def __init__(self, conflict_handler=conflict_handler, add_help=add_help, allow_abbrev=allow_abbrev) + self.register('action', 'unloadable_parsers', _UnloadableSubParsersAction) + + def add_subparsers(self, unloadable=False, **kwargs): + """ + Custom override. Sets a default title if one was not given. - def add_subparsers(self, **kwargs): - """Custom override. Sets a default title if one was not given.""" + :param unloadable: Flag whether this sub-parsers group will support unloading parsers + :param kwargs: additional keyword arguments + :return: argparse Subparser Action + """ if 'title' not in kwargs: kwargs['title'] = 'subcommands' + if unloadable: + kwargs['action'] = 'unloadable_parsers' + return super().add_subparsers(**kwargs) def error(self, message: str) -> None: diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index ca60a461f..2a1bcbf2e 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -43,7 +43,7 @@ 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 +from .argparse_custom import DEFAULT_ARGUMENT_PARSER, CompletionItem, _UnloadableSubParsersAction from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer from .command_definition import CommandSet, _partial_passthru from .constants import COMMAND_FUNC_PREFIX, COMPLETER_FUNC_PREFIX, HELP_FUNC_PREFIX @@ -260,6 +260,8 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, if not valid: raise ValueError("Invalid command name {!r}: {}".format(cur_cmd, errmsg)) + self._register_subcommands(self) + # Stores results from the last command run to enable usage of results in a Python script or interactive console # Built-in commands don't make use of this. It is purely there for user-defined commands and convenience. self.last_result = None @@ -429,12 +431,12 @@ def install_command_set(self, cmdset: CommandSet) -> None: installed_attributes = [] try: - for method in methods: - command = method[0][len(COMMAND_FUNC_PREFIX):] - command_wrapper = _partial_passthru(method[1], self) + for method_name, method in methods: + command = method_name[len(COMMAND_FUNC_PREFIX):] + command_wrapper = _partial_passthru(method, self) self.__install_command_function(command, command_wrapper, type(cmdset).__name__) - installed_attributes.append(method[0]) + installed_attributes.append(method_name) completer_func_name = COMPLETER_FUNC_PREFIX + command cmd_completer = getattr(cmdset, completer_func_name, None) @@ -451,6 +453,8 @@ def install_command_set(self, cmdset: CommandSet) -> None: installed_attributes.append(help_func_name) self._installed_command_sets.append(cmdset) + + self._register_subcommands(cmdset) except Exception: for attrib in installed_attributes: delattr(self, attrib) @@ -500,6 +504,9 @@ def uninstall_command_set(self, cmdset: CommandSet): :param cmdset: CommandSet to uninstall """ if cmdset in self._installed_command_sets: + + self._unregister_subcommands(cmdset) + methods = inspect.getmembers( cmdset, predicate=lambda meth: inspect.ismethod(meth) and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) @@ -517,6 +524,88 @@ def uninstall_command_set(self, cmdset: CommandSet): cmdset.on_unregister(self) self._installed_command_sets.remove(cmdset) + def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: + """ + Register sub-commands with their base command + + :param cmdset: CommandSet containing sub-commands + """ + if not (cmdset is self or cmdset in self._installed_command_sets): + raise ValueError('Adding sub-commands from an unregistered CommandSet') + + # find all methods that start with the sub-command prefix + methods = inspect.getmembers( + cmdset, + predicate=lambda meth: (inspect.ismethod(meth) or isinstance(meth, Callable)) + and hasattr(meth, constants.SUBCMD_ATTR_NAME) + and hasattr(meth, constants.SUBCMD_ATTR_COMMAND) + and hasattr(meth, constants.CMD_ATTR_ARGPARSER) + ) + + # iterate through all matching methods + for method_name, method in methods: + subcommand_name = getattr(method, constants.SUBCMD_ATTR_NAME) + command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND) + subcmd_parser = getattr(method, constants.CMD_ATTR_ARGPARSER) + + # Search for the base command function and verify it has an argparser defined + command_func = self.cmd_func(command_name) + if command_func is None or not hasattr(command_func, constants.CMD_ATTR_ARGPARSER): + raise TypeError('Could not find command: ' + command_name + ' needed by sub-command ' + str(method)) + command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER) + if command_parser is None: + raise TypeError('Could not find argparser for command: ' + command_name + ' needed by sub-command ' + str(method)) + + if hasattr(method, '__doc__') and method.__doc__ is not None: + help_text = method.__doc__.splitlines()[0] + else: + help_text = subcommand_name + + if isinstance(cmdset, CommandSet): + command_handler = _partial_passthru(method, self) + else: + command_handler = method + subcmd_parser.set_defaults(handler=command_handler) + + for action in command_parser._actions: + if isinstance(action, _UnloadableSubParsersAction): + action.add_parser(subcommand_name, parents=[subcmd_parser], help=help_text) + + def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: + """ + Unregister sub-commands from their base command + + :param cmdset: CommandSet containing sub-commands + """ + if not (cmdset is self or cmdset in self._installed_command_sets): + raise ValueError('Removing sub-commands from an unregistered CommandSet') + + # find all methods that start with the sub-command prefix + methods = inspect.getmembers( + cmdset, + predicate=lambda meth: (inspect.ismethod(meth) or isinstance(meth, Callable)) + and hasattr(meth, constants.SUBCMD_ATTR_NAME) + and hasattr(meth, constants.SUBCMD_ATTR_COMMAND) + and hasattr(meth, constants.CMD_ATTR_ARGPARSER) + ) + + # iterate through all matching methods + for method_name, method in methods: + subcommand_name = getattr(method, constants.SUBCMD_ATTR_NAME) + command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND) + + # Search for the base command function and verify it has an argparser defined + command_func = self.cmd_func(command_name) + if command_func is None or not hasattr(command_func, constants.CMD_ATTR_ARGPARSER): + raise TypeError('Could not find command: ' + command_name + ' needed by sub-command ' + str(method)) + command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER) + if command_parser is None: + raise TypeError('Could not find argparser for command: ' + command_name + ' needed by sub-command ' + str(method)) + + for action in command_parser._actions: + if isinstance(action, _UnloadableSubParsersAction): + action.remove_parser(subcommand_name) + def add_settable(self, settable: Settable) -> None: """ Convenience method to add a settable parameter to ``self.settables`` diff --git a/cmd2/constants.py b/cmd2/constants.py index 81d1a29ba..0135e3286 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -49,3 +49,7 @@ # Whether or not tokens are unquoted before sending to argparse CMD_ATTR_PRESERVE_QUOTES = 'preserve_quotes' + +# sub-command attributes for the base command name and the sub-command name +SUBCMD_ATTR_COMMAND = 'parent_command' +SUBCMD_ATTR_NAME = 'subcommand_name' diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 8c3739f1e..6e3b7acf1 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -335,3 +335,34 @@ def cmd_wrapper(*args: Any, **kwargs: Dict[str, Any]) -> Optional[bool]: # noinspection PyTypeChecker return arg_decorator + + +def as_subcommand_to(command: str, + subcommand: str, + parser: argparse.ArgumentParser) -> Callable[[argparse.Namespace], Optional[bool]]: + """ + Tag this method as a sub-command to an existing argparse decorated command. + + :param command: Command Name + :param subcommand: Sub-command name + :param parser: argparse Parser to for this sub-command + :return: Wrapper function that can receive an argparse.Namespace + """ + def arg_decorator(func: Callable): + _set_parser_prog(parser, subcommand) + + # If the description has not been set, then use the method docstring if one exists + if parser.description is None and func.__doc__: + parser.description = func.__doc__ + + parser.set_defaults(func=func) + + # # Set some custom attributes for this command + setattr(func, constants.SUBCMD_ATTR_COMMAND, command) + setattr(func, constants.CMD_ATTR_ARGPARSER, parser) + setattr(func, constants.SUBCMD_ATTR_NAME, subcommand) + + return func + + # noinspection PyTypeChecker + return arg_decorator diff --git a/docs/features/modular_commands.rst b/docs/features/modular_commands.rst index d94e225aa..82298c8f7 100644 --- a/docs/features/modular_commands.rst +++ b/docs/features/modular_commands.rst @@ -19,6 +19,9 @@ Features * Dynamically Loadable/Unloadable Commands - Command functions and CommandSets can both be loaded and unloaded dynamically during application execution. This can enable features such as dynamically loaded modules that add additional commands. +* Sub-command Injection - Sub-commands can be defined separately from the base command. This allows for a more + action-centric instead of object-centric command system while still organizing your code and handlers around the + objects being managed. See the examples for more details: https://github.com/python-cmd2/cmd2/tree/master/plugins/command_sets/examples @@ -199,3 +202,126 @@ You may need to disable command auto-loading if you need dynamically load comman if __name__ == '__main__': app = ExampleApp() app.cmdloop() + + +Injecting Sub-Commands +---------------------- + +Description +~~~~~~~~~~~ +Using the `with_argparse` decorator, it is possible to define sub-commands for your command. This has a tendency to +either drive your interface into an object-centric interface. For example, imagine you have a tool that manages your +media collection and you want to manage movies or shows. An object-centric approach would push you to have base commands +such as `movies` and `shows` which each have sub-commands `add`, `edit`, `list`, `delete`. If you wanted to present an +action-centric command set, so that `add`, `edit`, `list`, and `delete` are the base commands, you'd have to organize +your code around these similar actions rather than organizing your code around similar objects being managed. + +Sub-command injection allows you to inject sub-commands into a base command to present an interface that is sensible to +a user while still organizing your code in whatever structure make more logical sense to the developer. + +Example +~~~~~~~ + +This example is a variation on the Dynamic Commands example above. A `cut` command is introduced as a base +command and each CommandSet + +.. code-block:: python + + import argparse + import cmd2 + from cmd2 import CommandSet, with_argparser, with_category, with_default_category + + + @with_default_category('Fruits') + class LoadableFruits(CommandSet): + def __init__(self): + super().__init__() + + def do_apple(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Apple') + + banana_parser = cmd2.Cmd2ArgumentParser(add_help=False) + banana_parser.add_argument('direction', choices=['discs', 'lengthwise']) + + @cmd2.as_subcommand_to('cut', 'banana', banana_parser) + def cut_banana(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + """Cut banana""" + cmd.poutput('cutting banana: ' + ns.direction) + + + @with_default_category('Vegetables') + class LoadableVegetables(CommandSet): + def __init__(self): + super().__init__() + + def do_arugula(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Arugula') + + bokchoy_parser = cmd2.Cmd2ArgumentParser(add_help=False) + bokchoy_parser.add_argument('style', choices=['quartered', 'diced']) + + @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser) + def cut_bokchoy(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Bok Choy') + + + class ExampleApp(cmd2.Cmd): + """ + CommandSets are automatically loaded. Nothing needs to be done. + """ + + def __init__(self, *args, **kwargs): + # gotta have this or neither the plugin or cmd2 will initialize + super().__init__(*args, auto_load_commands=False, **kwargs) + + self._fruits = LoadableFruits() + self._vegetables = LoadableVegetables() + + load_parser = cmd2.Cmd2ArgumentParser('load') + load_parser.add_argument('cmds', choices=['fruits', 'vegetables']) + + @with_argparser(load_parser) + @with_category('Command Loading') + def do_load(self, ns: argparse.Namespace): + if ns.cmds == 'fruits': + try: + self.install_command_set(self._fruits) + self.poutput('Fruits loaded') + except ValueError: + self.poutput('Fruits already loaded') + + if ns.cmds == 'vegetables': + try: + self.install_command_set(self._vegetables) + self.poutput('Vegetables loaded') + except ValueError: + self.poutput('Vegetables already loaded') + + @with_argparser(load_parser) + def do_unload(self, ns: argparse.Namespace): + if ns.cmds == 'fruits': + self.uninstall_command_set(self._fruits) + self.poutput('Fruits unloaded') + + if ns.cmds == 'vegetables': + self.uninstall_command_set(self._vegetables) + self.poutput('Vegetables unloaded') + + cut_parser = cmd2.Cmd2ArgumentParser('cut') + cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut', unloadable=True) + + @with_argparser(cut_parser) + def do_cut(self, ns: argparse.Namespace): + func = getattr(ns, 'handler', None) + if func is not None: + # Call whatever subcommand function was selected + func(ns) + else: + # No subcommand was provided, so call help + self.poutput('This command does nothing without sub-parsers registered') + self.do_help('cut') + + + if __name__ == '__main__': + app = ExampleApp() + app.cmdloop() diff --git a/examples/modular_subcommands.py b/examples/modular_subcommands.py new file mode 100644 index 000000000..e4d2fe453 --- /dev/null +++ b/examples/modular_subcommands.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +# coding=utf-8 +"""A simple example demonstracting modular sub-command loading through CommandSets + +In this example, there are loadable CommandSets defined. Each CommandSet has 1 sub-command defined that will be +attached to the 'cut' command. + +The cut command is implemented with the `do_cut` function that has been tagged as an argparse command. + +The `load` and `unload` command will load and unload the CommandSets. The available top level commands as well as +sub-commands to the `cut` command will change depending on which CommandSets are loaded. +""" +import argparse +import cmd2 +from cmd2 import CommandSet, with_argparser, with_category, with_default_category + + +@with_default_category('Fruits') +class LoadableFruits(CommandSet): + def __init__(self): + super().__init__() + + def do_apple(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Apple') + + banana_parser = cmd2.Cmd2ArgumentParser(add_help=False) + banana_parser.add_argument('direction', choices=['discs', 'lengthwise']) + + @cmd2.as_subcommand_to('cut', 'banana', banana_parser) + def cut_banana(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + """Cut banana""" + cmd.poutput('cutting banana: ' + ns.direction) + + +@with_default_category('Vegetables') +class LoadableVegetables(CommandSet): + def __init__(self): + super().__init__() + + def do_arugula(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Arugula') + + bokchoy_parser = cmd2.Cmd2ArgumentParser(add_help=False) + bokchoy_parser.add_argument('style', choices=['quartered', 'diced']) + + @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser) + def cut_bokchoy(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Bok Choy') + + +class ExampleApp(cmd2.Cmd): + """ + CommandSets are automatically loaded. Nothing needs to be done. + """ + + def __init__(self, *args, **kwargs): + # gotta have this or neither the plugin or cmd2 will initialize + super().__init__(*args, auto_load_commands=False, **kwargs) + + self._fruits = LoadableFruits() + self._vegetables = LoadableVegetables() + + load_parser = cmd2.Cmd2ArgumentParser('load') + load_parser.add_argument('cmds', choices=['fruits', 'vegetables']) + + @with_argparser(load_parser) + @with_category('Command Loading') + def do_load(self, ns: argparse.Namespace): + if ns.cmds == 'fruits': + try: + self.install_command_set(self._fruits) + self.poutput('Fruits loaded') + except ValueError: + self.poutput('Fruits already loaded') + + if ns.cmds == 'vegetables': + try: + self.install_command_set(self._vegetables) + self.poutput('Vegetables loaded') + except ValueError: + self.poutput('Vegetables already loaded') + + @with_argparser(load_parser) + def do_unload(self, ns: argparse.Namespace): + if ns.cmds == 'fruits': + self.uninstall_command_set(self._fruits) + self.poutput('Fruits unloaded') + + if ns.cmds == 'vegetables': + self.uninstall_command_set(self._vegetables) + self.poutput('Vegetables unloaded') + + cut_parser = cmd2.Cmd2ArgumentParser('cut') + cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut', unloadable=True) + + @with_argparser(cut_parser) + def do_cut(self, ns: argparse.Namespace): + func = getattr(ns, 'handler', None) + if func is not None: + # Call whatever subcommand function was selected + func(ns) + else: + # No subcommand was provided, so call help + self.poutput('This command does nothing without sub-parsers registered') + self.do_help('cut') + + +if __name__ == '__main__': + app = ExampleApp() + app.cmdloop() From 540bc17d13636245c13f4eeb7a610e5949b89b48 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 28 Jul 2020 12:25:05 -0400 Subject: [PATCH 20/35] Adds unit tests for sub-commands and additional commandset edge cases --- cmd2/cmd2.py | 29 +-- .../test_commandset/test_commandset.py | 170 ++++++++++++++++++ 2 files changed, 187 insertions(+), 12 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 2a1bcbf2e..ea590facb 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -427,7 +427,7 @@ def install_command_set(self, cmdset: CommandSet) -> None: methods = inspect.getmembers( cmdset, predicate=lambda meth: (inspect.ismethod(meth) or isinstance(meth, Callable)) - and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) + and hasattr(meth, '__name__') and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) installed_attributes = [] try: @@ -435,21 +435,21 @@ def install_command_set(self, cmdset: CommandSet) -> None: command = method_name[len(COMMAND_FUNC_PREFIX):] command_wrapper = _partial_passthru(method, self) - self.__install_command_function(command, command_wrapper, type(cmdset).__name__) + self._install_command_function(command, command_wrapper, type(cmdset).__name__) installed_attributes.append(method_name) completer_func_name = COMPLETER_FUNC_PREFIX + command cmd_completer = getattr(cmdset, completer_func_name, None) if cmd_completer is not None: completer_wrapper = _partial_passthru(cmd_completer, self) - self.__install_completer_function(command, 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 is not None: help_wrapper = _partial_passthru(cmd_help, self) - self.__install_help_function(command, help_wrapper) + self._install_help_function(command, help_wrapper) installed_attributes.append(help_func_name) self._installed_command_sets.append(cmdset) @@ -458,9 +458,10 @@ def install_command_set(self, cmdset: CommandSet) -> None: except Exception: for attrib in installed_attributes: delattr(self, attrib) + self._installed_command_sets.remove(cmdset) raise - def __install_command_function(self, command, command_wrapper, context=''): + def _install_command_function(self, command: str, command_wrapper: Callable, context=''): cmd_func_name = COMMAND_FUNC_PREFIX + command # Make sure command function doesn't share naem with existing attribute @@ -484,19 +485,19 @@ def __install_command_function(self, command, command_wrapper, context=''): setattr(self, cmd_func_name, command_wrapper) - def __install_completer_function(self, cmd_name, cmd_completer): + def _install_completer_function(self, cmd_name: str, cmd_completer: Callable): 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): + def _install_help_function(self, cmd_name: str, cmd_help: Callable): 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) + setattr(self, help_func_name, cmd_help) def uninstall_command_set(self, cmdset: CommandSet): """ @@ -551,10 +552,12 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: # Search for the base command function and verify it has an argparser defined command_func = self.cmd_func(command_name) if command_func is None or not hasattr(command_func, constants.CMD_ATTR_ARGPARSER): - raise TypeError('Could not find command: ' + command_name + ' needed by sub-command ' + str(method)) + raise TypeError('Could not find command "{}" needed by sub-command: {}' + .format(command_name, str(method))) command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER) if command_parser is None: - raise TypeError('Could not find argparser for command: ' + command_name + ' needed by sub-command ' + str(method)) + raise TypeError('Could not find argparser for command "{}" needed by sub-command: {}' + .format(command_name, str(method))) if hasattr(method, '__doc__') and method.__doc__ is not None: help_text = method.__doc__.splitlines()[0] @@ -597,10 +600,12 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: # Search for the base command function and verify it has an argparser defined command_func = self.cmd_func(command_name) if command_func is None or not hasattr(command_func, constants.CMD_ATTR_ARGPARSER): - raise TypeError('Could not find command: ' + command_name + ' needed by sub-command ' + str(method)) + raise TypeError('Could not find command "{}" needed by sub-command: {}' + .format(command_name, str(method))) command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER) if command_parser is None: - raise TypeError('Could not find argparser for command: ' + command_name + ' needed by sub-command ' + str(method)) + raise TypeError('Could not find argparser for command "{}" needed by sub-command: {}' + .format(command_name, str(method))) for action in command_parser._actions: if isinstance(action, _UnloadableSubParsersAction): diff --git a/isolated_tests/test_commandset/test_commandset.py b/isolated_tests/test_commandset/test_commandset.py index c94c6690e..c7b2ac708 100644 --- a/isolated_tests/test_commandset/test_commandset.py +++ b/isolated_tests/test_commandset/test_commandset.py @@ -121,6 +121,10 @@ def test_custom_construct_commandsets(): with pytest.raises(ValueError): assert app.install_command_set(command_set_2) + # Verify that autoload doesn't conflict with a manually loaded CommandSet that could be autoloaded. + app2 = WithCommandSets(command_sets=[CommandSetA()]) + assert hasattr(app2, 'do_apple') + def test_load_commands(command_sets_manual): @@ -144,6 +148,9 @@ def test_load_commands(command_sets_manual): assert 'Alone' not in cmds_cats assert 'Fruits' not in cmds_cats + # uninstall a second time and verify no errors happen + command_sets_manual.uninstall_command_set(cmd_set) + # reinstall the command set and verify it is accessible command_sets_manual.install_command_set(cmd_set) @@ -211,3 +218,166 @@ def test_commandset_decorators(command_sets_app): assert len(result.stderr) > 0 assert 'unrecognized arguments' in result.stderr assert result.data is None + + +def test_load_commandset_errors(command_sets_manual, capsys): + cmd_set = CommandSetA() + + # create a conflicting command before installing CommandSet to verify rollback behavior + command_sets_manual._install_command_function('durian', cmd_set.do_durian) + with pytest.raises(ValueError): + command_sets_manual.install_command_set(cmd_set) + + # verify that the commands weren't installed + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + + assert 'Alone' not in cmds_cats + assert 'Fruits' not in cmds_cats + assert not command_sets_manual._installed_command_sets + + delattr(command_sets_manual, 'do_durian') + + # pre-create intentionally conflicting macro and alias names + command_sets_manual.app_cmd('macro create apple run_pyscript') + command_sets_manual.app_cmd('alias create banana run_pyscript') + + # now install a command set and verify the commands are now present + command_sets_manual.install_command_set(cmd_set) + out, err = capsys.readouterr() + + # verify aliases and macros are deleted with warning if they conflict with a command + assert "Deleting alias 'banana'" in err + assert "Deleting macro 'apple'" in err + + # verify duplicate commands are detected + with pytest.raises(ValueError): + command_sets_manual._install_command_function('banana', cmd_set.do_banana) + + # verify bad command names are detected + with pytest.raises(ValueError): + command_sets_manual._install_command_function('bad command', cmd_set.do_banana) + + # verify error conflict with existing completer function + with pytest.raises(ValueError): + command_sets_manual._install_completer_function('durian', cmd_set.complete_durian) + + # verify error conflict with existing help function + with pytest.raises(ValueError): + command_sets_manual._install_help_function('cranberry', cmd_set.help_cranberry) + + +class LoadableBase(cmd2.CommandSet): + def __init__(self, dummy): + super(LoadableBase, self).__init__() + self._dummy = dummy # prevents autoload + + cut_parser = cmd2.Cmd2ArgumentParser('cut') + cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut', unloadable=True) + + @cmd2.with_argparser(cut_parser) + def do_cut(self, ns: argparse.Namespace): + """Cut something""" + func = getattr(ns, 'handler', None) + if func is not None: + # Call whatever subcommand function was selected + func(ns) + else: + # No subcommand was provided, so call help + self.poutput('This command does nothing without sub-parsers registered') + self.do_help('cut') + + +class LoadableBadBase(cmd2.CommandSet): + def __init__(self, dummy): + super(LoadableBadBase, self).__init__() + self._dummy = dummy # prevents autoload + + def do_cut(self, ns: argparse.Namespace): + """Cut something""" + func = getattr(ns, 'handler', None) + if func is not None: + # Call whatever subcommand function was selected + func(ns) + else: + # No subcommand was provided, so call help + self.poutput('This command does nothing without sub-parsers registered') + self.do_help('cut') + + +@cmd2.with_default_category('Fruits') +class LoadableFruits(cmd2.CommandSet): + def __init__(self, dummy): + super(LoadableFruits, self).__init__() + self._dummy = dummy # prevents autoload + + def do_apple(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Apple') + + banana_parser = cmd2.Cmd2ArgumentParser(add_help=False) + banana_parser.add_argument('direction', choices=['discs', 'lengthwise']) + + @cmd2.as_subcommand_to('cut', 'banana', banana_parser) + def cut_banana(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + """Cut banana""" + cmd.poutput('cutting banana: ' + ns.direction) + + +@cmd2.with_default_category('Vegetables') +class LoadableVegetables(cmd2.CommandSet): + def __init__(self, dummy): + super(LoadableVegetables, self).__init__() + self._dummy = dummy # prevents autoload + + def do_arugula(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Arugula') + + bokchoy_parser = cmd2.Cmd2ArgumentParser(add_help=False) + bokchoy_parser.add_argument('style', choices=['quartered', 'diced']) + + @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser) + def cut_bokchoy(self, cmd: cmd2.Cmd, _: cmd2.Statement): + cmd.poutput('Bok Choy') + + +def test_subcommands(command_sets_manual): + + base_cmds = LoadableBase(1) + badbase_cmds = LoadableBadBase(1) + fruit_cmds = LoadableFruits(1) + veg_cmds = LoadableVegetables(1) + + # installing sub-commands without base command present raises exception + with pytest.raises(TypeError): + command_sets_manual.install_command_set(fruit_cmds) + + # if the base command is present but isn't an argparse command, expect exception + command_sets_manual.install_command_set(badbase_cmds) + with pytest.raises(TypeError): + command_sets_manual.install_command_set(fruit_cmds) + + # verify that the commands weren't installed + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'cut' in cmds_doc + assert 'Fruits' not in cmds_cats + + # Now install the good base commands + command_sets_manual.uninstall_command_set(badbase_cmds) + command_sets_manual.install_command_set(base_cmds) + + # verify that we catch an attempt to register subcommands when the commandset isn't installed + with pytest.raises(ValueError): + command_sets_manual._register_subcommands(fruit_cmds) + + # verify that command set install and uninstalls without problems + command_sets_manual.install_command_set(fruit_cmds) + command_sets_manual.install_command_set(veg_cmds) + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'Fruits' in cmds_cats + + command_sets_manual.uninstall_command_set(fruit_cmds) + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'Fruits' not in cmds_cats + + # verify a double-unregister raises exception + with pytest.raises(ValueError): + command_sets_manual._unregister_subcommands(fruit_cmds) From a4655f055a69bac412ac75e865380d5c2681e856 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Wed, 29 Jul 2020 17:59:47 -0400 Subject: [PATCH 21/35] Suggested PR Fixes. sub-commande => subcommand Added help/aliases to `as_subcommand_to` decorator. --- cmd2/argparse_custom.py | 2 +- cmd2/cmd2.py | 34 ++++++++----------- cmd2/constants.py | 3 +- cmd2/decorators.py | 23 +++++++++---- docs/features/modular_commands.rst | 10 +++--- examples/modular_subcommands.py | 6 ++-- .../test_commandset/test_commandset.py | 2 +- 7 files changed, 44 insertions(+), 36 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 689c1db75..9dde5347e 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -774,7 +774,7 @@ def __init__(self, allow_abbrev=allow_abbrev) self.register('action', 'unloadable_parsers', _UnloadableSubParsersAction) - def add_subparsers(self, unloadable=False, **kwargs): + def add_subparsers(self, unloadable: bool = False, **kwargs): """ Custom override. Sets a default title if one was not given. diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index ea590facb..e15a856ec 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -527,14 +527,14 @@ def uninstall_command_set(self, cmdset: CommandSet): def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: """ - Register sub-commands with their base command + Register subcommands with their base command - :param cmdset: CommandSet containing sub-commands + :param cmdset: CommandSet containing subcommands """ if not (cmdset is self or cmdset in self._installed_command_sets): - raise ValueError('Adding sub-commands from an unregistered CommandSet') + raise ValueError('Adding subcommands from an unregistered CommandSet') - # find all methods that start with the sub-command prefix + # find all methods that start with the subcommand prefix methods = inspect.getmembers( cmdset, predicate=lambda meth: (inspect.ismethod(meth) or isinstance(meth, Callable)) @@ -548,22 +548,18 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: subcommand_name = getattr(method, constants.SUBCMD_ATTR_NAME) command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND) subcmd_parser = getattr(method, constants.CMD_ATTR_ARGPARSER) + parser_args = getattr(method, constants.SUBCMD_ATTR_PARSER_ARGS, {}) # Search for the base command function and verify it has an argparser defined command_func = self.cmd_func(command_name) if command_func is None or not hasattr(command_func, constants.CMD_ATTR_ARGPARSER): - raise TypeError('Could not find command "{}" needed by sub-command: {}' + raise TypeError('Could not find command "{}" needed by subcommand: {}' .format(command_name, str(method))) command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER) if command_parser is None: - raise TypeError('Could not find argparser for command "{}" needed by sub-command: {}' + raise TypeError('Could not find argparser for command "{}" needed by subcommand: {}' .format(command_name, str(method))) - if hasattr(method, '__doc__') and method.__doc__ is not None: - help_text = method.__doc__.splitlines()[0] - else: - help_text = subcommand_name - if isinstance(cmdset, CommandSet): command_handler = _partial_passthru(method, self) else: @@ -572,18 +568,18 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: for action in command_parser._actions: if isinstance(action, _UnloadableSubParsersAction): - action.add_parser(subcommand_name, parents=[subcmd_parser], help=help_text) + action.add_parser(subcommand_name, parents=[subcmd_parser], **parser_args) def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: """ - Unregister sub-commands from their base command + Unregister subcommands from their base command - :param cmdset: CommandSet containing sub-commands + :param cmdset: CommandSet containing subcommands """ if not (cmdset is self or cmdset in self._installed_command_sets): - raise ValueError('Removing sub-commands from an unregistered CommandSet') + raise ValueError('Removing subcommands from an unregistered CommandSet') - # find all methods that start with the sub-command prefix + # find all methods that start with the subcommand prefix methods = inspect.getmembers( cmdset, predicate=lambda meth: (inspect.ismethod(meth) or isinstance(meth, Callable)) @@ -600,11 +596,11 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: # Search for the base command function and verify it has an argparser defined command_func = self.cmd_func(command_name) if command_func is None or not hasattr(command_func, constants.CMD_ATTR_ARGPARSER): - raise TypeError('Could not find command "{}" needed by sub-command: {}' + raise TypeError('Could not find command "{}" needed by subcommand: {}' .format(command_name, str(method))) command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER) if command_parser is None: - raise TypeError('Could not find argparser for command "{}" needed by sub-command: {}' + raise TypeError('Could not find argparser for command "{}" needed by subcommand: {}' .format(command_name, str(method))) for action in command_parser._actions: @@ -3387,7 +3383,7 @@ def _restore_cmd2_env(self, cmd2_env: _SavedCmd2Env) -> None: if 'gnureadline' in sys.modules: # Restore what the readline module pointed to if cmd2_env.readline_module is None: - del (sys.modules['readline']) + del sys.modules['readline'] else: sys.modules['readline'] = cmd2_env.readline_module diff --git a/cmd2/constants.py b/cmd2/constants.py index 0135e3286..88a1bb822 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -50,6 +50,7 @@ # Whether or not tokens are unquoted before sending to argparse CMD_ATTR_PRESERVE_QUOTES = 'preserve_quotes' -# sub-command attributes for the base command name and the sub-command name +# subcommand attributes for the base command name and the subcommand name SUBCMD_ATTR_COMMAND = 'parent_command' SUBCMD_ATTR_NAME = 'subcommand_name' +SUBCMD_ATTR_PARSER_ARGS = 'subcommand_parser_args' diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 6e3b7acf1..82ad8cd75 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 TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Tuple, Union from . import constants from .exceptions import Cmd2ArgparseError @@ -339,13 +339,18 @@ def cmd_wrapper(*args: Any, **kwargs: Dict[str, Any]) -> Optional[bool]: def as_subcommand_to(command: str, subcommand: str, - parser: argparse.ArgumentParser) -> Callable[[argparse.Namespace], Optional[bool]]: + parser: argparse.ArgumentParser, + *, + help_text: Optional[str] = None, + aliases: Iterable[str] = None) -> Callable[[argparse.Namespace], Optional[bool]]: """ - Tag this method as a sub-command to an existing argparse decorated command. + Tag this method as a subcommand to an existing argparse decorated command. :param command: Command Name - :param subcommand: Sub-command name - :param parser: argparse Parser to for this sub-command + :param subcommand: Subcommand name + :param parser: argparse Parser for this subcommand + :param help_text: Help message for this subcommand + :param aliases: Alternative names for this subcommand :return: Wrapper function that can receive an argparse.Namespace """ def arg_decorator(func: Callable): @@ -357,10 +362,16 @@ def arg_decorator(func: Callable): parser.set_defaults(func=func) - # # Set some custom attributes for this command + # Set some custom attributes for this command setattr(func, constants.SUBCMD_ATTR_COMMAND, command) setattr(func, constants.CMD_ATTR_ARGPARSER, parser) setattr(func, constants.SUBCMD_ATTR_NAME, subcommand) + parser_args = {} + if help_text is not None: + parser_args['help'] = help_text + if aliases is not None: + parser_args['aliases'] = aliases[:] + setattr(func, constants.SUBCMD_ATTR_PARSER_ARGS, parser_args) return func diff --git a/docs/features/modular_commands.rst b/docs/features/modular_commands.rst index 82298c8f7..3ead40ee3 100644 --- a/docs/features/modular_commands.rst +++ b/docs/features/modular_commands.rst @@ -19,7 +19,7 @@ Features * Dynamically Loadable/Unloadable Commands - Command functions and CommandSets can both be loaded and unloaded dynamically during application execution. This can enable features such as dynamically loaded modules that add additional commands. -* Sub-command Injection - Sub-commands can be defined separately from the base command. This allows for a more +* Subcommand Injection - Subcommands can be defined separately from the base command. This allows for a more action-centric instead of object-centric command system while still organizing your code and handlers around the objects being managed. @@ -204,19 +204,19 @@ You may need to disable command auto-loading if you need dynamically load comman app.cmdloop() -Injecting Sub-Commands +Injecting Subcommands ---------------------- Description ~~~~~~~~~~~ -Using the `with_argparse` decorator, it is possible to define sub-commands for your command. This has a tendency to +Using the `with_argparse` decorator, it is possible to define subcommands for your command. This has a tendency to either drive your interface into an object-centric interface. For example, imagine you have a tool that manages your media collection and you want to manage movies or shows. An object-centric approach would push you to have base commands -such as `movies` and `shows` which each have sub-commands `add`, `edit`, `list`, `delete`. If you wanted to present an +such as `movies` and `shows` which each have subcommands `add`, `edit`, `list`, `delete`. If you wanted to present an action-centric command set, so that `add`, `edit`, `list`, and `delete` are the base commands, you'd have to organize your code around these similar actions rather than organizing your code around similar objects being managed. -Sub-command injection allows you to inject sub-commands into a base command to present an interface that is sensible to +Subcommand injection allows you to inject subcommands into a base command to present an interface that is sensible to a user while still organizing your code in whatever structure make more logical sense to the developer. Example diff --git a/examples/modular_subcommands.py b/examples/modular_subcommands.py index e4d2fe453..1ac951aed 100644 --- a/examples/modular_subcommands.py +++ b/examples/modular_subcommands.py @@ -1,14 +1,14 @@ #!/usr/bin/env python3 # coding=utf-8 -"""A simple example demonstracting modular sub-command loading through CommandSets +"""A simple example demonstracting modular subcommand loading through CommandSets -In this example, there are loadable CommandSets defined. Each CommandSet has 1 sub-command defined that will be +In this example, there are loadable CommandSets defined. Each CommandSet has 1 subcommand defined that will be attached to the 'cut' command. The cut command is implemented with the `do_cut` function that has been tagged as an argparse command. The `load` and `unload` command will load and unload the CommandSets. The available top level commands as well as -sub-commands to the `cut` command will change depending on which CommandSets are loaded. +subcommands to the `cut` command will change depending on which CommandSets are loaded. """ import argparse import cmd2 diff --git a/isolated_tests/test_commandset/test_commandset.py b/isolated_tests/test_commandset/test_commandset.py index c7b2ac708..983857727 100644 --- a/isolated_tests/test_commandset/test_commandset.py +++ b/isolated_tests/test_commandset/test_commandset.py @@ -346,7 +346,7 @@ def test_subcommands(command_sets_manual): fruit_cmds = LoadableFruits(1) veg_cmds = LoadableVegetables(1) - # installing sub-commands without base command present raises exception + # installing subcommands without base command present raises exception with pytest.raises(TypeError): command_sets_manual.install_command_set(fruit_cmds) From fa850348251abebf6da68006e50b87e532f4251b Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Thu, 30 Jul 2020 01:32:29 -0400 Subject: [PATCH 22/35] Removed sub-class and instead patch argparse._SubParsersAction --- cmd2/argparse_custom.py | 50 ++++++++++--------- cmd2/cmd2.py | 6 +-- .../test_commandset/test_commandset.py | 2 +- 3 files changed, 30 insertions(+), 28 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 9dde5347e..6cfa66aec 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -528,6 +528,31 @@ def _match_argument_wrapper(self, action, arg_strings_pattern) -> int: # noinspection PyProtectedMember argparse.ArgumentParser._match_argument = _match_argument_wrapper + +############################################################################################################ +# Patch argparse._SubParsersAction to add remove_parser function +############################################################################################################ + +def _SubParsersAction_remove_parser(self, name): + """Removes a sub-parser from the sub-parsers group""" + for choice_action in self._choices_actions: + if choice_action.dest == name: + self._choices_actions.remove(choice_action) + break + + subparser = self._name_parser_map[name] + to_remove = [] + for name, parser in self._name_parser_map.items(): + if parser is subparser: + to_remove.append(name) + for name in to_remove: + del self._name_parser_map[name] + + +# noinspection PyProtectedMember +setattr(argparse._SubParsersAction, 'remove_parser', _SubParsersAction_remove_parser) + + ############################################################################################################ # Unless otherwise noted, everything below this point are copied from Python's # argparse implementation with minor tweaks to adjust output. @@ -724,24 +749,6 @@ def _format_args(self, action, default_metavar) -> str: return result -class _UnloadableSubParsersAction(argparse._SubParsersAction): - """Extends the argparse internal SubParsers action to allow sub-parsers to be removed dynamically""" - def remove_parser(self, name): - """Removes a sub-parser from the sub-parsers group""" - for choice_action in self._choices_actions: - if choice_action.dest == name: - self._choices_actions.remove(choice_action) - break - - subparser = self._name_parser_map[name] - to_remove = [] - for name, parser in self._name_parser_map.items(): - if parser is subparser: - to_remove.append(name) - for name in to_remove: - del self._name_parser_map[name] - - # noinspection PyCompatibility class Cmd2ArgumentParser(argparse.ArgumentParser): """Custom ArgumentParser class that improves error and help output""" @@ -772,22 +779,17 @@ def __init__(self, conflict_handler=conflict_handler, add_help=add_help, allow_abbrev=allow_abbrev) - self.register('action', 'unloadable_parsers', _UnloadableSubParsersAction) - def add_subparsers(self, unloadable: bool = False, **kwargs): + def add_subparsers(self, **kwargs): """ Custom override. Sets a default title if one was not given. - :param unloadable: Flag whether this sub-parsers group will support unloading parsers :param kwargs: additional keyword arguments :return: argparse Subparser Action """ if 'title' not in kwargs: kwargs['title'] = 'subcommands' - if unloadable: - kwargs['action'] = 'unloadable_parsers' - return super().add_subparsers(**kwargs) def error(self, message: str) -> None: diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index e15a856ec..c933db993 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -43,7 +43,7 @@ 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, _UnloadableSubParsersAction +from .argparse_custom import DEFAULT_ARGUMENT_PARSER, CompletionItem from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer from .command_definition import CommandSet, _partial_passthru from .constants import COMMAND_FUNC_PREFIX, COMPLETER_FUNC_PREFIX, HELP_FUNC_PREFIX @@ -567,7 +567,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: subcmd_parser.set_defaults(handler=command_handler) for action in command_parser._actions: - if isinstance(action, _UnloadableSubParsersAction): + if isinstance(action, argparse._SubParsersAction): action.add_parser(subcommand_name, parents=[subcmd_parser], **parser_args) def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: @@ -604,7 +604,7 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: .format(command_name, str(method))) for action in command_parser._actions: - if isinstance(action, _UnloadableSubParsersAction): + if isinstance(action, argparse._SubParsersAction): action.remove_parser(subcommand_name) def add_settable(self, settable: Settable) -> None: diff --git a/isolated_tests/test_commandset/test_commandset.py b/isolated_tests/test_commandset/test_commandset.py index 983857727..f16a6ff4e 100644 --- a/isolated_tests/test_commandset/test_commandset.py +++ b/isolated_tests/test_commandset/test_commandset.py @@ -272,7 +272,7 @@ def __init__(self, dummy): self._dummy = dummy # prevents autoload cut_parser = cmd2.Cmd2ArgumentParser('cut') - cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut', unloadable=True) + cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut') @cmd2.with_argparser(cut_parser) def do_cut(self, ns: argparse.Namespace): From 15298007b6e4ffe1b134d833acc349a7159bccd3 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 30 Jul 2020 12:47:36 -0400 Subject: [PATCH 23/35] Fixed typo in documentation --- cmd2/argparse_custom.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 6cfa66aec..80213bf8d 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -192,10 +192,10 @@ def my_completer_method(self, text, line, begidx, endidx, arg_tokens) completion and enables nargs range parsing. See _add_argument_wrapper for more details on these arguments. -``argparse.ArgumentParser._get_nargs_pattern`` - adds support to for nargs -ranges. See _get_nargs_pattern_wrapper for more details. +``argparse.ArgumentParser._get_nargs_pattern`` - adds support for nargs ranges. +See _get_nargs_pattern_wrapper for more details. -``argparse.ArgumentParser._match_argument`` - adds support to for nargs ranges. +``argparse.ArgumentParser._match_argument`` - adds support for nargs ranges. See _match_argument_wrapper for more details. """ From 0e6e2397aabe142f63cc098a23a26e55c3257178 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 30 Jul 2020 12:49:46 -0400 Subject: [PATCH 24/35] Fixed issue where we attempted to remove CommandSet from a list it was not in --- cmd2/cmd2.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index c933db993..3f1696cf4 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -458,7 +458,8 @@ def install_command_set(self, cmdset: CommandSet) -> None: except Exception: for attrib in installed_attributes: delattr(self, attrib) - self._installed_command_sets.remove(cmdset) + if cmdset in self._installed_command_sets: + self._installed_command_sets.remove(cmdset) raise def _install_command_function(self, command: str, command_wrapper: Callable, context=''): From 4e673fcf6da3f2d654118877a795978ad6591cab Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Thu, 30 Jul 2020 16:31:25 -0400 Subject: [PATCH 25/35] Fixes to how command callables are filtered from CommandSet --- cmd2/cmd2.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 3f1696cf4..8c9d40b8a 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -426,7 +426,7 @@ def install_command_set(self, cmdset: CommandSet) -> None: cmdset.on_register(self) methods = inspect.getmembers( cmdset, - predicate=lambda meth: (inspect.ismethod(meth) or isinstance(meth, Callable)) + predicate=lambda meth: isinstance(meth, Callable) and hasattr(meth, '__name__') and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) installed_attributes = [] @@ -511,7 +511,8 @@ def uninstall_command_set(self, cmdset: CommandSet): methods = inspect.getmembers( cmdset, - predicate=lambda meth: inspect.ismethod(meth) and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) + predicate=lambda meth: isinstance(meth, Callable) + and hasattr(meth, '__name__') and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) for method in methods: cmd_name = method[0][len(COMMAND_FUNC_PREFIX):] @@ -538,7 +539,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: # find all methods that start with the subcommand prefix methods = inspect.getmembers( cmdset, - predicate=lambda meth: (inspect.ismethod(meth) or isinstance(meth, Callable)) + predicate=lambda meth: isinstance(meth, Callable) and hasattr(meth, constants.SUBCMD_ATTR_NAME) and hasattr(meth, constants.SUBCMD_ATTR_COMMAND) and hasattr(meth, constants.CMD_ATTR_ARGPARSER) @@ -583,7 +584,7 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: # find all methods that start with the subcommand prefix methods = inspect.getmembers( cmdset, - predicate=lambda meth: (inspect.ismethod(meth) or isinstance(meth, Callable)) + predicate=lambda meth: isinstance(meth, Callable) and hasattr(meth, constants.SUBCMD_ATTR_NAME) and hasattr(meth, constants.SUBCMD_ATTR_COMMAND) and hasattr(meth, constants.CMD_ATTR_ARGPARSER) From c03a1446572dda9026046c1bc1aa63c03405e556 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 30 Jul 2020 17:30:41 -0400 Subject: [PATCH 26/35] Added handling for disabled commands to CommandSet functions --- cmd2/cmd2.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 8c9d40b8a..999c97cb9 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -517,6 +517,11 @@ def uninstall_command_set(self, cmdset: CommandSet): for method in methods: cmd_name = method[0][len(COMMAND_FUNC_PREFIX):] + # Enable the command before uninstalling it to make sure we remove both + # the real functions and the ones used by the DisabledCommand object. + if cmd_name in self.disabled_commands: + self.enable_command(cmd_name) + delattr(self, COMMAND_FUNC_PREFIX + cmd_name) if hasattr(self, COMPLETER_FUNC_PREFIX + cmd_name): @@ -553,11 +558,15 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: parser_args = getattr(method, constants.SUBCMD_ATTR_PARSER_ARGS, {}) # Search for the base command function and verify it has an argparser defined - command_func = self.cmd_func(command_name) - if command_func is None or not hasattr(command_func, constants.CMD_ATTR_ARGPARSER): + if command_name in self.disabled_commands: + command_func = self.disabled_commands[command_name].command_function + else: + command_func = self.cmd_func(command_name) + + if command_func is None: raise TypeError('Could not find command "{}" needed by subcommand: {}' .format(command_name, str(method))) - command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER) + command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None) if command_parser is None: raise TypeError('Could not find argparser for command "{}" needed by subcommand: {}' .format(command_name, str(method))) @@ -596,11 +605,15 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND) # Search for the base command function and verify it has an argparser defined - command_func = self.cmd_func(command_name) - if command_func is None or not hasattr(command_func, constants.CMD_ATTR_ARGPARSER): + if command_name in self.disabled_commands: + command_func = self.disabled_commands[command_name].command_function + else: + command_func = self.cmd_func(command_name) + + if command_func is None: raise TypeError('Could not find command "{}" needed by subcommand: {}' .format(command_name, str(method))) - command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER) + command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None) if command_parser is None: raise TypeError('Could not find argparser for command "{}" needed by subcommand: {}' .format(command_name, str(method))) From 31bbe3955f3a20ec2499cfa4e62dc0878ead0415 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 30 Jul 2020 20:08:05 -0400 Subject: [PATCH 27/35] Updated documentation --- cmd2/argparse_custom.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 80213bf8d..4c417a6dd 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -197,6 +197,10 @@ def my_completer_method(self, text, line, begidx, endidx, arg_tokens) ``argparse.ArgumentParser._match_argument`` - adds support for nargs ranges. See _match_argument_wrapper for more details. + +``argparse._SubParsersAction.remove_parser`` - new function which removes a +sub-parser from a sub-parsers group. See _SubParsersAction_remove_parser for +more details. """ import argparse @@ -534,7 +538,7 @@ def _match_argument_wrapper(self, action, arg_strings_pattern) -> int: ############################################################################################################ def _SubParsersAction_remove_parser(self, name): - """Removes a sub-parser from the sub-parsers group""" + """Removes a sub-parser from a sub-parsers group""" for choice_action in self._choices_actions: if choice_action.dest == name: self._choices_actions.remove(choice_action) From 229a35370ba18b8c207916bfee868670ffbfa872 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 30 Jul 2020 20:12:39 -0400 Subject: [PATCH 28/35] Updated documentation --- cmd2/argparse_custom.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 4c417a6dd..39ce81f48 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -537,8 +537,16 @@ def _match_argument_wrapper(self, action, arg_strings_pattern) -> int: # Patch argparse._SubParsersAction to add remove_parser function ############################################################################################################ -def _SubParsersAction_remove_parser(self, name): - """Removes a sub-parser from a sub-parsers group""" +def _SubParsersAction_remove_parser(self, name: str): + """ + Removes a sub-parser from a sub-parsers group + + This is a custom method being added to the argparse._SubParsersAction + class so cmd2 can remove subcommands from a parser. + + :param self: instance of the _SubParsersAction being edited + :param name: name of the sub-parser to remove + """ for choice_action in self._choices_actions: if choice_action.dest == name: self._choices_actions.remove(choice_action) From c58fec8758e45e9be9592e5dfe38e270c0c69ccc Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 31 Jul 2020 00:27:52 -0400 Subject: [PATCH 29/35] Fix a couple doc8 warnings --- docs/features/modular_commands.rst | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/features/modular_commands.rst b/docs/features/modular_commands.rst index 3ead40ee3..4c7286b71 100644 --- a/docs/features/modular_commands.rst +++ b/docs/features/modular_commands.rst @@ -13,7 +13,8 @@ add additional capabilities. Features ~~~~~~~~ -* Modular Command Sets - Commands can be broken into separate modules rather than in one god class holding all commands. +* Modular Command Sets - Commands can be broken into separate modules rather than in one god class holding all + commands. * Automatic Command Discovery - In your application, merely defining and importing a CommandSet is sufficient for cmd2 to discover and load your command. No manual registration is necessary. * Dynamically Loadable/Unloadable Commands - Command functions and CommandSets can both be loaded and unloaded @@ -211,10 +212,11 @@ Description ~~~~~~~~~~~ Using the `with_argparse` decorator, it is possible to define subcommands for your command. This has a tendency to either drive your interface into an object-centric interface. For example, imagine you have a tool that manages your -media collection and you want to manage movies or shows. An object-centric approach would push you to have base commands -such as `movies` and `shows` which each have subcommands `add`, `edit`, `list`, `delete`. If you wanted to present an -action-centric command set, so that `add`, `edit`, `list`, and `delete` are the base commands, you'd have to organize -your code around these similar actions rather than organizing your code around similar objects being managed. +media collection and you want to manage movies or shows. An object-centric approach would push you to have base +commands such as `movies` and `shows` which each have subcommands `add`, `edit`, `list`, `delete`. If you wanted to +present an action-centric command set, so that `add`, `edit`, `list`, and `delete` are the base commands, you'd have +to organize your code around these similar actions rather than organizing your code around similar objects being +managed. Subcommand injection allows you to inject subcommands into a base command to present an interface that is sensible to a user while still organizing your code in whatever structure make more logical sense to the developer. From 7c97f68e6a82a364c3c5beb20166af3414d4dee7 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 31 Jul 2020 00:32:41 -0400 Subject: [PATCH 30/35] Fix it so py.test by itself doesn't crash Change setup.cfg so that pytest only runs tests in the tests directory by default. Attempting to run tests in the isolated_tests directory was causing a crash --- setup.cfg | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.cfg b/setup.cfg index c1de2b05a..742709a62 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,7 @@ +[tool:pytest] +testpaths = + tests + [flake8] exclude = .git,.idea,.pytest_cache,.tox,.nox,.venv,.vscode,build,cmd2.egg-info,dist,htmlcov,__pycache__,*.egg max-line-length = 127 From 333096cd2229d181234dc80b210a697e93c856a8 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 31 Jul 2020 09:33:47 -0400 Subject: [PATCH 31/35] Updated Pipfile to do an editable/dev install of cmd2_ext_test so that "inv pytest" doesn't crash --- Pipfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Pipfile b/Pipfile index e03fc25d9..b384709cb 100644 --- a/Pipfile +++ b/Pipfile @@ -12,6 +12,7 @@ wcwidth = ">=0.1.7" [dev-packages] cmd2 = {editable = true,path = "."} +cmd2_ext_test = {editable = true,path = "plugins/ext_test"} codecov = "*" doc8 = "*" flake8 = "*" From b1d747112a866f52f5b7c415524e95845ea2cd6b Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 1 Aug 2020 14:16:48 -0400 Subject: [PATCH 32/35] Ignore plugins directory when running doc8 by itself outside of invoke --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 742709a62..a2b178bd4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,6 +16,6 @@ force_grid_wrap = 0 use_parentheses = true [doc8] -ignore-path=docs/_build,.git,.idea,.pytest_cache,.tox,.nox,.venv,.vscode,build,cmd2,examples,tests,cmd2.egg-info,dist,htmlcov,__pycache__,*.egg +ignore-path=docs/_build,.git,.idea,.pytest_cache,.tox,.nox,.venv,.vscode,build,cmd2,examples,tests,cmd2.egg-info,dist,htmlcov,__pycache__,*.egg,plugins max-line-length=117 verbose=0 From 51a3f76015b788f22ab2051ff87cfe51f79b86bb Mon Sep 17 00:00:00 2001 From: anselor Date: Sat, 1 Aug 2020 16:26:11 -0400 Subject: [PATCH 33/35] Now maintains a command->CommandSet mapping and passes the CommandSet through to the ArgparseCompleter if one is registered. For subcommands, the registered argparse instance for the subcommand is now tagged with the CommandSet from which it originated. If a CommandSet is detected, it's now passed in as 'self' for the completion functions. Fixes some issue found with removing a subcommand. Adds additional tests. Added a check to prevent removal of a CommandSet if it has commands with sub-commands from another CommandSet bound to it. Documentation improvements. Standardized around using CommandSetRegistrationException during commandset install/uninstall related errors. Added support for nested sub-command injection. --- cmd2/argparse_completer.py | 22 +- cmd2/argparse_custom.py | 46 +- cmd2/cmd2.py | 140 +++++-- cmd2/constants.py | 3 + cmd2/decorators.py | 9 +- cmd2/exceptions.py | 3 + .../test_commandset/test_commandset.py | 396 ++++++++++++++++-- 7 files changed, 539 insertions(+), 80 deletions(-) diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py index 6acb5abc3..0225d22f0 100644 --- a/cmd2/argparse_completer.py +++ b/cmd2/argparse_completer.py @@ -23,6 +23,7 @@ CompletionItem, generate_range_error, ) +from .command_definition import CommandSet from .table_creator import Column, SimpleTable from .utils import CompletionError, basic_complete @@ -181,7 +182,8 @@ def __init__(self, parser: argparse.ArgumentParser, cmd2_app: cmd2.Cmd, *, if isinstance(action, argparse._SubParsersAction): self._subcommand_action = action - def complete_command(self, tokens: List[str], text: str, line: str, begidx: int, endidx: int) -> List[str]: + def complete_command(self, tokens: List[str], text: str, line: str, begidx: int, endidx: int, *, + cmd_set: Optional[CommandSet] = None) -> List[str]: """ Complete the command using the argparse metadata and provided argument dictionary :raises: CompletionError for various types of tab completion errors @@ -358,7 +360,8 @@ def update_mutex_groups(arg_action: argparse.Action) -> None: completer = ArgparseCompleter(self._subcommand_action.choices[token], self._cmd2_app, parent_tokens=parent_tokens) - return completer.complete_command(tokens[token_index:], text, line, begidx, endidx) + return completer.complete_command(tokens[token_index:], text, line, begidx, endidx, + cmd_set=cmd_set) else: # Invalid subcommand entered, so no way to complete remaining tokens return [] @@ -403,7 +406,8 @@ def update_mutex_groups(arg_action: argparse.Action) -> None: # Check if we are completing a flag's argument if flag_arg_state is not None: completion_results = self._complete_for_arg(flag_arg_state.action, text, line, - begidx, endidx, consumed_arg_values) + begidx, endidx, consumed_arg_values, + cmd_set=cmd_set) # If we have results, then return them if completion_results: @@ -423,7 +427,8 @@ def update_mutex_groups(arg_action: argparse.Action) -> None: pos_arg_state = _ArgumentState(action) completion_results = self._complete_for_arg(pos_arg_state.action, text, line, - begidx, endidx, consumed_arg_values) + begidx, endidx, consumed_arg_values, + cmd_set=cmd_set) # If we have results, then return them if completion_results: @@ -543,7 +548,8 @@ def format_help(self, tokens: List[str]) -> str: def _complete_for_arg(self, arg_action: argparse.Action, text: str, line: str, begidx: int, endidx: int, - consumed_arg_values: Dict[str, List[str]]) -> List[str]: + consumed_arg_values: Dict[str, List[str]], *, + cmd_set: Optional[CommandSet] = None) -> List[str]: """ Tab completion routine for an argparse argument :return: list of completions @@ -563,6 +569,12 @@ def _complete_for_arg(self, arg_action: argparse.Action, kwargs = {} if isinstance(arg_choices, ChoicesCallable): if arg_choices.is_method: + cmd_set = getattr(self._parser, constants.PARSER_ATTR_COMMANDSET, cmd_set) + if cmd_set is not None: + if isinstance(cmd_set, CommandSet): + # If command is part of a CommandSet, `self` should be the CommandSet and Cmd will be next + if cmd_set is not None: + args.append(cmd_set) args.append(self._cmd2_app) # Check if arg_choices.to_call expects arg_tokens diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index 39ce81f48..e08db0058 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -60,18 +60,32 @@ def my_choices_function(): parser.add_argument('-o', '--options', choices_function=my_choices_function) -``choices_method`` - this is exactly like choices_function, but the function -needs to be an instance method of a cmd2-based class. When ArgparseCompleter -calls the method, it will pass the app instance as the self argument. This is -good in cases where the list of choices being generated relies on state data of -the cmd2-based app - - Example:: +``choices_method`` - this is equivalent to choices_function, but the function +needs to be an instance method of a cmd2.Cmd or cmd2.CommandSet subclass. When +ArgparseCompleter calls the method, it well detect whether is is bound to a +CommandSet or Cmd subclass. +If bound to a cmd2.Cmd subclass, it will pass the app instance as the `self` +argument. This is good in cases where the list of choices being generated +relies on state data of the cmd2-based app. +If bound to a cmd2.CommandSet subclass, it will pass the CommandSet instance +as the `self` argument, and the app instance as the positional argument. + + Example bound to cmd2.Cmd:: def my_choices_method(self): ... return my_generated_list + parser.add_argument("arg", choices_method=my_choices_method) + + Example bound to cmd2.CommandSEt:: + + def my_choices_method(self, app: cmd2.Cmd): + ... + return my_generated_list + + parser.add_argument("arg", choices_method=my_choices_method) + ``completer_function`` - pass a tab completion function that does custom completion. Since custom tab completion operations commonly need to modify cmd2's instance variables related to tab completion, it will be rare to need a @@ -84,10 +98,16 @@ def my_completer_function(text, line, begidx, endidx): return completions parser.add_argument('-o', '--options', completer_function=my_completer_function) -``completer_method`` - this is exactly like completer_function, but the -function needs to be an instance method of a cmd2-based class. When -ArgparseCompleter calls the method, it will pass the app instance as the self -argument. cmd2 provides a few completer methods for convenience (e.g., +``completer_method`` - this is equivalent to completer_function, but the function +needs to be an instance method of a cmd2.Cmd or cmd2.CommandSet subclass. When +ArgparseCompleter calls the method, it well detect whether is is bound to a +CommandSet or Cmd subclass. +If bound to a cmd2.Cmd subclass, it will pass the app instance as the `self` +argument. This is good in cases where the list of choices being generated +relies on state data of the cmd2-based app. +If bound to a cmd2.CommandSet subclass, it will pass the CommandSet instance +as the `self` argument, and the app instance as the positional argument. +cmd2 provides a few completer methods for convenience (e.g., path_complete, delimiter_complete) Example:: @@ -560,6 +580,10 @@ class so cmd2 can remove subcommands from a parser. for name in to_remove: del self._name_parser_map[name] + if name in self.choices: + del self.choices[name] + + # noinspection PyProtectedMember setattr(argparse._SubParsersAction, 'remove_parser', _SubParsersAction_remove_parser) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 999c97cb9..65aa88e06 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -48,7 +48,14 @@ from .command_definition import 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 +from .exceptions import ( + CommandSetRegistrationError, + Cmd2ShlexError, + EmbeddedConsoleExit, + EmptyStatement, + RedirectionError, + SkipPostcommandHooks +) from .history import History, HistoryItem from .parsing import Macro, MacroArg, Statement, StatementParser, shlex_split from .rl_utils import RlType, rl_get_point, rl_make_safe_prompt, rl_set_prompt, rl_type, rl_warning, vt100_support @@ -245,8 +252,8 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, shortcuts=shortcuts) # Load modular commands - self._installed_functions = [] # type: List[str] self._installed_command_sets = [] # type: List[CommandSet] + self._cmd_to_command_sets = {} # type: Dict[str, CommandSet] if command_sets: for command_set in command_sets: self.install_command_set(command_set) @@ -260,8 +267,6 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, if not valid: raise ValueError("Invalid command name {!r}: {}".format(cur_cmd, errmsg)) - self._register_subcommands(self) - # Stores results from the last command run to enable usage of results in a Python script or interactive console # Built-in commands don't make use of this. It is purely there for user-defined commands and convenience. self.last_result = None @@ -399,6 +404,8 @@ 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 + self._register_subcommands(self) + def _autoload_commands(self) -> None: """Load modular command definitions.""" # Search for all subclasses of CommandSet, instantiate them if they weren't provided in the constructor @@ -406,12 +413,11 @@ def _autoload_commands(self) -> None: 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: - continue - cmdset = cmdset_type() - self.install_command_set(cmdset) + if not (cmdset_type in existing_commandset_types or + len(init_sig.parameters) != 1 or + 'self' not in init_sig.parameters): + cmdset = cmdset_type() + self.install_command_set(cmdset) def install_command_set(self, cmdset: CommandSet) -> None: """ @@ -421,7 +427,7 @@ def install_command_set(self, cmdset: CommandSet) -> 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') + raise CommandSetRegistrationError('CommandSet ' + type(cmdset).__name__ + ' is already installed') cmdset.on_register(self) methods = inspect.getmembers( @@ -452,6 +458,8 @@ def install_command_set(self, cmdset: CommandSet) -> None: self._install_help_function(command, help_wrapper) installed_attributes.append(help_func_name) + self._cmd_to_command_sets[command] = cmdset + self._installed_command_sets.append(cmdset) self._register_subcommands(cmdset) @@ -460,19 +468,22 @@ def install_command_set(self, cmdset: CommandSet) -> None: delattr(self, attrib) if cmdset in self._installed_command_sets: self._installed_command_sets.remove(cmdset) + if cmdset in self._cmd_to_command_sets.values(): + self._cmd_to_command_sets = \ + {key: val for key, val in self._cmd_to_command_sets.items() if val is not cmdset} raise def _install_command_function(self, command: str, command_wrapper: Callable, context=''): cmd_func_name = COMMAND_FUNC_PREFIX + command - # Make sure command function doesn't share naem with existing attribute + # Make sure command function doesn't share name with existing attribute if hasattr(self, cmd_func_name): - raise ValueError('Attribute already exists: {} ({})'.format(cmd_func_name, context)) + raise CommandSetRegistrationError('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)) + raise CommandSetRegistrationError("Invalid command name {!r}: {}".format(command, errmsg)) # Check if command shares a name with an alias if command in self.aliases: @@ -490,14 +501,14 @@ def _install_completer_function(self, cmd_name: str, cmd_completer: Callable): completer_func_name = COMPLETER_FUNC_PREFIX + cmd_name if hasattr(self, completer_func_name): - raise ValueError('Attribute already exists: {}'.format(completer_func_name)) + raise CommandSetRegistrationError('Attribute already exists: {}'.format(completer_func_name)) setattr(self, completer_func_name, cmd_completer) def _install_help_function(self, cmd_name: str, cmd_help: Callable): help_func_name = HELP_FUNC_PREFIX + cmd_name if hasattr(self, help_func_name): - raise ValueError('Attribute already exists: {}'.format(help_func_name)) + raise CommandSetRegistrationError('Attribute already exists: {}'.format(help_func_name)) setattr(self, help_func_name, cmd_help) def uninstall_command_set(self, cmdset: CommandSet): @@ -506,7 +517,7 @@ def uninstall_command_set(self, cmdset: CommandSet): :param cmdset: CommandSet to uninstall """ if cmdset in self._installed_command_sets: - + self._check_uninstallable(cmdset) self._unregister_subcommands(cmdset) methods = inspect.getmembers( @@ -522,6 +533,9 @@ def uninstall_command_set(self, cmdset: CommandSet): if cmd_name in self.disabled_commands: self.enable_command(cmd_name) + if cmd_name in self._cmd_to_command_sets: + del self._cmd_to_command_sets[cmd_name] + delattr(self, COMMAND_FUNC_PREFIX + cmd_name) if hasattr(self, COMPLETER_FUNC_PREFIX + cmd_name): @@ -532,14 +546,42 @@ def uninstall_command_set(self, cmdset: CommandSet): cmdset.on_unregister(self) self._installed_command_sets.remove(cmdset) + def _check_uninstallable(self, cmdset: CommandSet): + methods = inspect.getmembers( + cmdset, + predicate=lambda meth: isinstance(meth, Callable) + and hasattr(meth, '__name__') and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) + + for method in methods: + command_name = method[0][len(COMMAND_FUNC_PREFIX):] + + # Search for the base command function and verify it has an argparser defined + if command_name in self.disabled_commands: + command_func = self.disabled_commands[command_name].command_function + else: + command_func = self.cmd_func(command_name) + + command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None) + def check_parser_uninstallable(parser): + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + for subparser in action.choices.values(): + attached_cmdset = getattr(subparser, constants.PARSER_ATTR_COMMANDSET, None) + if attached_cmdset is not None and attached_cmdset is not cmdset: + raise CommandSetRegistrationError( + 'Cannot uninstall CommandSet when another CommandSet depends on it') + check_parser_uninstallable(subparser) + if command_parser is not None: + check_parser_uninstallable(command_parser) + def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: """ Register subcommands with their base command - :param cmdset: CommandSet containing subcommands + :param cmdset: CommandSet or cmd2.Cmd subclass containing subcommands """ if not (cmdset is self or cmdset in self._installed_command_sets): - raise ValueError('Adding subcommands from an unregistered CommandSet') + raise CommandSetRegistrationError('Cannot register subcommands with an unregistered CommandSet') # find all methods that start with the subcommand prefix methods = inspect.getmembers( @@ -553,10 +595,14 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: # iterate through all matching methods for method_name, method in methods: subcommand_name = getattr(method, constants.SUBCMD_ATTR_NAME) - command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND) + full_command_name = getattr(method, constants.SUBCMD_ATTR_COMMAND) # type: str subcmd_parser = getattr(method, constants.CMD_ATTR_ARGPARSER) parser_args = getattr(method, constants.SUBCMD_ATTR_PARSER_ARGS, {}) + command_tokens = full_command_name.split() + command_name = command_tokens[0] + subcommand_names = command_tokens[1:] + # Search for the base command function and verify it has an argparser defined if command_name in self.disabled_commands: command_func = self.disabled_commands[command_name].command_function @@ -564,12 +610,12 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: command_func = self.cmd_func(command_name) if command_func is None: - raise TypeError('Could not find command "{}" needed by subcommand: {}' - .format(command_name, str(method))) + raise CommandSetRegistrationError('Could not find command "{}" needed by subcommand: {}' + .format(command_name, str(method))) command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None) if command_parser is None: - raise TypeError('Could not find argparser for command "{}" needed by subcommand: {}' - .format(command_name, str(method))) + raise CommandSetRegistrationError('Could not find argparser for command "{}" needed by subcommand: {}' + .format(command_name, str(method))) if isinstance(cmdset, CommandSet): command_handler = _partial_passthru(method, self) @@ -577,9 +623,23 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: command_handler = method subcmd_parser.set_defaults(handler=command_handler) - for action in command_parser._actions: + def find_subcommand(action: argparse.ArgumentParser, subcmd_names: List[str]) -> argparse.ArgumentParser: + if not subcmd_names: + return action + cur_subcmd = subcmd_names.pop(0) + for sub_action in action._actions: + if isinstance(sub_action, argparse._SubParsersAction): + for choice_name, choice in sub_action.choices.items(): + if choice_name == cur_subcmd: + return find_subcommand(choice, subcmd_names) + raise CommandSetRegistrationError('Could not find sub-command "{}"'.format(full_command_name)) + + target_parser = find_subcommand(command_parser, subcommand_names) + + for action in target_parser._actions: if isinstance(action, argparse._SubParsersAction): - action.add_parser(subcommand_name, parents=[subcmd_parser], **parser_args) + attached_parser = action.add_parser(subcommand_name, parents=[subcmd_parser], **parser_args) + setattr(attached_parser, constants.PARSER_ATTR_COMMANDSET, cmdset) def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: """ @@ -588,7 +648,7 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: :param cmdset: CommandSet containing subcommands """ if not (cmdset is self or cmdset in self._installed_command_sets): - raise ValueError('Removing subcommands from an unregistered CommandSet') + raise CommandSetRegistrationError('Cannot unregister subcommands with an unregistered CommandSet') # find all methods that start with the subcommand prefix methods = inspect.getmembers( @@ -610,13 +670,17 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: else: command_func = self.cmd_func(command_name) - if command_func is None: - raise TypeError('Could not find command "{}" needed by subcommand: {}' - .format(command_name, str(method))) + if command_func is None: # pragma: no cover + # This really shouldn't be possible since _register_subcommands would prevent this from happening + # but keeping in case it does for some strange reason + raise CommandSetRegistrationError('Could not find command "{}" needed by subcommand: {}' + .format(command_name, str(method))) command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None) - if command_parser is None: - raise TypeError('Could not find argparser for command "{}" needed by subcommand: {}' - .format(command_name, str(method))) + if command_parser is None: # pragma: no cover + # This really shouldn't be possible since _register_subcommands would prevent this from happening + # but keeping in case it does for some strange reason + raise CommandSetRegistrationError('Could not find argparser for command "{}" needed by subcommand: {}' + .format(command_name, str(method))) for action in command_parser._actions: if isinstance(action, argparse._SubParsersAction): @@ -1439,6 +1503,7 @@ def _completion_for_command(self, text: str, line: str, begidx: int, # Parse the command line statement = self.statement_parser.parse_command_only(line) command = statement.command + cmd_set = self._cmd_to_command_sets[command] if command in self._cmd_to_command_sets else None expanded_line = statement.command_and_args # We overwrote line with a properly formatted but fully stripped version @@ -1509,7 +1574,8 @@ def _completion_for_command(self, text: str, line: str, begidx: int, import functools compfunc = functools.partial(self._complete_argparse_command, argparser=argparser, - preserve_quotes=getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES)) + preserve_quotes=getattr(func, constants.CMD_ATTR_PRESERVE_QUOTES), + cmd_set=cmd_set) else: compfunc = self.completedefault @@ -1677,7 +1743,9 @@ def complete(self, text: str, state: int) -> Optional[str]: return None def _complete_argparse_command(self, text: str, line: str, begidx: int, endidx: int, *, - argparser: argparse.ArgumentParser, preserve_quotes: bool) -> List[str]: + argparser: argparse.ArgumentParser, + preserve_quotes: bool, + cmd_set: Optional[CommandSet] = None) -> List[str]: """Completion function for argparse commands""" from .argparse_completer import ArgparseCompleter completer = ArgparseCompleter(argparser, self) @@ -1686,7 +1754,7 @@ def _complete_argparse_command(self, text: str, line: str, begidx: int, endidx: # To have tab completion parsing match command line parsing behavior, # use preserve_quotes to determine if we parse the quoted or unquoted tokens. tokens_to_parse = raw_tokens if preserve_quotes else tokens - return completer.complete_command(tokens_to_parse, text, line, begidx, endidx) + return completer.complete_command(tokens_to_parse, text, line, begidx, endidx, cmd_set=cmd_set) def in_script(self) -> bool: """Return whether a text script is running""" diff --git a/cmd2/constants.py b/cmd2/constants.py index 88a1bb822..a88ad1e22 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -54,3 +54,6 @@ SUBCMD_ATTR_COMMAND = 'parent_command' SUBCMD_ATTR_NAME = 'subcommand_name' SUBCMD_ATTR_PARSER_ARGS = 'subcommand_parser_args' + +# arpparse attribute linking to command set instance +PARSER_ATTR_COMMANDSET = 'command_set' diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 82ad8cd75..9704abbf6 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -7,7 +7,7 @@ from .exceptions import Cmd2ArgparseError from .parsing import Statement -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover import cmd2 @@ -53,7 +53,10 @@ def _parse_positionals(args: Tuple) -> Tuple['cmd2.Cmd', Union[Statement, str]]: 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') + + # This shouldn't happen unless we forget to pass statement in `Cmd.onecmd` or + # somehow call the unbound class method. + raise TypeError('Expected arguments: cmd: cmd2.Cmd, statement: Union[Statement, str] Not found') # pragma: no cover def _arg_swap(args: Union[Tuple[Any], List[Any]], search_arg: Any, *replace_arg: Any) -> List[Any]: @@ -346,7 +349,7 @@ def as_subcommand_to(command: str, """ Tag this method as a subcommand to an existing argparse decorated command. - :param command: Command Name + :param command: Command Name. Space-delimited subcommands may optionally be specified :param subcommand: Subcommand name :param parser: argparse Parser for this subcommand :param help_text: Help message for this subcommand diff --git a/cmd2/exceptions.py b/cmd2/exceptions.py index 8a7fd81f5..c1815e1bd 100644 --- a/cmd2/exceptions.py +++ b/cmd2/exceptions.py @@ -24,6 +24,9 @@ class Cmd2ArgparseError(SkipPostcommandHooks): pass +class CommandSetRegistrationError(Exception): + pass + ############################################################################################################ # The following exceptions are NOT part of the public API and are intended for internal use only. ############################################################################################################ diff --git a/isolated_tests/test_commandset/test_commandset.py b/isolated_tests/test_commandset/test_commandset.py index f16a6ff4e..5387a9ffc 100644 --- a/isolated_tests/test_commandset/test_commandset.py +++ b/isolated_tests/test_commandset/test_commandset.py @@ -12,6 +12,8 @@ import cmd2 from cmd2 import utils from cmd2_ext_test import ExternalTestMixin +from .conftest import complete_tester, run_cmd +from cmd2.exceptions import CommandSetRegistrationError @cmd2.with_default_category('Fruits') @@ -58,12 +60,6 @@ def do_elderberry(self, cmd: cmd2.Cmd, ns: argparse.Namespace): cmd.last_result = {'arg1': ns.arg1} -class WithCommandSets(ExternalTestMixin, cmd2.Cmd): - """Class for testing custom help_* methods which override docstring help.""" - def __init__(self, *args, **kwargs): - super(WithCommandSets, self).__init__(*args, **kwargs) - - @cmd2.with_default_category('Command Set B') class CommandSetB(cmd2.CommandSet): def __init__(self, arg1): @@ -81,6 +77,12 @@ def do_crocodile(self, cmd: cmd2.Cmd, statement: cmd2.Statement): cmd.poutput('Crocodile!!') +class WithCommandSets(ExternalTestMixin, cmd2.Cmd): + """Class for testing custom help_* methods which override docstring help.""" + def __init__(self, *args, **kwargs): + super(WithCommandSets, self).__init__(*args, **kwargs) + + @pytest.fixture def command_sets_app(): app = WithCommandSets() @@ -107,6 +109,8 @@ def test_autoload_commands(command_sets_app): assert 'Fruits' in cmds_cats assert 'cranberry' in cmds_cats['Fruits'] + assert 'Command Set B' not in cmds_cats + def test_custom_construct_commandsets(): # Verifies that a custom initialized CommandSet loads correctly when passed into the constructor @@ -118,7 +122,7 @@ def test_custom_construct_commandsets(): # Verifies that the same CommandSet can not be loaded twice command_set_2 = CommandSetB('bar') - with pytest.raises(ValueError): + with pytest.raises(CommandSetRegistrationError): assert app.install_command_set(command_set_2) # Verify that autoload doesn't conflict with a manually loaded CommandSet that could be autoloaded. @@ -225,7 +229,7 @@ def test_load_commandset_errors(command_sets_manual, capsys): # create a conflicting command before installing CommandSet to verify rollback behavior command_sets_manual._install_command_function('durian', cmd_set.do_durian) - with pytest.raises(ValueError): + with pytest.raises(CommandSetRegistrationError): command_sets_manual.install_command_set(cmd_set) # verify that the commands weren't installed @@ -250,19 +254,19 @@ def test_load_commandset_errors(command_sets_manual, capsys): assert "Deleting macro 'apple'" in err # verify duplicate commands are detected - with pytest.raises(ValueError): + with pytest.raises(CommandSetRegistrationError): command_sets_manual._install_command_function('banana', cmd_set.do_banana) # verify bad command names are detected - with pytest.raises(ValueError): + with pytest.raises(CommandSetRegistrationError): command_sets_manual._install_command_function('bad command', cmd_set.do_banana) # verify error conflict with existing completer function - with pytest.raises(ValueError): + with pytest.raises(CommandSetRegistrationError): command_sets_manual._install_completer_function('durian', cmd_set.complete_durian) # verify error conflict with existing help function - with pytest.raises(ValueError): + with pytest.raises(CommandSetRegistrationError): command_sets_manual._install_help_function('cranberry', cmd_set.help_cranberry) @@ -275,7 +279,7 @@ def __init__(self, dummy): cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut') @cmd2.with_argparser(cut_parser) - def do_cut(self, ns: argparse.Namespace): + def do_cut(self, cmd: cmd2.Cmd, ns: argparse.Namespace): """Cut something""" func = getattr(ns, 'handler', None) if func is not None: @@ -283,8 +287,37 @@ def do_cut(self, ns: argparse.Namespace): func(ns) else: # No subcommand was provided, so call help - self.poutput('This command does nothing without sub-parsers registered') - self.do_help('cut') + cmd.poutput('This command does nothing without sub-parsers registered') + cmd.do_help('cut') + + + stir_parser = cmd2.Cmd2ArgumentParser('stir') + stir_subparsers = stir_parser.add_subparsers(title='item', help='what to stir') + + @cmd2.with_argparser(stir_parser) + def do_stir(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + """Stir something""" + func = getattr(ns, 'handler', None) + if func is not None: + # Call whatever subcommand function was selected + func(ns) + else: + # No subcommand was provided, so call help + cmd.poutput('This command does nothing without sub-parsers registered') + cmd.do_help('stir') + + stir_pasta_parser = cmd2.Cmd2ArgumentParser('pasta', add_help=False) + stir_pasta_parser.add_argument('--option', '-o') + stir_pasta_parser.add_subparsers(title='style', help='Stir style') + + @cmd2.as_subcommand_to('stir', 'pasta', stir_pasta_parser) + def stir_pasta(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + func = getattr(ns, 'handler', None) + if func is not None: + # Call whatever subcommand function was selected + func(ns) + else: + cmd.poutput('Stir pasta haphazardly') class LoadableBadBase(cmd2.CommandSet): @@ -292,7 +325,7 @@ def __init__(self, dummy): super(LoadableBadBase, self).__init__() self._dummy = dummy # prevents autoload - def do_cut(self, ns: argparse.Namespace): + def do_cut(self, cmd: cmd2.Cmd, ns: argparse.Namespace): """Cut something""" func = getattr(ns, 'handler', None) if func is not None: @@ -300,8 +333,8 @@ def do_cut(self, ns: argparse.Namespace): func(ns) else: # No subcommand was provided, so call help - self.poutput('This command does nothing without sub-parsers registered') - self.do_help('cut') + cmd.poutput('This command does nothing without sub-parsers registered') + cmd.do_help('cut') @cmd2.with_default_category('Fruits') @@ -316,12 +349,25 @@ def do_apple(self, cmd: cmd2.Cmd, _: cmd2.Statement): banana_parser = cmd2.Cmd2ArgumentParser(add_help=False) banana_parser.add_argument('direction', choices=['discs', 'lengthwise']) - @cmd2.as_subcommand_to('cut', 'banana', banana_parser) + @cmd2.as_subcommand_to('cut', 'banana', banana_parser, help_text='Cut banana', aliases=['bananer']) def cut_banana(self, cmd: cmd2.Cmd, ns: argparse.Namespace): """Cut banana""" cmd.poutput('cutting banana: ' + ns.direction) +class LoadablePastaStir(cmd2.CommandSet): + def __init__(self, dummy): + super(LoadablePastaStir, self).__init__() + self._dummy = dummy # prevents autoload + + stir_pasta_vigor_parser = cmd2.Cmd2ArgumentParser('vigor', add_help=False) + stir_pasta_vigor_parser.add_argument('frequency') + + @cmd2.as_subcommand_to('stir pasta', 'vigorously', stir_pasta_vigor_parser) + def stir_pasta_vigorously(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + cmd.poutput('stir the pasta vigorously') + + @cmd2.with_default_category('Vegetables') class LoadableVegetables(cmd2.CommandSet): def __init__(self, dummy): @@ -331,8 +377,11 @@ def __init__(self, dummy): def do_arugula(self, cmd: cmd2.Cmd, _: cmd2.Statement): cmd.poutput('Arugula') + def complete_style_arg(self, cmd: cmd2.Cmd, text: str, line: str, begidx: int, endidx: int) -> List[str]: + return ['quartered', 'diced'] + bokchoy_parser = cmd2.Cmd2ArgumentParser(add_help=False) - bokchoy_parser.add_argument('style', choices=['quartered', 'diced']) + bokchoy_parser.add_argument('style', completer_method=complete_style_arg) @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser) def cut_bokchoy(self, cmd: cmd2.Cmd, _: cmd2.Statement): @@ -347,12 +396,12 @@ def test_subcommands(command_sets_manual): veg_cmds = LoadableVegetables(1) # installing subcommands without base command present raises exception - with pytest.raises(TypeError): + with pytest.raises(CommandSetRegistrationError): command_sets_manual.install_command_set(fruit_cmds) # if the base command is present but isn't an argparse command, expect exception command_sets_manual.install_command_set(badbase_cmds) - with pytest.raises(TypeError): + with pytest.raises(CommandSetRegistrationError): command_sets_manual.install_command_set(fruit_cmds) # verify that the commands weren't installed @@ -365,19 +414,316 @@ def test_subcommands(command_sets_manual): command_sets_manual.install_command_set(base_cmds) # verify that we catch an attempt to register subcommands when the commandset isn't installed - with pytest.raises(ValueError): + with pytest.raises(CommandSetRegistrationError): command_sets_manual._register_subcommands(fruit_cmds) - # verify that command set install and uninstalls without problems + # verify that command set install without problems + command_sets_manual.install_command_set(fruit_cmds) + command_sets_manual.install_command_set(veg_cmds) + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'Fruits' in cmds_cats + + text = '' + line = 'cut {}'.format(text) + endidx = len(line) + begidx = endidx + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + + assert first_match is not None + # check that the alias shows up correctly + assert ['banana', 'bananer', 'bokchoy'] == command_sets_manual.completion_matches + + text = '' + line = 'cut bokchoy {}'.format(text) + endidx = len(line) + begidx = endidx + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + + assert first_match is not None + # verify that argparse completer in commandset functions correctly + assert ['diced', 'quartered'] == command_sets_manual.completion_matches + + # verify that command set uninstalls without problems + command_sets_manual.uninstall_command_set(fruit_cmds) + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() + assert 'Fruits' not in cmds_cats + + # verify a double-unregister raises exception + with pytest.raises(CommandSetRegistrationError): + command_sets_manual._unregister_subcommands(fruit_cmds) + command_sets_manual.uninstall_command_set(veg_cmds) + + # Disable command and verify subcommands still load and unload + command_sets_manual.disable_command('cut', 'disabled for test') + + # verify that command set install without problems command_sets_manual.install_command_set(fruit_cmds) command_sets_manual.install_command_set(veg_cmds) + + command_sets_manual.enable_command('cut') + cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() assert 'Fruits' in cmds_cats + text = '' + line = 'cut {}'.format(text) + endidx = len(line) + begidx = endidx + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + + assert first_match is not None + # check that the alias shows up correctly + assert ['banana', 'bananer', 'bokchoy'] == command_sets_manual.completion_matches + + text = '' + line = 'cut bokchoy {}'.format(text) + endidx = len(line) + begidx = endidx + first_match = complete_tester(text, line, begidx, endidx, command_sets_manual) + + assert first_match is not None + # verify that argparse completer in commandset functions correctly + assert ['diced', 'quartered'] == command_sets_manual.completion_matches + + # disable again and verify can still uninstnall + command_sets_manual.disable_command('cut', 'disabled for test') + + # verify that command set uninstalls without problems command_sets_manual.uninstall_command_set(fruit_cmds) cmds_cats, cmds_doc, cmds_undoc, help_topics = command_sets_manual._build_command_info() assert 'Fruits' not in cmds_cats # verify a double-unregister raises exception - with pytest.raises(ValueError): + with pytest.raises(CommandSetRegistrationError): command_sets_manual._unregister_subcommands(fruit_cmds) + + with pytest.raises(CommandSetRegistrationError): + command_sets_manual.uninstall_command_set(base_cmds) + + command_sets_manual.uninstall_command_set(veg_cmds) + command_sets_manual.uninstall_command_set(base_cmds) + +def test_nested_subcommands(command_sets_manual): + base_cmds = LoadableBase(1) + # fruit_cmds = LoadableFruits(1) + # veg_cmds = LoadableVegetables(1) + pasta_cmds = LoadablePastaStir(1) + + with pytest.raises(CommandSetRegistrationError): + command_sets_manual.install_command_set(pasta_cmds) + + command_sets_manual.install_command_set(base_cmds) + + command_sets_manual.install_command_set(pasta_cmds) + + with pytest.raises(CommandSetRegistrationError): + command_sets_manual.uninstall_command_set(base_cmds) + + class BadNestedSubcommands(cmd2.CommandSet): + def __init__(self, dummy): + super(BadNestedSubcommands, self).__init__() + self._dummy = dummy # prevents autoload + + stir_pasta_vigor_parser = cmd2.Cmd2ArgumentParser('vigor', add_help=False) + stir_pasta_vigor_parser.add_argument('frequency') + + @cmd2.as_subcommand_to('stir sauce', 'vigorously', stir_pasta_vigor_parser) + def stir_pasta_vigorously(self, cmd: cmd2.Cmd, ns: argparse.Namespace): + cmd.poutput('stir the pasta vigorously') + + with pytest.raises(CommandSetRegistrationError): + command_sets_manual.install_command_set(BadNestedSubcommands(1)) + + +class AppWithSubCommands(cmd2.Cmd): + """Class for testing custom help_* methods which override docstring help.""" + def __init__(self, *args, **kwargs): + super(AppWithSubCommands, self).__init__(*args, **kwargs) + + cut_parser = cmd2.Cmd2ArgumentParser('cut') + cut_subparsers = cut_parser.add_subparsers(title='item', help='item to cut') + + @cmd2.with_argparser(cut_parser) + def do_cut(self, ns: argparse.Namespace): + """Cut something""" + func = getattr(ns, 'handler', None) + if func is not None: + # Call whatever subcommand function was selected + func(ns) + else: + # No subcommand was provided, so call help + self.poutput('This command does nothing without sub-parsers registered') + self.do_help('cut') + + banana_parser = cmd2.Cmd2ArgumentParser(add_help=False) + banana_parser.add_argument('direction', choices=['discs', 'lengthwise']) + + @cmd2.as_subcommand_to('cut', 'banana', banana_parser, help_text='Cut banana', aliases=['bananer']) + def cut_banana(self, ns: argparse.Namespace): + """Cut banana""" + self.poutput('cutting banana: ' + ns.direction) + + def complete_style_arg(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: + return ['quartered', 'diced'] + + bokchoy_parser = cmd2.Cmd2ArgumentParser(add_help=False) + bokchoy_parser.add_argument('style', completer_method=complete_style_arg) + + @cmd2.as_subcommand_to('cut', 'bokchoy', bokchoy_parser) + def cut_bokchoy(self, _: cmd2.Statement): + self.poutput('Bok Choy') + + +@pytest.fixture +def static_subcommands_app(): + app = AppWithSubCommands() + return app + + +def test_static_subcommands(static_subcommands_app): + cmds_cats, cmds_doc, cmds_undoc, help_topics = static_subcommands_app._build_command_info() + assert 'Fruits' in cmds_cats + + text = '' + line = 'cut {}'.format(text) + endidx = len(line) + begidx = endidx + first_match = complete_tester(text, line, begidx, endidx, static_subcommands_app) + + assert first_match is not None + # check that the alias shows up correctly + assert ['banana', 'bananer', 'bokchoy'] == static_subcommands_app.completion_matches + + text = '' + line = 'cut bokchoy {}'.format(text) + endidx = len(line) + begidx = endidx + first_match = complete_tester(text, line, begidx, endidx, static_subcommands_app) + + assert first_match is not None + # verify that argparse completer in commandset functions correctly + assert ['diced', 'quartered'] == static_subcommands_app.completion_matches + + +# reproduces test_argparse.py except with SubCommands +class SubcommandSet(cmd2.CommandSet): + """ Example cmd2 application where we a base command which has a couple subcommands.""" + + def __init__(self, dummy): + super(SubcommandSet, self).__init__() + + # subcommand functions for the base command + def base_foo(self, cmd: cmd2.Cmd, args): + """foo subcommand of base command""" + cmd.poutput(args.x * args.y) + + def base_bar(self, cmd: cmd2.Cmd, args): + """bar subcommand of base command""" + cmd.poutput('((%s))' % args.z) + + def base_helpless(self, cmd: cmd2.Cmd, args): + """helpless subcommand of base command""" + cmd.poutput('((%s))' % args.z) + + # create the top-level parser for the base command + base_parser = argparse.ArgumentParser() + base_subparsers = base_parser.add_subparsers(dest='subcommand', metavar='SUBCOMMAND') + base_subparsers.required = True + + # create the parser for the "foo" subcommand + parser_foo = base_subparsers.add_parser('foo', help='foo help') + parser_foo.add_argument('-x', type=int, default=1, help='integer') + parser_foo.add_argument('y', type=float, help='float') + parser_foo.set_defaults(func=base_foo) + + # create the parser for the "bar" subcommand + parser_bar = base_subparsers.add_parser('bar', help='bar help', aliases=['bar_1', 'bar_2']) + parser_bar.add_argument('z', help='string') + parser_bar.set_defaults(func=base_bar) + + # create the parser for the "helpless" subcommand + # This subcommand has aliases and no help text. It exists to prevent changes to _set_parser_prog() which + # use an approach which relies on action._choices_actions list. See comment in that function for more + # details. + parser_bar = base_subparsers.add_parser('helpless', aliases=['helpless_1', 'helpless_2']) + parser_bar.add_argument('z', help='string') + parser_bar.set_defaults(func=base_bar) + + @cmd2.with_argparser(base_parser) + def do_base(self, cmd: cmd2.Cmd, args): + """Base command help""" + # Call whatever subcommand function was selected + func = getattr(args, 'func') + func(self, cmd, args) + + +@pytest.fixture +def subcommand_app(): + app = WithCommandSets(auto_load_commands=False, + command_sets=[SubcommandSet(1)]) + return app + + +def test_subcommand_foo(subcommand_app): + out, err = run_cmd(subcommand_app, 'base foo -x2 5.0') + assert out == ['10.0'] + + +def test_subcommand_bar(subcommand_app): + out, err = run_cmd(subcommand_app, 'base bar baz') + assert out == ['((baz))'] + +def test_subcommand_invalid(subcommand_app): + out, err = run_cmd(subcommand_app, 'base baz') + assert err[0].startswith('usage: base') + assert err[1].startswith("base: error: argument SUBCOMMAND: invalid choice: 'baz'") + +def test_subcommand_base_help(subcommand_app): + out, err = run_cmd(subcommand_app, 'help base') + assert out[0].startswith('usage: base') + assert out[1] == '' + assert out[2] == 'Base command help' + +def test_subcommand_help(subcommand_app): + # foo has no aliases + out, err = run_cmd(subcommand_app, 'help base foo') + assert out[0].startswith('usage: base foo') + assert out[1] == '' + assert out[2] == 'positional arguments:' + + # bar has aliases (usage should never show alias name) + out, err = run_cmd(subcommand_app, 'help base bar') + assert out[0].startswith('usage: base bar') + assert out[1] == '' + assert out[2] == 'positional arguments:' + + out, err = run_cmd(subcommand_app, 'help base bar_1') + assert out[0].startswith('usage: base bar') + assert out[1] == '' + assert out[2] == 'positional arguments:' + + out, err = run_cmd(subcommand_app, 'help base bar_2') + assert out[0].startswith('usage: base bar') + assert out[1] == '' + assert out[2] == 'positional arguments:' + + # helpless has aliases and no help text (usage should never show alias name) + out, err = run_cmd(subcommand_app, 'help base helpless') + assert out[0].startswith('usage: base helpless') + assert out[1] == '' + assert out[2] == 'positional arguments:' + + out, err = run_cmd(subcommand_app, 'help base helpless_1') + assert out[0].startswith('usage: base helpless') + assert out[1] == '' + assert out[2] == 'positional arguments:' + + out, err = run_cmd(subcommand_app, 'help base helpless_2') + assert out[0].startswith('usage: base helpless') + assert out[1] == '' + assert out[2] == 'positional arguments:' + +def test_subcommand_invalid_help(subcommand_app): + out, err = run_cmd(subcommand_app, 'help base baz') + assert out[0].startswith('usage: base') + From c0ad7baddc93df5d124d9c52bf1c78906b2c9975 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Mon, 3 Aug 2020 17:18:43 -0400 Subject: [PATCH 34/35] Updated changelog --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a6b4fcb..d3dfb1f8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.3.0 (August 4, 2020) +* Enchancements + * Added CommandSet - Enables defining a separate loadable module of commands to register/unregister + with your cmd2 application. + ## 1.2.1 (July 14, 2020) * Bug Fixes * Relax minimum version of `importlib-metadata` to >= 1.6.0 when using Python < 3.8 From b932ad437a5db56f0e31d58ae69fc760ed251004 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Tue, 4 Aug 2020 11:56:57 -0400 Subject: [PATCH 35/35] Minor formatting fixes. Injecting a function into namespace objects before passing to command handlers to access sub-command handlers --- cmd2/argparse_custom.py | 1 - cmd2/cmd2.py | 15 +++---- cmd2/constants.py | 3 ++ cmd2/decorators.py | 13 ++++++ cmd2/exceptions.py | 1 + docs/features/modular_commands.rst | 6 +-- examples/modular_subcommands.py | 6 +-- .../test_commandset/test_commandset.py | 40 +++++++++++-------- 8 files changed, 54 insertions(+), 31 deletions(-) diff --git a/cmd2/argparse_custom.py b/cmd2/argparse_custom.py index e08db0058..5dbb9f66e 100644 --- a/cmd2/argparse_custom.py +++ b/cmd2/argparse_custom.py @@ -584,7 +584,6 @@ class so cmd2 can remove subcommands from a parser. del self.choices[name] - # noinspection PyProtectedMember setattr(argparse._SubParsersAction, 'remove_parser', _SubParsersAction_remove_parser) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 65aa88e06..855431d04 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -413,9 +413,9 @@ def _autoload_commands(self) -> None: 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 not (cmdset_type in existing_commandset_types or - len(init_sig.parameters) != 1 or - 'self' not in init_sig.parameters): + if not (cmdset_type in existing_commandset_types + or len(init_sig.parameters) != 1 + or 'self' not in init_sig.parameters): cmdset = cmdset_type() self.install_command_set(cmdset) @@ -550,7 +550,7 @@ def _check_uninstallable(self, cmdset: CommandSet): methods = inspect.getmembers( cmdset, predicate=lambda meth: isinstance(meth, Callable) - and hasattr(meth, '__name__') and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) + and hasattr(meth, '__name__') and meth.__name__.startswith(COMMAND_FUNC_PREFIX)) for method in methods: command_name = method[0][len(COMMAND_FUNC_PREFIX):] @@ -562,6 +562,7 @@ def _check_uninstallable(self, cmdset: CommandSet): command_func = self.cmd_func(command_name) command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None) + def check_parser_uninstallable(parser): for action in parser._actions: if isinstance(action, argparse._SubParsersAction): @@ -621,7 +622,7 @@ def _register_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: command_handler = _partial_passthru(method, self) else: command_handler = method - subcmd_parser.set_defaults(handler=command_handler) + subcmd_parser.set_defaults(cmd2_handler=command_handler) def find_subcommand(action: argparse.ArgumentParser, subcmd_names: List[str]) -> argparse.ArgumentParser: if not subcmd_names: @@ -671,8 +672,8 @@ def _unregister_subcommands(self, cmdset: Union[CommandSet, 'Cmd']) -> None: command_func = self.cmd_func(command_name) if command_func is None: # pragma: no cover - # This really shouldn't be possible since _register_subcommands would prevent this from happening - # but keeping in case it does for some strange reason + # This really shouldn't be possible since _register_subcommands would prevent this from happening + # but keeping in case it does for some strange reason raise CommandSetRegistrationError('Could not find command "{}" needed by subcommand: {}' .format(command_name, str(method))) command_parser = getattr(command_func, constants.CMD_ATTR_ARGPARSER, None) diff --git a/cmd2/constants.py b/cmd2/constants.py index a88ad1e22..aa2ccb6ab 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -50,6 +50,9 @@ # Whether or not tokens are unquoted before sending to argparse CMD_ATTR_PRESERVE_QUOTES = 'preserve_quotes' +# optional attribute +SUBCMD_HANDLER = 'cmd2_handler' + # subcommand attributes for the base command name and the subcommand name SUBCMD_ATTR_COMMAND = 'parent_command' SUBCMD_ATTR_NAME = 'subcommand_name' diff --git a/cmd2/decorators.py b/cmd2/decorators.py index 9704abbf6..5947020f5 100644 --- a/cmd2/decorators.py +++ b/cmd2/decorators.py @@ -1,6 +1,7 @@ # coding=utf-8 """Decorators for ``cmd2`` commands""" import argparse +import types from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Tuple, Union from . import constants @@ -231,6 +232,12 @@ def cmd_wrapper(*args: Tuple[Any, ...], **kwargs: Dict[str, Any]) -> Optional[bo raise Cmd2ArgparseError else: setattr(ns, '__statement__', statement) + + def get_handler(self: argparse.Namespace) -> Optional[Callable]: + return getattr(self, constants.SUBCMD_HANDLER, None) + + setattr(ns, 'get_handler', types.MethodType(get_handler, ns)) + args_list = _arg_swap(args, statement, ns, unknown) return func(*args_list, **kwargs) @@ -316,6 +323,12 @@ def cmd_wrapper(*args: Any, **kwargs: Dict[str, Any]) -> Optional[bool]: raise Cmd2ArgparseError else: setattr(ns, '__statement__', statement) + + def get_handler(self: argparse.Namespace) -> Optional[Callable]: + return getattr(self, constants.SUBCMD_HANDLER, None) + + setattr(ns, 'get_handler', types.MethodType(get_handler, ns)) + args_list = _arg_swap(args, statement, ns) return func(*args_list, **kwargs) diff --git a/cmd2/exceptions.py b/cmd2/exceptions.py index c1815e1bd..b928f2934 100644 --- a/cmd2/exceptions.py +++ b/cmd2/exceptions.py @@ -31,6 +31,7 @@ class CommandSetRegistrationError(Exception): # The following exceptions are NOT part of the public API and are intended for internal use only. ############################################################################################################ + class Cmd2ShlexError(Exception): """Raised when shlex fails to parse a command line string in StatementParser""" pass diff --git a/docs/features/modular_commands.rst b/docs/features/modular_commands.rst index 4c7286b71..9823d3ac4 100644 --- a/docs/features/modular_commands.rst +++ b/docs/features/modular_commands.rst @@ -314,10 +314,10 @@ command and each CommandSet @with_argparser(cut_parser) def do_cut(self, ns: argparse.Namespace): - func = getattr(ns, 'handler', None) - if func is not None: + handler = ns.get_handler() + if handler is not None: # Call whatever subcommand function was selected - func(ns) + handler(ns) else: # No subcommand was provided, so call help self.poutput('This command does nothing without sub-parsers registered') diff --git a/examples/modular_subcommands.py b/examples/modular_subcommands.py index 1ac951aed..e1bc6b7b9 100644 --- a/examples/modular_subcommands.py +++ b/examples/modular_subcommands.py @@ -95,10 +95,10 @@ def do_unload(self, ns: argparse.Namespace): @with_argparser(cut_parser) def do_cut(self, ns: argparse.Namespace): - func = getattr(ns, 'handler', None) - if func is not None: + handler = ns.get_handler() + if handler is not None: # Call whatever subcommand function was selected - func(ns) + handler(ns) else: # No subcommand was provided, so call help self.poutput('This command does nothing without sub-parsers registered') diff --git a/isolated_tests/test_commandset/test_commandset.py b/isolated_tests/test_commandset/test_commandset.py index 5387a9ffc..90f0448cd 100644 --- a/isolated_tests/test_commandset/test_commandset.py +++ b/isolated_tests/test_commandset/test_commandset.py @@ -281,13 +281,13 @@ def __init__(self, dummy): @cmd2.with_argparser(cut_parser) def do_cut(self, cmd: cmd2.Cmd, ns: argparse.Namespace): """Cut something""" - func = getattr(ns, 'handler', None) - if func is not None: + handler = ns.get_handler() + if handler is not None: # Call whatever subcommand function was selected - func(ns) + handler(ns) else: # No subcommand was provided, so call help - cmd.poutput('This command does nothing without sub-parsers registered') + cmd.pwarning('This command does nothing without sub-parsers registered') cmd.do_help('cut') @@ -297,13 +297,13 @@ def do_cut(self, cmd: cmd2.Cmd, ns: argparse.Namespace): @cmd2.with_argparser(stir_parser) def do_stir(self, cmd: cmd2.Cmd, ns: argparse.Namespace): """Stir something""" - func = getattr(ns, 'handler', None) - if func is not None: + handler = ns.get_handler() + if handler is not None: # Call whatever subcommand function was selected - func(ns) + handler(ns) else: # No subcommand was provided, so call help - cmd.poutput('This command does nothing without sub-parsers registered') + cmd.pwarning('This command does nothing without sub-parsers registered') cmd.do_help('stir') stir_pasta_parser = cmd2.Cmd2ArgumentParser('pasta', add_help=False) @@ -312,10 +312,10 @@ def do_stir(self, cmd: cmd2.Cmd, ns: argparse.Namespace): @cmd2.as_subcommand_to('stir', 'pasta', stir_pasta_parser) def stir_pasta(self, cmd: cmd2.Cmd, ns: argparse.Namespace): - func = getattr(ns, 'handler', None) - if func is not None: + handler = ns.get_handler() + if handler is not None: # Call whatever subcommand function was selected - func(ns) + handler(ns) else: cmd.poutput('Stir pasta haphazardly') @@ -327,10 +327,10 @@ def __init__(self, dummy): def do_cut(self, cmd: cmd2.Cmd, ns: argparse.Namespace): """Cut something""" - func = getattr(ns, 'handler', None) - if func is not None: + handler = ns.get_handler() + if handler is not None: # Call whatever subcommand function was selected - func(ns) + handler(ns) else: # No subcommand was provided, so call help cmd.poutput('This command does nothing without sub-parsers registered') @@ -417,6 +417,9 @@ def test_subcommands(command_sets_manual): with pytest.raises(CommandSetRegistrationError): command_sets_manual._register_subcommands(fruit_cmds) + cmd_result = command_sets_manual.app_cmd('cut') + assert 'This command does nothing without sub-parsers registered' in cmd_result.stderr + # verify that command set install without problems command_sets_manual.install_command_set(fruit_cmds) command_sets_manual.install_command_set(veg_cmds) @@ -433,6 +436,9 @@ def test_subcommands(command_sets_manual): # check that the alias shows up correctly assert ['banana', 'bananer', 'bokchoy'] == command_sets_manual.completion_matches + cmd_result = command_sets_manual.app_cmd('cut banana discs') + assert 'cutting banana: discs' in cmd_result.stdout + text = '' line = 'cut bokchoy {}'.format(text) endidx = len(line) @@ -546,10 +552,10 @@ def __init__(self, *args, **kwargs): @cmd2.with_argparser(cut_parser) def do_cut(self, ns: argparse.Namespace): """Cut something""" - func = getattr(ns, 'handler', None) - if func is not None: + handler = ns.get_handler() + if handler is not None: # Call whatever subcommand function was selected - func(ns) + handler(ns) else: # No subcommand was provided, so call help self.poutput('This command does nothing without sub-parsers registered')