Add flask app with auto-reload to render the form template

This commit is contained in:
Benoît Seignovert 2024-02-14 10:02:07 +01:00
parent 433862d0ad
commit 2333ccd168
Signed by: Benoît Seignovert
GPG key ID: F5D8895227D18A0B
12 changed files with 662 additions and 61 deletions

View file

@ -10,7 +10,24 @@ cd glicid-spawner
poetry install poetry install
poetry run pre-commit 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 ```bash
source .venv/bin/activate source .venv/bin/activate
``` ```

118
poetry.lock generated
View file

@ -67,6 +67,17 @@ url = "https://github.com/jupyterhub/batchspawner.git"
reference = "main" reference = "main"
resolved_reference = "25918f6495a99d4083cbe3134d6e167d47cf11d1" 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]] [[package]]
name = "certifi" name = "certifi"
version = "2023.11.17" version = "2023.11.17"
@ -270,6 +281,20 @@ files = [
{file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, {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]] [[package]]
name = "colorama" name = "colorama"
version = "0.4.6" 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)"] 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)"] 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]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.0.3" version = "3.0.3"
@ -550,6 +597,17 @@ files = [
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, {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]] [[package]]
name = "jinja2" name = "jinja2"
version = "3.1.3" version = "3.1.3"
@ -650,6 +708,21 @@ traitlets = ">=4.3.2"
[package.extras] [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"] 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]] [[package]]
name = "mako" name = "mako"
version = "1.3.0" version = "1.3.0"
@ -1397,22 +1470,22 @@ files = [
[[package]] [[package]]
name = "tornado" name = "tornado"
version = "6.4" version = "6.2"
description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
optional = false optional = false
python-versions = ">= 3.8" python-versions = ">= 3.7"
files = [ files = [
{file = "tornado-6.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:02ccefc7d8211e5a7f9e8bc3f9e5b0ad6262ba2fbb683a6443ecc804e5224ce0"}, {file = "tornado-6.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:20f638fd8cc85f3cbae3c732326e96addff0a15e22d80f049e00121651e82e72"},
{file = "tornado-6.4-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:27787de946a9cffd63ce5814c33f734c627a87072ec7eed71f7fc4417bb16263"}, {file = "tornado-6.2-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:87dcafae3e884462f90c90ecc200defe5e580a7fbbb4365eda7c7c1eb809ebc9"},
{file = "tornado-6.4-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7894c581ecdcf91666a0912f18ce5e757213999e183ebfc2c3fdbf4d5bd764e"}, {file = "tornado-6.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba09ef14ca9893954244fd872798b4ccb2367c165946ce2dd7376aebdde8e3ac"},
{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.2-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8150f721c101abdef99073bf66d3903e292d851bee51910839831caba341a75"},
{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.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.4-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fd03192e287fbd0899dd8f81c6fb9cbbc69194d2074b38f384cb6fa72b80e9c2"}, {file = "tornado-6.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:5f8c52d219d4995388119af7ccaa0bcec289535747620116a58d830e7c25d8a8"},
{file = "tornado-6.4-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:88b84956273fbd73420e6d4b8d5ccbe913c65d31351b4c004ae362eba06e1f78"}, {file = "tornado-6.2-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:6fdfabffd8dfcb6cf887428849d30cf19a3ea34c2c248461e1f7d718ad30b66b"},
{file = "tornado-6.4-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:71ddfc23a0e03ef2df1c1397d859868d158c8276a0603b96cf86892bff58149f"}, {file = "tornado-6.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:1d54d13ab8414ed44de07efecb97d4ef7c39f7438cf5e976ccd356bebb1b5fca"},
{file = "tornado-6.4-cp38-abi3-win32.whl", hash = "sha256:6f8a6c77900f5ae93d8b4ae1196472d0ccc2775cc1dfdc9e7727889145c45052"}, {file = "tornado-6.2-cp37-abi3-win32.whl", hash = "sha256:5c87076709343557ef8032934ce5f637dbb552efa7b21d08e89ae7619ed0eb23"},
{file = "tornado-6.4-cp38-abi3-win_amd64.whl", hash = "sha256:10aeaa8006333433da48dec9fe417877f8bcc21f48dda8d661ae79da357b2a63"}, {file = "tornado-6.2-cp37-abi3-win_amd64.whl", hash = "sha256:e5f923aa6a47e133d1cf87d60700889d7eae68988704e20c75fb2d65677a8e4b"},
{file = "tornado-6.4.tar.gz", hash = "sha256:72291fa6e6bc84e626589f1c29d90a5a6d593ef5ae68052ee2ef000dfd273dee"}, {file = "tornado-6.2.tar.gz", hash = "sha256:9b630419bde84ec666bfd7ea0a4cb2a8a651c2d5cccdbdd1972a0c859dfc3c13"},
] ]
[[package]] [[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)"] 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)"] 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] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "542947d7dbd256b2b3cc75f7ca85e988b814d860e5f9f003281309ab2bf7eec2" content-hash = "3aee48cbff66c32e4121e9a776d19c466fae94258204526f99eaca50360bbfc7"

View file

@ -25,6 +25,9 @@ pre-commit = "^3.6.0"
pytest = "^8.0.0" pytest = "^8.0.0"
ruff = "^0.1.14" ruff = "^0.1.14"
pytest-cov = "^4.1.0" 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] [tool.ruff]
line-length = 100 line-length = 100

5
render/__init__.py Normal file
View 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
View 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
View 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
}

View 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>

View 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>

View 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

View file

@ -1,11 +1,9 @@
"""GLiCID spawner module.""" """GLiCID spawner module."""
from batchspawner import SlurmSpawner from batchspawner import SlurmSpawner
from jinja2 import Environment, PackageLoader, select_autoescape
from traitlets import Unicode from traitlets import Unicode
from .micromamba import get_envs from .form import options_form, options_from_form
from .resources import CPU, GPU, RAM
class GlicidSpawner(SlurmSpawner): class GlicidSpawner(SlurmSpawner):
@ -16,46 +14,10 @@ class GlicidSpawner(SlurmSpawner):
help='Glicid spawner singleuser command.', help='Glicid spawner singleuser command.',
).tag(config=True) ).tag(config=True)
def _options_form_default(self) -> str: def options_form(self) -> str:
"""JupyterHub rendered form template.""" """JupyterHub rendered form template."""
environment = Environment( return options_form(self.user.name)
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,
)
def options_from_form(self, formdata) -> dict: def options_from_form(self, formdata) -> dict:
"""Export options from form.""" """Export options from form."""
# Index of user resources choices return options_from_form(formdata)
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

View file

@ -6,7 +6,7 @@
</div> </div>
</div> </div>
<div class="form-group"> <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"> <div class="col-sm-9">
<select class="form-control" name="python-env"> <select class="form-control" name="python-env">
{%- for pyenv in python_envs -%} {%- for pyenv in python_envs -%}
@ -21,7 +21,7 @@
{%- for cpu in cpu_available -%} {%- for cpu in cpu_available -%}
<div class="col-sm-2"> <div class="col-sm-2">
<input type="radio" name="cpu" id="cpu_{{loop.index0}}" value="{{loop.index0}}" <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"> <label for="cpu_{{loop.index0}}" class="btn btn-default btn-block">
{{ cpu.description }} {{ cpu.description }}
</label> </label>
@ -35,7 +35,7 @@
{%- for ram in ram_available -%} {%- for ram in ram_available -%}
<div class="col-sm-2"> <div class="col-sm-2">
<input type="radio" name="ram" id="ram_{{loop.index0}}" value="{{loop.index0}}" <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"> <label for="ram_{{loop.index0}}" class="btn btn-default btn-block">
{{ ram.description }} {{ ram.description }}
</label> </label>
@ -49,7 +49,7 @@
{%- for gpu in gpu_available -%} {%- for gpu in gpu_available -%}
<div class="col-sm-2"> <div class="col-sm-2">
<input type="radio" name="gpu" id="gpu_{{loop.index0}}" value="{{loop.index0}}" <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"> <label for="gpu_{{loop.index0}}" class="btn btn-default btn-block">
{{ gpu.description }} {{ gpu.description }}
</label> </label>

90
tests/test_form.py Normal file
View 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',
}