diff --git a/README.md b/README.md index f654550..4ea60dc 100644 --- a/README.md +++ b/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 ``` diff --git a/poetry.lock b/poetry.lock index 7fc1fdd..b3415ab 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index c593d8e..0e537ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/render/__init__.py b/render/__init__.py new file mode 100644 index 0000000..4a04310 --- /dev/null +++ b/render/__init__.py @@ -0,0 +1,5 @@ +"""HTML dynamic template rendering submodule. + +This file is required for pytest pre-commit hook (pytest#3151). + +""" diff --git a/render/__main__.py b/render/__main__.py new file mode 100644 index 0000000..3de4708 --- /dev/null +++ b/render/__main__.py @@ -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() diff --git a/render/static/jupyter.css b/render/static/jupyter.css new file mode 100644 index 0000000..c3ca44b --- /dev/null +++ b/render/static/jupyter.css @@ -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 +} diff --git a/render/templates/form.html b/render/templates/form.html new file mode 100644 index 0000000..a143130 --- /dev/null +++ b/render/templates/form.html @@ -0,0 +1,90 @@ + + + + + + + [TEST] Spawner Form + + + + + + + + + + + + + + +
+ +
+

🚧 Spawner options form 🚧

+
+ +
+
+
+ {{spawner_options_form | safe}} +
+ +
+
+ +
+
+ +
+

🐛 Debug 🔄

+
+
+
+

🔤 Input options form

+
{{options|pprint}}
+
+
+ + +
+ + + + diff --git a/render/templates/options.html b/render/templates/options.html new file mode 100644 index 0000000..13d0572 --- /dev/null +++ b/render/templates/options.html @@ -0,0 +1,14 @@ +
+

📝 Submitted form data

+
{{formdata}}
+
+ +
+ {% if options %} +

✅ Parsed spawner options

+
{{options}}
+ {% else %} +

⛔️ Spawner options error

+
{{err}}
+ {% endif %} +
diff --git a/src/glicid_spawner/form.py b/src/glicid_spawner/form.py new file mode 100644 index 0000000..73c3ff7 --- /dev/null +++ b/src/glicid_spawner/form.py @@ -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 diff --git a/src/glicid_spawner/spawner.py b/src/glicid_spawner/spawner.py index 1dca90b..3d4d70b 100644 --- a/src/glicid_spawner/spawner.py +++ b/src/glicid_spawner/spawner.py @@ -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) diff --git a/src/glicid_spawner/templates/interactive.html b/src/glicid_spawner/templates/interactive.html index 9d71ca3..25ebf10 100644 --- a/src/glicid_spawner/templates/interactive.html +++ b/src/glicid_spawner/templates/interactive.html @@ -6,7 +6,7 @@
- +
+ data-max-duration="{{cpu.max_duration}}"{% if loop.first %} checked{% endif %}> @@ -35,7 +35,7 @@ {%- for ram in ram_available -%}
+ data-max-duration="{{ram.max_duration}}"{% if loop.first %} checked{% endif %}> @@ -49,7 +49,7 @@ {%- for gpu in gpu_available -%}
+ data-max-duration="{{gpu.max_duration}}"{% if loop.first %} checked{% endif %}> diff --git a/tests/test_form.py b/tests/test_form.py new file mode 100644 index 0000000..566a760 --- /dev/null +++ b/tests/test_form.py @@ -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 '
john-doe
' in html + + # Python environments + assert '' in html + assert '' in html + assert '' in html + + # CPU + assert ( + '' + in html + ) + assert '' in html + assert '' in html + assert '' in html + + # Memory + assert ( + '' + in html + ) + assert '' in html + assert '' in html + assert '' in html + + # GPU + assert ( + '' + in html + ) + assert '' in html + assert '' in html + assert '' 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', + }