Skip to content

Commit

Permalink
Merge pull request #785 from yuvipanda/iterate-value
Browse files Browse the repository at this point in the history
Support lists and dicts as values in `kubespawner_override`
  • Loading branch information
yuvipanda authored Sep 19, 2023
2 parents 9663b7e + 4797bec commit 574ca3f
Show file tree
Hide file tree
Showing 3 changed files with 126 additions and 15 deletions.
41 changes: 26 additions & 15 deletions kubespawner/spawner.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
make_service,
)
from .reflector import ResourceReflector
from .utils import recursive_update
from .utils import recursive_format, recursive_update


class PodReflector(ResourceReflector):
Expand Down Expand Up @@ -1523,9 +1523,10 @@ def _validate_image_pull_secrets(self, proposal):
Signature is: `List(Dict())`, where each item is a dictionary that has two keys:
- `display_name`: the human readable display name (should be HTML safe)
- `slug`: the machine readable slug to identify the profile
(missing slugs are generated from display_name)
- `default`: (Optional Bool) True if this is the default selected option
- `description`: Optional description of this profile displayed to the user.
- `slug`: (Optional) the machine readable string to identify the
profile (missing slugs are generated from display_name)
- `kubespawner_override`: a dictionary with overrides to apply to the KubeSpawner
settings. Each value can be either the final value to change or a callable that
take the `KubeSpawner` instance as parameter and return the final value. This can
Expand All @@ -1541,18 +1542,32 @@ def _validate_image_pull_secrets(self, proposal):
- `display_name`: Name used to identify this particular option
- `unlisted_choice`: Object to specify if there should be a free-form field if the user
selected "Other" as a choice:
- `enabled`: Boolean, whether the free form input should be enabled
- `display_name`: String, label for input field
- `display_name_in_choices`: Optional, display name for the choice
to specify an unlisted choice in the dropdown list of pre-defined
choices. Defaults to "Other...".
- `validation_regex`: Optional, regex that the free form input should match - eg. ^pangeo/.*$
- `validation_message`: Optional, validation message for the regex. Should describe the required
input format in a human-readable way.
- `kubespawner_override`: Object specifying what key:values should be over-ridden
with the value of the free form input, using `{value}` for the value to be substituted with
the user POSTed value in the `unlisted_choice` input field. eg:
- some_config_key: some_value-with-{value}-substituted-with-what-user-wrote
- `validation_regex`: Optional, regex that the free form input
should match, eg. `^pangeo/.*$`.
- `validation_message`: Optional, validation message for the regex.
Should describe the required input format in a human-readable way.
- `kubespawner_override`: a dictionary with overrides to apply to
the KubeSpawner settings, where the string `{value}` will be
substituted with what was filled in by the user if its found in
string values anywhere in the dictionary. As an example, if the
choice made is about an image tag for an image only to be used
with JupyterLab, it could look like this:
.. code-block:: python
{
"image_spec": "jupyter/datascience-notebook:{value}",
"default_url": "/lab",
"extra_labels: {
"user-specified-image-tag": "{value}",
},
}
- `choices`: A dictionary containing list of choices for the user to choose from
to set the value for this particular option. The key is an identifier for this
choice, and the value is a dictionary with the following possible keys:
Expand All @@ -1568,7 +1583,6 @@ def _validate_image_pull_secrets(self, proposal):
If the traitlet being overriden is a *dictionary*, the dictionary
will be *recursively updated*, rather than overriden. If you want to
remove a key, set its value to `None`
- `default`: (optional Bool) True if this is the default selected option
kubespawner setting overrides work in the following manner, with items further in the
list *replacing* (not merging with) items earlier in the list:
Expand Down Expand Up @@ -3191,10 +3205,7 @@ def _load_profile(self, slug, profile_list):
"kubespawner_override", {}
)
for k, v in option_overrides.items():
# FIXME: This logic restricts unlisted_choice to define
# kubespawner_override dictionaries where all keys
# have string values.
option_overrides[k] = v.format(value=unlisted_choice)
option_overrides[k] = recursive_format(v, value=unlisted_choice)
elif choice:
# A pre-defined choice was selected
option_overrides = option["choices"][choice].get(
Expand Down
41 changes: 41 additions & 0 deletions kubespawner/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,44 @@ def recursive_update(target, new):

else:
target[k] = v


class IgnoreMissing(dict):
"""
Dictionary subclass for use with format_map
Returns missing dictionary keys' values as "{key}", so format strings with
missing values just get rendered as is.
Stolen from https://docs.python.org/3/library/stdtypes.html#str.format_map
"""

def __missing__(self, key):
return f"{{{key}}}"


def recursive_format(format_object, **kwargs):
"""
Recursively format given object with values provided as keyword arguments.
If the given object (string, list, set, or dict) has items that do not have
placeholders for passed in kwargs, no formatting is performed.
recursive_format("{v}", v=5) -> Returns "5"
recrusive_format("{a}") -> Returns "{a}" rather than erroring, as is
the behavior of "format"
"""
if isinstance(format_object, str):
return format_object.format_map(IgnoreMissing(kwargs))
elif isinstance(format_object, list):
return [recursive_format(i, **kwargs) for i in format_object]
elif isinstance(format_object, set):
return {recursive_format(i, **kwargs) for i in format_object}
elif isinstance(format_object, dict):
return {
recursive_format(k, **kwargs): recursive_format(v, **kwargs)
for k, v in format_object.items()
}
else:
# Everything else just gets returned as is, unformatted
return format_object
59 changes: 59 additions & 0 deletions tests/test_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,65 @@ async def test_find_slug_exception():
spawner._get_profile('does-not-exist', profile_list)


async def test_unlisted_choice_non_string_override():
profiles = [
{
'display_name': 'CPU only',
'slug': 'cpu',
'profile_options': {
'image': {
'display_name': 'Image',
'unlisted_choice': {
'enabled': True,
'display_name': 'Image Location',
'validation_regex': '^pangeo/.*$',
'validation_message': 'Must be a pangeo image, matching ^pangeo/.*$',
'kubespawner_override': {
'image': '{value}',
'environment': {
'CUSTOM_IMAGE_USED': 'yes',
'CUSTOM_IMAGE': '{value}',
# This should just be passed through, as JUPYTER_USER is not replaced
'USER': '${JUPYTER_USER}',
# This should render as ${JUPYTER_USER}, as the {{ and }} escape them.
# this matches existing behavior for other replacements elsewhere
'USER_TEST': '${{JUPYTER_USER}}',
},
"init_containers": [
{
"name": "testing",
"image": "{value}",
"securityContext": {"runAsUser": 1000},
}
],
},
},
}
},
},
]
spawner = KubeSpawner(_mock=True)
spawner.profile_list = profiles

image = "pangeo/pangeo-notebook:latest"
# Set user option for image directly
spawner.user_options = {"profile": "cpu", "image--unlisted-choice": image}

# this shouldn't error
await spawner.load_user_options()

assert spawner.image == image
assert spawner.environment == {
'CUSTOM_IMAGE_USED': 'yes',
'CUSTOM_IMAGE': image,
'USER': '${JUPYTER_USER}',
'USER_TEST': '${JUPYTER_USER}',
}
assert spawner.init_containers == [
{"name": "testing", "image": image, 'securityContext': {'runAsUser': 1000}}
]


async def test_empty_user_options_and_profile_options_api():
profiles = [
{
Expand Down

0 comments on commit 574ca3f

Please sign in to comment.