Claude Code Django : le bordel qu’il laisse derrière lui

Épisode 2. Dans le premier, je racontais un git pull qui m’avait ramené vingt-trois commits et environ 1 400 lignes que je n’avais pas écrites, sur un logiciel de gestion de licences en Django que j’ai pourtant développé seul, à la main, sans IA, du premier fichier au dernier.

Pour ceux qui arrivent en cours de route : le back-end était terminé, il attendait que mon client trouve le temps d’attaquer l’interface React qu’il développe lui-même à 100 %, et depuis que cette phase a démarré, il code avec sa propre session Claude et pousse sur la branche que je récupère.

Moi, je découvre tout ça au git pull.

L’épisode 1 traitait une fonction de trois lignes. Il restait huit trouvailles dans ma liste, et celle qui m’a occupé le plus longtemps n’est pas un bug, ne provoque aucune erreur, ne se voit dans aucun test, et représente à elle seule 126 lignes à supprimer.

Parce que c’est ça, le motif que je retrouve à chaque fois : Claude ajoute le bon mécanisme, et laisse l’ancien en place.

Trois lignes mortes pour commencer

J’ai repris ma liste :

redonne moi la liste des corrections à faire sur le diff reçu

Trois petites choses au sommet, et elles disent déjà tout.

Un import de timezone dans serializers/product.py qui ne sert plus à rien, parce que le commit reçu a déplacé vers un autre fichier la seule fonction qui s’en servait, sans retirer l’import resté derrière.

Un json.dumps() remplacé par le nouveau json_dump() dans une méthode entièrement commentée, donc une modification qui ne s’exécute jamais, mais qui donne l’illusion que ce bloc est maintenu (j’ai supprimé les treize lignes, git les garde si le besoin revient).

Et cinq migrations de base de données séparées, numérotées 0135 à 0139, pour un seul lot de travail.

Au passage, une réponse m’a agacé : on me parlait d’un import « mort » et d’une « bombe à retardement ».

Donc utilise de vrais mots pour définir le problème

Un import mort, c’est un import inutilisé. Une bombe à retardement, c’était en réalité une condition qui teste le message de log après qu’un préfixe lui a été ajouté : aujourd’hui aucun appelant ne passe de préfixe, donc le test passe toujours, et le jour où quelqu’un s’en servira, la déduplication des lignes de log s’arrêtera sans que rien ne le signale. J’ai remonté le test avant la concaténation, deux lignes déplacées.

Vous voyez la différence ? Dans un cas je hoche la tête, dans l’autre je comprends.

Le vrai sujet : deux mécanismes pour le même travail

Le point suivant de ma liste, c’était celui-ci : le logiciel dispose de deux façons de faire circuler la requête HTTP courante jusqu’au système de journalisation.

La première est de moi et date du premier commit du projet. Chaque enregistrement de licence porte un attribut _request, initialisé à None dans son constructeur, et que le code d’appel doit penser à remplir :

nalpeironlicensecode._request = request  # used by log

Pourquoi cette plomberie ? Parce que quand la journalisation écrit une ligne, elle veut aussi enregistrer l’identifiant de session, l’adresse IP et le navigateur de celui qui a déclenché l’opération, et qu’un enregistrement rechargé depuis la base par l’ORM repasse par son constructeur, donc revient avec _request à None.

Résultat : vingt et une affectations dispersées dans huit fichiers, et seize appels qui repassent l’attribut à la fonction de log.

La deuxième façon a été ajoutée par le commit du 14 août, celui de mon client. Un middleware Django publie la requête au début du cycle et la retire à la fin :

token = set_current_request(request)
try:
    response = self.get_response(request)
finally:
    reset_current_request(token)

Et n’importe quel code exécuté pendant la requête peut la récupérer sans qu’on la lui passe.

C’est la bonne solution. C’est même exactement celle que j’aurais dû écrire au départ.

Sauf que les vingt et une affectations sont toujours là.

Ce qu’est un contextvar (le nom fait peur, la chose est simple)

Le mécanisme repose sur contextvars, un module de la bibliothèque standard prévu pour rendre une valeur disponible partout pendant une unité de travail, sans la passer en argument, et sans qu’elle déborde sur l’unité suivante.

Explique le fonctionnement de contextvars.ContextVar('current_request', default=None)

L’objet créé n’est pas un conteneur, c’est une clé : il sert à ranger et à relire une valeur dans un dictionnaire caché que Python attache au contexte d’exécution courant, ce qui veut dire qu’il faut le créer une seule fois, au niveau du module, jamais dans une fonction.

