diff --git a/docs/source/reference/spawners.md b/docs/source/reference/spawners.md
index f23ff127..9638c689 100644
--- a/docs/source/reference/spawners.md
+++ b/docs/source/reference/spawners.md
@@ -2,7 +2,7 @@
# Spawners
-A [Spawner][] starts each single-user notebook server.
+A [Spawner](#Spawner) starts each single-user notebook server.
The Spawner represents an abstract interface to a process,
and a custom Spawner needs to be able to take three actions:
@@ -37,7 +37,7 @@ Some examples include:
### Spawner.start
-`Spawner.start` should start a single-user server for a single user.
+[](#Spawner.start) should start a single-user server for a single user.
Information about the user can be retrieved from `self.user`,
an object encapsulating the user's name, authentication, and server info.
@@ -68,11 +68,11 @@ async def start(self):
When `Spawner.start` returns, the single-user server process should actually be running,
not just requested. JupyterHub can handle `Spawner.start` being very slow
(such as PBS-style batch queues, or instantiating whole AWS instances)
-via relaxing the `Spawner.start_timeout` config value.
+via relaxing the [](#Spawner.start_timeout) config value.
#### Note on IPs and ports
-`Spawner.ip` and `Spawner.port` attributes set the _bind_ URL,
+[](#Spawner.ip) and [](#Spawner.port) attributes set the _bind_ URL,
which the single-user server should listen on
(passed to the single-user process via the `JUPYTERHUB_SERVICE_URL` environment variable).
The _return_ value is the IP and port (or full URL) the Hub should _connect to_.
@@ -124,7 +124,7 @@ If both attributes are not present, the Exception will be shown to the user as u
### Spawner.poll
-`Spawner.poll` checks if the spawner is still running.
+[](#Spawner.poll) checks if the spawner is still running.
It should return `None` if it is still running,
and an integer exit status, otherwise.
@@ -133,7 +133,7 @@ to check if the local process is still running. On Windows, it uses `psutil.pid_
### Spawner.stop
-`Spawner.stop` should stop the process. It must be a tornado coroutine, which should return when the process has finished exiting.
+[](#Spawner.stop) should stop the process. It must be a tornado coroutine, which should return when the process has finished exiting.
## Spawner state
@@ -168,15 +168,14 @@ def clear_state(self):
## Spawner options form
-(new in 0.4)
-
Some deployments may want to offer options to users to influence how their servers are started.
-This may include cluster-based deployments, where users specify what resources should be available,
-or docker-based deployments where users can select from a list of base images.
+This may include cluster-based deployments, where users specify what memory or cpu resources should be available,
+or container-based deployments where users can select from a list of base images,
+or more complex configurations where users select a "profile" representing a bundle of settings to be applied together.
-This feature is enabled by setting `Spawner.options_form`, which is an HTML form snippet
+This feature is enabled by setting [](#Spawner.options_form), which is an HTML form snippet
inserted unmodified into the spawn form.
-If the `Spawner.options_form` is defined, when a user tries to start their server, they will be directed to a form page, like this:
+If the `Spawner.options_form` is defined, when a user tries to start their server they will be directed to a form page, like this:

@@ -186,28 +185,40 @@ See [this example](https://github.com/jupyterhub/jupyterhub/blob/HEAD/examples/s
### `Spawner.options_from_form`
-Options from this form will always be a dictionary of lists of strings, e.g.:
+Inputs from an HTML form always arrive as a dictionary of lists of strings, e.g.:
```python
-{
+formdata = {
'integer': ['5'],
+ 'checkbox': ['on'],
'text': ['some text'],
'select': ['a', 'b'],
}
```
-When `formdata` arrives, it is passed through `Spawner.options_from_form(formdata)`,
-which is a method to turn the form data into the correct structure.
-This method must return a dictionary, and is meant to interpret the lists-of-strings into the correct types. For example, the `options_from_form` for the above form would look like:
+When `formdata` arrives, it is passed through [](#Spawner.options_from_form):
```python
-def options_from_form(self, formdata):
+spawner.user_options = [await] spawner.options_from_form(formdata[, spawner=spawner])
+```
+
+to create `spawner.user_options`.
+
+[](#Spawner.options_from_form) is a configurable function to turn the HTTP form data into the correct structure for [](#Spawner.user_options).
+`options_from_form` must return a dictionary, and is meant to interpret the lists-of-strings a web form produces into the correct types.
+For example, the `options_from_form` for the above form might look like:
+
+```python
+def options_from_form(formdata):
options = {}
options['integer'] = int(formdata['integer'][0]) # single integer value
+ options['checkbox'] = formdata['checkbox'] == ['on']
options['text'] = formdata['text'][0] # single string value
options['select'] = formdata['select'] # list already correct
options['notinform'] = 'extra info' # not in the form at all
return options
+
+c.Spawner.options_from_form = options_from_form
```
which would return:
@@ -215,15 +226,114 @@ which would return:
```python
{
'integer': 5,
+ 'checkbox': True,
'text': 'some text',
'select': ['a', 'b'],
'notinform': 'extra info',
}
```
-When `Spawner.start` is called, this dictionary is accessible as `self.user_options`.
+### Applying user options
-[spawner]: https://github.com/jupyterhub/jupyterhub/blob/HEAD/jupyterhub/spawner.py
+The base Spawner class doesn't do anything with `user_options`, that is also up to your deployment and/or chosen Spawner.
+This is because exposing any options to users has security implications,
+so it is part of your Spawner and/or deployment configuration to expose the options you trust your users to set.
+
+[](#Spawner.apply_user_options) is the hook for taking `user_options` and applying whatever configuration it may represent.
+
+```python
+def apply_user_options(spawner, user_options):
+ if "image" in user_options and isinstance(user_options["image"], str):
+ spawner.image = user_options["image"]
+
+c.Spawner.apply_user_options = apply_user_options
+```
+
+:::{versionadded} 5.3
+JupyterHub 5.3 introduces [](#Spawner.apply_user_options) configuration.
+Previously, [](#Spawner.user_options) could only be consumed during [](#Spawner.start),
+at which point `user_options` is available to the Spawner instance as `self.user_options`.
+This approach requires subclassing, so it was not possible to apply new `user_options` via configuration.
+In JupyterHub 5.3, it is possible to fully expose user options,
+and for some simple cases, fully with _declarative_ configuration.
+:::
+
+### Declarative configuration for user options
+
+While [](#Spawner.options_from_form) and [](#Spawner.apply_user_options) are callables by nature,
+some simple cases can be represented by declarative configuration,
+which is most conveniently expressed in e.g. the yaml of the JupyterHub helm chart.
+The cases currently handled are:
+
+```python
+c.Spawner.options_form = """
+
+
+"""
+c.Spawner.options_from_form = "simple"
+c.Spawner.apply_user_options = {"image_input": "image", "debug_checkbox": "debug"}
+```
+
+The `simple` options_from_form does the very simplest interpretation of an html form,
+casting the lists of strings to single strings by getting the first item when there is only one.
+The only non-string handling it has is casting the checkbox value of `on` to True.
+
+So it turns this formdata:
+
+```python
+{
+ "image_input": ["my_image"],
+ "debug_checkbox": ["on"],
+}
+```
+
+into this `.user_options`
+
+```python
+{
+ "image_input": "my_image",
+ "debug_checkbox": True
+}
+```
+
+When `apply_user_options` is a dictionary, any input in `user_options` is looked up in this dictionary,
+and assigned to the corresponding Spawner attribute.
+Strings are passed through traitlets' `from_string` logic (what is used for setting values on the command-line),
+which means you can set numbers and things this way as well,
+even though `options_from_form` leaves these as strings.
+
+So in the above configuration, we have exposed `Spawner.debug` and `Spawner.image` without needing to write any functions.
+In jupyterhub helm chart yaml, this would look like:
+
+```yaml
+hub:
+ config:
+ KubeSpawner:
+ options_form: |
+
+
+ options_from_form: simple
+ apply_user_options:
+ image_input: image
+ debug_checkbox: debug
+```
+
+### Setting `user_options` directly via the REST API
+
+In addition to going through the options form, `user_options` may be set directly, via the REST API.
+The body of a POST request to spawn a server may be a JSON dictionary,
+which will be used to set `user_options` directly.
+When used this way, neither `options_form` nor `options_from_form` are involved,
+`user_options` is set directly, and only `apply_user_options` is called.
+
+```
+POST /hub/api/users/servers/:name
+{
+ "option": 5,
+ "bool": True,
+ "string": "value"
+}
+```
## Writing a custom spawner
diff --git a/jupyterhub/spawner.py b/jupyterhub/spawner.py
index 16d173b7..23d44551 100644
--- a/jupyterhub/spawner.py
+++ b/jupyterhub/spawner.py
@@ -623,7 +623,8 @@ class Spawner(LoggingConfigurable):
though it can contain bytes in addition to standard JSON data types.
This method should not have any side effects.
- Any handling of `user_options` should be done in `.start()`
+ Any handling of `user_options` should be done in `.apply_user_options()` (JupyterHub 5.3)
+ or `.start()` (JupyterHub 5.2 or older)
to ensure consistent behavior across servers
spawned via the API and form submission page.
@@ -642,9 +643,47 @@ class Spawner(LoggingConfigurable):
@default("options_from_form")
def _options_from_form(self):
- return self._default_options_from_form
+ return self._passthrough_options_from_form
- def _default_options_from_form(self, form_data):
+ @validate("options_from_form")
+ def _validate_options_from_form(self, proposal):
+ # coerce special string values to callable
+ if proposal.value == "passthrough":
+ return self._passthrough_options_from_form
+ elif proposal.value == "simple":
+ return self._simple_options_from_form
+ else:
+ return proposal.value
+
+ def _passthrough_options_from_form(self, form_data):
+ """The longstanding default behavior for options_from_form
+
+ explicit opt-in via `options_from_form = 'passthrough'`
+
+ .. versionadded:: 5.3
+ """
+ return form_data
+
+ def _simple_options_from_form(self, form_data):
+ """Simple options_from_form
+
+ Enable via `options_from_form = 'simple'
+
+ Assumes only scalar form inputs (no multiple-choice, no numbers)
+ and default checkboxes for booleans ('on' -> True)
+
+ .. versionadded:: 5.3
+ """
+ user_options = {}
+ for key, value_list in form_data.items():
+ if len(value_list) > 1:
+ value = value_list
+ else:
+ value = value_list[0]
+ if value == "on":
+ # default for checkbox
+ value = True
+ user_options[key] = value
return form_data
def run_options_from_form(self, form_data):
@@ -685,6 +724,80 @@ class Spawner(LoggingConfigurable):
"""
return self.options_from_form(query_data)
+ apply_user_options = Union(
+ [Callable(), Dict()],
+ config=True,
+ default_value=None,
+ allow_none=True,
+ help="""
+ Apply inputs from user_options to the Spawner
+
+ Typically takes values in user_options, validates them, and updates Spawner attributes::
+
+ def apply_user_options(spawner, user_options):
+ if "image" in user_options and isinstance(user_options["image"], str):
+ spawner.image = user_options["image"]
+
+ c.Spawner.apply_user_options = apply_user_options
+
+ Default: do nothing
+
+ Typically a callalble which takes `(spawner: Spawner, user_options: dict)`,
+ but for simple cases this can be a dict mapping user option fields to Spawner attribute names,
+ e.g.::
+
+ c.Spawner.apply_user_options = {"image_input": "image"}
+
+ allows users to specify the image attribute, but not any others.
+
+ .. note::
+
+ Because `user_options` is user input
+ and may be set directly via the REST API,
+ no assumptions should be made on its structure or contents.
+ An empty dict should always be supported.
+ Make sure to validate any inputs before applying them!
+
+ .. versionadded:: 5.3
+
+ Prior to 5.3, applying user options must be done in `Spawner.start()`.
+ """,
+ )
+
+ def _run_apply_user_options(self, user_options):
+ if isinstance(self.apply_user_options, dict):
+ return self._apply_user_options_dict(user_options)
+ elif self.apply_user_options:
+ return self.apply_user_options(self, user_options)
+ elif user_options:
+ keys = list(user_options)
+ self.log.warning(
+ f"Received unhandled user_options for {self._log_name}: {', '.join(keys)}"
+ )
+
+ def _apply_user_options_dict(self, user_options):
+ """if apply_user_options is a dict
+
+ Allows fully declarative apply_user_options configuration
+ for simple cases where users may set attributes directly
+ from values in user_options.
+ """
+ traits = self.traits()
+ for key, value in user_options.items():
+ attr = self.apply_user_options.get(key, None)
+ if attr is None:
+ self.log.warning(f"Unhandled user option {key} for {self._log_name}")
+ elif hasattr(self, attr):
+ # require traits? I think not, but we should require declaration, at least
+ # use trait from_string for string coercion, though
+ if isinstance(value, str) and attr in traits:
+ value = traits[attr].from_string(value)
+ setattr(self, attr, value)
+ else:
+ self.log.error(
+ f"No such Spawner attribute {attr} for user option {key} on {self._log_name}"
+ )
+
user_options = Dict(
help="""
Dict of user specified options for the user's spawned instance of a single-user server.
diff --git a/jupyterhub/user.py b/jupyterhub/user.py
index aba33720..42fe5e81 100644
--- a/jupyterhub/user.py
+++ b/jupyterhub/user.py
@@ -862,6 +862,11 @@ class User:
db.commit()
spawner.user_options = options
+ # apply user options
+ r = spawner._run_apply_user_options(spawner.user_options)
+ if inspect.isawaitable(r):
+ await r
+
# we are starting a new server, make sure it doesn't restore state
spawner.clear_state()