add apply_user_options hook

cover more cases with config without need to subclass
This commit is contained in:
Min RK
2025-02-19 16:00:50 +01:00
parent 3885affd68
commit a880fc4d6c
3 changed files with 251 additions and 23 deletions

View File

@@ -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:
![spawn-form](/images/spawn-form.png)
@@ -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 = """
<input name="image_input" type="text" value="quay.io/jupyterhub/singleuser:5.2"/>
<input name="debug_checkbox" type="checkbox" />
"""
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: |
<input name="image_input" type="text" value="quay.io/jupyterhub/singleuser:5.2"/>
<input name="debug_checkbox" type="checkbox" />
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

View File

@@ -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.

View File

@@ -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()