Repository: jupyterhub/jupyterhub

Issue:
Can't use `User.get_auth_state` to configure `Spawner.options_form`
I'm using JupyterHub 0.8.1 (on Kubernetes). I am attempting to create a custom `Spawner.options_form` whose content is based on group membership information returned by a custom Authenticator. The groups are successfully retrieved and stored using the `auth_state` interface (introduced in 0.8.0), but I don't think it's possible to use this data to configure the form from my Spawner. The fundamental issue (as I understand it), is that the `User.get_auth_state` method is async (returns a Future), whereas the `Spawner.options_form` method is synchronous.

I've reviewed the approach linked from here: https://github.com/jupyterhub/jupyterhub/issues/1542. This Authenticator/Spawner pair is creative, interesting and useful, but it does not utilize the `auth_state` interface to store information from the Authenticator and pass that to the Spawner. 

A similar (less specific) issue is open on the `wrapspawner` project, here: https://github.com/jupyterhub/wrapspawner/issues/7. I left some remarks there about what I see and why it doesn't appear to me to be possible to use the existing interfaces to accomplish what I described. 

I have attempted to directly block and obtain the result of the Future returned from `User.get_auth_state`, but I was unsuccessful in doing so. I tried:
1. `self.user.get_auth_state().result()`, but that doesn't work: `Exception: DummyFuture does not support blocking for results`
2. `IOLoop.current().run_sync(self.user.get_auth_state)`, but that failed with: `RuntimeError: This event loop is already running`

I considered attempting to pass the information (group memberships) between Authenticator and Spawner through some other route. In the issue on `wrapspawner` I pointed out that `Authenticator.pre_spawn_start` and `Spawner.pre_spawn_hook` are both called after the `Spawner.options_form` has been rendered. Furthermore, I don't see an existing place in the interfaces that will be durable across restarts (if the timing of one of the two `pre_spawn_*` hooks was correct, I believe it could be used to pass state in-memory, but that it would not be durable across restarts). 

Based on this investigation, it appears to me that the only option is to change the interface of `Spawner.options_form` to be async (return a Future), and to introduce `yield` to the call-sites that invoke it. This does not appear to be a massive change, but it also does not seem trivial. I'm also unsure as to whether `Traitlets` properties can return Futures, or whether the change to async will require converting `options_form` away from `Traitlets`.

Have I missed something? Is there some other way to access `auth_state` from `options_form`? Is there some less invasive (localized) way to allow `options_form` to retrieve `auth_state`?


Discussion:
If we want auth_state as an input to options_form, I think we are going to have to introduce a new async API for getting the options form (e.g. `async def get_options_form(self): ...`) with a default that simply returns `self.options_form`. I think that's probably the right thing to do, as there have been several similar requests for dynamic, if not async, options form creation.
I'm glad that you agree with my understanding (I wasn't entirely confident). I will introduce a PR that introduces the proposed API change.

One question: should I attempt to implement the new `get_options_form` method as a Traitlet property? I assume not, but please correct me if I'm wrong.

Resolving patch:
diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py
--- a/jupyterhub/handlers/pages.py
+++ b/jupyterhub/handlers/pages.py
@@ -87,16 +87,19 @@ class SpawnHandler(BaseHandler):
 
     Only enabled when Spawner.options_form is defined.
     """
+    @gen.coroutine
     def _render_form(self, message=''):
         user = self.get_current_user()
+        spawner_options_form = yield user.spawner.get_options_form()
         return self.render_template('spawn.html',
             user=user,
-            spawner_options_form=user.spawner.options_form,
+            spawner_options_form=spawner_options_form,
             error_message=message,
             url=self.request.uri,
         )
 
     @web.authenticated
+    @gen.coroutine
     def get(self):
         """GET renders form for spawning with user-specified options
 
@@ -109,7 +112,8 @@ def get(self):
             self.redirect(url)
             return
         if user.spawner.options_form:
-            self.finish(self._render_form())
+            form = yield self._render_form()
+            self.finish(form)
         else:
             # Explicit spawn request: clear _spawn_future
             # which may have been saved to prevent implicit spawns
diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py
--- a/jupyterhub/spawner.py
+++ b/jupyterhub/spawner.py
@@ -17,17 +17,17 @@
 
 from sqlalchemy import inspect
 
-from tornado import gen
+from tornado import gen, concurrent
 from tornado.ioloop import PeriodicCallback
 
 from traitlets.config import LoggingConfigurable
 from traitlets import (
-    Any, Bool, Dict, Instance, Integer, Float, List, Unicode,
+    Any, Bool, Dict, Instance, Integer, Float, List, Unicode, Union,
     observe, validate,
 )
 
 from .objects import Server
-from .traitlets import Command, ByteSpecification
+from .traitlets import Command, ByteSpecification, Callable
 from .utils import random_port, url_path_join, exponential_backoff
 
 
@@ -227,7 +227,10 @@ def name(self):
         help="Enable debug-logging of the single-user server"
     ).tag(config=True)
 
-    options_form = Unicode(
+    options_form = Union([
+            Unicode(),
+            Callable()
+        ],
         help="""
         An HTML form for options a user can specify on launching their server.
 
@@ -247,8 +250,32 @@ def name(self):
             </select>
 
         The data from this form submission will be passed on to your spawner in `self.user_options`
+
+        Instead of a form snippet string, this could also be a callable that takes as one
+        parameter the current spawner instance and returns a string. The callable will
+        be called asynchronously if it returns a future, rather than a str. Note that
+        the interface of the spawner class is not deemed stable across versions,
+        so using this functionality might cause your JupyterHub upgrades to break.
     """).tag(config=True)
 
+    @gen.coroutine
+    def get_options_form(self):
+        """Get the options form
+
+        Returns:
+          (Future(str)): the content of the options form presented to the user
+          prior to starting a Spawner.
+
+        .. versionadded:: 0.9.0
+            Introduced.
+        """
+        if callable(self.options_form):
+            options_form = yield gen.maybe_future(self.options_form(self))
+        else:
+            options_form = self.options_form
+
+        return options_form
+
     def options_from_form(self, form_data):
         """Interpret HTTP form data
 
diff --git a/jupyterhub/traitlets.py b/jupyterhub/traitlets.py
--- a/jupyterhub/traitlets.py
+++ b/jupyterhub/traitlets.py
@@ -4,7 +4,7 @@
 # Copyright (c) Jupyter Development Team.
 # Distributed under the terms of the Modified BSD License.
 
-from traitlets import List, Unicode, Integer, TraitError
+from traitlets import List, Unicode, Integer, TraitType, TraitError
 
 
 class URLPrefix(Unicode):
@@ -74,3 +74,20 @@ def validate(self, obj, value):
             raise TraitError('{val} is not a valid memory specification. Must be an int or a string with suffix K, M, G, T'.format(val=value))
         else:
             return int(float(num) * self.UNIT_SUFFIXES[suffix])
+
+
+class Callable(TraitType):
+    """
+    A trait which is callable.
+
+    Classes are callable, as are instances
+    with a __call__() method.
+    """
+
+    info_text = 'a callable'
+
+    def validate(self, obj, value):
+        if callable(value):
+           return value
+        else:
+            self.error(obj, value)


Test patch: