From c02ab23b3dc685846a39d81bc04dd047b08072a1 Mon Sep 17 00:00:00 2001 From: Min RK Date: Fri, 28 Sep 2018 10:08:10 +0200 Subject: [PATCH] allow spawners and authenticators to register via entrypoints jupyterhub.authenticators for authenticators, jupyterhub.spawners for spawners This has the effect that authenticators and spawners can be selected by name instead of full import string (e.g. 'github' or 'dummy' or 'kubernetes') and, perhaps more importantly, the autogenerated configuration file will include a section for each installed and registered class. --- docs/source/reference/authenticators.md | 39 +++++++++++++++++-- docs/source/reference/spawners.md | 37 ++++++++++++++++++ jupyterhub/app.py | 50 +++++++++++++++++-------- jupyterhub/traitlets.py | 46 ++++++++++++++++++++++- requirements.txt | 1 + setup.py | 11 ++++++ 6 files changed, 164 insertions(+), 20 deletions(-) diff --git a/docs/source/reference/authenticators.md b/docs/source/reference/authenticators.md index 68aa23d6..5c0bbfe5 100644 --- a/docs/source/reference/authenticators.md +++ b/docs/source/reference/authenticators.md @@ -68,7 +68,6 @@ Writing an Authenticator that looks up passwords in a dictionary requires only overriding this one method: ```python -from tornado import gen from IPython.utils.traitlets import Dict from jupyterhub.auth import Authenticator @@ -78,8 +77,7 @@ class DictionaryAuthenticator(Authenticator): help="""dict of username:password for authentication""" ) - @gen.coroutine - def authenticate(self, handler, data): + async def authenticate(self, handler, data): if self.passwords.get(data['username']) == data['password']: return data['username'] ``` @@ -136,6 +134,41 @@ See a list of custom Authenticators [on the wiki](https://github.com/jupyterhub/ If you are interested in writing a custom authenticator, you can read [this tutorial](http://jupyterhub-tutorial.readthedocs.io/en/latest/authenticators.html). +### Registering custom Authenticators via entry points + +As of JupyterHub 1.0, custom authenticators can register themselves via +the `jupyterhub.authenticators` entry point metadata. +To do this, in your `setup.py` add: + +```python +setup( + ... + entry_points={ + 'jupyterhub.authenticators': [ + 'myservice = mypackage:MyAuthenticator', + ], + }, +) +``` + +If you have added this metadata to your package, +users can select your authenticator with the configuration: + +```python +c.JupyterHub.authenticator_class = 'myservice' +``` + +instead of the full + +```python +c.JupyterHub.authenticator_class = 'mypackage:MyAuthenticator' +``` + +previously required. +Additionally, configurable attributes for your spawner will +appear in jupyterhub help output and auto-generated configuration files +via `jupyterhub --generate-config`. + ### Authentication state diff --git a/docs/source/reference/spawners.md b/docs/source/reference/spawners.md index 45d324a4..4ad0d9e2 100644 --- a/docs/source/reference/spawners.md +++ b/docs/source/reference/spawners.md @@ -10,6 +10,7 @@ and a custom Spawner needs to be able to take three actions: ## Examples + Custom Spawners for JupyterHub can be found on the [JupyterHub wiki](https://github.com/jupyterhub/jupyterhub/wiki/Spawners). Some examples include: @@ -174,6 +175,42 @@ When `Spawner.start` is called, this dictionary is accessible as `self.user_opti If you are interested in building a custom spawner, you can read [this tutorial](http://jupyterhub-tutorial.readthedocs.io/en/latest/spawners.html). +### Registering custom Spawners via entry points + +As of JupyterHub 1.0, custom Spawners can register themselves via +the `jupyterhub.spawners` entry point metadata. +To do this, in your `setup.py` add: + +```python +setup( + ... + entry_points={ + 'jupyterhub.spawners': [ + 'myservice = mypackage:MySpawner', + ], + }, +) +``` + +If you have added this metadata to your package, +users can select your authenticator with the configuration: + +```python +c.JupyterHub.spawner_class = 'myservice' +``` + +instead of the full + +```python +c.JupyterHub.spawner_class = 'mypackage:MySpawner' +``` + +previously required. +Additionally, configurable attributes for your spawner will +appear in jupyterhub help output and auto-generated configuration files +via `jupyterhub --generate-config`. + + ## Spawners, resource limits, and guarantees (Optional) Some spawners of the single-user notebook servers allow setting limits or diff --git a/jupyterhub/app.py b/jupyterhub/app.py index bec5bf3a..0348c065 100644 --- a/jupyterhub/app.py +++ b/jupyterhub/app.py @@ -42,7 +42,7 @@ from traitlets import ( Tuple, Type, Set, Instance, Bytes, Float, observe, default, ) -from traitlets.config import Application, catch_config_error +from traitlets.config import Application, Configurable, catch_config_error here = os.path.dirname(__file__) @@ -58,7 +58,7 @@ from .oauth.provider import make_provider from ._data import DATA_FILES_PATH from .log import CoroutineLogFormatter, log_request from .proxy import Proxy, ConfigurableHTTPProxy -from .traitlets import URLPrefix, Command +from .traitlets import URLPrefix, Command, EntryPointType from .utils import ( maybe_future, url_path_join, @@ -227,13 +227,19 @@ class JupyterHub(Application): 'upgrade-db': (UpgradeDB, "Upgrade your JupyterHub state database to the current version."), } - classes = List([ - Spawner, - LocalProcessSpawner, - Authenticator, - PAMAuthenticator, - CryptKeeper, - ]) + classes = List() + @default('classes') + def _load_classes(self): + classes = [Spawner, Authenticator, CryptKeeper] + for name, trait in self.traits(config=True).items(): + # load entry point groups into configurable class list + # so that they show up in config files, etc. + if isinstance(trait, EntryPointType): + for key, entry_point in trait.load_entry_points().items(): + cls = entry_point.load() + if cls not in classes and isinstance(cls, Configurable): + classes.append(cls) + return classes load_groups = Dict(List(Unicode()), help="""Dict of 'group': ['usernames'] to load at startup. @@ -651,20 +657,25 @@ class JupyterHub(Application): ).tag(config=True) _service_map = Dict() - authenticator_class = Type(PAMAuthenticator, Authenticator, + authenticator_class = EntryPointType( + default_value=PAMAuthenticator, + klass=Authenticator, + entry_point_group="jupyterhub.authenticators", help="""Class for authenticating users. - This should be a class with the following form: + This should be a subclass of :class:`jupyterhub.auth.Authenticator` - - constructor takes one kwarg: `config`, the IPython config object. - - with an authenticate method that: + with an :meth:`authenticate` method that: - is a coroutine (asyncio or tornado) - returns username on success, None on failure - takes two arguments: (handler, data), where `handler` is the calling web.RequestHandler, and `data` is the POST form data from the login page. + + .. versionchanged:: 1.0 + authenticators may be registered via entry points, + e.g. `c.JupyterHub.authenticator_class = 'pam'` """ ).tag(config=True) @@ -679,10 +690,17 @@ class JupyterHub(Application): ).tag(config=True) # class for spawning single-user servers - spawner_class = Type(LocalProcessSpawner, Spawner, + spawner_class = EntryPointType( + default_value=LocalProcessSpawner, + klass=Spawner, + entry_point_group="jupyterhub.spawners", help="""The class to use for spawning single-user servers. - Should be a subclass of Spawner. + Should be a subclass of :class:`jupyterhub.spawner.Spawner`. + + .. versionchanged:: 1.0 + spawners may be registered via entry points, + e.g. `c.JupyterHub.spawner_class = 'localprocess'` """ ).tag(config=True) diff --git a/jupyterhub/traitlets.py b/jupyterhub/traitlets.py index 4d189314..135eed94 100644 --- a/jupyterhub/traitlets.py +++ b/jupyterhub/traitlets.py @@ -4,7 +4,8 @@ Traitlets that are used in JupyterHub # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -from traitlets import List, Unicode, Integer, TraitType, TraitError +import entrypoints +from traitlets import List, Unicode, Integer, Type, TraitType, TraitError class URLPrefix(Unicode): @@ -91,3 +92,46 @@ class Callable(TraitType): return value else: self.error(obj, value) + + +class EntryPointType(Type): + """Entry point-extended Type + + classes can be registered via entry points + in addition to standard 'mypackage.MyClass' strings + """ + + _original_help = '' + + def __init__(self, *args, entry_point_group, **kwargs): + self.entry_point_group = entry_point_group + super().__init__(*args, **kwargs) + + @property + def help(self): + """Extend help by listing currently installed choices""" + chunks = [self._original_help] + chunks.append("Currently installed: ") + for key, entry_point in self.load_entry_points().items(): + chunks.append(" - {}: {}.{}".format(key, entry_point.module_name, entry_point.object_name)) + return '\n'.join(chunks) + + @help.setter + def help(self, value): + self._original_help = value + + def load_entry_points(self): + """Load my entry point group""" + # load the group + group = entrypoints.get_group_named(self.entry_point_group) + # make it case-insensitive + return {key.lower(): value for key, value in group.items()} + + def validate(self, obj, value): + if isinstance(value, str): + # first, look up in entry point registry + registry = self.load_entry_points() + key = value.lower() + if key in registry: + value = registry[key].load() + return super().validate(obj, value) diff --git a/requirements.txt b/requirements.txt index ec8fe23f..73278878 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ alembic async_generator>=1.8 +entrypoints traitlets>=4.3.2 tornado>=5.0 jinja2 diff --git a/setup.py b/setup.py index 27383c13..0f7a9c15 100755 --- a/setup.py +++ b/setup.py @@ -106,6 +106,17 @@ setup_args = dict( platforms = "Linux, Mac OS X", keywords = ['Interactive', 'Interpreter', 'Shell', 'Web'], python_requires = ">=3.5", + entry_points = { + 'jupyterhub.authenticators': [ + 'default = jupyterhub.auth:PAMAuthenticator', + 'pam = jupyterhub.auth:PAMAuthenticator', + 'dummy = jupyterhub.auth:DummyAuthenticator', + ], + 'jupyterhub.spawners': [ + 'default = jupyterhub.spawner:LocalProcessSpawner', + 'localprocess = jupyterhub.spawner:LocalProcessSpawner', + ], + }, classifiers = [ 'Intended Audience :: Developers', 'Intended Audience :: System Administrators',