Load plugins in configure()

This commit is contained in:
Matthew Planchard 2018-07-18 20:59:26 -05:00
parent 26d35cd9e9
commit 02f3d5af61
5 changed files with 138 additions and 25 deletions

@ -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

@ -3,5 +3,6 @@
from sys import version_info
PLUGIN_GROUPS = ('authenticators',)
PY2 = version_info < (3,)
STANDALONE_WELCOME = 'standalone'

@ -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:

10
tests/doubles.py Normal file

@ -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)

@ -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