URL: https://linuxfr.org/users/killruana/journaux/django-jupyter-lab Title: Django + Jupyter Lab = ❤️ Authors: jtremesay Date: 2023年07月18日T23:28:15+02:00 License: CC By-SA Tags: django et jupyter Score: 17 Au [travail](https://www.sereema.com/), un de mes juniors data-scientist est arrivé avec une demande toute innocente. "Ça serait bien qu'on puisse avoir un truc style [Jupyter Notebook](https://jupyter.org) sur la prod pour explorer plus facilement les données". Notre prod est sous [Django](https://www.djangoproject.com), un truc assez commun; et Jupyter aussi a l'air aussi rependu dans le domaine de la data-science. Je me suis dit que quelqu'un avait forcément répondu à la problématique. Et ben non. Pas du tout. Il existe bien [django-extensions](https://github.com/django-extensions/django-extensions) qui permet d'utiliser un projet Django dans des Notebook Jupyter. Mais je n'ai rien trouvé qui permet d'avoir un Jupyter Notebook intégré dans une application Django. Dommage. J'aurais bien aimé profité de notamment l'[authentification Django](https://docs.djangoproject.com/en/4.2/topics/auth/) afin que seul notre staff puisse accéder aux notebooks. En perdant suffisamment de temps dans les documentations et l'écosystème Jupyter, on fini par découvrir que [`jupyter-server`](https://pypi.org/project/jupyter-server/), le composant propulsant [`jupyter lab`](https://pypi.org/project/jupyterlab/), qui est le futur de [`jupyter notebook classic`](https://docs.jupyter.org/en/latest/install/notebook-classic.html), bien que ce dernier soit remplacé par [`jupyter notebook v7`](https://jupyter-notebook.readthedocs.io/en/latest/notebook_7_features.html) (‽‽‽) (j'ai RIEN compris à l'écosystème Jupyter, trop le bazar pour moi). Enfin bref, je disais que `jupyter-server` proposait la possibilité d'utiliser une [authentification custom](https://jupyter-server.readthedocs.io/en/latest/operators/security.html#authentication-and-authorization). TL;DR: Si on implémente la classe [`IdentityProvider`](https://jupyter-server.readthedocs.io/en/latest/operators/security.html#jupyter_server.auth.IdentityProvider), et plus spécifiquement la méthode `get_user(handler)`, on peut avoir notre propre authentification dans des notebook Jupyter via Django. Voici un exemple de comment y arriver. Attention, il s'agit d'une implémentation naïve et non un truc propre et efficace. Tout simplement parce que je ne peux pas copier coller le code du taf et que je ne suis pas motivé au point de tout refaire sur mon temps libre. On part sur un projet Django on ne peux plus standard : ```console $ mkdir django-notebook $ cd mkdir django-notebook $ python3 -m venv venv $ source venv/bin/activate $ pip install -U pip setuptools wheel && pip install Django django-extensions jupyterlab $ django-admin startproject proj . $ ./manage.py migrate $ ./manage.py createsuperuser ``` Maintenant, modifer `proj/settings.py` et ajouter `"django_extensions"` à la liste [`INSTALLED_APPS`](https://docs.djangoproject.com/en/4.2/ref/settings/#installed-apps). Vous pouvez lancer `./manage.py shell_plus --lab` pour s'assuser que votre projet Django est bien intégré à Jupyter Lab. Maintenant, on va créer le fichier `proj/jupyter.py` est y mettre le code suivant : ```python # Copyright 2023 Jonathan Tremesaygues # # Stronger Beer License # # Permission is hereby granted, free of charge, to any person obtaining a copy of # this software and associated documentation files (the "Software"), to deal in # the Software without restriction, including without limitation the rights to # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies # of the Software, and to permit persons to whom the Software is furnished to do # so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # If you happen to meet one of the copyright holders in a bar you are obligated # to buy them one pint of beer. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. from importlib import import_module from typing import Awaitable, Optional from asgiref.sync import sync_to_async from jupyter_server.auth import IdentityProvider from jupyter_server.auth.identity import User as JUser from jupyter_server.base.handlers import JupyterHandler from django.conf import settings from django.contrib import auth from django.contrib.auth.models import User as DUser # https://jupyter-server.readthedocs.io/en/latest/operators/security.html#jupyter_server.auth.IdentityProvider class DjangoIdentityProvider(IdentityProvider): def __init__(self, **kwargs): super().__init__(**kwargs) # C'est vraiment la méthode recommandée par Django pour charqer le bon # moteur de session>_< # https://docs.djangoproject.com/en/4.2/topics/http/sessions/#using-sessions-out-of-views self.SessionStore = import_module(settings.SESSION_ENGINE).SessionStore # https://jupyter-server.readthedocs.io/en/latest/operators/security.html#jupyter_server.auth.IdentityProvider.get_user async def get_user(self, handler: JupyterHandler) -> Optional[JUser] | Awaitable[Optional[JUser]]: # `get_user` est utilisé dans un contexte async mais Django préfère un # contexte sync pour parler avec la db. Et le SessionStore par défaut # utilise la db. Pis de toute façon faudra accéder à la DB pour charger # l'utilisateur. # https://docs.djangoproject.com/en/4.2/topics/async/ return await sync_to_async(self._get_user)(handler) def _get_user(self, handler: JupyterHandler) -> Optional[JUser]: # Essaye de récupérer le session id dans les cookies if (cookie_entry := handler.request.cookies.get(settings.SESSION_COOKIE_NAME)) is not None: # Charge la session correspondante session = self.SessionStore(session_key=cookie_entry.value).load() # Essaye de récupérer l'user id correspondant if (user_id := session[auth.SESSION_KEY]) is not None: try: # Essaye de charger l'utilisateur correspondant user = DUser.objects.get(pk=user_id) except DUser.DoesNotExist: # Utilisateur non trouvé pass else: # Est-ce que l'utilisateur est un admin? if user.is_staff: # L'utilisateur actuel est bien connecté et est un admin! # Crée un Jupyter user à partir du Django user return JUser(username=user.username) # Impossible d'authentifier l'utilisateur return None ``` Relancer le notebook pour qu'il utilise notre classe d'authentification : ```console $ NOTEBOOK_ARGUMENTS="--ServerApp.identity_provider_class=proj.jupyter.DjangoIdentityProvider --ServerApp.token= --ServerApp.password=" ./manage.py shell_plus --lab ``` À partir de là, tant que vous ne vous êtes pas authentifié par Django (via votre propre formulaire de connexion ou celui de l'admin de Django), le Notebook vous redirigera vers sa propre page de connexion qui ne sert plus à rien (pareil pour sa page de déconnexion). Est laissé en exercice au lecteur un code plus propre et optimale. Il faudrait notamment un système de cache afin de ne pas avoir à interroger la base de donnée à CHAQUE requête HTTP afin de savoir si l'utilisateur est connecté. Est aussi laissé en exercice en lecteur comment intégrer ça dans sa prod. Mais ça marche très bien dans un containeur Docker derrière un reverse proxy. Il faut juste trouver les bonnes options dans `jupyterlab --help-all` à mettre dans `NOTEBOOK_ARGUMENTS`. Pour info, il existe [django-read-only](https://pypi.org/project/django-read-only/). Ce projet permet de mettre un projet Django en lecture seule : impossible de modifier la base de données. Très utile pour s'assurer que personne ne fasse une connerie au travers des notebooks. Il suffit de définir la variable d'environnement `DJANGO_READ_ONLY`: ```console DJANGO_READ_ONLY=1 NOTEBOOK_ARGUMENTS=... ./manage.py shell_plus --lab ```