[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