Repository: jupyterhub/jupyterhub

Issue:
discover Spawners/Authenticators via entrypoints
`jupyterhub --generate-config` uses traitlets' `Application.classes` to select the list of classes whose config should be included in the config file. There isn't currently a good way for custom classes to be included in the config file (e.g. custom Spawners, etc.), so producing a generated config file with defaults for these classes is generally not done.

If Authenticators and Spawners identified themselves via [entry_points](https://setuptools.readthedocs.io/en/latest/setuptools.html#dynamic-discovery-of-services-and-plugins) then we could populate this list from any configurable classes currently installed.

We just need to pick the entrypoint names. Presumably `jupyterhub.spawners` and `jupyterhub.authenticators`.


Discussion:

Resolving patch:
diff --git a/jupyterhub/app.py b/jupyterhub/app.py
--- a/jupyterhub/app.py
+++ b/jupyterhub/app.py
@@ -42,7 +42,7 @@
     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 ._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 @@ def _deprecate_api_tokens(self, change):
     ).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 @@ def _authenticator_default(self):
     ).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
--- a/jupyterhub/traitlets.py
+++ b/jupyterhub/traitlets.py
@@ -4,7 +4,8 @@
 # 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 @@ def validate(self, obj, value):
            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/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -106,6 +106,17 @@ def get_package_data():
     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',


Test patch: