Repository: python-cmd2/cmd2
Pull request #954 (2020-07-13 19:29:37)

Brought in cmd2 plugin template as a first-class member of cmd2 proper

codecov[bot]: # [Codecov](https://codecov.io/gh/python-cmd2/cmd2/pull/954?src=pr&el=h1) Report
> Merging [#954](https://codecov.io/gh/python-cmd2/cmd2/pull/954?src=pr&el=desc) into [master](https://codecov.io/gh/python-cmd2/cmd2/commit/0cb83910890a1a7ddf257b0dbce4f20db77726ce&el=desc) will **increase** coverage by `0.05%`.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/python-cmd2/cmd2/pull/954/graphs/tree.svg?width=650&height=150&src=pr&token=eks2PUwa6A)](https://codecov.io/gh/python-cmd2/cmd2/pull/954?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #954      +/-   ##
==========================================
+ Coverage   97.72%   97.78%   +0.05%     
==========================================
  Files          19       17       -2     
  Lines        4183     4150      -33     
==========================================
- Hits         4088     4058      -30     
+ Misses         95       92       -3     
```


| [Impacted Files](https://codecov.io/gh/python-cmd2/cmd2/pull/954?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [cmd2/utils.py](https://codecov.io/gh/python-cmd2/cmd2/pull/954/diff?src=pr&el=tree#diff-Y21kMi91dGlscy5weQ==) | `98.00% <0.00%> (-0.25%)` | :arrow_down: |
| [plugins/ext\_test/cmd2\_ext\_test/\_\_init\_\_.py](https://codecov.io/gh/python-cmd2/cmd2/pull/954/diff?src=pr&el=tree#diff-cGx1Z2lucy9leHRfdGVzdC9jbWQyX2V4dF90ZXN0L19faW5pdF9fLnB5) | | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/python-cmd2/cmd2/pull/954?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/python-cmd2/cmd2/pull/954?src=pr&el=footer). Last update [0cb8391...664e954](https://codecov.io/gh/python-cmd2/cmd2/pull/954?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/noxfile.py b/noxfile.py
index 217dd8f8..1821c127 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -12,15 +12,16 @@ def docs(session):
 
 
 @nox.session(python=['3.5', '3.6', '3.7', '3.8', '3.9'])
-def tests(session):
-    session.install('invoke', './[test]')
-    session.run('invoke', 'pytest', '--junit', '--no-pty')
+@nox.parametrize('plugin', [None, 'ext_test', 'template'])
+def tests(session, plugin):
+    if plugin is None:
+        session.install('invoke', './[test]')
+        session.run('invoke', 'pytest', '--junit', '--no-pty')
+    else:
+        session.install('invoke', '.')
 
-    # cd into test directory to run other unit test
-    session.chdir('./plugins/ext_test')
-    session.install('.[test]')
-    session.run('invoke', 'pytest', '--junit', '--no-pty', '--append-cov')
+        # 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')
 
-    # return to top directory to submit coverage
-    session.chdir('../..')
     session.run('codecov')
diff --git a/plugins/README.txt b/plugins/README.txt
new file mode 100644
index 00000000..6bbf89f2
--- /dev/null
+++ b/plugins/README.txt
@@ -0,0 +1,1 @@
+For information about creating a cmd2 plugin, see template/README.md
diff --git a/plugins/tasks.py b/plugins/tasks.py
index cc9f5157..06104429 100644
--- a/plugins/tasks.py
+++ b/plugins/tasks.py
@@ -9,16 +9,13 @@ Make sure you satisfy the following Python module requirements if you are trying
     - setuptools >= 39.1.0
 """
 import os
-import re
-import shutil
-import sys
-
 import invoke
 
 from plugins.ext_test import tasks as ext_test_tasks
+from plugins.template import tasks as template_tasks
 
 # create namespaces
-namespace = invoke.Collection()
+namespace = invoke.Collection(ext_test=ext_test_tasks, template=template_tasks)
 namespace_clean = invoke.Collection('clean')
 namespace.add_collection(namespace_clean, 'clean')
 
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)


Test patch:
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 02fd29b1..731a0f3b 100644
--- a/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py
+++ b/plugins/ext_test/cmd2_ext_test/cmd2_ext_test.py
@@ -2,12 +2,17 @@
 # coding=utf-8
 """External test interface plugin"""
 
-from typing import Optional
+from typing import Optional, TYPE_CHECKING
 
 import cmd2
 
+if TYPE_CHECKING:
+    _Base = cmd2.Cmd
+else:
+    _Base = object
 
-class ExternalTestMixin:
+
+class ExternalTestMixin(_Base):
     """A cmd2 plugin (mixin class) that exposes an interface to execute application commands from python"""
 
     def __init__(self, *args, **kwargs):
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