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',