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.vandew...@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='s...@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 + + +@click.command() +def something(arg): + click.echo('passed') + + +raise Exception('I am a broken plugin. Send help.') + + +@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 +@click.command() +@click.argument('arg') +def cmd1(arg): + """Test command 1""" + click.echo('passed') + +@click.command() +@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 +@cligj.plugins.group(plugins=iter_entry_points('cligj.test_plugins')) +def good_cli(): + """Good CLI group.""" + pass + + +@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 _______________________________________________ Pkg-grass-devel mailing list Pkg-grass-devel@lists.alioth.debian.org http://lists.alioth.debian.org/cgi-bin/mailman/listinfo/pkg-grass-devel