diff options
-rw-r--r-- | cmd2/cmd2.py | 5 | ||||
-rw-r--r-- | cmd2/command_definition.py | 2 | ||||
-rw-r--r-- | cmd2/decorators.py | 5 | ||||
-rw-r--r-- | docs/features/commands.rst | 12 | ||||
-rw-r--r-- | docs/features/index.rst | 1 | ||||
-rw-r--r-- | docs/features/modular_commands.rst | 201 | ||||
-rw-r--r-- | examples/modular_commands/commandset_custominit.py | 2 | ||||
-rw-r--r-- | examples/modular_commands_basic.py | 37 | ||||
-rw-r--r-- | examples/modular_commands_dynamic.py | 86 | ||||
-rw-r--r-- | examples/modular_commands_main.py | 5 | ||||
-rwxr-xr-x | examples/plumbum_colors.py | 3 | ||||
-rw-r--r-- | isolated_tests/__init__.py | 0 | ||||
-rw-r--r-- | isolated_tests/test_commandset/test_commandset.py | 1 | ||||
-rw-r--r-- | noxfile.py | 4 | ||||
-rw-r--r-- | plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py | 2 | ||||
-rw-r--r-- | plugins/template/cmd2_myplugin/__init__.py | 2 | ||||
-rw-r--r-- | plugins/template/cmd2_myplugin/myplugin.py | 2 | ||||
-rw-r--r-- | plugins/template/setup.py | 1 | ||||
-rw-r--r-- | plugins/template/tasks.py | 1 | ||||
-rw-r--r-- | plugins/template/tests/test_myplugin.py | 2 | ||||
-rw-r--r-- | tasks.py | 27 | ||||
-rw-r--r-- | tox.ini | 21 |
22 files changed, 374 insertions, 48 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index affd395f..ca60a461 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -37,7 +37,6 @@ import pickle 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 @@ class Cmd(cmd.Cmd): 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 0645de2a..1858c80b 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 aad44ac4..8c3739f1 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 13a4ac1f..8e61a472 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 efc0fe67..48590b6a 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 00000000..d94e225a --- /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 fa26644b..5a574a59 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 00000000..9f4a0bd2 --- /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 00000000..81dbad82 --- /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 fd10d8d3..b698e00f 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 typing import Dict, Iterable, List, Optional 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 ed65f245..a30e4c70 100755 --- a/examples/plumbum_colors.py +++ b/examples/plumbum_colors.py @@ -27,10 +27,9 @@ WARNING: This example requires the plumbum package, which isn't normally require """ 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 00000000..e69de29b --- /dev/null +++ b/isolated_tests/__init__.py diff --git a/isolated_tests/test_commandset/test_commandset.py b/isolated_tests/test_commandset/test_commandset.py index 023ea30d..c94c6690 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 - @@ -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 df54e112..b1827f02 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 e66b62cd..838d828a 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 4f1ff0e9..816198b0 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 17d06fa8..cb1dfd8e 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 3fcb4cbf..dcde1804 100644 --- a/plugins/template/tasks.py +++ b/plugins/template/tasks.py @@ -8,7 +8,6 @@ import shutil 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 4149f7df..d61181a6 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 ###### # @@ -52,21 +52,28 @@ namespace.add_collection(namespace_clean, 'clean') @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 ec8ccbc7..00000000 --- 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 |