diff --git a/pypiserver/config.py b/pypiserver/config.py index 4911ec2..c2abadf 100644 --- a/pypiserver/config.py +++ b/pypiserver/config.py @@ -8,10 +8,10 @@ from os import environ, path from textwrap import dedent import pkg_resources -from pkg_resources import iter_entry_points from . import __version__ from .bottle import server_names +from .core import load_plugins from .const import STANDALONE_WELCOME @@ -166,14 +166,7 @@ class Config(object): self.help_formatter = help_formatter self.parser_cls = parser_cls self.parser_type = parser_type - self._plugins = { - 'auth': {}, - } - - def load_plugins(self): - """Load plugins for later access.""" - for entrypoint in iter_entry_points('pypiserver.authenticators'): - self._plugins['auth'][entrypoint.name] = entrypoint.load() + self._plugins = load_plugins() def get_default(self, subcommand='run'): """Return a parsed config with default argument values. @@ -219,7 +212,6 @@ class Config(object): def _get_parser(self): """Return a hydrated parser.""" - self.load_plugins() parser = self.parser_cls( description='PyPI-compatible package server', formatter_class=self.help_formatter @@ -446,16 +438,16 @@ class Config(object): default=environ.get('PYPISERVER_PASSWORD_FILE'), help=dedent('''\ use apache htpasswd file PASSWORD_FILE to set usernames & - passwords when authenticating certain actions (see -a option). - If you want to allow unauthorized access, set this option - and -a to '.' + passwords when authenticating certain actions (see + -a option). If you want to allow unauthorized access, + set this option and -a to '.' ''') ) security.add_argument( '--auth-backend', dest='auther', default=environ.get('PYPISERVER_AUTH_BACKEND'), - choices=self._plugins['auth'].keys(), + choices=self._plugins['authenticators'].keys(), help=( 'Specify an authentication backend. By default, will attempt ' 'to use an htpasswd file if provided. If specified, must ' @@ -477,7 +469,7 @@ class Config(object): :param ArgumentParser parser: the "run" subcommand parser """ - for name, plugin in self._plugins['auth'].items(): + for name, plugin in self._plugins['authenticators'].items(): self.add_plugin_group(parser, name, plugin) @staticmethod diff --git a/pypiserver/const.py b/pypiserver/const.py index d65c288..5f10c8a 100644 --- a/pypiserver/const.py +++ b/pypiserver/const.py @@ -3,5 +3,6 @@ from sys import version_info +PLUGIN_GROUPS = ('authenticators',) PY2 = version_info < (3,) STANDALONE_WELCOME = 'standalone' diff --git a/pypiserver/core.py b/pypiserver/core.py index 733dc24..fb76204 100644 --- a/pypiserver/core.py +++ b/pypiserver/core.py @@ -11,8 +11,9 @@ import re import sys import pkg_resources +from pkg_resources import iter_entry_points -from .const import PY2, STANDALONE_WELCOME +from .const import PLUGIN_GROUPS, PY2, STANDALONE_WELCOME if PY2: from io import open @@ -35,6 +36,31 @@ _pkgname_parts_re = re.compile( re.I) +def _validate_roots(roots): + """Validate roots. + + :param List[str] roots: a list of package roots. + """ + for root in roots: + try: + os.listdir(root) + except OSError as exc: + raise ValueError( + 'Error while trying to list root({}): ' + '{}'.format(root, repr(exc)) + ) + + +def validate_config(config): + """Check config arguments. + + :param argparse.Namespace config: a config namespace + + :raises ValueError: if a config value is invalid + """ + _validate_roots(config.roots) + + def configure(config): """Validate configuration and return with a package list. @@ -43,13 +69,8 @@ def configure(config): :return: 2-tuple (Configure, package-list) :rtype: tuple """ - for r in config.roots: - try: - os.listdir(r) - except OSError: - err = sys.exc_info()[1] - msg = "Error: while trying to list root(%s): %s" - sys.exit(msg % (r, err)) + validate_config(config) + add_plugins_to_config(config) def packages(): """Return an iterable over package files in package roots.""" @@ -87,6 +108,43 @@ def configure(config): return config, packages +def load_plugins(*groups): + """Load pypiserver plugins. + + :param groups: the plugin group(s) names (str) to load. Group names + must be one of ``const.PLUGIN_GROUPS``. If no groups are + provided, all groups will be loaded. + + :return: a dict whose keys are plugin group names and whose values + are nested dicts whose keys are plugin names and whose values + are the loaded plugins. + :rtype: dict + """ + if groups and not all(g in PLUGIN_GROUPS for g in groups): + raise ValueError( + 'Invalid group provided. Groups must ' + 'be one of: {}'.format(PLUGIN_GROUPS) + ) + groups = groups if groups else PLUGIN_GROUPS + plugins = {} + for group in groups: + plugins.setdefault(group, {}) + for plugin in iter_entry_points('pypiserver.{}'.format(group)): + plugins[group][plugin.name] = plugin.load() + return plugins + + +def add_plugins_to_config(config, plugins=None): + """Load plugins if necessary and add to a config object. + + :param argparse.Namespace config: a config namespace + :param dict plugins: an optional loaded plugin dict. If not + provided, plugins will be loaded. + """ + plugins = load_plugins() if plugins is None else plugins + config.plugins = plugins + + def auth_by_htpasswd_file(ht_pwd_file, username, password): """The default ``config.auther``.""" if ht_pwd_file is not None: diff --git a/tests/doubles.py b/tests/doubles.py new file mode 100644 index 0000000..53a0725 --- /dev/null +++ b/tests/doubles.py @@ -0,0 +1,10 @@ +"""Utilities for constructing test doubles.""" + + +class GenericNamespace(object): + """A generic namespace constructed from kwargs.""" + + def __init__(self, **kwargs): + """Convert kwargs to attributes on the instantiated object.""" + for key, value in kwargs.items(): + setattr(self, key, value) diff --git a/tests/test_core.py b/tests/test_core.py index ba675df..5608a1b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -9,6 +9,8 @@ import pytest from pypiserver import __main__, core +from .doubles import GenericNamespace + # Enable logging to detect any problems with it __main__.init_logging(level=logging.NOTSET) @@ -86,8 +88,8 @@ def test_listdir_bad_name(tmpdir): hashes = [ - ('sha256', 'e3b0c44298fc1c149afbf4c8996fb924'), # empty-sha256 - ('md5', 'd41d8cd98f00b204e9800998ecf8427e'), # empty-md5 + ('sha256', 'e3b0c44298fc1c149afbf4c8996fb924'), # empty-sha256 + ('md5', 'd41d8cd98f00b204e9800998ecf8427e'), # empty-md5 ] @@ -96,3 +98,53 @@ def test_hashfile(tmpdir, algo, digest): f = tmpdir.join("empty") f.ensure() assert core.digest_file(f.strpath, algo) == digest + + +def test_load_plugins(): + """Test loading plugins. + + We should at least be able to get the ones included with the full + passlib install. + """ + plugins = core.load_plugins() + assert 'htpasswd' in plugins['authenticators'] + + +def test_load_plugin_group(): + """Test loading a single plugin group. + + This test is not quite definitive at the time of authorship since + there's only one plugin (therefore the output will be the same as + for ``load_plugins()`` with no arguments). However, as soon as + a second plugin type is added, it'll become more meaningful. + """ + auth_plugins = core.load_plugins('authenticators') + assert 'htpasswd' in auth_plugins['authenticators'] + + +def test_load_plugin_bad_group(): + """Test that trying to load a bad group raises an error.""" + with pytest.raises(ValueError): + # hopefully this is never a legit plugin type + core.load_plugins('fhgwgad') + + +def test_load_plugins_bad_and_good_group(): + """Test that the bad group is detected even among a good one.""" + with pytest.raises(ValueError): + core.load_plugins('authenticators', 'wheelchair_assassins') + + +def test_add_plugins_to_config_load(monkeypatch): + """Test that load_plugins() is called for no provided plugins.""" + monkeypatch.setattr(core, 'load_plugins', lambda *x: 'plugin_stub') + config = GenericNamespace() + core.add_plugins_to_config(config) + assert config.plugins == 'plugin_stub' # pylint: disable=no-member + + +def test_add_plugins_to_config_no_load(): + """Test adding passed plugins to a config.""" + config = GenericNamespace() + core.add_plugins_to_config(config, plugins='plugins!') + assert config.plugins == 'plugins!' # pylint: disable=no-member