From 884db5a816daac361a176f2b369bc39d7606f823 Mon Sep 17 00:00:00 2001 From: Rick Izzo Date: Sat, 26 Oct 2019 23:57:24 -0400 Subject: [PATCH] inital work on opening up entrypoints and areas for dynamic loading of backends and behavior --- src/hangar/backends/__init__.py | 5 +- src/hangar/backends/hdf5_00.py | 40 ++++++ src/hangar/backends/hdf5_01.py | 40 ++++++ src/hangar/backends/numpy_10.py | 27 ++++ src/hangar/backends/remote_50.py | 27 ++++ src/hangar/backends/selection.py | 103 +++++++-------- src/hangar/cli/cli.py | 9 +- src/hangar/cli/utils.py | 134 ++++++++++++++++++++ tests/test_cli_utils.py | 208 +++++++++++++++++++++++++++++++ tox.ini | 2 +- 10 files changed, 531 insertions(+), 64 deletions(-) create mode 100644 tests/test_cli_utils.py diff --git a/src/hangar/backends/__init__.py b/src/hangar/backends/__init__.py index 7b9a8b66..96041ae1 100644 --- a/src/hangar/backends/__init__.py +++ b/src/hangar/backends/__init__.py @@ -1,11 +1,10 @@ from .selection import BACKEND_ACCESSOR_MAP from .selection import backend_decoder -from .selection import backend_from_heuristics from .selection import is_local_backend from .selection import backend_opts_from_heuristics from .selection import parse_user_backend_opts __all__ = [ - 'BACKEND_ACCESSOR_MAP', 'backend_decoder', 'backend_from_heuristics', - 'is_local_backend', 'backend_opts_from_heuristics', 'parse_user_backend_opts' + 'BACKEND_ACCESSOR_MAP', 'backend_decoder', 'is_local_backend', + 'backend_opts_from_heuristics', 'parse_user_backend_opts' ] diff --git a/src/hangar/backends/hdf5_00.py b/src/hangar/backends/hdf5_00.py index 1010aba6..30c6cf9f 100644 --- a/src/hangar/backends/hdf5_00.py +++ b/src/hangar/backends/hdf5_00.py @@ -294,6 +294,46 @@ def hdf5_00_decode(db_val: bytes) -> HDF5_00_DataHashSpec: return raw_val +# -------------------------- Filter Heuristics -------------------------------- + + +def hdf5_00_heuristic_filter_opts(prototype: np.ndarray) -> dict: + """generate default filter options from a prototype array + + Parameters + ---------- + prototype : :class:`numpy.ndarray` + sample array of expected shape and datatype + + Returns + ------- + dict + mapping containing default filter opts that the hdf5_00 storage manager + will accept. + + TODO + ---- + + Do something with the prototype arrays, or get rid of the argument, it's + just taking up space at this point. + """ + opts = { + 'default': { + 'shuffle': None, + 'complib': 'blosc:zstd', + 'complevel': 3, + }, + 'backup': { + 'shuffle': 'byte', + 'complib': 'lzf', + 'complevel': None, + }, + } + hdf5BloscAvail = h5py.h5z.filter_avail(32001) + opts = opts['default'] if hdf5BloscAvail else opts['backup'] + return opts + + # ------------------------- Accessor Object ----------------------------------- diff --git a/src/hangar/backends/hdf5_01.py b/src/hangar/backends/hdf5_01.py index 8e038e05..a782a219 100644 --- a/src/hangar/backends/hdf5_01.py +++ b/src/hangar/backends/hdf5_01.py @@ -330,6 +330,46 @@ def hdf5_01_decode(db_val: bytes) -> HDF5_01_DataHashSpec: return raw_val +# -------------------------- Filter Heuristics -------------------------------- + + +def hdf5_01_heuristic_filter_opts(prototype: np.ndarray) -> dict: + """generate default filter options from a prototype array + + Parameters + ---------- + prototype : :class:`numpy.ndarray` + sample array of expected shape and datatype + + Returns + ------- + dict + mapping containing default filter opts that the hdf5_00 storage manager + will accept. + + TODO + ---- + + Do something with the prototype arrays, or get rid of the argument, it's + just taking up space at this point. + """ + opts = { + 'default': { + 'shuffle': 'byte', + 'complib': 'blosc:lz4hc', + 'complevel': 5, + }, + 'backup': { + 'shuffle': 'byte', + 'complib': 'lzf', + 'complevel': None, + }, + } + hdf5BloscAvail = h5py.h5z.filter_avail(32001) + opts = opts['default'] if hdf5BloscAvail else opts['backup'] + return opts + + # ------------------------- Accessor Object ----------------------------------- diff --git a/src/hangar/backends/numpy_10.py b/src/hangar/backends/numpy_10.py index 12af0400..bfffe717 100644 --- a/src/hangar/backends/numpy_10.py +++ b/src/hangar/backends/numpy_10.py @@ -178,6 +178,33 @@ def numpy_10_decode(db_val: bytes) -> NUMPY_10_DataHashSpec: return raw_val +# -------------------------- Filter Heuristics -------------------------------- + + +def numpy_10_heuristic_filter_opts(prototype: np.ndarray) -> dict: + """generate default filter options from a prototype array + + Parameters + ---------- + prototype : :class:`numpy.ndarray` + sample array of expected shape and datatype + + Returns + ------- + dict + mapping containing default filter opts that the numpy_10 storage manager + will accept. + + TODO + ---- + * Implement at rest compression of the memmap file? Gzip or something? + + * Do something with the prototype arrays, or get rid of the argument, it's + just taking up space at this point. + """ + opts = {} + return opts + # ------------------------- Accessor Object ----------------------------------- diff --git a/src/hangar/backends/remote_50.py b/src/hangar/backends/remote_50.py index f55b8131..9f77a3bf 100644 --- a/src/hangar/backends/remote_50.py +++ b/src/hangar/backends/remote_50.py @@ -103,6 +103,33 @@ def remote_50_decode(db_val: bytes) -> REMOTE_50_DataHashSpec: return raw_val +# -------------------------- Filter Heuristics -------------------------------- + + +def remote_50_heuristic_filter_opts(prototype: np.ndarray) -> dict: + """generate default filter options from a prototype array + + Parameters + ---------- + prototype : :class:`numpy.ndarray` + sample array of expected shape and datatype + + Returns + ------- + dict + mapping containing default filter opts that the remote_50 storage manager + will accept. + + TODO + ---- + * Is this even necessary? This method doesn't really even store anything, + it has no use for filter opts, but I'm including it now just to symetric + across all the backends. + """ + opts = {} + return opts + + # ------------------------- Accessor Object ----------------------------------- diff --git a/src/hangar/backends/selection.py b/src/hangar/backends/selection.py index e37f7487..e6f14a71 100644 --- a/src/hangar/backends/selection.py +++ b/src/hangar/backends/selection.py @@ -83,18 +83,27 @@ reaching out to the Hangar core development team so we can guide you through the process. """ -from typing import Dict, Union, Callable, Mapping, NamedTuple, Optional +from typing import Callable, Dict, Mapping, NamedTuple, Optional, Union import numpy as np +import pkg_resources -from .hdf5_00 import HDF5_00_FileHandles, hdf5_00_decode, HDF5_00_DataHashSpec -from .hdf5_01 import HDF5_01_FileHandles, hdf5_01_decode, HDF5_01_DataHashSpec -from .numpy_10 import NUMPY_10_FileHandles, numpy_10_decode, NUMPY_10_DataHashSpec -from .remote_50 import REMOTE_50_Handler, remote_50_decode, REMOTE_50_DataHashSpec +from .hdf5_00 import (HDF5_00_DataHashSpec, HDF5_00_FileHandles, + hdf5_00_decode, hdf5_00_heuristic_filter_opts) +from .hdf5_01 import (HDF5_01_DataHashSpec, HDF5_01_FileHandles, + hdf5_01_decode, hdf5_01_heuristic_filter_opts) +from .numpy_10 import (NUMPY_10_DataHashSpec, NUMPY_10_FileHandles, + numpy_10_decode, numpy_10_heuristic_filter_opts) +from .remote_50 import (REMOTE_50_DataHashSpec, REMOTE_50_Handler, + remote_50_decode, remote_50_heuristic_filter_opts) -# -------------------------- Parser Types and Mapping ------------------------- +custom_backends = {} +for entry_point in pkg_resources.iter_entry_points('hangar.backends'): + custom_backends[entry_point.name] = entry_point.load() + +# -------------------------- Parser Types and Mapping ------------------------- _DataHashSpecs = Union[ HDF5_00_DataHashSpec, @@ -103,7 +112,6 @@ REMOTE_50_DataHashSpec] _ParserMap = Mapping[bytes, Callable[[bytes], _DataHashSpecs]] - BACKEND_DECODER_MAP: _ParserMap = { # LOCALS -> [00:50] b'00': hdf5_00_decode, @@ -122,19 +130,32 @@ _BeAccessors = Union[HDF5_00_FileHandles, HDF5_01_FileHandles, NUMPY_10_FileHandles, REMOTE_50_Handler] _AccessorMap = Dict[str, _BeAccessors] - BACKEND_ACCESSOR_MAP: _AccessorMap = { # LOCALS -> [0:50] '00': HDF5_00_FileHandles, '01': HDF5_01_FileHandles, '10': NUMPY_10_FileHandles, - '20': None, # tiledb_20 - Reserved # REMOTES -> [50:100] '50': REMOTE_50_Handler, - '60': None, # url_60 - Reserved } +BACKEND_HEURISTIC_FILTER_OPTS_MAP = { + '00': hdf5_00_heuristic_filter_opts, + '01': hdf5_01_heuristic_filter_opts, + '10': numpy_10_heuristic_filter_opts, + '50': remote_50_heuristic_filter_opts, +} + + +for k, v in custom_backends.items(): + fmt_code = getattr(v, '_FmtCode') + _DataHashSpecs = Union[_DataHashSpecs, getattr(v, f'{k.upper()}_DataHashSpec')] + BACKEND_DECODER_MAP[fmt_code.encode()] = getattr(v, f'{k.lower()}_decode') + BACKEND_HEURISTIC_FILTER_OPTS_MAP[fmt_code] = getattr(v, f'{k.lower()}_heuristic_filter_opts') + BACKEND_ACCESSOR_MAP[f'{fmt_code}'] = getattr(v, f'{k.upper()}_FileHandles') + + # ------------------------ Selector Functions --------------------------------- @@ -164,9 +185,9 @@ def backend_decoder(db_val: bytes) -> _DataHashSpecs: BackendOpts = NamedTuple('BackendOpts', [('backend', str), ('opts', dict)]) -def backend_from_heuristics(array: np.ndarray, - named_samples: bool, - variable_shape: bool) -> str: +def _backend_from_proto_heuristics(array: np.ndarray, + named_samples: bool, + variable_shape: bool) -> str: """Given a prototype array, attempt to select the appropriate backend. Parameters @@ -185,7 +206,10 @@ def backend_from_heuristics(array: np.ndarray, TODO ---- - Configuration of this entire module as the available backends fill out. + * Need to have each backend report some type of score based on the array + prototype, otherwise this is going to be a mess. + * At the current implemention, this will never actually pick one of the + custom backends / heuristics. """ # uncompressed numpy memmap data is most appropriate for data whose shape is # likely small tabular row data (CSV or such...) @@ -243,45 +267,10 @@ def backend_opts_from_heuristics(backend: str, In the current implementation, the `array` parameter is unused. Either come up with a use or remove it from the parameter list. """ - if backend == '10': - opts = {} - elif backend == '00': - import h5py - opts = { - 'default': { - 'shuffle': None, - 'complib': 'blosc:zstd', - 'complevel': 3, - }, - 'backup': { - 'shuffle': 'byte', - 'complib': 'lzf', - 'complevel': None, - }, - } - hdf5BloscAvail = h5py.h5z.filter_avail(32001) - opts = opts['default'] if hdf5BloscAvail else opts['backup'] - elif backend == '01': - import h5py - opts = { - 'default': { - 'shuffle': 'byte', - 'complib': 'blosc:lz4hc', - 'complevel': 5, - }, - 'backup': { - 'shuffle': 'byte', - 'complib': 'lzf', - 'complevel': None, - }, - } - hdf5BloscAvail = h5py.h5z.filter_avail(32001) - opts = opts['default'] if hdf5BloscAvail else opts['backup'] - elif backend == '50': - opts = {} - else: - raise ValueError('Should not have been able to not select backend') - + if backend not in BACKEND_HEURISTIC_FILTER_OPTS_MAP: + raise ValueError(f'Selected backend: {backend} is not available.') + func = BACKEND_HEURISTIC_FILTER_OPTS_MAP[backend] + opts = func(array) return opts @@ -338,9 +327,9 @@ def parse_user_backend_opts(backend_opts: Optional[Union[str, dict]], backend = backend_opts['backend'] opts = {k: v for k, v in backend_opts.items() if k != 'backend'} elif backend_opts is None: - backend = backend_from_heuristics(array=prototype, - named_samples=named_samples, - variable_shape=variable_shape) + backend = _backend_from_proto_heuristics(array=prototype, + named_samples=named_samples, + variable_shape=variable_shape) opts = backend_opts_from_heuristics(backend=backend, array=prototype, named_samples=named_samples, @@ -348,4 +337,4 @@ def parse_user_backend_opts(backend_opts: Optional[Union[str, dict]], else: raise ValueError(f'Backend opts value: {backend_opts} is invalid') - return BackendOpts(backend=backend, opts=opts) \ No newline at end of file + return BackendOpts(backend=backend, opts=opts) diff --git a/src/hangar/cli/cli.py b/src/hangar/cli/cli.py index cc60c37c..566ec704 100644 --- a/src/hangar/cli/cli.py +++ b/src/hangar/cli/cli.py @@ -16,18 +16,21 @@ import os import time from pathlib import Path +import warnings +from pkg_resources import iter_entry_points import click import numpy as np -from hangar import Repository, __version__ - -from .utils import parse_custom_arguments, StrOrIntType +from hangar import Repository +from hangar import __version__ +from hangar.cli.utils import with_plugins, parse_custom_arguments, StrOrIntType pass_repo = click.make_pass_decorator(Repository, ensure=True) +@with_plugins(iter_entry_points('hangar.cli.plugins')) @click.group(no_args_is_help=True, add_help_option=True, invoke_without_command=True) @click.version_option(version=__version__, help='display current Hangar Version') @click.pass_context diff --git a/src/hangar/cli/utils.py b/src/hangar/cli/utils.py index ec06ef11..b6c15310 100644 --- a/src/hangar/cli/utils.py +++ b/src/hangar/cli/utils.py @@ -1,5 +1,59 @@ +""" +Core components for click_plugins + + +An extension module for click to enable registering CLI commands via setuptools +entry-points. + from pkg_resources import iter_entry_points + import click + from click_plugins import with_plugins + @with_plugins(iter_entry_points('entry_point.name')) + @click.group() + def cli(): + '''Commandline interface for something.''' + @cli.command() + @click.argument('arg') + def subcommand(arg): + '''A subcommand for something else''' + +from click_plugins.core import with_plugins + +__version__ = '1.1.1' +__author__ = 'Kevin Wurster, Sean Gillies' +__email__ = 'wursterk@gmail.com, sean.gillies@gmail.com' +__source__ = 'https://github.com/click-contrib/click-plugins' +__license__ = ''' +New BSD License +Copyright (c) 2015-2019, Kevin D. Wurster, Sean C. Gillies +All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither click-plugins nor the names of its contributors may not be used to + endorse or promote products derived from this software without specific prior + written permission. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +''' +""" import click +import os +import sys +import traceback + class StrOrIntType(click.ParamType): """Custom type for click to parse the sample name @@ -49,3 +103,83 @@ def parse_custom_arguments(click_args: list) -> dict: raise RuntimeError(f"Could not parse argument {key}. It should be prefixed with `--`") parsed[key[2:]] = val return parsed + + +def with_plugins(plugins): + + """ + 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()`. + Returns + ------- + click.Group() + """ + + def decorator(group): + if not isinstance(group, click.Group): + raise TypeError("Plugins can only be attached to an instance of click.Group()") + + for entry_point in plugins or (): + try: + group.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. + group.add_command(BrokenCommand(entry_point.name)) + + return group + + return decorator + + +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 a `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 + 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 traceback instead of doing nothing. + """ + + click.echo(self.help, color=ctx.color) + ctx.exit(1) + + def parse_args(self, ctx, args): + return args diff --git a/tests/test_cli_utils.py b/tests/test_cli_utils.py new file mode 100644 index 00000000..2081f044 --- /dev/null +++ b/tests/test_cli_utils.py @@ -0,0 +1,208 @@ +"""Original code author tests for the click-plugin package. + +__version__ = '1.1.1' +__author__ = 'Kevin Wurster, Sean Gillies' +__email__ = 'wursterk@gmail.com, sean.gillies@gmail.com' +__source__ = 'https://github.com/click-contrib/click-plugins' +__license__ = ''' +New BSD License +Copyright (c) 2015-2019, Kevin D. Wurster, Sean C. Gillies +All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. +* Neither click-plugins nor the names of its contributors may not be used to + endorse or promote products derived from this software without specific prior + written permission. +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +''' +""" +import pytest +from pkg_resources import EntryPoint +from pkg_resources import iter_entry_points +from pkg_resources import working_set + +import click +from click.testing import CliRunner +from hangar.cli.utils import with_plugins + + +@pytest.fixture(scope='function') +def runner(request): + return CliRunner() + + +# 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 `click.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['click']._ep_map = { + '_test_hangar_plugins.test_cli_utils': { + 'cmd1': EntryPoint.parse( + 'cmd1=tests.test_cli_utils:cmd1', dist=DistStub()), + 'cmd2': EntryPoint.parse( + 'cmd2=tests.test_cli_utils:cmd2', dist=DistStub()) + }, + '_test_hangar_plugins.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 +@with_plugins(iter_entry_points('_test_hangar_plugins.test_cli_utils')) +@click.group() +def good_cli(): + """Good CLI group.""" + pass + +@with_plugins(iter_entry_points('_test_hangar_plugins.broken_plugins')) +@click.group() +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('_test_hangar_plugins.test_cli_utils')]) > 1 + assert len([ep for ep in iter_entry_points('_test_hangar_plugins.broken_plugins')]) > 1 + + +def test_register_and_run(runner): + + result = runner.invoke(good_cli) + assert result.exit_code == 0 + + for ep in iter_entry_points('_test_hangar_plugins'): + cmd_result = runner.invoke(good_cli, [ep.name, 'something']) + assert cmd_result.exit_code == 0 + assert cmd_result.output.strip() == 'passed' + + +def test_broken_register_and_run(runner): + + result = runner.invoke(broken_cli) + assert result.exit_code == 0 + assert u'\U0001F4A9' in result.output or u'\u2020' in result.output + + for ep in iter_entry_points('_test_hangar_plugins.broken_plugins'): + cmd_result = runner.invoke(broken_cli, [ep.name]) + assert cmd_result.exit_code != 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 == 0 + assert sub_cli.name in result.output + for ep in iter_entry_points('_test_hangar_plugins.test_cli_utils'): + assert ep.name in result.output + + # Same as above but the sub-group has plugins + @with_plugins(plugins=iter_entry_points('_test_hangar_plugins.test_cli_utils')) + @good_cli.group(name='sub-cli-plugins') + def sub_cli_plugins(): + """Sub CLI with plugins.""" + pass + + @sub_cli_plugins.command() + @click.argument('arg') + def cmd1(arg): + """Test command 1""" + click.echo('passed') + + result = runner.invoke(good_cli, ['sub-cli-plugins']) + assert result.exit_code == 0 + for ep in iter_entry_points('_test_hangar_plugins.test_cli_utils'): + 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 == 0 + assert result.output.strip() == 'passed' + + +def test_exception(): + # Decorating something that isn't a click.Group() should fail + with pytest.raises(TypeError): + @with_plugins([]) + @click.command() + def cli(): + """Whatever""" + + +def test_broken_register_and_run_with_help(runner): + result = runner.invoke(broken_cli) + assert result.exit_code == 0 + assert u'\U0001F4A9' in result.output or u'\u2020' in result.output + + for ep in iter_entry_points('_test_hangar_plugins.broken_plugins'): + cmd_result = runner.invoke(broken_cli, [ep.name, "--help"]) + assert cmd_result.exit_code != 0 + assert 'Traceback' in cmd_result.output + + +def test_broken_register_and_run_with_args(runner): + result = runner.invoke(broken_cli) + assert result.exit_code == 0 + assert u'\U0001F4A9' in result.output or u'\u2020' in result.output + + for ep in iter_entry_points('_test_hangar_plugins.broken_plugins'): + cmd_result = runner.invoke(broken_cli, [ep.name, "-a", "b"]) + assert cmd_result.exit_code != 0 + assert 'Traceback' in cmd_result.output \ No newline at end of file diff --git a/tox.ini b/tox.ini index 80ce2c54..4b057cac 100644 --- a/tox.ini +++ b/tox.ini @@ -48,7 +48,7 @@ basepython = setenv = PYTHONPATH={toxinidir}/tests PYTHONUNBUFFERED=yes -usedevelop = true +usedevelop = false passenv = * commands =