Skip to content

Commit

Permalink
Merge pull request #401 from stv0g/profile-slugs
Browse files Browse the repository at this point in the history
  • Loading branch information
minrk authored Jun 3, 2020
2 parents 956a12d + 5a7e5f5 commit 2530634
Show file tree
Hide file tree
Showing 3 changed files with 39 additions and 24 deletions.
54 changes: 33 additions & 21 deletions kubespawner/spawner.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
from kubespawner.reflector import NamespacedResourceReflector
from asyncio import sleep
from async_generator import async_generator, yield_

from slugify import slugify

class PodReflector(NamespacedResourceReflector):
"""
Expand Down Expand Up @@ -1062,9 +1062,9 @@ def _deprecated_changed(self, change):
<div class='form-group' id='kubespawner-profiles-list'>
{% for profile in profile_list %}
<label for='profile-item-{{ loop.index0 }}' class='form-control input-group'>
<label for='profile-item-{{ profile.slug }}' class='form-control input-group'>
<div class='col-md-1'>
<input type='radio' name='profile' id='profile-item-{{ loop.index0 }}' value='{{ loop.index0 }}' {% if profile.default %}checked{% endif %} />
<input type='radio' name='profile' id='profile-item-{{ profile.slug }}' value='{{ profile.slug }}' {% if profile.default %}checked{% endif %} />
</div>
<div class='col-md-11'>
<strong>{{ profile.display_name }}</strong>
Expand Down Expand Up @@ -1101,6 +1101,8 @@ def _deprecated_changed(self, change):
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 ifentify the profile
(missing slugs are generated from display_name)
- `description`: Optional description of this profile displayed to the user.
- `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
Expand All @@ -1112,6 +1114,7 @@ def _deprecated_changed(self, change):
c.KubeSpawner.profile_list = [
{
'display_name': 'Training Env - Python',
'slug': 'training-python',
'default': True,
'kubespawner_override': {
'image': 'training/python:label',
Expand All @@ -1120,27 +1123,31 @@ def _deprecated_changed(self, change):
}
}, {
'display_name': 'Training Env - Datascience',
'slug': 'training-datascience',
'kubespawner_override': {
'image': 'training/datascience:label',
'cpu_limit': 4,
'mem_limit': '8G',
}
}, {
'display_name': 'DataScience - Small instance',
'slug': 'datascience-small',
'kubespawner_override': {
'image': 'datascience/small:label',
'cpu_limit': 10,
'mem_limit': '16G',
}
}, {
'display_name': 'DataScience - Medium instance',
'slug': 'datascience-medium',
'kubespawner_override': {
'image': 'datascience/medium:label',
'cpu_limit': 48,
'mem_limit': '96G',
}
}, {
'display_name': 'DataScience - Medium instance (GPUx2)',
'slug': 'datascience-gpu2x',
'kubespawner_override': {
'image': 'datascience/medium:label',
'cpu_limit': 48,
Expand Down Expand Up @@ -1897,13 +1904,14 @@ def _env_keep_default(self):
_profile_list = None

def _render_options_form(self, profile_list):
self._profile_list = profile_list
self._profile_list = self._init_profile_list(profile_list)
profile_form_template = Environment(loader=BaseLoader).from_string(self.profile_form_template)
return profile_form_template.render(profile_list=profile_list)
return profile_form_template.render(profile_list=self._profile_list)

@gen.coroutine
def _render_options_form_dynamically(self, current_spawner):
profile_list = yield gen.maybe_future(self.profile_list(current_spawner))
profile_list = self._init_profile_list(profile_list)
return self._render_options_form(profile_list)

@default('options_form')
Expand Down Expand Up @@ -1944,22 +1952,14 @@ def options_from_form(self, formdata):
Returns:
user_options (dict): the selected profile in the user_options form,
e.g. ``{"profile": "8 CPUs"}``
e.g. ``{"profile": "cpus-8"}``
"""
if not self.profile_list or self._profile_list is None:
return formdata
# Default to first profile if somehow none is provided
try:
selected_profile = int(formdata.get('profile', [0])[0])
options = self._profile_list[selected_profile]
except (TypeError, IndexError, ValueError):
raise web.HTTPError(400, "No such profile: %i", formdata.get('profile', None))
return {
'profile': options['display_name']
'profile': formdata.get('profile', [None])[0]
}

