summaryrefslogtreecommitdiff
path: root/plugins/template
diff options
context:
space:
mode:
authorEric Lin <anselor@gmail.com>2020-07-13 15:28:40 -0400
committeranselor <anselor@gmail.com>2020-07-14 19:26:30 -0400
commite38684d219847f636562ab2720b82aae4a6fd408 (patch)
treeec20d7a46b8cba1a662d46e79d5e004590bee93b /plugins/template
parent683d049299c0cf7a2821b639a95ad0911bab1bc7 (diff)
downloadcmd2-git-e38684d219847f636562ab2720b82aae4a6fd408.tar.gz
Brought in cmd2 plugin template as a first-class member of cmd2 proper
Diffstat (limited to 'plugins/template')
-rw-r--r--plugins/template/CHANGELOG.md12
-rw-r--r--plugins/template/LICENSE21
-rw-r--r--plugins/template/README.md329
-rw-r--r--plugins/template/build-pyenvs.sh53
-rw-r--r--plugins/template/cmd2_myplugin/__init__.py15
-rw-r--r--plugins/template/cmd2_myplugin/myplugin.py69
-rw-r--r--plugins/template/cmd2_myplugin/pylintrc10
-rw-r--r--plugins/template/examples/example.py21
-rw-r--r--plugins/template/setup.py59
-rw-r--r--plugins/template/tasks.py203
-rw-r--r--plugins/template/tests/__init__.py2
-rw-r--r--plugins/template/tests/pylintrc19
-rw-r--r--plugins/template/tests/test_myplugin.py67
13 files changed, 880 insertions, 0 deletions
diff --git a/plugins/template/CHANGELOG.md b/plugins/template/CHANGELOG.md
new file mode 100644
index 00000000..adf951ec
--- /dev/null
+++ b/plugins/template/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).
+
+## 1.0.0 (2018-07-24)
+
+### Added
+- Created plugin template and initial documentation
+
+
diff --git a/plugins/template/LICENSE b/plugins/template/LICENSE
new file mode 100644
index 00000000..b1784d5d
--- /dev/null
+++ b/plugins/template/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2018 Jared Crapo
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/plugins/template/README.md b/plugins/template/README.md
new file mode 100644
index 00000000..327a226e
--- /dev/null
+++ b/plugins/template/README.md
@@ -0,0 +1,329 @@
+# cmd2 Plugin Template
+
+## Table of Contents
+
+- [Using this template](#using-this-template)
+- [Naming](#naming)
+- [Adding functionality](#adding-functionality)
+- [Examples](#examples)
+- [Development Tasks](#development-tasks)
+- [Packaging and Distribution](#packaging-and-distribution)
+- [License](#license)
+
+
+## Using this template
+
+This template assumes you are creating a new cmd2 plugin called `myplugin`. Your
+plugin will have a different name. You will need to rename some of the files and
+directories in this template. Don't forget to modify the imports and `setup.py`.
+
+You'll probably also want to rewrite the README :)
+
+
+## Naming
+
+You should prefix the name of your project with `cmd2-`. Within that project,
+you should have a package with a prefix of `cmd2_`.
+
+
+## Adding functionality
+
+There are many ways to add functionality to `cmd2` using a plugin. Most plugins
+will be implemented as a mixin. A mixin is a class that encapsulates and injects
+code into another class. Developers who use a plugin in their `cmd2` project,
+will inject the plugin's code into their subclass of `cmd2.Cmd`.
+
+
+### Mixin and Initialization
+
+The following short example shows how to mix in a plugin and how the plugin
+gets initialized.
+
+Here's the plugin:
+
+```python
+class MyPlugin:
+ def __init__(self, *args, **kwargs):
+ # code placed here runs before cmd2.Cmd initializes
+ super().__init__(*args, **kwargs)
+ # code placed here runs after cmd2.Cmd initializes
+```
+
+and an example app which uses the plugin:
+
+```python
+import cmd2
+import cmd2_myplugin
+
+class Example(cmd2_myplugin.MyPlugin, cmd2.Cmd):
+ """An class to show how to use a plugin"""
+ def __init__(self, *args, **kwargs):
+ # code placed here runs before cmd2.Cmd or
+ # any plugins initialize
+ super().__init__(*args, **kwargs)
+ # code placed here runs after cmd2.Cmd and
+ # all plugins have initialized
+```
+
+Note how the plugin must be inherited (or mixed in) before `cmd2.Cmd`. This is
+required for two reasons:
+
+- The `cmd.Cmd.__init__()` method in the python standard library does not call
+ `super().__init__()`. Because of this oversight, if you don't inherit from `MyPlugin` first, the
+ `MyPlugin.__init__()` method will never be called.
+- You may want your plugin to be able to override methods from `cmd2.Cmd`.
+ If you mixin the plugin after `cmd2.Cmd`, the python method resolution order
+ will call `cmd2.Cmd` methods before it calls those in your plugin.
+
+
+### Add commands
+
+Your plugin can add user visable commands. You do it the same way in a plugin
+that you would in a `cmd2.Cmd` app:
+
+```python
+class MyPlugin:
+
+ def do_say(self, statement):
+ """Simple say command"""
+ self.poutput(statement)
+```
+
+You have all the same capabilities within the plugin that you do inside a
+`cmd2.Cmd` app, including argument parsing via decorators and custom help
+methods.
+
+### Add (or hide) settings
+
+A plugin may add user controllable settings to the application. Here's an
+example:
+
+```python
+class MyPlugin:
+ def __init__(self, *args, **kwargs):
+ # code placed here runs before cmd2.Cmd initializes
+ super().__init__(*args, **kwargs)
+ # code placed here runs after cmd2.Cmd initializes
+ self.mysetting = 'somevalue'
+ self.settable.update({'mysetting': 'short help message for mysetting'})
+```
+
+You can also hide settings from the user by removing them from `self.settable`.
+
+### Decorators
+
+Your plugin can provide a decorator which users of your plugin can use to wrap
+functionality around their own commands.
+
+### Override methods
+
+Your plugin can override core `cmd2.Cmd` methods, changing their behavior.
+This approach should be used sparingly, because it is very brittle. If a
+developer chooses to use multiple plugins in their application, and several of
+the plugins override the same method, only the first plugin to be mixed in
+will have the overridden method called.
+
+Hooks are a much better approach.
+
+### Hooks
+
+Plugins can register hooks, which are called by `cmd2.Cmd` during various points
+in the application and command processing lifecycle. Plugins should not override
+any of the deprecated hook methods, instead they should register their hooks as
+[described](https://cmd2.readthedocs.io/en/latest/hooks.html) in the cmd2
+documentation.
+
+You should name your hooks so that they begin with the name of your plugin. Hook
+methods get mixed into the `cmd2` application and this naming convention helps
+avoid unintentional method overriding.
+
+
+Here's a simple example:
+
+```python
+class MyPlugin:
+
+ def __init__(self, *args, **kwargs):
+ # code placed here runs before cmd2 initializes
+ super().__init__(*args, **kwargs)
+ # code placed here runs after cmd2 initializes
+ # this is where you register any hook functions
+ self.register_postparsing_hook(self.cmd2_myplugin_postparsing_hook)
+
+ def cmd2_myplugin_postparsing_hook(self, data: cmd2.plugin.PostparsingData) -> cmd2.plugin.PostparsingData:
+ """Method to be called after parsing user input, but before running the command"""
+ self.poutput('in postparsing_hook')
+ return data
+```
+
+Registration allows multiple plugins (or even the application itself) to each inject code
+to be called during the application or command processing lifecycle.
+
+See the [cmd2 hook documentation](https://cmd2.readthedocs.io/en/latest/hooks.html)
+for full details of the application and command lifecycle, including all
+available hooks and the ways hooks can influence the lifecycle.
+
+
+### Classes and Functions
+
+Your plugin can also provide classes and functions which can be used by
+developers of cmd2 based applications. Describe these classes and functions in
+your documentation so users of your plugin will know what's available.
+
+
+## Examples
+
+Include an example or two in the `examples` directory which demonstrate how your
+plugin works. This will help developers utilize it from within their
+application.
+
+
+## Development Tasks
+
+This project uses many other python modules for various development tasks,
+including testing, linting, building wheels, and distributing releases. These
+modules can be configured many different ways, which can make it difficult to
+learn the specific incantations required for each project you are familiar with.
+
+This project uses [invoke](<http://www.pyinvoke.org>) to provide a clean,
+high level interface for these development tasks. To see the full list of
+functions available:
+```
+$ invoke -l
+```
+
+You can run multiple tasks in a single invocation, for example:
+```
+$ invoke clean docs sdist wheel
+```
+
+That one command will remove all superflous cache, testing, and build
+files, render the documentation, and build a source distribution and a
+wheel distribution.
+
+For more information, read `tasks.py`.
+
+While developing your plugin, you should make sure you support all versions of
+python supported by cmd2, and all supported platforms. cmd2 uses a three
+tiered testing strategy to accomplish this objective.
+
+- [pytest](https://pytest.org) runs the unit tests
+- [tox](https://tox.readthedocs.io/) runs the unit tests on multiple versions
+ of python
+- [AppVeyor](https://www.appveyor.com/) and [TravisCI](https://travis-ci.com)
+ run the tests on the various supported platforms
+
+This plugin template is set up to use the same strategy.
+
+
+### Create python environments
+
+This project uses [tox](https://tox.readthedocs.io/en/latest/) to run the test
+suite against multiple python versions. I recommend
+[pyenv](https://github.com/pyenv/pyenv) with the
+[pyenv-virtualenv](https://github.com/pyenv/pyenv-virtualenv>) plugin to manage
+these various versions. If you are a Windows user, `pyenv` won't work for you,
+but [conda](https://conda.io/) can also be used to solve this problem.
+
+This distribution includes a shell script `build-pyenvs.sh` which
+automates the creation of these environments.
+
+If you prefer to create these virtualenvs by hand, do the following:
+```
+$ cd cmd2_abbrev
+$ pyenv install 3.7.0
+$ pyenv virtualenv -p python3.7 3.7.0 cmd2-3.7
+$ pyenv install 3.6.5
+$ pyenv virtualenv -p python3.6 3.6.5 cmd2-3.6
+$ pyenv install 3.5.5
+$ pyenv virtualenv -p python3.5 3.5.5 cmd2-3.5
+$ pyenv install 3.4.8
+$ pyenv virtualenv -p python3.4 3.4.8 cmd2-3.4
+```
+
+Now set pyenv to make all three of those available at the same time:
+```
+$ pyenv local cmd2-3.7 cmd2-3.6 cmd2-3.5 cmd2-3.4
+```
+
+Whether you ran the script, or did it by hand, you now have isolated virtualenvs
+for each of the major python versions. This table shows various python commands,
+the version of python which will be executed, and the virtualenv it will
+utilize.
+
+| Command | python | virtualenv |
+| ----------- | ------ | ---------- |
+| `python` | 3.7.0 | cmd2-3.6 |
+| `python3` | 3.7.0 | cmd2-3.6 |
+| `python3.7` | 3.7.0 | cmd2-3.7 |
+| `python3.6` | 3.6.5 | cmd2-3.6 |
+| `python3.5` | 3.5.5 | cmd2-3.5 |
+| `python3.4` | 3.4.8 | cmd2-3.4 |
+| `pip` | 3.7.0 | cmd2-3.6 |
+| `pip3` | 3.7.0 | cmd2-3.6 |
+| `pip3.7` | 3.7.0 | cmd2-3.7 |
+| `pip3.6` | 3.6.5 | cmd2-3.6 |
+| `pip3.5` | 3.5.5 | cmd2-3.5 |
+| `pip3.4` | 3.4.8 | cmd2-3.4 |
+
+## Install Dependencies
+
+Install all the development dependencies:
+```
+$ pip install -e .[dev]
+```
+
+This command also installs `cmd2-myplugin` "in-place", so the package points to
+the source code instead of copying files to the python `site-packages` folder.
+
+All the dependencies now have been installed in the `cmd2-3.7`
+virtualenv. If you want to work in other virtualenvs, you'll need to manually
+select it, and install again::
+
+ $ pyenv shell cmd2-3.4
+ $ pip install -e .[dev]
+
+Now that you have your python environments created, you need to install the
+package in place, along with all the other development dependencies:
+```
+$ pip install -e .[dev]
+```
+
+
+### Running unit tests
+
+Run `invoke pytest` from the top level directory of your plugin to run all the
+unit tests found in the `tests` directory.
+
+
+### Use tox to run unit tests in multiple versions of python
+
+The included `tox.ini` is setup to run the unit tests in python 3.4, 3.5, 3.6,
+and 3.7. You can run your unit tests in all of these versions of python by:
+```
+$ invoke tox
+```
+
+
+### Run unit tests on multiple platforms
+
+[AppVeyor](https://github.com/marketplace/appveyor) and
+[TravisCI](https://docs.travis-ci.com/user/getting-started/) offer free plans
+for open source projects.
+
+
+## Packaging and Distribution
+
+When creating your `setup.py` file, keep the following in mind:
+
+- use the keywords `cmd2 plugin` to make it easier for people to find your plugin
+- since cmd2 uses semantic versioning, you should use something like
+ `install_requires=['cmd2 >= 0.9.4, <=2']` to make sure that your plugin
+ doesn't try and run with a future version of `cmd2` with which it may not be
+ compatible
+
+
+## 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/template/build-pyenvs.sh b/plugins/template/build-pyenvs.sh
new file mode 100644
index 00000000..39c28aa1
--- /dev/null
+++ b/plugins/template/build-pyenvs.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+#
+
+# create pyenv environments for each minor version of python
+# supported by this project
+#
+# this script uses terms from Semantic Versioning https://semver.org/
+# version numbers are: major.minor.patch
+#
+# this script will delete and recreate existing virtualenvs named
+# cmd2-3.7, etc. It will also create a .python-version
+#
+# Prerequisites:
+# - *nix-ish environment like macOS or Linux
+# - pyenv installed
+# - pyenv-virtualenv installed
+# - readline and openssl libraries installed so pyenv can
+# build pythons
+#
+
+# Make a array of the python minor versions we want to install.
+# Order matters in this list, because it's the order that the
+# virtualenvs will be added to '.python-version'. Feel free to modify
+# this list, but note that this script intentionally won't install
+# dev, rc, or beta python releases
+declare -a pythons=("3.7" "3.6" "3.5" "3.4")
+
+# function to find the latest patch of a minor version of python
+function find_latest_version {
+ pyenv install -l | \
+ sed -En -e "s/^ *//g" -e "/(dev|b|rc)/d" -e "/^$1/p" | \
+ tail -1
+}
+
+# empty out '.python-version'
+> .python-version
+
+# loop through the pythons
+for minor_version in "${pythons[@]}"
+do
+ patch_version=$( find_latest_version "$minor_version" )
+ # use pyenv to install the latest versions of python
+ # if it's already installed don't install it again
+ pyenv install -s "$patch_version"
+
+ envname="cmd2-$minor_version"
+ # remove the associated virtualenv
+ pyenv uninstall -f "$envname"
+ # create a new virtualenv
+ pyenv virtualenv -p "python$minor_version" "$patch_version" "$envname"
+ # append the virtualenv to .python-version
+ echo "$envname" >> .python-version
+done
diff --git a/plugins/template/cmd2_myplugin/__init__.py b/plugins/template/cmd2_myplugin/__init__.py
new file mode 100644
index 00000000..41f0b9cc
--- /dev/null
+++ b/plugins/template/cmd2_myplugin/__init__.py
@@ -0,0 +1,15 @@
+#
+# coding=utf-8
+"""Description of myplugin
+
+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:
+ __version__ = 'unknown'
diff --git a/plugins/template/cmd2_myplugin/myplugin.py b/plugins/template/cmd2_myplugin/myplugin.py
new file mode 100644
index 00000000..5fa12caf
--- /dev/null
+++ b/plugins/template/cmd2_myplugin/myplugin.py
@@ -0,0 +1,69 @@
+#
+# coding=utf-8
+"""An example cmd2 plugin"""
+
+import functools
+from typing import Callable, TYPE_CHECKING
+
+import cmd2
+
+if TYPE_CHECKING:
+ _Base = cmd2.Cmd
+else:
+ _Base = object
+
+
+def empty_decorator(func: Callable) -> Callable:
+ """An empty decorator for myplugin"""
+
+ @functools.wraps(func)
+ def _empty_decorator(self, *args, **kwargs):
+ self.poutput("in the empty decorator")
+ func(self, *args, **kwargs)
+
+ _empty_decorator.__doc__ = func.__doc__
+ return _empty_decorator
+
+
+class MyPluginMixin(_Base):
+ """A mixin class which adds a 'say' command to a cmd2 subclass
+
+ The order in which you add the mixin matters. Say you want to
+ use this mixin in a class called MyApp.
+
+ class MyApp(cmd2_myplugin.MyPlugin, cmd2.Cmd):
+ def __init__(self, *args, **kwargs):
+ # gotta have this or neither the plugin or cmd2 will initialize
+ super().__init__(*args, **kwargs)
+ """
+
+ def __init__(self, *args, **kwargs):
+ # code placed here runs before cmd2 initializes
+ super().__init__(*args, **kwargs)
+ # code placed here runs after cmd2 initializes
+ # this is where you register any hook functions
+ self.register_preloop_hook(self.cmd2_myplugin_preloop_hook)
+ self.register_postloop_hook(self.cmd2_myplugin_postloop_hook)
+ self.register_postparsing_hook(self.cmd2_myplugin_postparsing_hook)
+
+ def do_say(self, statement):
+ """Simple say command"""
+ self.poutput(statement)
+
+ #
+ # define hooks as functions, not methods
+ def cmd2_myplugin_preloop_hook(self) -> None:
+ """Method to be called before the command loop begins"""
+ self.poutput("preloop hook")
+
+ def cmd2_myplugin_postloop_hook(self) -> None:
+ """Method to be called after the command loop finishes"""
+ self.poutput("postloop hook")
+
+ def cmd2_myplugin_postparsing_hook(
+ self,
+ data: cmd2.plugin.PostparsingData
+ ) -> cmd2.plugin.PostparsingData:
+ """Method to be called after parsing user input, but before running the command"""
+ self.poutput('in postparsing hook')
+ return data
diff --git a/plugins/template/cmd2_myplugin/pylintrc b/plugins/template/cmd2_myplugin/pylintrc
new file mode 100644
index 00000000..2f6d3de2
--- /dev/null
+++ b/plugins/template/cmd2_myplugin/pylintrc
@@ -0,0 +1,10 @@
+#
+# pylint configuration
+#
+# $ pylint --rcfile=cmd2_myplugin/pylintrc cmd2_myplugin
+#
+
+[messages control]
+# too-few-public-methods pylint expects a class to have at
+# least two public methods
+disable=too-few-public-methods
diff --git a/plugins/template/examples/example.py b/plugins/template/examples/example.py
new file mode 100644
index 00000000..2c9b8e5c
--- /dev/null
+++ b/plugins/template/examples/example.py
@@ -0,0 +1,21 @@
+#
+# coding=utf-8
+
+import cmd2
+import cmd2_myplugin
+
+
+class Example(cmd2_myplugin.MyPlugin, 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)
+
+ @cmd2_myplugin.empty_decorator
+ def do_something(self, arg):
+ self.poutput('this is the something command')
+
+
+if __name__ == '__main__':
+ app = Example()
+ app.cmdloop()
diff --git a/plugins/template/setup.py b/plugins/template/setup.py
new file mode 100644
index 00000000..17d06fa8
--- /dev/null
+++ b/plugins/template/setup.py
@@ -0,0 +1,59 @@
+#
+# 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-myplugin',
+ # use_scm_version=True, # use_scm_version doesn't work if setup.py isn't in the repository root
+ version='1.0.1',
+
+ description='A template used to build plugins for cmd2',
+ long_description=long_description,
+ long_description_content_type='text/markdown',
+ keywords='cmd2 plugin',
+
+ author='Kotfu',
+ author_email='kotfu@kotfu.net',
+ url='https://github.com/python-cmd2/cmd2-plugin-template',
+ license='MIT',
+
+ packages=['cmd2_myplugin'],
+
+ python_requires='>=3.4',
+ install_requires=['cmd2 >= 0.9.4, <=2'],
+ setup_requires=['setuptools_scm'],
+
+ 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/template/tasks.py b/plugins/template/tasks.py
new file mode 100644
index 00000000..3fcb4cbf
--- /dev/null
+++ b/plugins/template/tasks.py
@@ -0,0 +1,203 @@
+#
+# -*- coding: utf-8 -*-
+"""Development related tasks to be run with 'invoke'"""
+
+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_myplugin --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', '.coverage']
+ rmrf(dirs)
+
+
+namespace_clean.add_task(pytest_clean, 'pytest')
+
+
+@invoke.task
+def pylint(context):
+ """Check code quality using pylint"""
+ context.run('pylint --rcfile=cmd2_myplugin/pylintrc cmd2_myplugin')
+
+
+namespace.add_task(pylint)
+
+
+@invoke.task
+def pylint_tests(context):
+ """Check code quality of test suite using pylint"""
+ context.run('pylint --rcfile=tests/pylintrc tests')
+
+
+namespace.add_task(pylint_tests)
+
+
+#####
+#
+# build and distribute
+#
+#####
+BUILDDIR = 'build'
+DISTDIR = 'dist'
+
+
+@invoke.task
+def build_clean(context):
+ """Remove the build directory"""
+ # pylint: disable=unused-argument
+ rmrf(BUILDDIR)
+
+
+namespace_clean.add_task(build_clean, 'build')
+
+
+@invoke.task
+def dist_clean(context):
+ """Remove the dist directory"""
+ # pylint: disable=unused-argument
+ rmrf(DISTDIR)
+
+
+namespace_clean.add_task(dist_clean, 'dist')
+
+
+@invoke.task
+def eggs_clean(context):
+ """Remove egg directories"""
+ # pylint: disable=unused-argument
+ dirs = set()
+ dirs.add('.eggs')
+ for name in os.listdir(os.curdir):
+ if name.endswith('.egg-info'):
+ dirs.add(name)
+ if name.endswith('.egg'):
+ dirs.add(name)
+ rmrf(dirs)
+
+
+namespace_clean.add_task(eggs_clean, 'eggs')
+
+
+@invoke.task
+def bytecode_clean(context):
+ """Remove __pycache__ directories and *.pyc files"""
+ # pylint: disable=unused-argument
+ dirs = set()
+ for root, dirnames, files in os.walk(os.curdir):
+ if '__pycache__' in dirnames:
+ dirs.add(os.path.join(root, '__pycache__'))
+ for file in files:
+ if file.endswith(".pyc"):
+ dirs.add(os.path.join(root, file))
+ print("Removing __pycache__ directories and .pyc files")
+ rmrf(dirs, verbose=False)
+
+
+namespace_clean.add_task(bytecode_clean, 'bytecode')
+
+#
+# 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"""
+ context.run('python setup.py sdist')
+
+
+namespace.add_task(sdist)
+
+
+@invoke.task(pre=[clean_all])
+def wheel(context):
+ """Build a wheel distribution"""
+ context.run('python setup.py bdist_wheel')
+
+
+namespace.add_task(wheel)
+
+#
+# these two tasks are commented out so you don't
+# accidentally run them and upload this template to pypi
+#
+
+# @invoke.task(pre=[sdist, wheel])
+# def pypi(context):
+# """Build and upload a distribution to pypi"""
+# 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"""
+# context.run('twine upload --repository-url https://test.pypi.org/legacy/ dist/*')
+# namespace.add_task(pypi_test)
diff --git a/plugins/template/tests/__init__.py b/plugins/template/tests/__init__.py
new file mode 100644
index 00000000..eb198dc0
--- /dev/null
+++ b/plugins/template/tests/__init__.py
@@ -0,0 +1,2 @@
+#
+# empty file to create a package
diff --git a/plugins/template/tests/pylintrc b/plugins/template/tests/pylintrc
new file mode 100644
index 00000000..1dd17c1c
--- /dev/null
+++ b/plugins/template/tests/pylintrc
@@ -0,0 +1,19 @@
+#
+# pylint configuration for tests package
+#
+# $ pylint --rcfile=tests/pylintrc tests
+#
+
+[basic]
+# allow for longer method and function names
+method-rgx=(([a-z][a-z0-9_]{2,50})|(_[a-z0-9_]*))$
+function-rgx=(([a-z][a-z0-9_]{2,50})|(_[a-z0-9_]*))$
+
+[messages control]
+# too-many-public-methods -> test classes can have lots of methods, so let's ignore those
+# missing-docstring -> prefer method names instead of docstrings
+# no-self-use -> test methods part of a class hardly ever use self
+# unused-variable -> sometimes we are expecting exceptions
+# redefined-outer-name -> pylint fixtures cause these
+# protected-access -> we want to test private methods
+disable=too-many-public-methods,missing-docstring,no-self-use,unused-variable,redefined-outer-name,protected-access
diff --git a/plugins/template/tests/test_myplugin.py b/plugins/template/tests/test_myplugin.py
new file mode 100644
index 00000000..4149f7df
--- /dev/null
+++ b/plugins/template/tests/test_myplugin.py
@@ -0,0 +1,67 @@
+#
+# coding=utf-8
+
+from cmd2 import cmd2
+import cmd2_myplugin
+
+######
+#
+# define a class which uses our plugin and some convenience functions
+#
+######
+
+
+class MyApp(cmd2_myplugin.MyPluginMixin, cmd2.Cmd):
+ """Simple subclass of cmd2.Cmd with our SayMixin plugin included."""
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ @cmd2_myplugin.empty_decorator
+ def do_empty(self, args):
+ self.poutput("running the empty command")
+
+#
+# You can't use a fixture to instantiate your app if you want to use
+# to use the capsys fixture to capture the output. cmd2.Cmd sets
+# internal variables to sys.stdout and sys.stderr on initialization
+# and then uses those internal variables instead of sys.stdout. It does
+# this so you can redirect output from within the app. The capsys fixture
+# can't capture the output properly in this scenario.
+#
+# If you have extensive initialization needs, create a function
+# to initialize your cmd2 application.
+
+
+def init_app():
+ app = MyApp()
+ return app
+
+
+#####
+#
+# unit tests
+#
+#####
+
+def test_say(capsys):
+ # call our initialization function instead of using a fixture
+ app = init_app()
+ # run our mixed in command
+ app.onecmd_plus_hooks('say hello')
+ # use the capsys fixture to retrieve the output on stdout and stderr
+ out, err = capsys.readouterr()
+ # make our assertions
+ assert out == 'in postparsing hook\nhello\n'
+ assert not err
+
+
+def test_decorator(capsys):
+ # call our initialization function instead of using a fixture
+ app = init_app()
+ # run one command in the app
+ app.onecmd_plus_hooks('empty')
+ # use the capsys fixture to retrieve the output on stdout and stderr
+ out, err = capsys.readouterr()
+ # make our assertions
+ assert out == 'in postparsing hook\nin the empty decorator\nrunning the empty command\n'
+ assert not err