Réagir aux changements avec le module signals de Django 3


Le module signals contient une implémentation du design pattern observer, c’est à dire un moyen de lier un callback à un événement afin de pouvoir y réagir quand il se déclenche.

Les événements peuvent être aussi divers que “une requête a commencé” ou “un modèle a été sauvé en base de données”.

L’utilisation d’un signal se fait en 3 temps:

  1. décider à quel événement on veut réagir;
  2. décider ce qu’on veut faire quand il se déclenche;
  3. associer l’événement à l’action.

Par exemple, après la suppression d’un modèle, je veux faire un travail de nettoyage dans ma base de données.

Je choisis l’événement auquel je veux réagir dans la liste des signaux disponibles:

from django.db.models.signals import post_delete

Ici j’ai choisi post_delete, car c’est un signal qui est envoyé après la suppression d’un modèle automatiquement par Django.

Ensuite je décide de ce que je veux faire quand l’action se déclenche en écrivant une fonction:

def reaction_au_signal(sender, **kwargs):
    # faire ici mon opération de nettoyage

Vous noterez les arguments très spécifiques que j’accepte. Ils sont propres à l’implémentation des signaux de Django et cette signature est obligatoire. sender est l’objet qui envoie le signal. Dans notre cas ce sera la classe du modèle supprimé. kwargs c’est tout le reste, et son contenu dépend du type de signal reçu. Généralement il y a plein d’informations dedans sur le contexte qui nous permettent de prendre des décisions.

Ainsi, pour le signal post_delete, kwargs contient l’instance du modèle supprimé (dont les valeurs ne sont donc plus en base de données, ne faites pas de query avec !) et le nom de la base de données utilisés si vous en utilisez plusieurs.

Enfin il faut associer l’événement à l’action, en utilisant la fonction connect().

post_delete.connect(reaction_au_signal)

Le code complet donne ceci:

from django.db.models.signals import post_delete
 
def reaction_au_signal(sender, **kwargs):
    # faire ici mon opération de nettoyage dans la base de donnée
 
post_delete.connect(reaction_au_signal)

Réponses à quelques questions existentielles

Mais pourquoi que à quoi ça sert de quoi donc ?

Pourquoi ne pas utiliser plutôt l’héritage, ou mettre des hooks de callback ? Et bien tout simplement parce que les signaux permettent de réagir à une événement sans avoir à toucher le code qui génère l’événement. C’est un moyen très flexible et puissant de réagir à ce que fait du code dans une autre application. Ou de permettre à du code d’autres applications de réagir à vos événements sans avoir à toucher à votre code. Car oui, vous pouvez définir vos propres signaux.

Je les mets où ces signaux ?

Généralement dans le module qui contient la même sémantique que le signal: si c’est pour les requêtes HTTP, dans views.py, si c’est pour l’ORM, dans models.py, etc.

Est-ce que Dieu existe ?

Cher lecteur, merci de ne pas prononcer mon nom en vain.

Quelques astuces avec les signaux

Il est rare que vous vouliez réagir à la suppression de tous les modèles, ou de toutes les requêtes. Généralement on veut uniquement réagir à un signal pour un émetteur précis. Django permet cela en vous laissant choisir le sender:

 
class TopModel(models.Model):
    bonnet = models.TextField(max_char=1, default="A")
 
post_delete.connect(reaction_au_signal, sender=TopModel)

Ainsi, reaction_au_signal() ne sera appelée qu’à la suppression d’un objet de type TopModel.

Sachez aussi qu’il existe une syntaxe à base de décorateurs. Ca fait la même chose, mais perso je préfère la style:

from django.db.models.signals import post_delete
from django.dispatch import receiver
 
@receiver(pre_save, sender=MyModel)
def reaction_au_signal(sender, **kwargs):
    ...

Il faut aussi savoir que parfois, certains modules sont exécutés plusieurs fois, et si le handler du signal est déclaré dedans, il va être attaché plusieurs fois au signal, et donc lancé plusieurs fois quand le signal se déclenche. Pas glop.

Pour éviter ça, on peut passer un identifiant unique en attachant le signal:

post_delete.connect(reaction_au_signal, dispatch_uid="une_chaine_de_caracteres_quelconque")

dispatch_uid peut contenir n’importe quoi, pourvu qu’il soit unique à cette association signal/callback.

Il y a aussi un autre paramètre est nommé weak. C’est un booléen par défaut mis sur True qui décide si la référence vers le handler est une référence faible ou non. Mettez le sur False si votre callback n’a aucune autre référence que le signal, par exemple si la fonction est générée à la volée, ou si c’est une lambda.

Enfin, les signaux ne sont PAS exécutés dans un thread à part, donc ils bloquent l’exécution du programme comme le reste du code. La bonne nouvelle, c’est que ça vous permet d’utiliser pdb dedans. C’est particulièrement utile pour les signaux complexes comme m2m_changed qui est assez compliqué et pour lequel on s’y reprend à plusieurs fois.

Ah, juste un dernier détails. Model.objects.update() ne déclenche aucun signal…

m2m_changed, ce petit bâtard

La plupart des signaux sont super faciles à manipuler. Sauf un. m2m_changed. C’est un enculé.

Il faut savoir que quand vous utilisez models.ManyToManyField, une troisième table est automatiquement et silencieusement créé qui contient l’association entre vos deux modèles, avec le modèle correspondant. Mais ce modèle ne déclenche pas les signaux *_save et *_delete.

Donc si vous voulez réagir à Model.objects.add(), clear() et consorts, il va falloir se mapper sur m2m_changed, un espèce de fourre tout qui gère l’intégralité des cas de figures.

