From 9aab8ddd1063125d2a754cdfc0a83af64bc39a7d Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Thu, 16 Jul 2020 17:29:11 -0400 Subject: [PATCH 1/6] Initial checkin of Modular Command definitions as a plugin. --- cmd2/cmd2.py | 53 +-- docs/api/command_definition.rst | 5 + docs/api/index.rst | 1 + docs/plugins/index.rst | 1 + docs/plugins/modular_commands.rst | 141 +++++++ plugins/ext_test/cmd2_ext_test/__init__.py | 4 +- plugins/modular_cmd/CHANGELOG.md | 12 + plugins/modular_cmd/README.md | 129 ++++++ .../modular_cmd/cmd2_modular_cmds/__init__.py | 28 ++ .../cmd2_modular_cmds/command_definition.py | 129 ++++++ .../cmd2_modular_cmds/modular_mixin.py | 238 ++++++++++++ .../examples/modular_commands/__init__.py | 0 .../modular_commands/commandset_basic.py | 122 ++++++ .../modular_commands/commandset_custominit.py | 33 ++ .../examples/modular_commands_main.py | 128 ++++++ plugins/modular_cmd/setup.py | 66 ++++ plugins/modular_cmd/tasks.py | 202 ++++++++++ plugins/modular_cmd/tests/__init__.py | 2 + plugins/modular_cmd/tests/conftest.py | 196 ++++++++++ plugins/modular_cmd/tests/test_commandset.py | 367 ++++++++++++++++++ 20 files changed, 1829 insertions(+), 28 deletions(-) create mode 100644 docs/api/command_definition.rst create mode 100644 docs/plugins/modular_commands.rst create mode 100644 plugins/modular_cmd/CHANGELOG.md create mode 100644 plugins/modular_cmd/README.md create mode 100644 plugins/modular_cmd/cmd2_modular_cmds/__init__.py create mode 100644 plugins/modular_cmd/cmd2_modular_cmds/command_definition.py create mode 100644 plugins/modular_cmd/cmd2_modular_cmds/modular_mixin.py create mode 100644 plugins/modular_cmd/examples/modular_commands/__init__.py create mode 100644 plugins/modular_cmd/examples/modular_commands/commandset_basic.py create mode 100644 plugins/modular_cmd/examples/modular_commands/commandset_custominit.py create mode 100644 plugins/modular_cmd/examples/modular_commands_main.py create mode 100644 plugins/modular_cmd/setup.py create mode 100644 plugins/modular_cmd/tasks.py create mode 100644 plugins/modular_cmd/tests/__init__.py create mode 100644 plugins/modular_cmd/tests/conftest.py create mode 100644 plugins/modular_cmd/tests/test_commandset.py diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index cdee35232..70ec508c2 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -200,7 +200,7 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, self.max_completion_items = 50 # A dictionary mapping settable names to their Settable instance - self.settables = dict() + self.settables = dict() # type: Dict[str, Settable] self.build_settables() # Use as prompt for multiline commands on the 2nd+ line of input @@ -220,7 +220,7 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, self.exclude_from_history = ['eof', 'history'] # Dictionary of macro names and their values - self.macros = dict() + self.macros = dict() # type: Dict[str, Macro] # Keeps track of typed command history in the Python shell self._py_history = [] @@ -249,14 +249,14 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, self.last_result = None # Used by run_script command to store current script dir as a LIFO queue to support _relative_run_script command - self._script_dir = [] + self._script_dir = [] # type: List[str] # Context manager used to protect critical sections in the main thread from stopping due to a KeyboardInterrupt self.sigint_protection = utils.ContextFlag() # If the current command created a process to pipe to, then this will be a ProcReader object. # Otherwise it will be None. It's used to know when a pipe process can be killed and/or waited upon. - self._cur_pipe_proc_reader = None + self._cur_pipe_proc_reader = None # type: Optional[utils.ProcReader] # Used to keep track of whether we are redirecting or piping output self._redirecting = False @@ -280,7 +280,7 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, self.broken_pipe_warning = '' # Commands that will run at the beginning of the command loop - self._startup_commands = [] + self._startup_commands = [] # type: List[str] # If a startup script is provided and exists, then execute it in the startup commands if startup_script: @@ -289,7 +289,7 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, self._startup_commands.append("run_script {}".format(utils.quote_string(startup_script))) # Transcript files to run instead of interactive command loop - self._transcript_files = None + self._transcript_files = None # type: Optional[List[str]] # Check for command line args if allow_cli_args: @@ -333,7 +333,7 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, *, # Commands that have been disabled from use. This is to support commands that are only available # during specific states of the application. This dictionary's keys are the command names and its # values are DisabledCommand objects. - self.disabled_commands = dict() + self.disabled_commands = dict() # type: Dict[str, DisabledCommand] # If any command has been categorized, then all other commands that haven't been categorized # will display under this section in the help output. @@ -1910,7 +1910,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: self._cur_pipe_proc_reader, self._redirecting) # The ProcReader for this command - cmd_pipe_proc_reader = None + cmd_pipe_proc_reader = None # type: Optional[utils.ProcReader] if not self.allow_redirection: # Don't return since we set some state variables at the end of the function @@ -2694,16 +2694,31 @@ def do_help(self, args: argparse.Namespace) -> None: def _help_menu(self, verbose: bool = False) -> None: """Show a list of commands which help can be displayed for""" + cmds_cats, cmds_doc, cmds_undoc, help_topics = self._build_command_info() + + if len(cmds_cats) == 0: + # No categories found, fall back to standard behavior + self.poutput("{}".format(str(self.doc_leader))) + self._print_topics(self.doc_header, cmds_doc, verbose) + else: + # Categories found, Organize all commands by category + self.poutput('{}'.format(str(self.doc_leader))) + self.poutput('{}'.format(str(self.doc_header)), end="\n\n") + for category in sorted(cmds_cats.keys(), key=self.default_sort_key): + self._print_topics(category, cmds_cats[category], verbose) + self._print_topics(self.default_category, cmds_doc, verbose) + + self.print_topics(self.misc_header, help_topics, 15, 80) + self.print_topics(self.undoc_header, cmds_undoc, 15, 80) + + def _build_command_info(self): # Get a sorted list of help topics help_topics = sorted(self.get_help_topics(), key=self.default_sort_key) - # Get a sorted list of visible command names visible_commands = sorted(self.get_visible_commands(), key=self.default_sort_key) - cmds_doc = [] cmds_undoc = [] cmds_cats = {} - for command in visible_commands: func = self.cmd_func(command) has_help_func = False @@ -2724,21 +2739,7 @@ def _help_menu(self, verbose: bool = False) -> None: cmds_doc.append(command) else: cmds_undoc.append(command) - - if len(cmds_cats) == 0: - # No categories found, fall back to standard behavior - self.poutput("{}".format(str(self.doc_leader))) - self._print_topics(self.doc_header, cmds_doc, verbose) - else: - # Categories found, Organize all commands by category - self.poutput('{}'.format(str(self.doc_leader))) - self.poutput('{}'.format(str(self.doc_header)), end="\n\n") - for category in sorted(cmds_cats.keys(), key=self.default_sort_key): - self._print_topics(category, cmds_cats[category], verbose) - self._print_topics(self.default_category, cmds_doc, verbose) - - self.print_topics(self.misc_header, help_topics, 15, 80) - self.print_topics(self.undoc_header, cmds_undoc, 15, 80) + return cmds_cats, cmds_doc, cmds_undoc, help_topics def _print_topics(self, header: str, cmds: List[str], verbose: bool) -> None: """Customized version of print_topics that can switch between verbose or traditional output""" diff --git a/docs/api/command_definition.rst b/docs/api/command_definition.rst new file mode 100644 index 000000000..7992eb401 --- /dev/null +++ b/docs/api/command_definition.rst @@ -0,0 +1,5 @@ +cmd2_modular_cmds.command_definition +===================================== + +.. automodule:: cmd2_modular_cmds.command_definition + :members: diff --git a/docs/api/index.rst b/docs/api/index.rst index cc899ba1c..d18f41870 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -31,6 +31,7 @@ This documentation is for ``cmd2`` version |version|. py_bridge table_creator utils + command_definition **Modules** diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 23221c3de..94b80475e 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -4,4 +4,5 @@ Plugins .. toctree:: :maxdepth: 1 + modular_commands external_test diff --git a/docs/plugins/modular_commands.rst b/docs/plugins/modular_commands.rst new file mode 100644 index 000000000..5e652b039 --- /dev/null +++ b/docs/plugins/modular_commands.rst @@ -0,0 +1,141 @@ +Modular Commands Plugin +======================= + +Overview +-------- + +.. _cmd2_modular_commands_plugin: + https://github.com/python-cmd2/cmd2/tree/master/plugins/modular_commands/ + +The cmd2_modular_commands_plugin_ 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 +~~~~~~~~ + +* Arbitrary Functions as Commands - Functions can be registered as commands in cmd2. +* 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 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 +----------------- + +Class Definition +~~~~~~~~~~~~~~~~ + +The following short example shows how to add the ModularCommandsMixin to your cmd2 application. + +Define your cmd2 application + +.. code-block:: python + + import cmd2 + from cmd2_modular_cmds import ModularCommandsMixin + + class ExampleApp(ModularCommandsMixin, cmd2.Cmd): + """An class to show how to use a plugin""" + 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') + +Command Functions +~~~~~~~~~~~~~~~~~ + +Individual functions can be registered as commands with khe ``register_command`` decorator. It can be used with +other cmd2 decorators without problems. The order of decoration also does not matter. + +Any functions defined with your command function that match ``help_`` or ``complete_`` are also detected +and registered as help and completion functions for the command. + +.. code-block:: python + + import cmd2 + from cmd2_modular_cmds import ModularCommandsMixin, register_command + + @register_command + @cmd2.with_category("AAA") + def do_function_as_command(cmd: cmd2.Cmd, statement: cmd2.Statement): + """ + This is an example of registering a function as a command + :param cmd: + :param statement: + :return: + """ + cmd.poutput('Unbound Command: {}'.format(statement.args)) + + # This is detected and registered as the help function for `function_as_command` + def help_function_as_command(cmd: cmd2.Cmd): + cmd.poutput('Help for func_with_help') + + + class ExampleApp(ModularCommandsMixin, cmd2.Cmd): + """An class to show how to use a plugin""" + 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') + + +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. + +.. code-block:: python + + import cmd2 + from cmd2_modular_cmds import ModularCommandsMixin, 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(ModularCommandsMixin, cmd2.Cmd): + """ + An class to show how to use a plugin. + 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, **kwargs) + + def do_something(self, arg): + self.last_result = 5 + self.poutput('this is the something command') + + diff --git a/plugins/ext_test/cmd2_ext_test/__init__.py b/plugins/ext_test/cmd2_ext_test/__init__.py index dbbc42505..555d2b622 100644 --- a/plugins/ext_test/cmd2_ext_test/__init__.py +++ b/plugins/ext_test/cmd2_ext_test/__init__.py @@ -1,8 +1,8 @@ # # coding=utf-8 -"""Description of myplugin +"""cmd2 External Python Testing Mixin -An overview of what myplugin does. +Allows developers to exercise their cmd2 application using the PyScript interface """ try: diff --git a/plugins/modular_cmd/CHANGELOG.md b/plugins/modular_cmd/CHANGELOG.md new file mode 100644 index 000000000..8461cb610 --- /dev/null +++ b/plugins/modular_cmd/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + +## 0.1.0 (2020-07-??) + +### Added +- Initial contribution + + diff --git a/plugins/modular_cmd/README.md b/plugins/modular_cmd/README.md new file mode 100644 index 000000000..58c782fe7 --- /dev/null +++ b/plugins/modular_cmd/README.md @@ -0,0 +1,129 @@ +# cmd2 Modular Commands + +## Table of Contents + +- [Overview](#overview) +- [Example cmd2 Application](#example-cmd2-application) +- [Command Functions](#command-functions) +- [Command Sets](#command-sets) +- [License](#license) + + +## Overview + +This plugin 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: +* Arbitrary Functions as Commands - Functions can be registered as commands in cmd2. +* 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 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 + +## Example cmd2 Application + +The following short example shows how to add the ModularCommandsMixin to your cmd2 application. + +Define your cmd2 application + +```python +import cmd2 +from cmd2_modular_cmds import ModularCommandsMixin + +class ExampleApp(ModularCommandsMixin, cmd2.Cmd): + """An class to show how to use a plugin""" + 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') +``` + +## Command Functions + +Individual functions can be registered as commands. The ``register_command`` decorator can be used with +other cmd2 decorators without problems. The order of decoration also does not matter. + +Any functions defined with your command function that match `help_` or `complete_` are also detected +and registered as help and completion functions for the command. + +```python +import cmd2 +from cmd2_modular_cmds import ModularCommandsMixin, register_command + +@register_command +@cmd2.with_category("AAA") +def do_function_as_command(cmd: cmd2.Cmd, statement: cmd2.Statement): + """ + This is an example of registering a function as a command + :param cmd: + :param statement: + :return: + """ + cmd.poutput('Unbound Command: {}'.format(statement.args)) + +# This is detected and registered as the help function for `function_as_command` +def help_function_as_command(cmd: cmd2.Cmd): + cmd.poutput('Help for func_with_help') + + +class ExampleApp(ModularCommandsMixin, cmd2.Cmd): + """An class to show how to use a plugin""" + 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') +``` + +## Command Sets + +CommandSets group multiple commands together. A new decorator ``with_default_category`` is provided +to decorate CommandSets. Individual commands may be omitted from this default category by specifying +another category. CommandSet methods will always expect self, and cmd2.Cmd as the first two parameters. + +```python +import cmd2 +from cmd2_modular_cmds import ModularCommandsMixin, 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(ModularCommandsMixin, cmd2.Cmd): + """An class to show how to use a plugin""" + 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') +``` + +## License + +cmd2 [uses the very liberal MIT license](https://github.com/python-cmd2/cmd2/blob/master/LICENSE). +We invite plugin authors to consider doing the same. diff --git a/plugins/modular_cmd/cmd2_modular_cmds/__init__.py b/plugins/modular_cmd/cmd2_modular_cmds/__init__.py new file mode 100644 index 000000000..62bec8f9e --- /dev/null +++ b/plugins/modular_cmd/cmd2_modular_cmds/__init__.py @@ -0,0 +1,28 @@ +# +# coding=utf-8 +"""cmd2 Modular Command Mixin + +Allows registration of arbitrary functions and CommandSets as commands in a cmd2 application +""" + +try: + # For python 3.8 and later + import importlib.metadata as importlib_metadata +except ImportError: + # For everyone else + import importlib_metadata +try: + __version__ = importlib_metadata.version(__name__) +except importlib_metadata.PackageNotFoundError: + # package is not installed + __version__ = 'unknown' + +from .command_definition import CommandSet, with_default_category, register_command +from .modular_mixin import ModularCommandsMixin + +__all__ = [ + 'CommandSet', + 'ModularCommandsMixin', + 'register_command', + 'with_default_category', +] diff --git a/plugins/modular_cmd/cmd2_modular_cmds/command_definition.py b/plugins/modular_cmd/cmd2_modular_cmds/command_definition.py new file mode 100644 index 000000000..504479f1b --- /dev/null +++ b/plugins/modular_cmd/cmd2_modular_cmds/command_definition.py @@ -0,0 +1,129 @@ +# coding=utf-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 cmd2.constants import COMMAND_FUNC_PREFIX + +# Allows IDEs to resolve types without impacting imports at runtime, breaking circular dependency issues +try: # pragma: no cover + from typing import TYPE_CHECKING + if TYPE_CHECKING: + import cmd2 + +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: + """ + 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) + + def __setattr__(self, key, value): + return setattr(self.func, 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): + """ + 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 + have a category specified. + + :param category: category to put all uncategorized commands in + :return: decorator function + """ + + def decorate_class(cls: Type[CommandSet]): + from cmd2.constants import CMD_ATTR_HELP_CATEGORY + import inspect + from cmd2.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[cmd2.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): + """ + Called by ``cmd2.Cmd`` when a CommandSet is unregistered and removed. + + :param cmd: + :type cmd: cmd2.Cmd + """ + self._cmd = None diff --git a/plugins/modular_cmd/cmd2_modular_cmds/modular_mixin.py b/plugins/modular_cmd/cmd2_modular_cmds/modular_mixin.py new file mode 100644 index 000000000..a4d34df6f --- /dev/null +++ b/plugins/modular_cmd/cmd2_modular_cmds/modular_mixin.py @@ -0,0 +1,238 @@ +# +# coding=utf-8 +"""External test interface plugin""" + +import inspect +import types +from typing import ( + Callable, + Iterable, + List, + Optional, + TYPE_CHECKING +) + +import cmd2 +from cmd2.constants import COMMAND_FUNC_PREFIX, COMPLETER_FUNC_PREFIX, HELP_FUNC_PREFIX + +from .command_definition import CommandSet, _REGISTERED_COMMANDS, _partial_passthru + +if TYPE_CHECKING: + _Base = cmd2.Cmd +else: + _Base = object + + +class ModularCommandsMixin(_Base): + """A cmd2 plugin (mixin class) that adds support for grouping commands into CommandSets""" + + def __init__(self, + *args, + command_sets: Optional[Iterable[CommandSet]] = None, + auto_load_commands: bool = True, + **kwargs): + """ + + :type self: cmd2.Cmd + :param args: + :param kwargs: + """ + # code placed here runs before cmd2 initializes + super().__init__(*args, **kwargs) + + # code placed here runs after cmd2 initializes + # Load modular commands + 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) + + if auto_load_commands: + self._autoload_commands() + + 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) + + # 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] + 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) + + 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: + command = method[0][len(COMMAND_FUNC_PREFIX):] + command_wrapper = _partial_passthru(method[1], self) + + 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 is not None: + completer_wrapper = _partial_passthru(cmd_completer, self) + 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) + 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 + :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_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) diff --git a/plugins/modular_cmd/examples/modular_commands/__init__.py b/plugins/modular_cmd/examples/modular_commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/modular_cmd/examples/modular_commands/commandset_basic.py b/plugins/modular_cmd/examples/modular_commands/commandset_basic.py new file mode 100644 index 000000000..f6355db57 --- /dev/null +++ b/plugins/modular_cmd/examples/modular_commands/commandset_basic.py @@ -0,0 +1,122 @@ +# coding=utf-8 +""" +A simple example demonstrating a loadable command set +""" +from typing import List + +from cmd2 import Cmd, Statement, with_category +from cmd2.utils import CompletionError +from cmd2_modular_cmds import CommandSet, register_command, with_default_category + + +@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 + 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/plugins/modular_cmd/examples/modular_commands/commandset_custominit.py b/plugins/modular_cmd/examples/modular_commands/commandset_custominit.py new file mode 100644 index 000000000..3aad14b61 --- /dev/null +++ b/plugins/modular_cmd/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_modular_cmds import CommandSet, register_command, 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)) + + +@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/plugins/modular_cmd/examples/modular_commands_main.py b/plugins/modular_cmd/examples/modular_commands_main.py new file mode 100644 index 000000000..b7c3a2254 --- /dev/null +++ b/plugins/modular_cmd/examples/modular_commands_main.py @@ -0,0 +1,128 @@ +#!/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, Iterable, List, Optional + +from cmd2 import Cmd, Cmd2ArgumentParser, CompletionItem, with_argparser +from cmd2_modular_cmds import CommandSet, ModularCommandsMixin +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 + +# 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 WithModularCommands(ModularCommandsMixin, Cmd): + 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]: + """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") + my_sets = [CustomInitCommandSet('First argument', 'Second argument')] + app = WithModularCommands(command_sets=my_sets) + sys.exit(app.cmdloop()) diff --git a/plugins/modular_cmd/setup.py b/plugins/modular_cmd/setup.py new file mode 100644 index 000000000..b0405ff10 --- /dev/null +++ b/plugins/modular_cmd/setup.py @@ -0,0 +1,66 @@ +# +# coding=utf-8 + +import os + +import setuptools + +# +# get the long description from the README file +here = os.path.abspath(os.path.dirname(__file__)) +with open(os.path.join(here, 'README.md'), encoding='utf-8') as f: + long_description = f.read() + +setuptools.setup( + name='cmd2-modular-commands', + version='0.1.0', + # TODO: Figure out why this doesn't work on CI Server + # use_scm_version={ + # 'root': '../..', + # 'relative_to': __file__, + # 'git_describe_command': 'git describe --dirty --tags --long --match plugin-ext-test*' + # }, + + description='Modular commands plugin for cmd2. Allows for commands to be defined in individual modules instead' + ' of a single god class containing all possible commands.', + long_description=long_description, + long_description_content_type='text/markdown', + keywords='cmd2 test plugin', + + author='Eric Lin', + author_email='anselor@gmail.com', + url='https://github.com/python-cmd2/cmd2/', + license='MIT', + + packages=['cmd2_modular_cmds'], + + python_requires='>=3.4', + install_requires=['cmd2 >= 0.9.4, <=2'], + setup_requires=['setuptools_scm >= 3.0'], + + classifiers=[ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Operating System :: OS Independent', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + ], + + # dependencies for development and testing + # $ pip install -e .[dev] + extras_require={ + 'test': [ + 'codecov', + 'coverage', + 'pytest', + 'pytest-cov', + ], + 'dev': ['setuptools_scm', 'pytest', 'codecov', 'pytest-cov', + 'pylint', 'invoke', 'wheel', 'twine'] + }, +) diff --git a/plugins/modular_cmd/tasks.py b/plugins/modular_cmd/tasks.py new file mode 100644 index 000000000..63de55c13 --- /dev/null +++ b/plugins/modular_cmd/tasks.py @@ -0,0 +1,202 @@ +# +# coding=utf-8 +# flake8: noqa E302 +"""Development related tasks to be run with 'invoke'. + +Make sure you satisfy the following Python module requirements if you are trying to publish a release to PyPI: + - twine >= 1.11.0 + - wheel >= 0.31.0 + - setuptools >= 39.1.0 +""" +import os +import pathlib +import shutil + +import invoke + +TASK_ROOT = pathlib.Path(__file__).resolve().parent +TASK_ROOT_STR = str(TASK_ROOT) + +# shared function +def rmrf(items, verbose=True): + """Silently remove a list of directories or files""" + if isinstance(items, str): + items = [items] + + for item in items: + if verbose: + print("Removing {}".format(item)) + shutil.rmtree(item, ignore_errors=True) + # rmtree doesn't remove bare files + try: + os.remove(item) + except FileNotFoundError: + pass + + +# create namespaces +namespace = invoke.Collection() +namespace_clean = invoke.Collection('clean') +namespace.add_collection(namespace_clean, 'clean') + +##### +# +# pytest, tox, pylint, and codecov +# +##### + + +@invoke.task +def pytest(context, junit=False, pty=True, append_cov=False): + """Run tests and code coverage using pytest""" + ROOT_PATH = TASK_ROOT.parent.parent + + with context.cd(str(ROOT_PATH)): + command_str = 'pytest --cov=cmd2_modular_cmds --cov-report=term --cov-report=html' + if append_cov: + command_str += ' --cov-append' + if junit: + command_str += ' --junitxml=junit/test-results.xml' + command_str += ' ' + str((TASK_ROOT/'tests').relative_to(ROOT_PATH)) + context.run(command_str, pty=pty) + + +namespace.add_task(pytest) + + +@invoke.task +def pytest_clean(context): + """Remove pytest cache and code coverage files and directories""" + # pylint: disable=unused-argument + with context.cd(TASK_ROOT_STR): + dirs = ['.pytest_cache', '.cache', 'htmlcov', '.coverage'] + rmrf(dirs) + + +namespace_clean.add_task(pytest_clean, 'pytest') + + +@invoke.task +def mypy(context): + """Run mypy optional static type checker""" + with context.cd(TASK_ROOT_STR): + context.run("mypy cmd2_modular_cmds") + namespace.add_task(mypy) + + +namespace.add_task(mypy) + + +@invoke.task +def mypy_clean(context): + """Remove mypy cache directory""" + # pylint: disable=unused-argument + with context.cd(TASK_ROOT_STR): + dirs = ['.mypy_cache', 'dmypy.json', 'dmypy.sock'] + rmrf(dirs) + + +namespace_clean.add_task(mypy_clean, 'mypy') + + +##### +# +# documentation +# +##### + + +##### +# +# build and distribute +# +##### +BUILDDIR = 'build' +DISTDIR = 'dist' + + +@invoke.task +def build_clean(context): + """Remove the build directory""" + # pylint: disable=unused-argument + with context.cd(TASK_ROOT_STR): + rmrf(BUILDDIR) + + +namespace_clean.add_task(build_clean, 'build') + + +@invoke.task +def dist_clean(context): + """Remove the dist directory""" + # pylint: disable=unused-argument + with context.cd(TASK_ROOT_STR): + rmrf(DISTDIR) + + +namespace_clean.add_task(dist_clean, 'dist') + + +# +# make a dummy clean task which runs all the tasks in the clean namespace +clean_tasks = list(namespace_clean.tasks.values()) +@invoke.task(pre=list(namespace_clean.tasks.values()), default=True) +def clean_all(context): + """Run all clean tasks""" + # pylint: disable=unused-argument + pass + + +namespace_clean.add_task(clean_all, 'all') + + +@invoke.task(pre=[clean_all]) +def sdist(context): + """Create a source distribution""" + with context.cd(TASK_ROOT_STR): + context.run('python setup.py sdist') + + +namespace.add_task(sdist) + + +@invoke.task(pre=[clean_all]) +def wheel(context): + """Build a wheel distribution""" + with context.cd(TASK_ROOT_STR): + context.run('python setup.py bdist_wheel') + + +namespace.add_task(wheel) + + +@invoke.task(pre=[sdist, wheel]) +def pypi(context): + """Build and upload a distribution to pypi""" + with context.cd(TASK_ROOT_STR): + context.run('twine upload dist/*') + + +namespace.add_task(pypi) + + +@invoke.task(pre=[sdist, wheel]) +def pypi_test(context): + """Build and upload a distribution to https://test.pypi.org""" + with context.cd(TASK_ROOT_STR): + context.run('twine upload --repository-url https://test.pypi.org/legacy/ dist/*') + + +namespace.add_task(pypi_test) + + +# Flake8 - linter and tool for style guide enforcement and linting +@invoke.task +def flake8(context): + """Run flake8 linter and tool for style guide enforcement""" + with context.cd(TASK_ROOT_STR): + context.run("flake8 --ignore=E252,W503 --max-complexity=26 --max-line-length=127 --show-source --statistics " + "--exclude=.git,__pycache__,.tox,.nox,.eggs,*.egg,.venv,.idea,.pytest_cache,.vscode,build,dist,htmlcov") + + +namespace.add_task(flake8) diff --git a/plugins/modular_cmd/tests/__init__.py b/plugins/modular_cmd/tests/__init__.py new file mode 100644 index 000000000..eb198dc04 --- /dev/null +++ b/plugins/modular_cmd/tests/__init__.py @@ -0,0 +1,2 @@ +# +# empty file to create a package diff --git a/plugins/modular_cmd/tests/conftest.py b/plugins/modular_cmd/tests/conftest.py new file mode 100644 index 000000000..5b1a6f05b --- /dev/null +++ b/plugins/modular_cmd/tests/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/plugins/modular_cmd/tests/test_commandset.py b/plugins/modular_cmd/tests/test_commandset.py new file mode 100644 index 000000000..cdfd9b38b --- /dev/null +++ b/plugins/modular_cmd/tests/test_commandset.py @@ -0,0 +1,367 @@ +# coding=utf-8 +# flake8: noqa E302 +""" +Test CommandSet +""" + +from typing import List + +import pytest + +import cmd2 +from cmd2 import utils +from cmd2_modular_cmds import ModularCommandsMixin, CommandSet, register_command, with_default_category, command_definition + +from .conftest import complete_tester, normalize, run_cmd + + +@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)) + + +@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) + + +@with_default_category('Command Set') +class CommandSetA(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 WithModularCommands(ModularCommandsMixin, cmd2.Cmd): + """Class for testing custom help_* methods which override docstring help.""" + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +@with_default_category('Command Set B') +class CommandSetB(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 = WithModularCommands() + return app + + +@pytest.fixture() +def command_sets_manual(): + app = WithModularCommands(auto_load_commands=False) + return app + + +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 + 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'] + + +def test_custom_construct_commandsets(): + # Verifies that a custom initialized CommandSet loads correctly when passed into the constructor + command_set = CommandSetB('foo') + app = WithModularCommands(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() + + # 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 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() + + 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'] + + 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 + + # 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): + """Documentation Comment""" + print('Do stuff {} - {}'.format(arg1, arg2)) + + my_partial = 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 8a119adc57f92c6fa56f23570fcbe2042dd0b28b Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Thu, 16 Jul 2020 18:57:53 -0400 Subject: [PATCH 2/6] Hooked up tests with nox. --- .coveragerc | 3 ++- noxfile.py | 11 +++++++---- plugins/modular_cmd/setup.py | 1 + plugins/tasks.py | 3 ++- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.coveragerc b/.coveragerc index 96bf5d72f..ad8b6730a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,7 +1,8 @@ # .coveragerc to control coverage.py [run] # Source -source = cmd2/ +source = plugins/*/cmd2_*/ + cmd2/ # (boolean, default False): whether to measure branch coverage in addition to statement coverage. branch = False diff --git a/noxfile.py b/noxfile.py index 1821c127a..e3de4631c 100644 --- a/noxfile.py +++ b/noxfile.py @@ -3,7 +3,7 @@ @nox.session(python=['3.7']) def docs(session): - session.install('sphinx', 'sphinx-rtd-theme', '.') + session.install('sphinx', 'sphinx-rtd-theme', '.', './plugins/modular_cmd/') session.chdir('docs') tmpdir = session.create_tmp() @@ -12,16 +12,19 @@ def docs(session): @nox.session(python=['3.5', '3.6', '3.7', '3.8', '3.9']) -@nox.parametrize('plugin', [None, 'ext_test', 'template']) +@nox.parametrize('plugin', [None, 'ext_test', 'template', 'modular_cmd', 'coverage']) def tests(session, plugin): if plugin is None: session.install('invoke', './[test]') session.run('invoke', 'pytest', '--junit', '--no-pty') + elif plugin == 'coverage': + session.install('invoke', 'codecov', 'coverage') + session.run('codecov') else: session.install('invoke', '.') # cd into test directory to run other unit test session.install('plugins/{}[test]'.format(plugin)) - session.run('invoke', 'plugin.{}.pytest'.format(plugin.replace('_', '-')), '--junit', '--no-pty') + session.run('invoke', 'plugin.{}.pytest'.format(plugin.replace('_', '-')), '--junit', '--no-pty', '--append-cov') - session.run('codecov') + # session.run('codecov') diff --git a/plugins/modular_cmd/setup.py b/plugins/modular_cmd/setup.py index b0405ff10..6c4b4c667 100644 --- a/plugins/modular_cmd/setup.py +++ b/plugins/modular_cmd/setup.py @@ -55,6 +55,7 @@ # $ pip install -e .[dev] extras_require={ 'test': [ + "gnureadline; sys_platform=='darwin'", # include gnureadline on macOS to ensure it is available in tox env 'codecov', 'coverage', 'pytest', diff --git a/plugins/tasks.py b/plugins/tasks.py index 061044294..932236efe 100644 --- a/plugins/tasks.py +++ b/plugins/tasks.py @@ -13,9 +13,10 @@ from plugins.ext_test import tasks as ext_test_tasks from plugins.template import tasks as template_tasks +from plugins.modular_cmd import tasks as modular_cmd_tasks # create namespaces -namespace = invoke.Collection(ext_test=ext_test_tasks, template=template_tasks) +namespace = invoke.Collection(ext_test=ext_test_tasks, template=template_tasks, modular_cmd=modular_cmd_tasks) namespace_clean = invoke.Collection('clean') namespace.add_collection(namespace_clean, 'clean') From 21e5eee05d8c48e619790f5e8298d801a4a147d4 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Fri, 17 Jul 2020 16:42:17 -0400 Subject: [PATCH 3/6] Updated some initialization to match new approach. Tagged some IDE-only lines as no-cover --- cmd2/__init__.py | 3 ++- plugins/ext_test/cmd2_ext_test/__init__.py | 4 ++-- plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py | 2 +- plugins/modular_cmd/cmd2_modular_cmds/__init__.py | 4 ++-- .../modular_cmd/cmd2_modular_cmds/modular_mixin.py | 2 +- plugins/template/cmd2_myplugin/__init__.py | 13 +++++++++---- plugins/template/cmd2_myplugin/myplugin.py | 2 +- 7 files changed, 18 insertions(+), 12 deletions(-) diff --git a/cmd2/__init__.py b/cmd2/__init__.py index 8c07fb808..c3c1f87e7 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -11,7 +11,7 @@ import importlib_metadata try: __version__ = importlib_metadata.version(__name__) -except importlib_metadata.PackageNotFoundError: +except importlib_metadata.PackageNotFoundError: # pragma: no cover # package is not installed pass @@ -31,6 +31,7 @@ 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 +from . import plugin from .parsing import Statement from .py_bridge import CommandResult from .utils import categorize, CompletionError, Settable diff --git a/plugins/ext_test/cmd2_ext_test/__init__.py b/plugins/ext_test/cmd2_ext_test/__init__.py index 555d2b622..21fd000b3 100644 --- a/plugins/ext_test/cmd2_ext_test/__init__.py +++ b/plugins/ext_test/cmd2_ext_test/__init__.py @@ -8,12 +8,12 @@ try: # For python 3.8 and later import importlib.metadata as importlib_metadata -except ImportError: +except ImportError: # pragma: no cover # For everyone else import importlib_metadata try: __version__ = importlib_metadata.version(__name__) -except importlib_metadata.PackageNotFoundError: +except importlib_metadata.PackageNotFoundError: # pragma: no cover # package is not installed __version__ = 'unknown' 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 731a0f3ba..df54e1129 100644 --- a/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py +++ b/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py @@ -6,7 +6,7 @@ import cmd2 -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover _Base = cmd2.Cmd else: _Base = object diff --git a/plugins/modular_cmd/cmd2_modular_cmds/__init__.py b/plugins/modular_cmd/cmd2_modular_cmds/__init__.py index 62bec8f9e..ad2a2e021 100644 --- a/plugins/modular_cmd/cmd2_modular_cmds/__init__.py +++ b/plugins/modular_cmd/cmd2_modular_cmds/__init__.py @@ -8,12 +8,12 @@ try: # For python 3.8 and later import importlib.metadata as importlib_metadata -except ImportError: +except ImportError: # pragma: no cover # For everyone else import importlib_metadata try: __version__ = importlib_metadata.version(__name__) -except importlib_metadata.PackageNotFoundError: +except importlib_metadata.PackageNotFoundError: # pragma: no cover # package is not installed __version__ = 'unknown' diff --git a/plugins/modular_cmd/cmd2_modular_cmds/modular_mixin.py b/plugins/modular_cmd/cmd2_modular_cmds/modular_mixin.py index a4d34df6f..a91e27c3b 100644 --- a/plugins/modular_cmd/cmd2_modular_cmds/modular_mixin.py +++ b/plugins/modular_cmd/cmd2_modular_cmds/modular_mixin.py @@ -17,7 +17,7 @@ from .command_definition import CommandSet, _REGISTERED_COMMANDS, _partial_passthru -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover _Base = cmd2.Cmd else: _Base = object diff --git a/plugins/template/cmd2_myplugin/__init__.py b/plugins/template/cmd2_myplugin/__init__.py index 41f0b9ccd..e66b62cdb 100644 --- a/plugins/template/cmd2_myplugin/__init__.py +++ b/plugins/template/cmd2_myplugin/__init__.py @@ -5,11 +5,16 @@ An overview of what myplugin does. """ -from pkg_resources import get_distribution, DistributionNotFound - from .myplugin import empty_decorator, MyPluginMixin # noqa: F401 try: - __version__ = get_distribution(__name__).version -except DistributionNotFound: + # For python 3.8 and later + import importlib.metadata as importlib_metadata +except ImportError: # pragma: no cover + # For everyone else + import importlib_metadata +try: + __version__ = importlib_metadata.version(__name__) +except importlib_metadata.PackageNotFoundError: # pragma: no cover + # package is not installed __version__ = 'unknown' diff --git a/plugins/template/cmd2_myplugin/myplugin.py b/plugins/template/cmd2_myplugin/myplugin.py index 5fa12cafd..4f1ff0e99 100644 --- a/plugins/template/cmd2_myplugin/myplugin.py +++ b/plugins/template/cmd2_myplugin/myplugin.py @@ -7,7 +7,7 @@ import cmd2 -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover _Base = cmd2.Cmd else: _Base = object From a8535d749fd6381368be3cad324f3bbfe2485cf2 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Fri, 17 Jul 2020 18:49:32 -0400 Subject: [PATCH 4/6] 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 a8d2490aefb2d9df90d0e6160d32db62aaa70238 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Sat, 18 Jul 2020 16:51:46 -0400 Subject: [PATCH 5/6] Added an additional check for isinstance(method, Callable) since there are scenarios where inspect.ismethod() fails for some reason --- plugins/modular_cmd/cmd2_modular_cmds/modular_mixin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/modular_cmd/cmd2_modular_cmds/modular_mixin.py b/plugins/modular_cmd/cmd2_modular_cmds/modular_mixin.py index a91e27c3b..f87f438ee 100644 --- a/plugins/modular_cmd/cmd2_modular_cmds/modular_mixin.py +++ b/plugins/modular_cmd/cmd2_modular_cmds/modular_mixin.py @@ -87,7 +87,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 4a92c3cce4559c6fc16bda7720bb95b68c5d96b1 Mon Sep 17 00:00:00 2001 From: Eric Lin Date: Sat, 18 Jul 2020 17:09:06 -0400 Subject: [PATCH 6/6] 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