@gen.coroutine
def _load_profile(self, profile_name):
def _load_profile(self, slug):
"""Load a profile by name
Called by load_user_options
Expand All @@ -1972,13 +1972,13 @@ def _load_profile(self, profile_name):
# explicit default, not the first
default_profile = profile

if profile['display_name'] == profile_name:
if profile['slug'] == slug:
break
else:
if profile_name:
if slug:
# name specified, but not found
raise ValueError("No such profile: %s. Options include: %s" % (
profile_name, ', '.join(p['display_name'] for p in self._profile_list)
slug, ', '.join(p['slug'] for p in self._profile_list)
))
else:
# no name specified, use the default
Expand All @@ -1998,6 +1998,14 @@ def _load_profile(self, profile_name):
# used for warning about ignoring unrecognised options
_user_option_keys = {'profile',}

def _init_profile_list(self, profile_list):
# generate missing slug fields from display_name
for profile in profile_list:
if 'slug' not in profile:
profile['slug'] = slugify(profile['display_name'])

return profile_list

@gen.coroutine
def load_user_options(self):
"""Load user options from self.user_options dict
Expand All @@ -2007,11 +2015,15 @@ def load_user_options(self):
Only supported argument by default is 'profile'.
Override in subclasses to support other options.
"""

if self._profile_list is None:
if callable(self.profile_list):
self._profile_list = yield gen.maybe_future(self.profile_list(self))
profile_list = yield gen.maybe_future(self.profile_list(self))
else:
self._profile_list = self.profile_list
profile_list = self.profile_list

self._profile_list = self._init_profile_list(profile_list)

selected_profile = self.user_options.get('profile', None)
if self._profile_list:
yield self._load_profile(selected_profile)
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
install_requires=[
'async_generator>=1.8',
'escapism',
'python-slugify',
'jupyterhub>=0.8',
'jinja2',
'kubernetes>=10.1.0',
Expand Down
8 changes: 5 additions & 3 deletions tests/test_spawner.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ def test_get_pod_manifest_tolerates_mixed_input():
_test_profiles = [
{
'display_name': 'Training Env - Python',
'slug': 'training-python',
'default': True,
'kubespawner_override': {
'image': 'training/python:label',
Expand All @@ -201,6 +202,7 @@ def test_get_pod_manifest_tolerates_mixed_input():
},
{
'display_name': 'Training Env - Datascience',
'slug': 'training-datascience',
'kubespawner_override': {
'image': 'training/datascience:label',
'cpu_limit': 4,
Expand All @@ -216,9 +218,9 @@ async def test_user_options_set_from_form():
spawner.profile_list = _test_profiles
# render the form
await spawner.get_options_form()
spawner.user_options = spawner.options_from_form({'profile': [1]})
spawner.user_options = spawner.options_from_form({'profile': [_test_profiles[1]['slug']]})
assert spawner.user_options == {
'profile': _test_profiles[1]['display_name'],
'profile': _test_profiles[1]['slug'],
}
# nothing should be loaded yet
assert spawner.cpu_limit is None
Expand All @@ -232,7 +234,7 @@ async def test_user_options_api():
spawner = KubeSpawner(_mock=True)
spawner.profile_list = _test_profiles
# set user_options directly (e.g. via api)
spawner.user_options = {'profile': _test_profiles[1]['display_name']}
spawner.user_options = {'profile': _test_profiles[1]['slug']}

# nothing should be loaded yet
assert spawner.cpu_limit is None
Expand Down

0 comments on commit 2530634

Please sign in to comment.