├── .github └── workflows │ └── python-publish.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── requirements.txt ├── screenshot.png ├── setup.py ├── version.py └── wrapspawner ├── __init__.py └── wrapspawner.py /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [released] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install build twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: '__token__' 28 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 29 | run: | 30 | python -m build 31 | python -m twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## unreleased changes 4 | 5 | Added (user) 6 | 7 | Added (developer) 8 | 9 | Changed 10 | 11 | Fixed 12 | 13 | ## v1.0.1 14 | 15 | Minor release to publish bugfix: #45 - Fix common_traits calculation 16 | 17 | ## v1.0.0 18 | 19 | Initial proper release published to PyPI. Believed to work with Jupyterhub versions >= 0.9, <= 1.1. 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Welcome! As a [Jupyter](https://jupyter.org) project, we follow the [Jupyter contributor guide](https://jupyter.readthedocs.io/en/latest/contributor/content-contributor.html). 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Project Jupyter Contributors 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.md 2 | include LICENSE 3 | include version.py 4 | include requirements.txt 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wrapspawner for Jupyterhub 2 | 3 | This package includes **WrapSpawner** and **ProfilesSpawner**, which provide mechanisms for runtime configuration of spawners. 4 | The inspiration for their development was to allow users to select from a range of pre-defined batch job profiles, but 5 | their operation is completely generic. 6 | 7 | ## Installation 8 | 9 | 1. Most users can install via pip: 10 | 11 | `pip install wrapspawner` 12 | 13 | To install an editable copy for development, from root directory of this repo (where setup.py is), run `pip install -e .` 14 | See also [pip VCS support](https://pip.pypa.io/en/stable/reference/pip_install/#vcs-support) if you need a specific revision. 15 | 16 | 2. Add lines in `jupyterhub_config.py` for the spawner you intend to use, e.g. 17 | 18 | ```python 19 | c.JupyterHub.spawner_class = 'wrapspawner.ProfilesSpawner' 20 | ``` 21 | 3. Depending on the spawner, additional configuration will likely be needed. 22 | 23 | ## Wrapper and Profile Spawners 24 | 25 | ### Overview 26 | 27 | [`WrapSpawner`](https://github.com/jupyterhub/wrapspawner/blob/master/wrapspawner/wrapspawner.py#L1) 28 | provides a mechanism to wrap the interface of a JupyterHub Spawner such that 29 | the Spawner class to use for single-user servers can be chosen dynamically. 30 | Subclasses may modify the class or properties of the child Spawner at any point 31 | before `start()` is called (e.g. from Authenticator `pre_spawn` hooks or options form 32 | processing) and that state will be preserved on restart. The `start/stop/poll` 33 | methods are not real coroutines, but simply pass through the Futures returned 34 | by the wrapped Spawner class. 35 | 36 | [`ProfilesSpawner`](https://github.com/jupyterhub/wrapspawner/blob/master/wrapspawner/wrapspawner.py#L120) 37 | leverages JupyterHub's `Spawner` "options form" feature to allow user-driven 38 | configuration of Spawner classes while permitting: 39 | 40 | * configuration of Spawner classes that don't natively implement `options_form` 41 | * administrator control of allowed configuration changes 42 | * runtime choice of which Spawner backend to launch 43 | 44 | ### Example 45 | 46 | Here is a screenshot of a typical dropdown menu letting the user choose between several SLURM instances: 47 | ![](screenshot.png) 48 | 49 | The following configuration snippet lets the user choose between a Jupyter server 50 | running as a local process or one of two different Docker Images to run within `DockerSpawner`. 51 | 52 | ```python 53 | c.JupyterHub.spawner_class = 'wrapspawner.ProfilesSpawner' 54 | c.Spawner.http_timeout = 120 55 | #------------------------------------------------------------------------------ 56 | # ProfilesSpawner configuration 57 | #------------------------------------------------------------------------------ 58 | # List of profiles to offer for selection. Signature is: 59 | # List(Tuple( Unicode, Unicode, Type(Spawner), Dict )) 60 | # corresponding to profile display name, unique key, Spawner class, 61 | # dictionary of spawner config options. 62 | # 63 | # The first three values will be exposed in the input_template as {display}, 64 | # {key}, and {type} 65 | # 66 | c.ProfilesSpawner.profiles = [ 67 | ( "Host process", 'local', 'jupyterhub.spawner.LocalProcessSpawner', {'ip':'0.0.0.0'} ), 68 | ('Docker Python 3', 'singleuser', 'dockerspawner.SystemUserSpawner', dict(image="jupyterhub/singleuser")), 69 | ('Docker Python 3 Scipy', 'scipy-notebook', 'dockerspawner.SystemUserSpawner', dict(image="jupyter/scipy-notebook")), 70 | ] 71 | ``` 72 | 73 | ## History 74 | 75 | These mechanisms originated as part of the [`batchspawner`](https://github.com/jupyterhub/batchspawner) package. 76 | The `batchspawner` README contains additional examples on the use of ProfilesSpawner. 77 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | jupyterhub>=2.2 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jupyterhub/wrapspawner/cb74046aa0217cf48c76944fae2d8c0cbae2a701/screenshot.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # Copyright (c) Jupyter Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | 7 | #----------------------------------------------------------------------------- 8 | # Minimal Python version sanity check (from IPython/Jupyterhub) 9 | #----------------------------------------------------------------------------- 10 | 11 | from __future__ import print_function 12 | 13 | import os 14 | import sys 15 | 16 | from distutils.core import setup 17 | 18 | pjoin = os.path.join 19 | here = os.path.abspath(os.path.dirname(__file__)) 20 | 21 | # Get the current package version. 22 | version_ns = {} 23 | with open(pjoin(here, 'version.py')) as f: 24 | exec(f.read(), {}, version_ns) 25 | 26 | setup_args = dict( 27 | name = 'wrapspawner', 28 | packages = ['wrapspawner'], 29 | version = version_ns['__version__'], 30 | description = """Wrapspawner: A spawner for Jupyterhub to wrap other spawners and allow the user to choose among them.""", 31 | long_description = "", 32 | author = "Michael Milligan", 33 | author_email = "milligan@umn.edu", 34 | url = "http://jupyter.org", 35 | license = "BSD", 36 | platforms = "Linux, Mac OS X", 37 | keywords = ['Interactive', 'Interpreter', 'Shell', 'Web'], 38 | classifiers = [ 39 | 'Intended Audience :: Developers', 40 | 'Intended Audience :: System Administrators', 41 | 'Intended Audience :: Science/Research', 42 | 'License :: OSI Approved :: BSD License', 43 | 'Programming Language :: Python', 44 | 'Programming Language :: Python :: 3', 45 | ], 46 | ) 47 | 48 | # setuptools requirements 49 | if 'setuptools' in sys.modules: 50 | setup_args['install_requires'] = install_requires = [] 51 | with open('requirements.txt') as f: 52 | for line in f.readlines(): 53 | req = line.strip() 54 | if not req or req.startswith(('-e', '#')): 55 | continue 56 | install_requires.append(req) 57 | 58 | 59 | def main(): 60 | setup(**setup_args) 61 | 62 | if __name__ == '__main__': 63 | main() 64 | -------------------------------------------------------------------------------- /version.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Jupyter Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | version_info = ( 5 | 1, 6 | 0, 7 | 2, 8 | 'dev', # comment-out this line for a release 9 | ) 10 | __version__ = '.'.join(map(str, version_info[:3])) 11 | 12 | if len(version_info) > 3: 13 | __version__ = '%s-%s' % (__version__, version_info[3]) 14 | -------------------------------------------------------------------------------- /wrapspawner/__init__.py: -------------------------------------------------------------------------------- 1 | from .wrapspawner import * 2 | -------------------------------------------------------------------------------- /wrapspawner/wrapspawner.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) Regents of the University of Minnesota 2 | # Distributed under the terms of the Modified BSD License. 3 | 4 | """WrapSpawner classes 5 | 6 | WrapSpawner provides a mechanism to wrap the interface of a Spawner such that 7 | the Spawner class to use for single-user servers can be chosen dynamically. 8 | The child Spawner is created and started using the same logic as in User.spawn(), 9 | but not until start() or load_state() is called. Thus, subclasses may modify the 10 | class or properties of the child Spawner at any earlier point (e.g. from 11 | Authenticator pre_spawn hooks or options form processing). 12 | 13 | Note that there is no straightforward way to simply save the child_class Type 14 | value in the ORM, so a subclass that provides a way to change the child_class 15 | must also arrange to reproduce that change when state is reloaded. 16 | 17 | Provided as an initial application is ProfilesSpawner, which accepts a list 18 | of provided Spawner configurations, and generates an options form from that list. 19 | Using this mechanism, the administrator can provide users with a pre-approved 20 | selection of Spawner configurations. 21 | """ 22 | 23 | import json 24 | import re 25 | import urllib.request 26 | 27 | from tornado import concurrent 28 | 29 | from jupyterhub.spawner import LocalProcessSpawner, Spawner 30 | from traitlets import ( 31 | Instance, Type, Tuple, List, Dict, Unicode, Any 32 | ) 33 | from traitlets import directional_link, validate, TraitError 34 | 35 | # Only needed for DockerProfilesSpawner 36 | try: 37 | import docker 38 | except ImportError: 39 | pass 40 | 41 | # Utility to create dummy Futures to return values through yields 42 | def _yield_val(x=None): 43 | f = concurrent.Future() 44 | f.set_result(x) 45 | return f 46 | 47 | class WrapSpawner(Spawner): 48 | 49 | # Grab this from constructor args in case some Spawner ever wants it 50 | config = Any() 51 | 52 | child_class = Type(LocalProcessSpawner, Spawner, 53 | config=True, 54 | help="""The class to wrap for spawning single-user servers. 55 | Should be a subclass of Spawner. 56 | """ 57 | ) 58 | 59 | child_config = Dict(default_value={}, 60 | config=True, 61 | help="Dictionary of config values to apply to wrapped spawner class." 62 | ) 63 | 64 | child_state = Dict(default_value={}) 65 | 66 | child_spawner = Instance(Spawner, allow_none=True) 67 | 68 | def construct_child(self): 69 | if self.child_spawner is None: 70 | self.child_spawner = self.child_class( 71 | user = self.user, 72 | db = self.db, 73 | hub = self.hub, 74 | authenticator = self.authenticator, 75 | oauth_client_id = self.oauth_client_id, 76 | cookie_options = self.cookie_options, 77 | orm_spawner = self.orm_spawner, 78 | server = self._server, 79 | config = self.config, 80 | **self.child_config 81 | ) 82 | # initial state will always be wrong since it will see *our* state 83 | self.child_spawner.clear_state() 84 | if self.child_state: 85 | self.child_spawner.load_state(self.child_state) 86 | 87 | # link traits common between self and child 88 | common_traits = ( 89 | set(self.trait_names()) & 90 | set(self.child_spawner.trait_names()) - 91 | set(self.child_config.keys()) 92 | ) 93 | for trait in common_traits: 94 | directional_link((self, trait), (self.child_spawner, trait)) 95 | return self.child_spawner 96 | 97 | def load_child_class(self, state): 98 | # Subclasses must arrange for correct child_class setting from load_state 99 | pass 100 | 101 | def load_state(self, state): 102 | super().load_state(state) 103 | self.load_child_class(state) 104 | self.child_config.update(state.get('child_conf', {})) 105 | self.child_state = state.get('child_state', {}) 106 | self.construct_child() 107 | 108 | def get_state(self): 109 | state = super().get_state() 110 | state['child_conf'] = self.child_config 111 | if self.child_spawner: 112 | self.child_state = state['child_state'] = self.child_spawner.get_state() 113 | return state 114 | 115 | def clear_state(self): 116 | super().clear_state() 117 | if self.child_spawner: 118 | self.child_spawner.clear_state() 119 | self.child_state = {} 120 | self.child_config = {} 121 | self.child_spawner = None 122 | 123 | # proxy functions for start/poll/stop 124 | # pass back the child's Future, or create a dummy if needed 125 | 126 | def start(self): 127 | if not self.child_spawner: 128 | self.construct_child() 129 | return self.child_spawner.start() 130 | 131 | def stop(self, now=False): 132 | if self.child_spawner: 133 | return self.child_spawner.stop(now) 134 | else: 135 | return _yield_val() 136 | 137 | def poll(self): 138 | if self.child_spawner: 139 | return self.child_spawner.poll() 140 | else: 141 | return _yield_val(1) 142 | 143 | if hasattr(Spawner, 'progress'): 144 | @property 145 | def progress(self): 146 | if self.child_spawner: 147 | return self.child_spawner.progress 148 | else: 149 | raise RuntimeError("No child spawner yet exists - can not get progress yet") 150 | 151 | 152 | class ProfilesSpawner(WrapSpawner): 153 | 154 | """ProfilesSpawner - leverages the Spawner options form feature to allow user-driven 155 | configuration of Spawner classes while permitting: 156 | 1) configuration of Spawner classes that don't natively implement options_form 157 | 2) administrator control of allowed configuration changes 158 | 3) runtime choice of which Spawner backend to launch 159 | """ 160 | 161 | profiles = List( 162 | trait = Tuple( Unicode(), Unicode(), Type(Spawner), Dict() ), 163 | default_value = [ ( 'Local Notebook Server', 'local', LocalProcessSpawner, 164 | {'start_timeout': 15, 'http_timeout': 10} ) ], 165 | minlen = 1, 166 | config = True, 167 | help = """List of profiles to offer for selection. Signature is: 168 | List(Tuple( Unicode, Unicode, Type(Spawner), Dict )) corresponding to 169 | profile display name, unique key, Spawner class, dictionary of spawner config options. 170 | 171 | The first three values will be exposed in the input_template as {display}, {key}, and {type}""" 172 | ) 173 | 174 | @validate("profiles") 175 | def _validate_profiles(self, proposal): 176 | profiles = proposal.value 177 | 178 | seen = set() 179 | duplicated = {p[1] for p in profiles if p[1] in seen or seen.add(p[1])} 180 | if len(duplicated): 181 | raise TraitError( 182 | f"Invalid wrapspawner profiles, profiles keys are not unique : {duplicated}") 183 | 184 | return profiles 185 | 186 | child_profile = Unicode() 187 | 188 | form_template = Unicode( 189 | """ 190 | 193 | """, 194 | config = True, 195 | help = """Template to use to construct options_form text. {input_template} is replaced with 196 | the result of formatting input_template against each item in the profiles list.""" 197 | ) 198 | 199 | first_template = Unicode('selected', 200 | config=True, 201 | help="Text to substitute as {first} in input_template" 202 | ) 203 | 204 | input_template = Unicode(""" 205 | """, 206 | config = True, 207 | help = """Template to construct {input_template} in form_template. This text will be formatted 208 | against each item in the profiles list, in order, using the following key names: 209 | ( display, key, type ) for the first three items in the tuple, and additionally 210 | first = "checked" (taken from first_template) for the first item in the list, so that 211 | the first item starts selected.""" 212 | ) 213 | 214 | def _options_form_default(self): 215 | temp_keys = [ dict(display=p[0], key=p[1], type=p[2], first='') for p in self.profiles ] 216 | temp_keys[0]['first'] = self.first_template 217 | text = ''.join([ self.input_template.format(**tk) for tk in temp_keys ]) 218 | return self.form_template.format(input_template=text) 219 | 220 | def options_from_form(self, formdata): 221 | # Default to first profile if somehow none is provided 222 | return dict(profile=formdata.get('profile', [self.profiles[0][1]])[0]) 223 | 224 | # load/get/clear : save/restore child_profile (and on load, use it to update child class/config) 225 | 226 | def select_profile(self, profile): 227 | # Select matching profile, or do nothing (leaving previous or default config in place) 228 | for p in self.profiles: 229 | if p[1] == profile: 230 | self.child_class = p[2] 231 | self.child_config = p[3] 232 | break 233 | 234 | def construct_child(self): 235 | self.child_profile = self.user_options.get('profile', "") 236 | self.select_profile(self.child_profile) 237 | super().construct_child() 238 | 239 | def load_child_class(self, state): 240 | try: 241 | self.child_profile = state['profile'] 242 | except KeyError: 243 | self.child_profile = '' 244 | self.select_profile(self.child_profile) 245 | 246 | def get_state(self): 247 | state = super().get_state() 248 | state['profile'] = self.child_profile 249 | return state 250 | 251 | def clear_state(self): 252 | super().clear_state() 253 | self.child_profile = '' 254 | 255 | class DockerProfilesSpawner(ProfilesSpawner): 256 | 257 | """DockerProfilesSpawner - leverages ProfilesSpawner to dynamically create DockerSpawner 258 | profiles dynamically by looking for docker images that end with "jupyterhub". Due to the 259 | profiles being dynamic the "profiles" config item from the ProfilesSpawner is renamed as 260 | "default_profiles". Please note that the "docker" and DockerSpawner packages are required 261 | for this spawner to work. 262 | """ 263 | 264 | default_profiles = List( 265 | trait = Tuple( Unicode(), Unicode(), Type(Spawner), Dict() ), 266 | default_value = [], 267 | config = True, 268 | help = """List of profiles to offer in addition to docker images for selection. Signature is: 269 | List(Tuple( Unicode, Unicode, Type(Spawner), Dict )) corresponding to 270 | profile display name, unique key, Spawner class, dictionary of spawner config options. 271 | 272 | The first three values will be exposed in the input_template as {display}, {key}, and {type}""" 273 | ) 274 | 275 | docker_spawner_args = Dict( 276 | default_value = {}, 277 | config = True, 278 | help = "Args to pass to DockerSpawner." 279 | ) 280 | 281 | jupyterhub_docker_tag_re = re.compile('^.*jupyterhub$') 282 | 283 | def _nvidia_args(self): 284 | try: 285 | resp = urllib.request.urlopen('http://localhost:3476/v1.0/docker/cli/json') 286 | body = resp.read().decode('utf-8') 287 | args = json.loads(body) 288 | return dict( 289 | read_only_volumes={vol.split(':')[0]: vol.split(':')[1] for vol in args['Volumes']}, 290 | extra_create_kwargs={"volume_driver": args['VolumeDriver']}, 291 | extra_host_config={"devices": args['Devices']}, 292 | ) 293 | except urllib.error.URLError: 294 | return {} 295 | 296 | 297 | def _docker_profile(self, nvidia_args, image): 298 | spawner_args = dict(container_image=image, network_name=self.user.name) 299 | spawner_args.update(self.docker_spawner_args) 300 | spawner_args.update(nvidia_args) 301 | nvidia_enabled = "w/GPU" if len(nvidia_args) > 0 else "no GPU" 302 | return ("Docker: (%s): %s"%(nvidia_enabled, image), "docker-%s"%(image), "dockerspawner.SystemUserSpawner", spawner_args) 303 | 304 | def _jupyterhub_docker_tags(self): 305 | try: 306 | include_jh_tags = lambda tag: self.jupyterhub_docker_tag_re.match(tag) 307 | return filter(include_jh_tags, [tag for image in docker.from_env().images.list() for tag in image.tags]) 308 | except NameError: 309 | raise Exception('The docker package is not installed and is a dependency for DockerProfilesSpawner') 310 | 311 | def _docker_profiles(self): 312 | return [self._docker_profile(self._nvidia_args(), tag) for tag in self._jupyterhub_docker_tags()] 313 | 314 | @property 315 | def profiles(self): 316 | return self.default_profiles + self._docker_profiles() 317 | 318 | @property 319 | def options_form(self): 320 | temp_keys = [ dict(display=p[0], key=p[1], type=p[2], first='') for p in self.profiles] 321 | temp_keys[0]['first'] = self.first_template 322 | text = ''.join([ self.input_template.format(**tk) for tk in temp_keys ]) 323 | return self.form_template.format(input_template=text) 324 | 325 | 326 | # vim: set ai expandtab softtabstop=4: 327 | 328 | --------------------------------------------------------------------------------