Le set() renvoie un jeton qui mémorise ce qu’il y avait avant, et le reset(token) restaure exactement cet état, ce qui est plus solide que de remettre None à la sortie.

Et surtout, chaque thread démarre avec son propre contexte, vide, donc deux requêtes traitées en même temps ne se marchent pas dessus, ce qui serait le cas avec une simple variable globale (c’est la différence avec threading.local, qui isole aussi entre threads mais ignore l’asynchrone et n’offre pas ce système de jeton).

Le finally garantit qu’une erreur dans la vue ne laisse pas la requête publiée derrière elle. C’est précisément ce que l’attribut posé sur l’instance ne fait jamais.

Le problème, ce n’est pas que ça marche mal

C’est que ça marche.

Les deux mécanismes cohabitent, la fonction de log les consulte l’un après l’autre, et le résultat est correct dans tous les cas. Personne ne verra jamais rien.

Mais regardez ce que ça produit.

Les vingt et une affectations ne servent plus à rien pendant une requête, puisque le contextvar fournit déjà la même information, gratuitement et sans oubli possible. C’est du code qui a l’air indispensable et qui ne l’est plus.

L’inverse est vrai aussi, et c’est plus embêtant : si l’une de ces vingt et une lignes disparaissait par erreur, rien ne casserait, parce que le contextvar rattraperait. Le mécanisme n’est donc plus vérifiable par ses effets. Il est devenu intestable.

Pire, l’ancien gagne sur le nouveau : un objet conservé au-delà de la requête garde un _request périmé, et fera écrire dans les logs la session d’une requête précédente, alors que le contextvar, lui, est toujours à jour.

Et tant qu’un enregistrement porte cette référence, il maintient en vie l’objet requête, donc la session et la trace du corps stockée par le middleware.

Je vends de l’infogérance de serveurs Linux, donc je vois régulièrement l’autre bout de ce problème : un processus dont la mémoire monte au fil de la journée, et une équipe qui cherche la cause sur la machine alors qu’elle est dans le code, sur une ligne qui garde une référence plus longtemps que prévu. Ici les quantités en jeu restent modestes et je n’ai rien mesuré, mais le motif est celui-là.

Il y avait même des gardes devenues fausses, du genre if hasattr(self, '_request') sur une classe dont le constructeur garantit l’attribut, donc une condition toujours vraie que tout le monde lit comme une précaution.

126 lignes en moins, et rien ne change

Avant de supprimer quoi que ce soit, j’ai voulu la preuve que le remplaçant couvrait bien tout :

est-ce que le get_session_id va fonctionner si on enlève A

Oui, et c’est vérifiable point par point : notre middleware est déclaré après celui des sessions, donc request.session existe déjà quand la requête est publiée ; son code n’a ni filtre d’URL ni sortie anticipée ; la vue, DRF, les serializers, les modèles et les signaux s’exécutent tous à l’intérieur ; il n’y a ni thread ni code asynchrone dans le dépôt ; et hors requête, en ligne de commande, les deux mécanismes rendent None de la même façon.

Alors j’ai supprimé le premier. Onze fichiers, 71 lignes ajoutées, 126 supprimées, aucun changement de comportement attendu.

Deux endroits ont demandé un traitement particulier, parce qu’ils lisaient l’attribut pour autre chose que journaliser (le courriel d’alerte d’usage excessif, qui affichait sinon une session vide, et deux fonctions d’aperçu qui s’en servaient pour construire leur réponse d’erreur).

Un troisième endroit garde son self._request et c’est normal : ce n’est pas le même motif, c’est une dépendance passée au constructeur d’un objet de service, qui lit le corps de la requête pour son propre travail.

La ligne qui tombait en erreur depuis juillet 2025

Et là, en supprimant, je tombe sur celle-ci, dans la validation d’un serializer :

nalpeironlicensecode._request = self.context['request']

La variable nalpeironlicensecode n’existe pas dans cette portée. Dans cette méthode, l’objet s’appelle self._nalpeironlicensecode, et aucun import ne fournit ce nom.

Donc cette ligne lève un NameError dès que le code de licence est trouvé, c’est-à-dire à chaque appel réussi de l’endpoint concerné.

Elle date du 9 juillet 2025. Elle est de moi. Elle a plus d’un an.

Je ne l’ai pas trouvée en cherchant un bug, je ne l’ai pas trouvée en lisant les logs, je ne l’ai pas trouvée en écrivant un test. Je l’ai trouvée parce que je supprimais du code inutile et qu’elle était dedans.

C’est l’argument que je n’arrive jamais à faire passer auprès d’un client qui veut aller vite : le code mort ne coûte pas zéro, il coûte les pannes qu’il vous cache.