Il s’utilise comme les autres:

def handler(sender, **kwargs):
   # truc
 
m2m_changed.connect(handler)

Mais déjà, première différence, si vous voulez filtrer sur le sender, il faut utiliser le modèle autogénéré de la troisième table qui est un attribut de l’attribut du modèle qui définit la relation many to many. Vous suivez ? Non ? Relisez la phrase. Ça donne ça:

 
class Tic(object):
 
     partenaire = models.ManyToManyField(Tac)
 
def handler(sender, **kwargs):
   # truc
 
m2m_changed.connect(handler, sender=Tic.partenaire.through)

through contenant toujours le modèle voulu.

Ensuite, et c’est là la partie bien relou, kwargs va contenir en plus de instance et using:

action

Ce qui arrive pendant le signal. En gros au lieu d’avoir 6 signaux, on en a un auquel on passe la valeur: “pre_add, post_add, pre_remove, post_remove, pre_clear, post_clear“. Du coup votre code doit gérer tous les cas dans une seule fonction à grand coup de if/else. Génial !

reverse

Indique dans quel sens est la relation. Hyper confusionant.

En gros, si j’ai:

class Tic(object):
     partenaire = models.ManyToManyField(Tac)

reverse est sur False, si on part de Tic pour aller vers Tac, et True si on part de Tac pour aller vers Tic, car le ManyToManyField est déclaré dans Tic. C’est complètement arbitraire, car il n’y a bien entendu aucun sens à une relation M2M, c’est justement ce qui la différencie d’un M2One.

Et là où ça devient vraiment fendard, c’est que la valeur des autres paramètres changent selon la valeur de reverse. Il va falloir rajouter des if/else dans vos if/else.

model

La classe qui a été ajoutée, retirée ou wipée de la relation. Dans notre cas, si reverse, c’est Tic, sinon, c’est Tac.

pk_set

Donc le cas des actions add et remove (mais pas clear), ceci est la liste des objets concernés. Elles sont des id d’instances de Tic, si reverse, sinon des id d’instances de Tac.

Créer son propre signal

C’est tellement facile qu’au début on est pas sûr d’avoir tout fait.

Supposons que vous voulez faire une application qui envoie un POUET ! et que vous vouliez également permettre à une autre personne de réagir à cet événement indispensable.

Dans un fichier signals.py, vous allez écrire:

import django.dispatch
 
pre_pouet = django.dispatch.Signal(providing_args=["pouet"])
post_pouet = django.dispatch.Signal(providing_args=["pouet"])

Ouai, c’est tout. Vous avez créé deux signaux importables qui attendent un argument: le pouet.

Et pour déclencher ces signaux, c’est très simple:

from signals import pre_pouet, post_pouet
 
class PouetGenerator(object):
    ...
 
    def send_pouet(self, pouet="Alors là, je dis pouet !"):
 
        pre_pouet.send(sender=self, pouet=pouet)
        print pouet
        post_pouet.send(sender=self, pouet=pouet)

C’était vachement dur !

Et si quelqu’un veut réagir à vos pouets, il peut faire:

def pouet_handler(sender, **kwargs):
    print "Tiens, un pouet !"
 
post_pouet.connect(pouet_handler)

Dans la série des subtilités, vous avez, en plus de send(), la possibilités d’appeler send_robust(). C’est la même chose, mais les exceptions sont attrapées ce qui permet à tous handlers de recevoir le signal, même en cas d’erreur. Le sender reçoit les exceptions dans un tuple à la fin.

A ce stade là, vous aurez compris, handler, receiver et callback désignent la même chose: la fonction qui réagit à l’événement. Je les ai utilisées un peu partout sans y prendre garde dans l’article, donc je mets cette note pour le cas où je vous ai perdu.

Last word

Signal.disconnect() est l’inverse de connect(). Je ne m’en suis jamais servis.

3 thoughts on “Réagir aux changements avec le module signals de Django

  • Sam Post author

    J’ai écris l’article sur la demande d’un lecteur, mais son email me renvoit un delivery notification failure.

    Si tu lis ce message, c’est pour toi mec :-)

    (je vais partir du principe que l’adresse est bonne est que le merdier qui arrive sur youtube en ce moment perturbe aussi gmail)

  • fylb

    content de voir que je suis pas le seul à utiliser des pouet dans mon code :)
    Le disconnect, je l’utilise un peu, on est basé sur satchmo, qui utilise pas mal de signaux, et y’en a certains “listeners built-in” qu’on a désactivé comme ça, plutôt que de taper dans le code.

  • ASHpl

    J’ai perdu énormément de temps à buter sur un problème plutôt simple à la base (pour changer), aussi comme c’est en lisant cet article que j’ai pu comprendre comment fonctionnait les signaux et que ça a fait “tilt” pour trouver la solution, voici ce que j’ai compris, qui n’est jamais précisé (sans doute évidente) mais qui aurait pu me faire gagner du temps, et donc peut-être à d’autre :

    À propos de la déclaration des signaux et de leur fonctions : Elle peut bien se faire dans le fichier models.py, mais PAS dans une classe modèle, comme on le ferait pour une méthode.

    En tout cas c’est très propre comme solution pour résoudre le problème des fichiers qu’on aimerait voir supprimés avec les objets auxquels ils sont liés.

Leave a comment

Your email address will not be published. Required fields are marked *

Utilisez <pre lang='python'>VOTRE CODE</pre> pour insérer un block de code coloré

Des questions Python sans rapport avec l'article ? Posez-les sur IndexError.