summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEric Lin <anselor@gmail.com>2020-07-24 12:21:43 -0400
committeranselor <anselor@gmail.com>2020-08-04 13:38:08 -0400
commit06cee9126839c465a356f8b44a5f008853eb8cad (patch)
tree88de1a9f07f20fb6a7e1a8f77b1c48fb41382d19
parent787a31931ed4c4a18ae66a570d396b12b2b7b525 (diff)
downloadcmd2-git-06cee9126839c465a356f8b44a5f008853eb8cad.tar.gz
updated imports
Added additional documentation
-rw-r--r--cmd2/cmd2.py5
-rw-r--r--cmd2/command_definition.py2
-rw-r--r--cmd2/decorators.py5
-rw-r--r--docs/features/commands.rst12
-rw-r--r--docs/features/index.rst1
-rw-r--r--docs/features/modular_commands.rst201
-rw-r--r--examples/modular_commands/commandset_custominit.py2
-rw-r--r--examples/modular_commands_basic.py37
-rw-r--r--examples/modular_commands_dynamic.py86
-rw-r--r--examples/modular_commands_main.py5
-rwxr-xr-xexamples/plumbum_colors.py3
-rw-r--r--isolated_tests/__init__.py0
-rw-r--r--isolated_tests/test_commandset/test_commandset.py1
-rw-r--r--noxfile.py4
-rw-r--r--plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py2
-rw-r--r--plugins/template/cmd2_myplugin/__init__.py2
-rw-r--r--plugins/template/cmd2_myplugin/myplugin.py2
-rw-r--r--plugins/template/setup.py1
-rw-r--r--plugins/template/tasks.py1
-rw-r--r--plugins/template/tests/test_myplugin.py2
-rw-r--r--tasks.py27
-rw-r--r--tox.ini21
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
-
diff --git a/noxfile.py b/noxfile.py
index df4e97aa..ec8a16e2 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -22,7 +22,9 @@ def docs(session):
def tests(session, plugin):
if plugin is None:
session.install('invoke', './[test]')
- session.run('invoke', 'pytest', '--junit', '--no-pty')
+ session.run('invoke', 'pytest', '--junit', '--no-pty', '--base')
+ session.install('./plugins/ext_test/')
+ session.run('invoke', 'pytest', '--junit', '--no-pty', '--isolated')
elif plugin == 'coverage':
session.install('invoke', 'codecov', 'coverage')
session.run('codecov')
diff --git a/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py b/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py
index 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
######
#
diff --git a/tasks.py b/tasks.py
index 811e3245..eef310ec 100644
--- a/tasks.py
+++ b/tasks.py
@@ -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