diff --git a/.travis.yml b/.travis.yml index a10d9ee..72754c7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,24 +1,23 @@ language: python sudo: false -cache: - directories: - - ~/.cache/pip +cache: pip python: - 2.7 - 3.3 - 3.4 - 3.5 + - 3.6 - pypy - pypy3 install: - pip install coveralls - - pip install -e .\[dev\] + - pip install -e .\[all\] script: - - py.test tests --cov click_plugins --cov-report term-missing + - pytest --cov click_plugins --cov-report term-missing after_success: - coveralls diff --git a/LICENSE.txt b/LICENSE.txt index ce22248..6027cea 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ New BSD License -Copyright (c) 2015-2016, Kevin D. Wurster, Sean C. Gillies +Copyright (c) 2015-2017, Kevin D. Wurster, Sean C. Gillies All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.rst b/README.rst index 8008fbf..217d240 100644 --- a/README.rst +++ b/README.rst @@ -165,7 +165,7 @@ Developing $ git clone https://github.com/click-contrib/click-plugins.git $ cd click-plugins $ pip install -e .\[dev\] - $ py.test tests --cov click_plugins --cov-report term-missing + $ pytest tests --cov click_plugins --cov-report term-missing Changelog diff --git a/click_plugins/__init__.py b/click_plugins/__init__.py index 8f79ae2..88442d3 100644 --- a/click_plugins/__init__.py +++ b/click_plugins/__init__.py @@ -31,7 +31,7 @@ def subcommand(arg): __license__ = ''' New BSD License -Copyright (c) 2015-2016, Kevin D. Wurster, Sean C. Gillies +Copyright (c) 2015-2017, Kevin D. Wurster, Sean C. Gillies All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/click_plugins/core.py b/click_plugins/core.py index 318424c..08b429b 100644 --- a/click_plugins/core.py +++ b/click_plugins/core.py @@ -1,27 +1,23 @@ -""" -Core components for click_plugins -""" +"""Core components for click_plugins""" -import click - import os import sys import traceback +import click -def with_plugins(plugins): - """ - A decorator to register external CLI commands to an instance of - `click.Group()`. +def with_plugins(entry_points): + + """A decorator to register external CLI commands to an instance of + ``click.Group()``. Parameters ---------- - plugins : iter - An iterable producing one `pkg_resources.EntryPoint()` per iteration. - attrs : **kwargs, optional - Additional keyword arguments for instantiating `click.Group()`. + entry_points : iter + An iterable producing one ``pkg_resources.EntryPoint()`` per + iteration. Returns ------- @@ -30,16 +26,18 @@ def with_plugins(plugins): def decorator(group): if not isinstance(group, click.Group): - raise TypeError("Plugins can only be attached to an instance of click.Group()") + raise TypeError( + "Plugins can only be attached to an instance of " + "'click.Group()'.") - for entry_point in plugins or (): + for ep in entry_points: try: - group.add_command(entry_point.load()) + group.add_command(ep.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. - group.add_command(BrokenCommand(entry_point.name)) + group.add_command(BrokenCommand(ep.name)) return group @@ -48,41 +46,60 @@ def decorator(group): 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. + """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 a `click.Command()`. + """Define the special help messages after instantiating a + ``click.Command()``. + + Parameters + ---------- + name : str + For ``click.Command()``. """ click.Command.__init__(self, name) - util_name = os.path.basename(sys.argv and sys.argv[0] or __file__) - - if os.environ.get('CLICK_PLUGINS_HONESTLY'): # pragma no cover + if os.environ.get('CLICK_PLUGINS_HONESTLY') == 'TRUE': icon = u'\U0001F4A9' else: icon = u'\u2020' + # Override the command's short help with a warning message about how + # the command is not functioning properly + prog_name = os.path.basename(sys.argv and sys.argv[0] or __file__) + self.short_help = ( + u"{icon} Warning: could not load plugin. See: " + "'$ {prog_name} {name} --help'.".format( + icon=icon, prog_name=prog_name, name=name)) + + # Override the command's long help with the exception traceback. + # The call to 'traceback.format_exec()' function attempts to + # access 'Exception.__context__' which doesn't exist on Python + # 3.3 and 3.4, but appears to exist on 2.7 and >= 3.5. + if sys.version_info >= (3, 5) or sys.version[:2] == (2, 7): + tb = traceback.format_exc(chain=True) + else: + tb = traceback.format_exc() 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)) + "its author for help.\n\n\b\n".format(os.linesep) + + tb) def invoke(self, ctx): - """ - Print the traceback instead of doing nothing. + """Print the traceback instead of doing nothing. + + Parameters + ---------- + ctx : click.Context + CLI context. """ click.echo(self.help, color=ctx.color) diff --git a/setup.cfg b/setup.cfg index 7c2b287..a7da507 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,5 @@ [bdist_wheel] -universal = 1 \ No newline at end of file +universal = 1 + +[tool:pytest] +testpaths = tests diff --git a/setup.py b/setup.py index b575b5a..643a229 100755 --- a/setup.py +++ b/setup.py @@ -1,12 +1,11 @@ #!/usr/bin/env python -""" -Setup script for click-plugins -""" +"""Setup script for click-plugins""" import codecs +import itertools as it import os from setuptools import find_packages @@ -17,22 +16,41 @@ long_desc = f.read().strip() -version = None -author = None -email = None -source = None +def parse_dunder_line(string): + + """Take a line like: + + "__version__ = '0.0.8'" + + and turn it into a tuple: + + ('__version__', '0.0.8') + + Not very fault tolerant. + """ + + # Split the line and remove outside quotes + variable, value = (s.strip() for s in string.split('=')[:2]) + value = value[1:-1].strip() + return variable, value + + with open(os.path.join('click_plugins', '__init__.py')) as f: - for line in f: - if line.strip().startswith('__version__'): - version = line.split('=')[1].strip().replace('"', '').replace("'", '') - elif line.strip().startswith('__author__'): - author = line.split('=')[1].strip().replace('"', '').replace("'", '') - elif line.strip().startswith('__email__'): - email = line.split('=')[1].strip().replace('"', '').replace("'", '') - elif line.strip().startswith('__source__'): - source = line.split('=')[1].strip().replace('"', '').replace("'", '') - elif None not in (version, author, email, source): - break + dunders = dict(map( + parse_dunder_line, filter(lambda l: l.strip().startswith('__'), f))) + version = dunders['__version__'] + author = dunders['__author__'] + email = dunders['__email__'] + source = dunders['__source__'] + + +extras_require = { + 'test': [ + 'pytest>=3.0', + 'pytest-cov', + ] +} +extras_require['all'] = list(it.chain(*extras_require.values())) setup( @@ -45,18 +63,17 @@ 'Development Status :: 5 - Production/Stable', 'License :: OSI Approved :: BSD License', 'Programming Language :: Python', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 3', ], - description="An extension module for click to enable registering CLI commands " - "via setuptools entry-points.", - extras_require={ - 'dev': [ - 'pytest', - 'pytest-cov', - 'wheel', - 'coveralls' - ], - }, + description="An extension module for click to enable registering CLI " + "commands via setuptools entry-points.", + extras_require=extras_require, include_package_data=True, install_requires=['click>=3.0'], keywords='click plugin setuptools entry-point', diff --git a/tests/__init__.py b/tests/__init__.py index 3c1c95d..ce0b1f9 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -# This file is required for some of the tests of Python 2 +"""This file is required for some tests on Python 2.""" diff --git a/tests/conftest.py b/tests/conftest.py index 3aac933..c4aae04 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,11 @@ +"""``pytest`` fixtures.""" + + from click.testing import CliRunner import pytest @pytest.fixture(scope='function') -def runner(request): +def runner(): return CliRunner() diff --git a/tests/test_plugins.py b/tests/test_plugins.py index be899cb..b9620f6 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,9 +1,14 @@ +"""Test core ``click-plugins`` functionality.""" + + from pkg_resources import EntryPoint from pkg_resources import iter_entry_points from pkg_resources import working_set +import os import click from click_plugins import with_plugins +from click_plugins.core import BrokenCommand import pytest @@ -12,13 +17,13 @@ @click.argument('arg') def cmd1(arg): """Test command 1""" - click.echo('passed') + click.echo('passed cmd1 with arg: {}'.format(arg)) @click.command() @click.argument('arg') def cmd2(arg): """Test command 2""" - click.echo('passed') + click.echo('passed cmd2 with arg: {}'.format(arg)) # Manually register plugins in an entry point and put broken plugins in a @@ -61,6 +66,7 @@ def good_cli(): """Good CLI group.""" pass + @with_plugins(iter_entry_points('_test_click_plugins.broken_plugins')) @click.group() def broken_cli(): @@ -68,26 +74,37 @@ def broken_cli(): 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('_test_click_plugins.test_plugins')]) > 1 - assert len([ep for ep in iter_entry_points('_test_click_plugins.broken_plugins')]) > 1 +@pytest.mark.parametrize('entry_point,gt', [ + ('_test_click_plugins.test_plugins', 1), + ('_test_click_plugins.broken_plugins', 1)]) +def test_registered(entry_point, gt): + + """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(list(iter_entry_points(entry_point))) > 1 def test_register_and_run(runner): + """Make sure all registered commands can run.""" + result = runner.invoke(good_cli) assert result.exit_code is 0 for ep in iter_entry_points('_test_click_plugins.test_plugins'): cmd_result = runner.invoke(good_cli, [ep.name, 'something']) assert cmd_result.exit_code is 0 - assert cmd_result.output.strip() == 'passed' + assert cmd_result.output.strip() \ + == 'passed {} with arg: something'.format(ep.name) def test_broken_register_and_run(runner): + """Broken commands produce different help text.""" + result = runner.invoke(broken_cli) assert result.exit_code is 0 assert u'\U0001F4A9' in result.output or u'\u2020' in result.output @@ -100,8 +117,11 @@ def test_broken_register_and_run(runner): 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 + """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.""" @@ -114,7 +134,7 @@ def sub_cli(): assert ep.name in result.output # Same as above but the sub-group has plugins - @with_plugins(plugins=iter_entry_points('_test_click_plugins.test_plugins')) + @with_plugins(iter_entry_points('_test_click_plugins.test_plugins')) @good_cli.group() def sub_cli_plugins(): """Sub CLI with plugins.""" @@ -128,13 +148,28 @@ def sub_cli_plugins(): # 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' + assert result.output.strip() == 'passed cmd1 with arg: something' def test_exception(): - # Decorating something that isn't a click.Group() should fail + + """Decorating something that isn't a click.Group() should fail.""" + with pytest.raises(TypeError): @with_plugins([]) @click.command() def cli(): """Whatever""" + + +@pytest.mark.parametrize('enabled,code', [ + ('TRUE', u'\U0001F4A9'), + ('FALSE', u'\u2020')]) +def test_honestly(enabled, code): + var = 'CLICK_PLUGINS_HONESTLY' + try: + os.environ[var] = enabled + cmd = BrokenCommand('test_honestly') + assert cmd.short_help.startswith(code) + finally: + os.environ.pop(var, None)