Neuf fois la même précaution

Un objet reçoit les données d’abonnement envoyées par Cleverbridge, dans un sous-dictionnaire qui peut manquer, et onze accesseurs se protégeaient chacun de leur côté :

return self._m_recurringBilling.get('gracePeriodDays', 0) if self._m_recurringBilling else 0

Neuf fois la même queue de phrase, plus deux variantes. Normalisez une bonne fois à la construction et les onze accesseurs redeviennent lisibles.

Sauf qu’il y a un piège, et mon client l’avait vu avant moi :

pourquoi self._m_recurringBilling = cb_item.get('recurringBilling') or {} et pas : self._m_recurringBilling = cb_item.get('recurringBilling', {})

Excellente question, et la réponse tient en une phrase : la valeur par défaut de get() ne sert que si la clé est absente, alors que Cleverbridge envoie parfois la clé présente avec la valeur null.

Dans ce cas .get('recurringBilling', {}) rend None, la valeur stockée, pas le défaut, et les onze accesseurs repartent en AttributeError. Le or {}, lui, couvre les trois cas : clé absente, clé nulle, dictionnaire vide.

Ce n’est pas une supposition, c’est une panne déjà vécue, racontée dans le commentaire d’un test du projet. Et le test ne trancherait pas la question, puisqu’il construit un cas où la clé est absente : la différence n’apparaîtrait qu’en production.

Quatre fois la même boucle

Dernier morceau : la fonction d’import parcourait quatre fois la même liste de champs, une fois par valeur cherchée, avec à chaque fois la même boucle recopiée.

Une seule lecture en dictionnaire au début, et les quatre boucles deviennent quatre lectures.

Avec trois détails à préserver quand même, parce que « simplifier » ne veut pas dire « changer le comportement » : une des quatre clés ne doit rien écraser quand elle est absente, les doublons doivent continuer à garder la dernière valeur, et une clé de liste manquante doit continuer à provoquer la même erreur qu’avant.

Le passage où l’analyse était fausse

Je gardais un point pour la fin : à chaque ligne de log, le programme remonte toute la pile d’appels Python pour enregistrer d’où vient l’écriture, y compris pour les lignes de niveau debug, et c’est ce qui a imposé une migration élargissant la colonne de 255 à 1024 caractères.

Ça m’avait été présenté comme un coût de production déraisonnable. J’ai demandé une mesure. Résultat : entre 3 et 15 microsecondes par appel selon la profondeur, contre plusieurs centaines de microsecondes pour l’écriture en base qui suit immédiatement.

Quelques pour cent. L’argument ne tenait pas, et il m’a été dit franchement que l’analyse précédente était fausse.

Ce qui restait, en revanche, c’est que la fonction appelle inspect.currentframe() sans vérifier le résultat, alors que la documentation prévoit qu’il puisse rendre None, et que tout le reste de la fonction de log est écrit pour ne jamais lever d’exception, précisément pour ne pas masquer l’erreur qu’on essayait de journaliser.

Deux lignes de garde, et on passe d’une refonte imaginaire à un correctif réel.

(Mon détail préféré de la journée, hors sujet mais je le laisse là : la commande de purge des logs écrit une ligne de log au début et à la fin, dans la table qu’elle est en train de vider.)

Expert Python et Django, en renfort

Je vends de l’infogérance de serveurs Linux, pas du développement, mais c’est bien du Python et du Django que je fais sur ce projet, et je peux le faire ailleurs.

Si vous avez une application Django déjà en production et que personne n’a le temps de relire ce qui entre dans le dépôt, je peux intervenir en renfort, pour la relecture, la correction, la préparation des mises en production, ou le serveur Linux qui l’héberge, en complément de l’infogérance ou séparément.

La prochaine fois

Il me reste de la matière, et notamment le moment où j’ai demandé une nouvelle fonctionnalité plutôt qu’une correction, et où la vraie difficulté n’était pas de l’écrire mais de trouver, avant de l’écrire, tout ce qu’elle allait déclencher ailleurs.

Neuf commits, pas encore poussés, et les tests à lancer avant. 😉

Sur ce projet j’interviens sur du code écrit par quelqu’un d’autre, mais je peux aussi prendre une application Django de bout en bout, du premier fichier jusqu’au serveur Linux qui la fait tourner. Écrivez-moi.

Vous préférez ne plus intervenir vous-même sur votre serveur ? L’infogérance serveur Linux prend en charge la surveillance, les mises à jour et les interventions.

Ces autres articles ou sujets pourraient vous intéresser :

Faisons le point sur vos besoins