Add flask app with auto-reload to render the form template
This commit is contained in:
parent
433862d0ad
commit
2333ccd168
12 changed files with 662 additions and 61 deletions
19
README.md
19
README.md
|
@ -10,7 +10,24 @@ cd glicid-spawner
|
|||
poetry install
|
||||
poetry run pre-commit install
|
||||
```
|
||||
To activate the virtual environement:
|
||||
|
||||
To test the spawner:
|
||||
```bash
|
||||
poetry run pytest
|
||||
```
|
||||
|
||||
To lint and format the source code:
|
||||
```bash
|
||||
poetry run ruff check . --fix
|
||||
poetry run ruff format
|
||||
```
|
||||
|
||||
To render the form template (with live reload):
|
||||
```bash
|
||||
poetry run python -m render
|
||||
```
|
||||
|
||||
To activate the virtual environement globally:
|
||||
```bash
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
|
118
poetry.lock
generated
118
poetry.lock
generated
|
@ -67,6 +67,17 @@ url = "https://github.com/jupyterhub/batchspawner.git"
|
|||
reference = "main"
|
||||
resolved_reference = "25918f6495a99d4083cbe3134d6e167d47cf11d1"
|
||||
|
||||
[[package]]
|
||||
name = "blinker"
|
||||
version = "1.7.0"
|
||||
description = "Fast, simple object-to-object and broadcast signaling"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "blinker-1.7.0-py3-none-any.whl", hash = "sha256:c3f865d4d54db7abc53758a01601cf343fe55b84c1de4e3fa910e420b438d5b9"},
|
||||
{file = "blinker-1.7.0.tar.gz", hash = "sha256:e6820ff6fa4e4d1d8e2747c2283749c3f547e4fee112b98555cdcdae32996182"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2023.11.17"
|
||||
|
@ -270,6 +281,20 @@ files = [
|
|||
{file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.1.7"
|
||||
description = "Composable command line interface toolkit"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
|
||||
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
|
@ -443,6 +468,28 @@ docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1
|
|||
testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"]
|
||||
typing = ["typing-extensions (>=4.8)"]
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "3.0.2"
|
||||
description = "A simple framework for building complex web applications."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "flask-3.0.2-py3-none-any.whl", hash = "sha256:3232e0e9c850d781933cf0207523d1ece087eb8d87b23777ae38456e2fbe7c6e"},
|
||||
{file = "flask-3.0.2.tar.gz", hash = "sha256:822c03f4b799204250a7ee84b1eddc40665395333973dfb9deebfe425fefcb7d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
blinker = ">=1.6.2"
|
||||
click = ">=8.1.3"
|
||||
itsdangerous = ">=2.1.2"
|
||||
Jinja2 = ">=3.1.2"
|
||||
Werkzeug = ">=3.0.0"
|
||||
|
||||
[package.extras]
|
||||
async = ["asgiref (>=3.2)"]
|
||||
dotenv = ["python-dotenv"]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.0.3"
|
||||
|
@ -550,6 +597,17 @@ files = [
|
|||
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.1.2"
|
||||
description = "Safely pass data to untrusted environments and back."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"},
|
||||
{file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.3"
|
||||
|
@ -650,6 +708,21 @@ traitlets = ">=4.3.2"
|
|||
[package.extras]
|
||||
test = ["beautifulsoup4[html5lib]", "coverage", "cryptography", "jsonschema", "jupyterlab (>=3)", "mock", "nbclassic", "playwright", "pytest (>=3.3)", "pytest-asyncio (>=0.17)", "pytest-cov", "requests-mock", "virtualenv"]
|
||||
|
||||
[[package]]
|
||||
name = "livereload"
|
||||
version = "2.5.1"
|
||||
description = "Python LiveReload is an awesome tool for web developers"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "livereload-2.5.1-py2-none-any.whl", hash = "sha256:5ed6506f5d526ee712da9f3739c27714e6f3376f3e481728d298efceae0ec83a"},
|
||||
{file = "livereload-2.5.1.tar.gz", hash = "sha256:422de10d7ea9467a1ba27cbaffa84c74b809d96fb1598d9de4b9b676adf35e2c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
six = "*"
|
||||
tornado = "*"
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.3.0"
|
||||
|
@ -1397,22 +1470,22 @@ files = [
|
|||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
version = "6.4"
|
||||
version = "6.2"
|
||||
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
|
||||
optional = false
|
||||
python-versions = ">= 3.8"
|
||||
python-versions = ">= 3.7"
|
||||
files = [
|
||||
{file = "tornado-6.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:02ccefc7d8211e5a7f9e8bc3f9e5b0ad6262ba2fbb683a6443ecc804e5224ce0"},
|
||||
{file = "tornado-6.4-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:27787de946a9cffd63ce5814c33f734c627a87072ec7eed71f7fc4417bb16263"},
|
||||
{file = "tornado-6.4-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7894c581ecdcf91666a0912f18ce5e757213999e183ebfc2c3fdbf4d5bd764e"},
|
||||
{file = "tornado-6.4-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e43bc2e5370a6a8e413e1e1cd0c91bedc5bd62a74a532371042a18ef19e10579"},
|
||||
{file = "tornado-6.4-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0251554cdd50b4b44362f73ad5ba7126fc5b2c2895cc62b14a1c2d7ea32f212"},
|
||||
{file = "tornado-6.4-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fd03192e287fbd0899dd8f81c6fb9cbbc69194d2074b38f384cb6fa72b80e9c2"},
|
||||
{file = "tornado-6.4-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:88b84956273fbd73420e6d4b8d5ccbe913c65d31351b4c004ae362eba06e1f78"},
|
||||
{file = "tornado-6.4-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:71ddfc23a0e03ef2df1c1397d859868d158c8276a0603b96cf86892bff58149f"},
|
||||
{file = "tornado-6.4-cp38-abi3-win32.whl", hash = "sha256:6f8a6c77900f5ae93d8b4ae1196472d0ccc2775cc1dfdc9e7727889145c45052"},
|
||||
{file = "tornado-6.4-cp38-abi3-win_amd64.whl", hash = "sha256:10aeaa8006333433da48dec9fe417877f8bcc21f48dda8d661ae79da357b2a63"},
|
||||
{file = "tornado-6.4.tar.gz", hash = "sha256:72291fa6e6bc84e626589f1c29d90a5a6d593ef5ae68052ee2ef000dfd273dee"},
|
||||
{file = "tornado-6.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:20f638fd8cc85f3cbae3c732326e96addff0a15e22d80f049e00121651e82e72"},
|
||||
{file = "tornado-6.2-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:87dcafae3e884462f90c90ecc200defe5e580a7fbbb4365eda7c7c1eb809ebc9"},
|
||||
{file = "tornado-6.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba09ef14ca9893954244fd872798b4ccb2367c165946ce2dd7376aebdde8e3ac"},
|
||||
{file = "tornado-6.2-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8150f721c101abdef99073bf66d3903e292d851bee51910839831caba341a75"},
|
||||
{file = "tornado-6.2-cp37-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3a2f5999215a3a06a4fc218026cd84c61b8b2b40ac5296a6db1f1451ef04c1e"},
|
||||
{file = "tornado-6.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5f8c52d219d4995388119af7ccaa0bcec289535747620116a58d830e7c25d8a8"},
|
||||
{file = "tornado-6.2-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:6fdfabffd8dfcb6cf887428849d30cf19a3ea34c2c248461e1f7d718ad30b66b"},
|
||||
{file = "tornado-6.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1d54d13ab8414ed44de07efecb97d4ef7c39f7438cf5e976ccd356bebb1b5fca"},
|
||||
{file = "tornado-6.2-cp37-abi3-win32.whl", hash = "sha256:5c87076709343557ef8032934ce5f637dbb552efa7b21d08e89ae7619ed0eb23"},
|
||||
{file = "tornado-6.2-cp37-abi3-win_amd64.whl", hash = "sha256:e5f923aa6a47e133d1cf87d60700889d7eae68988704e20c75fb2d65677a8e4b"},
|
||||
{file = "tornado-6.2.tar.gz", hash = "sha256:9b630419bde84ec666bfd7ea0a4cb2a8a651c2d5cccdbdd1972a0c859dfc3c13"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1477,7 +1550,24 @@ platformdirs = ">=3.9.1,<5"
|
|||
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
|
||||
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.0.1"
|
||||
description = "The comprehensive WSGI web application library."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "werkzeug-3.0.1-py3-none-any.whl", hash = "sha256:90a285dc0e42ad56b34e696398b8122ee4c681833fb35b8334a095d82c56da10"},
|
||||
{file = "werkzeug-3.0.1.tar.gz", hash = "sha256:507e811ecea72b18a404947aded4b3390e1db8f826b494d76550ef45bb3b1dcc"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
MarkupSafe = ">=2.1.1"
|
||||
|
||||
[package.extras]
|
||||
watchdog = ["watchdog (>=2.3)"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.10"
|
||||
content-hash = "542947d7dbd256b2b3cc75f7ca85e988b814d860e5f9f003281309ab2bf7eec2"
|
||||
content-hash = "3aee48cbff66c32e4121e9a776d19c466fae94258204526f99eaca50360bbfc7"
|
||||
|
|
|
@ -25,6 +25,9 @@ pre-commit = "^3.6.0"
|
|||
pytest = "^8.0.0"
|
||||
ruff = "^0.1.14"
|
||||
pytest-cov = "^4.1.0"
|
||||
flask = "^3.0.2"
|
||||
livereload = "<2.5.2" # FIXME: python-livereload#170
|
||||
tornado = "<6.3.0" # FIXME: python-livereload#270
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
|
5
render/__init__.py
Normal file
5
render/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
"""HTML dynamic template rendering submodule.
|
||||
|
||||
This file is required for pytest pre-commit hook (pytest#3151).
|
||||
|
||||
"""
|
60
render/__main__.py
Normal file
60
render/__main__.py
Normal file
|
@ -0,0 +1,60 @@
|
|||
"""Web Server Gateway Interface with autoreload to render spawner template.
|
||||
|
||||
Usage: `python -m render`
|
||||
|
||||
"""
|
||||
from pathlib import Path
|
||||
from traceback import format_exc
|
||||
|
||||
import glicid_spawner
|
||||
from flask import Flask, render_template, request
|
||||
from glicid_spawner.form import options_attrs, options_form, options_from_form
|
||||
from livereload import Server
|
||||
|
||||
# Monkeypatch
|
||||
USERNAME = 'john-doe'
|
||||
glicid_spawner.micromamba.MICROMAMBA_ROOT = (
|
||||
Path(__file__).parent / '..' / 'tests' / 'data' / 'micromamba'
|
||||
).resolve()
|
||||
glicid_spawner.micromamba.GLOBAL_USER = 'global'
|
||||
glicid_spawner.micromamba.GLOBAL_EXCLUDED = 'qux'
|
||||
|
||||
# Flask app
|
||||
app = Flask(__name__)
|
||||
app.debug = True
|
||||
|
||||
|
||||
@app.route('/')
|
||||
def home():
|
||||
"""Form spawner home page."""
|
||||
return render_template(
|
||||
'form.html', spawner_options_form=options_form(USERNAME), options=options_attrs(USERNAME)
|
||||
)
|
||||
|
||||
|
||||
@app.route('/submit', methods=['POST'])
|
||||
def submit():
|
||||
"""Reformat form data and extract spawner options.
|
||||
|
||||
https://jupyterhub.readthedocs.io/en/stable/reference/spawners.html#spawner-options-from-form
|
||||
|
||||
"""
|
||||
formdata = dict(request.form.lists())
|
||||
|
||||
# Trying to parse the options from the formdata
|
||||
try:
|
||||
return render_template(
|
||||
'options.html', formdata=formdata, options=options_from_form(formdata)
|
||||
)
|
||||
except Exception:
|
||||
return render_template('options.html', formdata=formdata, err=format_exc())
|
||||
|
||||
|
||||
def server_autoreload():
|
||||
"""Start auto-reload server."""
|
||||
server = Server(app.wsgi_app)
|
||||
server.serve()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
server_autoreload()
|
215
render/static/jupyter.css
Normal file
215
render/static/jupyter.css
Normal file
|
@ -0,0 +1,215 @@
|
|||
/* Jupyterhub style.css */
|
||||
.btn-jupyter {
|
||||
color: #fff;
|
||||
background-color: #f37524;
|
||||
border-color: #e34f21
|
||||
}
|
||||
|
||||
.btn-jupyter.focus,
|
||||
.btn-jupyter:focus {
|
||||
color: #fff;
|
||||
background-color: #d85c0c;
|
||||
border-color: #76270f
|
||||
}
|
||||
|
||||
.btn-jupyter:hover {
|
||||
color: #fff;
|
||||
background-color: #d85c0c;
|
||||
border-color: #b13b16
|
||||
}
|
||||
|
||||
.btn-jupyter.active,
|
||||
.btn-jupyter:active,
|
||||
.open>.dropdown-toggle.btn-jupyter {
|
||||
color: #fff;
|
||||
background-color: #d85c0c;
|
||||
background-image: none;
|
||||
border-color: #b13b16
|
||||
}
|
||||
|
||||
.btn-jupyter.active.focus,
|
||||
.btn-jupyter.active:focus,
|
||||
.btn-jupyter.active:hover,
|
||||
.btn-jupyter:active.focus,
|
||||
.btn-jupyter:active:focus,
|
||||
.btn-jupyter:active:hover,
|
||||
.open>.dropdown-toggle.btn-jupyter.focus,
|
||||
.open>.dropdown-toggle.btn-jupyter:focus,
|
||||
.open>.dropdown-toggle.btn-jupyter:hover {
|
||||
color: #fff;
|
||||
background-color: #b64d0a;
|
||||
border-color: #76270f
|
||||
}
|
||||
|
||||
.btn-jupyter.disabled.focus,
|
||||
.btn-jupyter.disabled:focus,
|
||||
.btn-jupyter.disabled:hover,
|
||||
.btn-jupyter[disabled].focus,
|
||||
.btn-jupyter[disabled]:focus,
|
||||
.btn-jupyter[disabled]:hover,
|
||||
fieldset[disabled] .btn-jupyter.focus,
|
||||
fieldset[disabled] .btn-jupyter:focus,
|
||||
fieldset[disabled] .btn-jupyter:hover {
|
||||
background-color: #f37524;
|
||||
border-color: #e34f21
|
||||
}
|
||||
|
||||
.btn-jupyter .badge {
|
||||
color: #f37524;
|
||||
background-color: #fff
|
||||
}
|
||||
|
||||
@media (max-width:480px) {
|
||||
#jupyterhub-logo {
|
||||
margin-left: 15px
|
||||
}
|
||||
}
|
||||
|
||||
#jupyterhub-logo .jpy-logo {
|
||||
height: 28px;
|
||||
margin-top: 6px
|
||||
}
|
||||
|
||||
@media (max-width:480px) {
|
||||
.navbar-right li span {
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: 10px 15px
|
||||
}
|
||||
}
|
||||
|
||||
#header {
|
||||
border-bottom: 1px solid #e7e7e7
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none
|
||||
}
|
||||
|
||||
#progress-log {
|
||||
margin-top: 8px
|
||||
}
|
||||
|
||||
.progress-log-event {
|
||||
border-top: 1px solid #e7e7e7;
|
||||
padding: 8px
|
||||
}
|
||||
|
||||
.feedback-container {
|
||||
margin-top: 16px
|
||||
}
|
||||
|
||||
.feedback-widget {
|
||||
padding: 5px 0 0 6px
|
||||
}
|
||||
|
||||
.feedback-widget i {
|
||||
font-size: 2em;
|
||||
color: #d3d3d3
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 8px #f37524;
|
||||
border-color: #f37524;
|
||||
outline-color: #f37524
|
||||
}
|
||||
|
||||
i.sort-icon {
|
||||
margin-left: 4px
|
||||
}
|
||||
|
||||
tr.pagination-row>td.pagination-page-info {
|
||||
vertical-align: middle
|
||||
}
|
||||
|
||||
.version_footer {
|
||||
bottom: 0;
|
||||
width: 100%
|
||||
}
|
||||
|
||||
div.error {
|
||||
margin: 2em;
|
||||
text-align: center
|
||||
}
|
||||
|
||||
div.ajax-error {
|
||||
padding: 1em;
|
||||
text-align: center;
|
||||
color: #a94442;
|
||||
background-color: #f2dede;
|
||||
border-color: #ebccd1
|
||||
}
|
||||
|
||||
div.ajax-error hr {
|
||||
border-top-color: #e4b9c0
|
||||
}
|
||||
|
||||
div.ajax-error .alert-link {
|
||||
color: #843534
|
||||
}
|
||||
|
||||
div.error>h1 {
|
||||
font-size: 300%;
|
||||
line-height: normal
|
||||
}
|
||||
|
||||
div.error>p {
|
||||
font-size: 200%;
|
||||
line-height: normal
|
||||
}
|
||||
|
||||
#login-main {
|
||||
display: table;
|
||||
height: 80vh
|
||||
}
|
||||
|
||||
#login-main #insecure-login-warning {
|
||||
background-color: #fcf8e3;
|
||||
padding: 10px
|
||||
}
|
||||
|
||||
a#login-main #insecure-login-warning:focus,
|
||||
a#login-main #insecure-login-warning:hover {
|
||||
background-color: #f7ecb5
|
||||
}
|
||||
|
||||
#login-main .service-login {
|
||||
text-align: center;
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
margin: auto auto 20% auto
|
||||
}
|
||||
|
||||
#login-main form {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
margin: auto auto 20% auto;
|
||||
width: 350px
|
||||
}
|
||||
|
||||
#login-main .login_error {
|
||||
color: #ff4500;
|
||||
font-weight: 700;
|
||||
text-align: center
|
||||
}
|
||||
|
||||
#login-main .auth-form-header {
|
||||
padding: 10px 20px;
|
||||
color: #fff;
|
||||
background: #f37524;
|
||||
border-radius: 3px 3px 0 0;
|
||||
font-size: large
|
||||
}
|
||||
|
||||
#login-main .auth-form-header>h1 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
margin: 0
|
||||
}
|
||||
|
||||
#login-main .auth-form-body {
|
||||
padding: 20px;
|
||||
border: thin silver solid;
|
||||
border-top: none;
|
||||
border-radius: 0 0 3px 3px
|
||||
}
|
90
render/templates/form.html
Normal file
90
render/templates/form.html
Normal file
|
@ -0,0 +1,90 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>[TEST] Spawner Form</title>
|
||||
<!-- Latest compiled and minified CSS -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@3.4.1/dist/css/bootstrap.min.css"
|
||||
integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous">
|
||||
|
||||
<!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
|
||||
<script src="https://code.jquery.com/jquery-1.12.4.min.js"
|
||||
integrity="sha384-nvAa0+6Qg9clwYCGGPpDQLVpLNn0fRaROjHqs13t4Ggj3Ez50XnGQqc/r8MhnRDZ"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<!-- Latest compiled and minified JavaScript -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@3.4.1/dist/js/bootstrap.min.js"
|
||||
integrity="sha384-aJ21OjlMXNL5UyIl/XNwTMqvzeRMZH2w8c5cRVpzpU8Y5bApTppSuUkhZXN0VxHd"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<!-- Jupyter CSS stylesheet -->
|
||||
<link rel="stylesheet" href="/static/jupyter.css" type="text/css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<div class="row text-center">
|
||||
<h1>🚧 Spawner options form 🚧</h1>
|
||||
</div>
|
||||
|
||||
<div class="row col-sm-offset-2 col-sm-8">
|
||||
<form enctype="multipart/form-data" id="spawn_form" action="/submit" method="post" role="form">
|
||||
<br>
|
||||
{{spawner_options_form | safe}}
|
||||
<br>
|
||||
<div class="feedback-container">
|
||||
<input type="submit" value="Submit" class="btn btn-jupyter form-control">
|
||||
<div class="feedback-widget hidden">
|
||||
<i class="fa fa-spinner"></i>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="clearfix visible-block"></div>
|
||||
<hr>
|
||||
|
||||
<div class="row text-center">
|
||||
<h2>🐛 Debug <a href="/">🔄</a></h2>
|
||||
</div>
|
||||
<div id="debug-results">
|
||||
<div class="row col-sm-offset-2 col-sm-8">
|
||||
<h3>🔤 Input options form</h3>
|
||||
<pre>{{options|pprint}}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var $debug = $('#debug-results');
|
||||
var $form = $('#spawn_form');
|
||||
|
||||
$('input').change(function(){
|
||||
$form.submit();
|
||||
});
|
||||
|
||||
$('select').change(function(){
|
||||
$form.submit();
|
||||
});
|
||||
|
||||
$form.submit(function (e) {
|
||||
e.preventDefault();
|
||||
$.ajax({
|
||||
type: "POST",
|
||||
url: "/submit",
|
||||
data: $("#spawn_form").serialize(),
|
||||
success: function (content) {
|
||||
$debug.empty();
|
||||
$debug.append(content);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
14
render/templates/options.html
Normal file
14
render/templates/options.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
<div class="row col-sm-offset-2 col-sm-8">
|
||||
<h3>📝 Submitted form data</h3>
|
||||
<pre><code>{{formdata}}</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="row col-sm-offset-2 col-sm-8 text-success">
|
||||
{% if options %}
|
||||
<h3>✅ Parsed spawner options</h3>
|
||||
<pre><code>{{options}}</code></pre>
|
||||
{% else %}
|
||||
<h3>⛔️ Spawner options error</h3>
|
||||
<pre><code>{{err}}</code></pre>
|
||||
{% endif %}
|
||||
</div>
|
55
src/glicid_spawner/form.py
Normal file
55
src/glicid_spawner/form.py
Normal file
|
@ -0,0 +1,55 @@
|
|||
"""GLiCID form templates module."""
|
||||
|
||||
from jinja2 import Environment, PackageLoader, select_autoescape
|
||||
|
||||
from .micromamba import get_envs
|
||||
from .resources import CPU, GPU, RAM
|
||||
|
||||
TEMPLATES = Environment(
|
||||
loader=PackageLoader('glicid_spawner'),
|
||||
autoescape=select_autoescape(),
|
||||
)
|
||||
|
||||
|
||||
def options_attrs(username: str) -> dict:
|
||||
"""Form options attributes."""
|
||||
return {
|
||||
'username': username,
|
||||
'python_envs': get_envs(username),
|
||||
'cpu_available': CPU,
|
||||
'ram_available': RAM,
|
||||
'gpu_available': GPU,
|
||||
}
|
||||
|
||||
|
||||
def options_form(username: str) -> str:
|
||||
"""Render default spawner form."""
|
||||
template = TEMPLATES.get_template('interactive.html')
|
||||
return template.render(**options_attrs(username))
|
||||
|
||||
|
||||
def options_from_form(formdata) -> dict:
|
||||
"""Export options from default form."""
|
||||
# Resources choices indexes
|
||||
i_cpu = int(formdata['cpu'][0])
|
||||
i_ram = int(formdata['ram'][0])
|
||||
i_gpu = int(formdata['gpu'][0])
|
||||
|
||||
duration = min(
|
||||
CPU[i_cpu].max_duration,
|
||||
RAM[i_ram].max_duration,
|
||||
GPU[i_gpu].max_duration,
|
||||
)
|
||||
|
||||
# Export options
|
||||
options = {
|
||||
'pyenv': formdata['python-env'][0],
|
||||
'nprocs': int(CPU[i_cpu].description),
|
||||
'memory': RAM[i_ram].description.replace(' ', ''),
|
||||
'runtime': f'{duration:02d}:00:00',
|
||||
}
|
||||
|
||||
if i_gpu:
|
||||
options['gres'] = 'gpu:' + GPU[i_gpu].description.lower()
|
||||
|
||||
return options
|
|
@ -1,11 +1,9 @@
|
|||
"""GLiCID spawner module."""
|
||||
|
||||
from batchspawner import SlurmSpawner
|
||||
from jinja2 import Environment, PackageLoader, select_autoescape
|
||||
from traitlets import Unicode
|
||||
|
||||
from .micromamba import get_envs
|
||||
from .resources import CPU, GPU, RAM
|
||||
from .form import options_form, options_from_form
|
||||
|
||||
|
||||
class GlicidSpawner(SlurmSpawner):
|
||||
|
@ -16,46 +14,10 @@ class GlicidSpawner(SlurmSpawner):
|
|||
help='Glicid spawner singleuser command.',
|
||||
).tag(config=True)
|
||||
|
||||
def _options_form_default(self) -> str:
|
||||
def options_form(self) -> str:
|
||||
"""JupyterHub rendered form template."""
|
||||
environment = Environment(
|
||||
loader=PackageLoader('glicid_spawner'),
|
||||
autoescape=select_autoescape(),
|
||||
)
|
||||
template = environment.get_template('interactive.html')
|
||||
|
||||
return template.render(
|
||||
username=self.user.name,
|
||||
python_envs=get_envs(self.user.name),
|
||||
cpu_available=CPU,
|
||||
ram_available=RAM,
|
||||
gpu_available=GPU,
|
||||
)
|
||||
return options_form(self.user.name)
|
||||
|
||||
def options_from_form(self, formdata) -> dict:
|
||||
"""Export options from form."""
|
||||
# Index of user resources choices
|
||||
i_cpu = int(formdata['cpu'][0])
|
||||
i_ram = int(formdata['ram'][0])
|
||||
i_gpu = int(formdata['gpu'][0])
|
||||
|
||||
duration = min(
|
||||
CPU[i_cpu].max_duration,
|
||||
RAM[i_ram].max_duration,
|
||||
GPU[i_gpu].max_duration,
|
||||
)
|
||||
|
||||
# Export options
|
||||
options = {}
|
||||
|
||||
options['pyenv'] = formdata['python-env'][0]
|
||||
|
||||
options['cpus-per-task'] = CPU[i_cpu].description
|
||||
options['mem'] = RAM[i_ram].description.replace(' ', '')
|
||||
|
||||
if i_gpu:
|
||||
options['gres'] = 'gpu:' + GPU[i_gpu].description
|
||||
|
||||
options['time'] = f'{duration:02d}:00:00'
|
||||
|
||||
return options
|
||||
return options_from_form(formdata)
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="username" class="col-sm-3 control-label">Python environment:</label>
|
||||
<label for="python-env" class="col-sm-3 control-label">Python environment:</label>
|
||||
<div class="col-sm-9">
|
||||
<select class="form-control" name="python-env">
|
||||
{%- for pyenv in python_envs -%}
|
||||
|
@ -21,7 +21,7 @@
|
|||
{%- for cpu in cpu_available -%}
|
||||
<div class="col-sm-2">
|
||||
<input type="radio" name="cpu" id="cpu_{{loop.index0}}" value="{{loop.index0}}"
|
||||
data-max-duration="{{cpu.max_duration}}" {%- if loop.first -%}checked{%- endif -%}>
|
||||
data-max-duration="{{cpu.max_duration}}"{% if loop.first %} checked{% endif %}>
|
||||
<label for="cpu_{{loop.index0}}" class="btn btn-default btn-block">
|
||||
{{ cpu.description }}
|
||||
</label>
|
||||
|
@ -35,7 +35,7 @@
|
|||
{%- for ram in ram_available -%}
|
||||
<div class="col-sm-2">
|
||||
<input type="radio" name="ram" id="ram_{{loop.index0}}" value="{{loop.index0}}"
|
||||
data-max-duration="{{ram.max_duration}}" {%- if loop.first -%}checked{%- endif -%}>
|
||||
data-max-duration="{{ram.max_duration}}"{% if loop.first %} checked{% endif %}>
|
||||
<label for="ram_{{loop.index0}}" class="btn btn-default btn-block">
|
||||
{{ ram.description }}
|
||||
</label>
|
||||
|
@ -49,7 +49,7 @@
|
|||
{%- for gpu in gpu_available -%}
|
||||
<div class="col-sm-2">
|
||||
<input type="radio" name="gpu" id="gpu_{{loop.index0}}" value="{{loop.index0}}"
|
||||
data-max-duration="{{gpu.max_duration}}" {%- if loop.first -%}checked{%- endif -%}>
|
||||
data-max-duration="{{gpu.max_duration}}"{% if loop.first %} checked{% endif %}>
|
||||
<label for="gpu_{{loop.index0}}" class="btn btn-default btn-block">
|
||||
{{ gpu.description }}
|
||||
</label>
|
||||
|
|
90
tests/test_form.py
Normal file
90
tests/test_form.py
Normal file
|
@ -0,0 +1,90 @@
|
|||
"""Test form templates module."""
|
||||
|
||||
import re
|
||||
|
||||
from glicid_spawner import form, micromamba
|
||||
from glicid_spawner.form import options_form, options_from_form
|
||||
|
||||
|
||||
def test_options_form(monkeypatch):
|
||||
"""Test options form render."""
|
||||
monkeypatch.setattr(
|
||||
form,
|
||||
'get_envs',
|
||||
lambda username: [
|
||||
micromamba.MicromambaEnv('USER', 'foo', f'/{username}/envs/foo'),
|
||||
micromamba.MicromambaEnv('USER', 'bar', f'/{username}/envs/bar'),
|
||||
micromamba.MicromambaEnv('GLOBAL', 'baz', '/global/envs/baz'),
|
||||
],
|
||||
)
|
||||
|
||||
html = re.sub(r'\n\s+', ' ', options_form('john-doe')) # trim line breaks
|
||||
|
||||
# Username
|
||||
assert '<div class="form-control-static">john-doe</div>' in html
|
||||
|
||||
# Python environments
|
||||
assert '<option value="/john-doe/envs/foo">foo (USER)</option>' in html
|
||||
assert '<option value="/john-doe/envs/bar">bar (USER)</option>' in html
|
||||
assert '<option value="/global/envs/baz">baz (GLOBAL)</option>' in html
|
||||
|
||||
# CPU
|
||||
assert (
|
||||
'<input type="radio" name="cpu" id="cpu_0" value="0" data-max-duration="24" checked>'
|
||||
in html
|
||||
)
|
||||
assert '<label for="cpu_0" class="btn btn-default btn-block"> 1 </label>' in html
|
||||
assert '<input type="radio" name="cpu" id="cpu_5" value="5" data-max-duration="1">' in html
|
||||
assert '<label for="cpu_5" class="btn btn-default btn-block"> 24 </label>' in html
|
||||
|
||||
# Memory
|
||||
assert (
|
||||
'<input type="radio" name="ram" id="ram_0" value="0" data-max-duration="24" checked>'
|
||||
in html
|
||||
)
|
||||
assert '<label for="ram_0" class="btn btn-default btn-block"> 4 GB </label>' in html
|
||||
assert '<input type="radio" name="ram" id="ram_5" value="5" data-max-duration="1">' in html
|
||||
assert '<label for="ram_5" class="btn btn-default btn-block"> 96 GB </label>' in html
|
||||
|
||||
# GPU
|
||||
assert (
|
||||
'<input type="radio" name="gpu" id="gpu_0" value="0" data-max-duration="24" checked>'
|
||||
in html
|
||||
)
|
||||
assert '<label for="gpu_0" class="btn btn-default btn-block"> No </label>' in html
|
||||
assert '<input type="radio" name="gpu" id="gpu_1" value="1" data-max-duration="1">' in html
|
||||
assert '<label for="gpu_1" class="btn btn-default btn-block"> A100 </label>' in html
|
||||
|
||||
|
||||
def test_options_from_form():
|
||||
"""Test options from form parser."""
|
||||
# No GPU
|
||||
formdata = {
|
||||
'python-env': ['/john-doe/envs/bar'],
|
||||
'cpu': ['1'],
|
||||
'ram': ['2'],
|
||||
'gpu': ['0'],
|
||||
}
|
||||
|
||||
assert options_from_form(formdata) == {
|
||||
'pyenv': '/john-doe/envs/bar',
|
||||
'nprocs': 2,
|
||||
'memory': '16GB',
|
||||
'runtime': '06:00:00',
|
||||
}
|
||||
|
||||
# No GPU
|
||||
formdata = {
|
||||
'python-env': ['/global/envs/baz'],
|
||||
'cpu': ['0'],
|
||||
'ram': ['0'],
|
||||
'gpu': ['1'],
|
||||
}
|
||||
|
||||
assert options_from_form(formdata) == {
|
||||
'pyenv': '/global/envs/baz',
|
||||
'nprocs': 1,
|
||||
'memory': '4GB',
|
||||
'runtime': '01:00:00',
|
||||
'gres': 'gpu:a100',
|
||||
}
|
Loading…
Add table
Reference in a new issue