mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-16 06:22:59 +00:00
add apply_user_options hook
cover more cases with config without need to subclass
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# Spawners
|
# 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,
|
The Spawner represents an abstract interface to a process,
|
||||||
and a custom Spawner needs to be able to take three actions:
|
and a custom Spawner needs to be able to take three actions:
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ Some examples include:
|
|||||||
|
|
||||||
### Spawner.start
|
### 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`,
|
Information about the user can be retrieved from `self.user`,
|
||||||
an object encapsulating the user's name, authentication, and server info.
|
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,
|
When `Spawner.start` returns, the single-user server process should actually be running,
|
||||||
not just requested. JupyterHub can handle `Spawner.start` being very slow
|
not just requested. JupyterHub can handle `Spawner.start` being very slow
|
||||||
(such as PBS-style batch queues, or instantiating whole AWS instances)
|
(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
|
#### 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
|
which the single-user server should listen on
|
||||||
(passed to the single-user process via the `JUPYTERHUB_SERVICE_URL` environment variable).
|
(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_.
|
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
|
||||||
|
|
||||||
`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,
|
It should return `None` if it is still running,
|
||||||
and an integer exit status, otherwise.
|
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
|
||||||
|
|
||||||
`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
|
## Spawner state
|
||||||
|
|
||||||
@@ -168,15 +168,14 @@ def clear_state(self):
|
|||||||
|
|
||||||
## Spawner options form
|
## Spawner options form
|
||||||
|
|
||||||
(new in 0.4)
|
|
||||||
|
|
||||||
Some deployments may want to offer options to users to influence how their servers are started.
|
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,
|
This may include cluster-based deployments, where users specify what memory or cpu resources should be available,
|
||||||
or docker-based deployments where users can select from a list of base images.
|
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.
|
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`
|
### `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
|
```python
|
||||||
{
|
formdata = {
|
||||||
'integer': ['5'],
|
'integer': ['5'],
|
||||||
|
'checkbox': ['on'],
|
||||||
'text': ['some text'],
|
'text': ['some text'],
|
||||||
'select': ['a', 'b'],
|
'select': ['a', 'b'],
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
When `formdata` arrives, it is passed through `Spawner.options_from_form(formdata)`,
|
When `formdata` arrives, it is passed through [](#Spawner.options_from_form):
|
||||||
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:
|
|
||||||
|
|
||||||
```python
|
```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 = {}
|
||||||
options['integer'] = int(formdata['integer'][0]) # single integer value
|
options['integer'] = int(formdata['integer'][0]) # single integer value
|
||||||
|
options['checkbox'] = formdata['checkbox'] == ['on']
|
||||||
options['text'] = formdata['text'][0] # single string value
|
options['text'] = formdata['text'][0] # single string value
|
||||||
options['select'] = formdata['select'] # list already correct
|
options['select'] = formdata['select'] # list already correct
|
||||||
options['notinform'] = 'extra info' # not in the form at all
|
options['notinform'] = 'extra info' # not in the form at all
|
||||||
return options
|
return options
|
||||||
|
|
||||||
|
c.Spawner.options_from_form = options_from_form
|
||||||
```
|
```
|
||||||
|
|
||||||
which would return:
|
which would return:
|
||||||
@@ -215,15 +226,114 @@ which would return:
|
|||||||
```python
|
```python
|
||||||
{
|
{
|
||||||
'integer': 5,
|
'integer': 5,
|
||||||
|
'checkbox': True,
|
||||||
'text': 'some text',
|
'text': 'some text',
|
||||||
'select': ['a', 'b'],
|
'select': ['a', 'b'],
|
||||||
'notinform': 'extra info',
|
'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
|
## Writing a custom spawner
|
||||||
|
|
||||||
|
@@ -623,7 +623,8 @@ class Spawner(LoggingConfigurable):
|
|||||||
though it can contain bytes in addition to standard JSON data types.
|
though it can contain bytes in addition to standard JSON data types.
|
||||||
|
|
||||||
This method should not have any side effects.
|
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
|
to ensure consistent behavior across servers
|
||||||
spawned via the API and form submission page.
|
spawned via the API and form submission page.
|
||||||
|
|
||||||
@@ -642,9 +643,47 @@ class Spawner(LoggingConfigurable):
|
|||||||
|
|
||||||
@default("options_from_form")
|
@default("options_from_form")
|
||||||
def _options_from_form(self):
|
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
|
return form_data
|
||||||
|
|
||||||
def run_options_from_form(self, form_data):
|
def run_options_from_form(self, form_data):
|
||||||
@@ -685,6 +724,80 @@ class Spawner(LoggingConfigurable):
|
|||||||
"""
|
"""
|
||||||
return self.options_from_form(query_data)
|
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(
|
user_options = Dict(
|
||||||
help="""
|
help="""
|
||||||
Dict of user specified options for the user's spawned instance of a single-user server.
|
Dict of user specified options for the user's spawned instance of a single-user server.
|
||||||
|
@@ -862,6 +862,11 @@ class User:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
spawner.user_options = options
|
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
|
# we are starting a new server, make sure it doesn't restore state
|
||||||
spawner.clear_state()
|
spawner.clear_state()
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user