[python-cligj] 01/03: Imported Upstream version 0.2.0
Johan Van de Wauw
johanvdw-guest at moszumanska.debian.org
Mon Jun 15 15:16:35 UTC 2015
This is an automated email from the git hooks/post-receive script.
johanvdw-guest pushed a commit to branch master
in repository python-cligj.
commit 5c835a908fce0f7e4ee091cd00ebaf8f0a7f3bba
Author: Johan Van de Wauw <johan.vandewauw at gmail.com>
Date: Mon Jun 15 17:08:48 2015 +0200
Imported Upstream version 0.2.0
---
.gitignore | 4 +
CHANGES.txt | 9 +++
README.rst | 38 +++++++++
cligj/plugins.py | 207 ++++++++++++++++++++++++++++++++++++++++++++++++
setup.py | 6 +-
tests/__init__.py | 2 +
tests/broken_plugins.py | 20 +++++
tests/test_plugins.py | 133 +++++++++++++++++++++++++++++++
8 files changed, 416 insertions(+), 3 deletions(-)
diff --git a/.gitignore b/.gitignore
index db4561e..338ecab 100755
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ __pycache__/
# Distribution / packaging
.Python
env/
+venv/
build/
develop-eggs/
dist/
@@ -52,3 +53,6 @@ docs/_build/
# PyBuilder
target/
+
+# PyCharm IDE
+.idea/
diff --git a/CHANGES.txt b/CHANGES.txt
new file mode 100644
index 0000000..322d831
--- /dev/null
+++ b/CHANGES.txt
@@ -0,0 +1,9 @@
+0.2.0 (2015-05-28)
+------------------
+- Addition of a pluggable command group class and a corresponding click-style
+ decorator (#2, #3).
+
+0.1.0 (2015-01-06)
+------------------
+- Initial release: a collection of GeoJSON-related command line arguments and
+ options for use with Click (#1).
diff --git a/README.rst b/README.rst
index 97a7c75..f1ec307 100755
--- a/README.rst
+++ b/README.rst
@@ -60,3 +60,41 @@ On the command line it works like this.
^^{'type': 'Feature', 'id': '2'}
In this example, ``^^`` represents 0x1e.
+
+
+Plugins
+-------
+
+``cligj`` can also facilitate loading external `click-based <http://click.pocoo.org/4/>`_
+plugins via `setuptools entry points <https://pythonhosted.org/setuptools/setuptools.html#dynamic-discovery-of-services-and-plugins>`_.
+The ``cligj.plugins`` module contains a special ``group()`` decorator that behaves exactly like
+``click.group()`` except that it offers the opportunity load plugins and attach them to the
+group as it is istantiated.
+
+.. code-block:: python
+
+ from pkg_resources import iter_entry_points
+
+ import cligj.plugins
+ import click
+
+ @cligj.plugins.group(plugins=iter_entry_points('module.entry_points'))
+ def cli():
+
+ """A CLI application."""
+
+ pass
+
+ @cli.command()
+ @click.argument('arg')
+ def printer(arg):
+
+ """Print arg."""
+
+ click.echo(arg)
+
+ @cli.group(plugins=iter_entry_points('other_module.more_plugins'))
+ def plugins():
+
+ """A sub-group that contains plugins from a different module."""
+ pass
diff --git a/cligj/plugins.py b/cligj/plugins.py
new file mode 100644
index 0000000..7ba38fc
--- /dev/null
+++ b/cligj/plugins.py
@@ -0,0 +1,207 @@
+"""
+Common components required to enable setuptools plugins.
+
+In general the components defined here are slightly modified or subclassed
+versions of core click components. This is required in order to insert code
+that loads entry points when necessary while still maintaining a simple API
+is only slightly different from the click API. Here's how it works:
+
+When defining a main commandline group:
+
+ >>> import click
+ >>> @click.group()
+ ... def cli():
+ ... '''A commandline interface.'''
+ ... pass
+
+The `click.group()` decorator turns `cli()` into an instance of `click.Group()`.
+Subsequent commands hang off of this group:
+
+ >>> @cli.command()
+ ... @click.argument('val')
+ ... def printer(val):
+ ... '''Print a value.'''
+ ... click.echo(val)
+
+At this point the entry points, which are just instances of `click.Command()`,
+can be added to the main group with:
+
+ >>> from pkg_resources import iter_entry_points
+ >>> for ep in iter_entry_points('module.commands'):
+ ... cli.add_command(ep.load())
+
+This works but its not very Pythonic, is vulnerable to typing errors, must be
+manually updated if a better method is discovered, and most importantly, if an
+entry point throws an exception on completely crashes the group the command is
+attached to.
+
+A better time to load the entry points is when the group they will be attached
+to is instantiated. This requires slight modifications to the `click.group()`
+decorator and `click.Group()` to let them load entry points as needed. If the
+modified `group()` decorator is used on the same group like this:
+
+ >>> from pkg_resources import iter_entry_points
+ >>> import cligj.plugins
+ >>> @cligj.plugins.group(plugins=iter_entry_points('module.commands'))
+ ... def cli():
+ ... '''A commandline interface.'''
+ ... pass
+
+Now the entry points are loaded before the normal `click.group()` decorator
+is called, except it returns a modified `Group()` so if we hang another group
+off of `cli()`:
+
+ >>> @cli.group(plugins=iter_entry_points('other_module.commands'))
+ ... def subgroup():
+ ... '''A subgroup with more plugins'''
+ ... pass
+
+We can register additional plugins in a sub-group.
+
+Catching broken plugins is done in the modified `group()` which attaches instances
+of `BrokenCommand()` to the group instead of instances of `click.Command()`. The
+broken commands have special help messages and override `click.Command.invoke()`
+so the user gets a useful error message with a traceback if they attempt to run
+the command or use `--help`.
+"""
+
+
+import os
+import sys
+import traceback
+
+import click
+
+
+class BrokenCommand(click.Command):
+
+ """
+ Rather than completely crash the CLI when a broken plugin is loaded, this
+ class provides a modified help message informing the user that the plugin is
+ broken and they should contact the owner. If the user executes the plugin
+ or specifies `--help` a traceback is reported showing the exception the
+ plugin loader encountered.
+ """
+
+ def __init__(self, name):
+
+ """
+ Define the special help messages after instantiating `click.Command()`.
+
+ Parameters
+ ----------
+ name : str
+ Name of command.
+ """
+
+ click.Command.__init__(self, name)
+
+ util_name = os.path.basename(sys.argv and sys.argv[0] or __file__)
+
+ if os.environ.get('CLIGJ_HONESTLY'): # pragma no cover
+ icon = u'\U0001F4A9'
+ else:
+ icon = u'\u2020'
+
+ self.help = (
+ "\nWarning: entry point could not be loaded. Contact "
+ "its author for help.\n\n\b\n"
+ + traceback.format_exc())
+ self.short_help = (
+ icon + " Warning: could not load plugin. See `%s %s --help`."
+ % (util_name, self.name))
+
+ def invoke(self, ctx):
+
+ """
+ Print the error message instead of doing nothing.
+
+ Parameters
+ ----------
+ ctx : click.Context
+ Required for click.
+ """
+
+ click.echo(self.help, color=ctx.color)
+ ctx.exit(1) # Defaults to 0 but we want an error code
+
+
+class Group(click.Group):
+
+ """
+ A subclass of `click.Group()` that returns the modified `group()` decorator
+ when `Group.group()` is called. Used by the modified `group()` decorator.
+ So many groups...
+
+ See the main docstring in this file for a full explanation.
+ """
+
+ def __init__(self, **kwargs):
+ click.Group.__init__(self, **kwargs)
+
+ def group(self, *args, **kwargs):
+
+ """
+ Return the modified `group()` rather than `click.group()`. This
+ gives the user an opportunity to assign entire groups of plugins
+ to their own subcommand group.
+
+ See the main docstring in this file for a full explanation.
+ """
+
+ def decorator(f):
+ cmd = group(*args, **kwargs)(f)
+ self.add_command(cmd)
+ return cmd
+
+ return decorator
+
+
+def group(plugins=None, **kwargs):
+
+ """
+ A special group decorator that behaves exactly like `click.group()` but
+ allows for additional plugins to be loaded.
+
+ Example:
+
+ >>> import cligj.plugins
+ >>> from pkg_resources import iter_entry_points
+ >>> plugins = iter_entry_points('module.entry_points')
+ >>> @cligj.plugins.group(plugins=plugins)
+ ... def cli():
+ ... '''A CLI aplication'''
+ ... pass
+
+ Plugins that raise an exception on load are caught and converted to an
+ instance of `BrokenCommand()`, which has better error handling and prevents
+ broken plugins from taking crashing the CLI.
+
+ See the main docstring in this file for a full explanation.
+
+ Parameters
+ ----------
+ plugins : iter
+ An iterable that produces one entry point per iteration.
+ kwargs : **kwargs
+ Additional arguments for `click.Group()`.
+ """
+
+ def decorator(f):
+
+ kwargs.setdefault('cls', Group)
+ grp = click.group(**kwargs)(f)
+
+ if plugins is not None:
+ for entry_point in plugins:
+ try:
+ grp.add_command(entry_point.load())
+
+ except Exception:
+ # Catch this so a busted plugin doesn't take down the CLI.
+ # Handled by registering a dummy command that does nothing
+ # other than explain the error.
+ grp.add_command(BrokenCommand(entry_point.name))
+ return grp
+
+ return decorator
diff --git a/setup.py b/setup.py
index 8e5a646..103de36 100755
--- a/setup.py
+++ b/setup.py
@@ -8,15 +8,15 @@ with codecs_open('README.rst', encoding='utf-8') as f:
setup(name='cligj',
- version='0.1.0',
- description=u"Click params for GeoJSON CLI",
+ version='0.2.0',
+ description=u"Click params for commmand line interfaces to GeoJSON",
long_description=long_description,
classifiers=[],
keywords='',
author=u"Sean Gillies",
author_email='sean at mapbox.com',
url='https://github.com/mapbox/cligj',
- license='MIT',
+ license='BSD',
packages=find_packages(exclude=['ez_setup', 'examples', 'tests']),
include_package_data=True,
zip_safe=False,
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..fdb24e7
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,2 @@
+# Do not delete this file. It makes the tests directory behave like a Python
+# module, which is required to manually register and test plugins.
\ No newline at end of file
diff --git a/tests/broken_plugins.py b/tests/broken_plugins.py
new file mode 100644
index 0000000..ccc14c5
--- /dev/null
+++ b/tests/broken_plugins.py
@@ -0,0 +1,20 @@
+"""
+We detect plugins that throw an exception on import, so just throw an exception
+to mimic a problem.
+"""
+
+
+import click
+
+
+ at click.command()
+def something(arg):
+ click.echo('passed')
+
+
+raise Exception('I am a broken plugin. Send help.')
+
+
+ at click.command()
+def after():
+ pass
diff --git a/tests/test_plugins.py b/tests/test_plugins.py
new file mode 100644
index 0000000..ea7ea5d
--- /dev/null
+++ b/tests/test_plugins.py
@@ -0,0 +1,133 @@
+"""Unittests for ``cligj.plugins``."""
+
+
+import os
+from pkg_resources import EntryPoint
+from pkg_resources import iter_entry_points
+from pkg_resources import working_set
+
+import click
+
+import cligj.plugins
+
+
+# Create a few CLI commands for testing
+ at click.command()
+ at click.argument('arg')
+def cmd1(arg):
+ """Test command 1"""
+ click.echo('passed')
+
+ at click.command()
+ at click.argument('arg')
+def cmd2(arg):
+ """Test command 2"""
+ click.echo('passed')
+
+
+# Manually register plugins in an entry point and put broken plugins in a
+# different entry point.
+
+# The `DistStub()` class gets around an exception that is raised when
+# `entry_point.load()` is called. By default `load()` has `requires=True`
+# which calls `dist.requires()` and the `cligj.plugins.group()` decorator
+# doesn't allow us to change this. Because we are manually registering these
+# plugins the `dist` attribute is `None` so we can just create a stub that
+# always returns an empty list since we don't have any requirements. A full
+# `pkg_resources.Distribution()` instance is not needed because there isn't
+# a package installed anywhere.
+class DistStub(object):
+ def requires(self, *args):
+ return []
+
+working_set.by_key['cligj']._ep_map = {
+ 'cligj.test_plugins': {
+ 'cmd1': EntryPoint.parse(
+ 'cmd1=tests.test_plugins:cmd1', dist=DistStub()),
+ 'cmd2': EntryPoint.parse(
+ 'cmd2=tests.test_plugins:cmd2', dist=DistStub())
+ },
+ 'cligj.broken_plugins': {
+ 'before': EntryPoint.parse(
+ 'before=tests.broken_plugins:before', dist=DistStub()),
+ 'after': EntryPoint.parse(
+ 'after=tests.broken_plugins:after', dist=DistStub()),
+ 'do_not_exist': EntryPoint.parse(
+ 'do_not_exist=tests.broken_plugins:do_not_exist', dist=DistStub())
+ }
+}
+
+
+# Main CLI groups - one with good plugins attached and the other broken
+ at cligj.plugins.group(plugins=iter_entry_points('cligj.test_plugins'))
+def good_cli():
+ """Good CLI group."""
+ pass
+
+
+ at cligj.plugins.group(plugins=iter_entry_points('cligj.broken_plugins'))
+def broken_cli():
+ """Broken CLI group."""
+ pass
+
+
+def test_registered():
+ # Make sure the plugins are properly registered. If this test fails it
+ # means that some of the for loops in other tests may not be executing.
+ assert len([ep for ep in iter_entry_points('cligj.test_plugins')]) > 1
+ assert len([ep for ep in iter_entry_points('cligj.broken_plugins')]) > 1
+
+
+def test_register_and_run(runner):
+
+ result = runner.invoke(good_cli)
+ assert result.exit_code is 0
+
+ for ep in iter_entry_points('cligj.test_plugins'):
+ cmd_result = runner.invoke(good_cli, [ep.name, 'something'])
+ assert cmd_result.exit_code is 0
+ assert cmd_result.output.strip() == 'passed'
+
+
+def test_broken_register_and_run(runner):
+
+ result = runner.invoke(broken_cli)
+ assert result.exit_code is 0
+ assert u'\U0001F4A9' in result.output or u'\u2020' in result.output
+
+ for ep in iter_entry_points('cligj.broken_plugins'):
+ cmd_result = runner.invoke(broken_cli, [ep.name])
+ assert cmd_result.exit_code is not 0
+ assert 'Traceback' in cmd_result.output
+
+
+def test_group_chain(runner):
+
+ # Attach a sub-group to a CLI and get execute it without arguments to make
+ # sure both the sub-group and all the parent group's commands are present
+ @good_cli.group()
+ def sub_cli():
+ """Sub CLI."""
+ pass
+
+ result = runner.invoke(good_cli)
+ assert result.exit_code is 0
+ assert sub_cli.name in result.output
+ for ep in iter_entry_points('cligj.test_plugins'):
+ assert ep.name in result.output
+
+ # Same as above but the sub-group has plugins
+ @good_cli.group(plugins=iter_entry_points('cligj.test_plugins'))
+ def sub_cli_plugins():
+ """Sub CLI with plugins."""
+ pass
+
+ result = runner.invoke(good_cli, ['sub_cli_plugins'])
+ assert result.exit_code is 0
+ for ep in iter_entry_points('cligj.test_plugins'):
+ assert ep.name in result.output
+
+ # Execute one of the sub-group's commands
+ result = runner.invoke(good_cli, ['sub_cli_plugins', 'cmd1', 'something'])
+ assert result.exit_code is 0
+ assert result.output.strip() == 'passed'
--
Alioth's /usr/local/bin/git-commit-notice on /srv/git.debian.org/git/pkg-grass/python-cligj.git
More information about the Pkg-grass-devel
mailing list