Journal Django + Jupyter Lab = ❤️

Posté par  (site web personnel) . Licence CC By‑SA.
Étiquettes :
17
18
juil.
2023

Au travail, 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 sur la prod pour explorer plus facilement les données".

Notre prod est sous Django, 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 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 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, le composant propulsant jupyter lab, qui est le futur de jupyter notebook classic, bien que ce dernier soit remplacé par jupyter notebook v7 (‽‽‽) (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.

TL;DR: Si on implémente la classe 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 :

$ 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.

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 :

# 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 :

$ 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. 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:

DJANGO_READ_ONLY=1 NOTEBOOK_ARGUMENTS=… ./manage.py shell_plus --lab
  • # Précision sur les notebook

    Posté par  (site web personnel) . Évalué à 6.

    Il faut utiliser le kernel "Django Shell-Plus" pour exécuter vos notebooks.

    De plus, le notebook est exécuté dans un contexte asynchrone. Ce qui rend relou la réutilisation de ton vieux code synchrone.

    Si tu ne veux pas te prendre la tête, voila un boilerplate à utiliser dans le notebook pour simplifier tout ça :

    from asgiref.sync import sync_to_async
    from django.contrib.auth.models import User
    
    @sync_to_async
    def main():
        # Ton code synchrone ici
        print(User.objects.all())
    
    await main()

    Tu peux aussi utiliser Numpy et matplotlib pour faire de jolis graphs.

    • [^] # Re: Précision sur les notebook

      Posté par  (site web personnel) . Évalué à 6.

      Puisqu'on en est à enjoliver les notebooks Jupyter, j'ai commis une petite bibliothèque qui permet de créer des interfaces graphiques un peu plus sophistiquées qu'avec ipywidgets.

      Exemple :

      Animation montrant un notebook Jupyter utilisant le toolkit Atlas

      Une autre interface crée avec un notebook Jupyter (cliquer sur l'image pour voir le notebook correspondant) :

      L'application 'Contacts' tel qu'affichée dans un noteobok Jupyter

      Cyberdépendance, cyberharcèlement, pédocriminalité… : Zelbinium, pour que les smartphones soient la solution, pas le problème !

  • # Dommage cette licence non libre

    Posté par  . Évalué à 4. Dernière modification le 19 juillet 2023 à 15:07.

    # If you happen to meet one of the copyright holders in a bar you are obligated
    # to buy them one pint of beer.
    

    Arf. Désolé de faire les rabats-joie, et je comprend que c'est pour le fun, mais du coup, ton code durement travaillé ne peut tout simplement pas être réutilisé sérieusement dans un projet libre. Si on réfléchit sérieusement aux conséquences d'une telle licence, quelqu'un qui utilise ce code se voit forcé, jusqu'à la fin de ses jours, de demander à tous les gens croisés dans n'importe quel bar si un bout de ce code est à eux et d'avoir l'argent pour leur payer une bière. En espérant que le service est toujours ouvert.

    Je ne vais pas étudier ton code, et si un jour je devais me retrouver devant le problème, je vais devoir refaire le boulot. Tu as l'air d'être dans une optique de partage, du coup les conséquences de cette licence un peu farfelue pour le fun sont un peu regrettables.

    Ce serait bien que les licences reste sérieuses pour qu'elles restent des outils sur lesquels on peut compter. "Ça va, ce n'est pas sérieux, on rigole" n'est pas vraiment une option de cet outil légal. Au pire il y a la licence WTFPL qui joue le rôle de licence fun mais suffisamment robuste. À la limite la licence Beerware, qui n'impose rien de spécial devrait fonctionner aussi.

    Ces licences amusantes… ne le sont pas du tout quand elles sont utilisées pour de vrai.

    • [^] # Re: Dommage cette licence non libre

      Posté par  (site web personnel) . Évalué à 3.

      Ça interdit presque de facto les briefs d'équipe dans un bar. Si n développeurs se rencontrent et payent n - 1 bières à chacun autre développeur, les fûts vont rapidement prendre cher au fur et à mesure que l'équipe s'agrandi (et les devs aussi par ailleurs). Sans compter qu'une telle disposition peut motiver à l'agrandissement de l'équipe…

      Adhérer à l'April, ça vous tente ?

      • [^] # Re: Dommage cette licence non libre

        Posté par  . Évalué à 2. Dernière modification le 19 juillet 2023 à 17:28.

        Ah oui, et tu penses que si un·e dev part aux toilettes et revient, ça compte comme une nouvelle rencontre et donc :

        • ce·tte dev doit payer une pinte aux n-1 autres,
        • chaque n-1 autre dev doit à nouveau lui payer une pinte ?
    • [^] # Re: Dommage cette licence non libre

      Posté par  . Évalué à 1.

      Si on réfléchit sérieusement aux conséquences d'une telle licence, quelqu'un qui utilise ce code se voit forcé, jusqu'à la fin de ses jours, de demander à tous les gens croisés dans n'importe quel bar si un bout de ce code est à eux et d'avoir l'argent pour leur payer une bière.

      Je ne crois pas que tenter d'analyser la licence sans en étudier la légalité et extrapoler à l'outrance les vides que l'on ne comprends pas puisse être appelé "réfléchir sérieusement aux conséquences".

      Et monter sur tes grands chevaux en mode "cacher ce code que je ne saurais voir"… Le snippet n'est pas licenciable il ne représente pas une création originale et il lui serait impossible de s'en attribué la paternité.

      Inutile de surjouer l'étranglement signaler simplement que c'est une licence non libre (mais je pense que c'est tout à fait volontaire pour faire référence à un lien récent sachant qu'il n'est pas possible d'apposer une licence sur un code aussi triviale).

      https://linuxfr.org/users/barmic/journaux/y-en-a-marre-de-ce-gros-troll

      • [^] # Re: Dommage cette licence non libre

        Posté par  . Évalué à 3. Dernière modification le 20 juillet 2023 à 10:23.

        Si j'ajoute "potentielles" après "réfléchir sérieusement aux conséquences", ça te plaît mieux ?

        Sinon, on dirait que tu m'as volé mes grands chevaux, et qu'en plus tu as ajouté les tiens…

        Ton argumentaire repose sur :

        • on ne connait pas vraiment les implications légales" et
        • "ce n'est pas licenciable parce que c'est trop petit

        Mais alors, pourquoi compliquer les choses avec une licence douteuse, et aussi es-tu vraiment sûr de ce que tu avances ?

        • [^] # Re: Dommage cette licence non libre

          Posté par  . Évalué à 2.

          Sinon, on dirait que tu m'as volé mes grands chevaux, et qu'en plus tu as ajouté les tiens…

          Toujours, c'est une règle : se mettre au niveau de son interlocuteur ;)

          Ton argumentaire repose sur :

          • on ne connait pas vraiment les implications légales" et
          • "ce n'est pas licenciable parce que c'est trop petit

          Disons que les implications loufoques comme « on va être obligé de demander à toutes les personnes de tous les bars où on va pour le restant de nos jours s'ils ont contribué à ce bout de code » c'est bien pour rigoler entre amis, mais tu te doute bien que ça ne marche pas comme ça.

          Pour le second c'est pas une question de taille, mais de ce que ça représente. Il va être réellement compliqué pour quiconque de démontrer sa paternité sur un exemple de comment s'utilise des API dont il n'a même pas lui même la paternité.

          Mais alors, pourquoi compliquer les choses avec une licence douteuse

          Parce qu'il trouvait cette idée de licence marrante ? Que ça blague tombe à l'eau c'est une chose, mais faut souffler un peu. Si tu veux être rigoriste s'il n'avait mis aucune licence ce serait le droit d'auteur qui s'appliquerait et qui serait ce qu'il y a de plus restrictif. Pourtant je doute que quiconque aurait émis une quelconque plainte en disant que c'est pas libre (exemple avec l'un des journaux de serge_sans_paille donc le code n'est pas libre).

          aussi es-tu vraiment sûr de ce que tu avances ?

          Je n'ai pas l'impression d'avancer plus de choses que toi, mais oui j'en suis parfaitement convaincu et serait prêt à faire valoir mes droits si besoin.

          https://linuxfr.org/users/barmic/journaux/y-en-a-marre-de-ce-gros-troll

          • [^] # Re: Dommage cette licence non libre

            Posté par  (site web personnel) . Évalué à 2.

            Parce qu'il trouvait cette idée de licence marrante ? Que ça blague tombe à l'eau c'est une chose, mais faut souffler un peu.

            J’ai surtout lu le texte de la licence trop vite. Je pensais que c’était une MIT avec une blague en plus. Je n’avais pas réalisé que la manière dont c’était écrit ajoutait une contrainte forte et rendait le code non libre.

            Si tu veux être rigoriste s'il n'avait mis aucune licence ce serait le droit d'auteur qui s'appliquerait et qui serait ce qu'il y a de plus restrictif

            Non. Comme le journal lui même est sous licence CC BY-SA, sans mention contraire, le code inclu dans le journal aurait aussi été sous CC BY-SA. (IANAL)

            • [^] # Re: Dommage cette licence non libre

              Posté par  . Évalué à 2.

              Non. Comme le journal lui même est sous licence CC BY-SA, sans mention contraire, le code inclu dans le journal aurait aussi été sous CC BY-SA. (IANAL)

              Effectivement et c'est le cas du journal de serge que j'avais mis comme exemple my bad.

              https://linuxfr.org/users/barmic/journaux/y-en-a-marre-de-ce-gros-troll

    • [^] # Re: Dommage cette licence non libre

      Posté par  (site web personnel) . Évalué à 2.

      Tu as raison sur le fond. Voici le même code sous licence MIT.

      # Copyright 2023 Jonathan Tremesaygues
      #
      # 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.
      #
      # 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
      • [^] # Re: Dommage cette licence non libre

        Posté par  . Évalué à 2.

        Top !

        J'espère que mon commentaire t'a paru moins "grands chevaux" qu'à barmic, ce n'était clairement pas l'intention.

        • [^] # Re: Dommage cette licence non libre

          Posté par  (site web personnel) . Évalué à 4.

          Comment dire… J’ai vu ton commentaire hier, mais je n’ai répondu qu’aujourd’hui, le temps que l’envie de te traiter de tout un tas de nom d’oiseaux redescende suffisamment.

          Car même si tu as parfaitement raison sur le fond, tout comme barmic<, j’ai trouvé la forme très violente. Sur le coup, j’ai même cru que c’était zenitram qui me répondait :-/

          • [^] # Re: Dommage cette licence non libre

            Posté par  . Évalué à 3. Dernière modification le 20 juillet 2023 à 17:50.

            Arf, toutes mes plus plates excuses alors !

            La semaine est un peu difficile (mais ça n'excuse pas). Ça n'a pas du aider la rédaction et j'aurais pu la fermer du coup. Je plaide coupable, vaut mieux écrire quand on y est disposé.

            Et je reconnais que la réaction de barmic est tout à fait logique aussi du coup.

            • [^] # Re: Dommage cette licence non libre

              Posté par  (site web personnel) . Évalué à 3.

              Arf, toutes mes plus plates excuses alors !

              Pas de soucis :)

              Ça n'a pas du aider la rédaction et j'aurais pu la fermer du coup. Je plaide coupable, vaut mieux écrire quand on y est disposé.

              Boarf. Au moins comme ça on a résolu le problème juridique du snippet. Parce que comme tu l'as dit, le but est avant tout que ça puisse servir à d'autre. Ça m'aurait bien fait gagner 2 semaines de prototypage si j'avais pu tomber sur mon journal quand j'ai dû dev ça pour le taf :D

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.