Aller au contenu
English

Lire les erreurs

  • Leçon 13
  • 45 min
  • Module 5 : Débogage et fiabilité
Leçon 13 sur 18 · Python pour l'ingénierie de l'IA en pratiqueLire les erreurs

Ce que vous saurez faire

Trouvez la cause de deux vrais problèmes dans ticket-cleaner. Le premier arrête le programme avec une trace d’appels. Le second donne un résultat incorrect sans aucune erreur. La méthode reste la même pour chaque bogue. Recueillez les faits, trouvez où le résultat attendu diffère du résultat réel, puis réduisez le problème pour montrer sa cause.

Carnet JupyterFaites cette leçon dans un carnet JupyterColab · Kaggle · GitHub · en anglais

Le carnet (notebook) Jupyter contient les deux analyses, la tâche et sa vérification, avec les instructions. Ses premières cellules créent le dossier ticket-cleaner tel qu’il est au début de cette leçon. Elles installent aussi httpx et téléchargent les fichiers de données. Le carnet fonctionne donc sans votre projet local. Lisez les explications ici et exécutez le code dans le carnet.

Le carnet est en anglais. Colab et Kaggle ouvrent votre propre copie. Il faut un compte Google ou Kaggle gratuit. Vous êtes bloqué ? Consultez le carnet de solution.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 45 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur (uniquement pour le parcours local; le carnet n’en a pas besoin)

Testé avec Python 3.14.6 sur macOS. Les numéros de ligne de vos traces d’appels peuvent différer si vos fichiers contiennent plus ou moins de lignes. Le carnet a été testé dans Jupyter.

Quand un programme échoue, on peut vouloir modifier quelque chose et le relancer jusqu’à ce qu’il fonctionne. Cela fonctionne parfois, mais vous ne découvrez pas la cause, et le bogue revient souvent. Une meilleure méthode suit un ordre fixe :

Arbre de décision. Si le programme s'arrête avec une trace d'appels, lisez la dernière ligne, puis trouvez le dernier cadre dans votre code. S'il donne un résultat incorrect, notez la sortie attendue et la sortie réelle. Trouvez ensuite la première étape où elles diffèrent avec print ou un point d'arrêt. Puis demandez-vous : pouvez-vous reproduire le problème volontairement? Sinon, recueillez d'autres faits et posez de nouveau la question. Si oui, réduisez l'entrée à un exemple minimal qui échoue, corrigez le problème et gardez l'exemple comme test de non-régression.
Du symptôme à la cause : un arrêt et un résultat incorrect commencent différemment. Les deux aboutissent à un problème que vous pouvez reproduire volontairement.
Description textuelle du diagramme

Commencez par le symptôme. Si le programme s’arrête avec une trace d’appels, lisez la dernière ligne pour connaître le type d’erreur et le message. Trouvez ensuite le dernier cadre qui appartient à votre code. Si le programme donne un résultat incorrect, notez la sortie attendue et la sortie réelle. Trouvez ensuite la première étape où elles diffèrent, avec print ou un point d’arrêt. Les deux parcours posent ensuite la même question : pouvez-vous reproduire le problème volontairement? Sinon, recueillez d’autres faits (entrée, paramètres, versions) et posez de nouveau la question. Si oui, réduisez l’entrée jusqu’à ce qu’il ne reste que la cause. C’est un exemple minimal qui échoue. Corrigez le bogue et gardez l’exemple comme test de non-régression.

Un collègue envoie un fichier JSON provenant d’un autre système. Créez-le dans votre projet sous le nom data/broken.json :

data/broken.json
[
{"id": "T-3001", "status": "open", "category": "billing", "priority": 2},
null,
{"id": "T-3003", "status": "closed", "category": "login", "priority": 3}
]

Exécutez le programme avec ce fichier. Sur macOS et Linux, vous pouvez définir la variable pour une seule commande :

Fenêtre de terminal
TICKETS_SOURCE=data/broken.json python count_tickets.py
Sortie
Source: data/broken.json
Traceback (most recent call last):
File "/Users/amira/projects/ticket-cleaner/count_tickets.py", line 34, in <module>
main()
~~~~^^
File "/Users/amira/projects/ticket-cleaner/count_tickets.py", line 23, in main
tickets, rejected = split_records(rows)
~~~~~~~~~~~~~^^^^^^
File "/Users/amira/projects/ticket-cleaner/ticket_cleaner/parsing.py", line 50, in split_records
problems = find_problems(raw)
File "/Users/amira/projects/ticket-cleaner/ticket_cleaner/parsing.py", line 21, in find_problems
if clean_text(raw.get(field)) == "":
^^^^^^^
AttributeError: 'NoneType' object has no attribute 'get'

Une trace d’appels est le chemin des appels de fonction qui ont mené à l’erreur. Chaque appel forme un cadre : un fichier, un numéro de ligne, le nom de la fonction et la ligne de code. Le premier cadre marque le début du programme, et le dernier indique où l’erreur s’est produite. Lisez donc la trace de bas en haut :

À lire Ce que cela vous indique
La dernière ligne Le type d’erreur, AttributeError, et le message : une valeur de type NoneType n’a pas de .get. La seule valeur de type NoneType est None.
Le dernier cadre Dans find_problems, la ligne raw.get(field). Les marques ^^^^ montrent la partie exacte : raw.get. Donc, raw vaut None.
Le cadre au-dessus split_records a appelé find_problems(raw) avec une des lignes.
Les cadres plus haut Comment le programme est arrivé là : main() a appelé split_records. Lisez-les seulement au besoin.

Les faits jusqu’ici : une ligne vaut None. D’où vient-elle? json.load transforme la valeur JSON null en None, et le deuxième élément du fichier est null. La cause se trouve donc dans l’entrée : un enregistrement qui n’est pas un dictionnaire. find_problems supposait que chaque ligne était un dictionnaire, et son contrat ne précisait pas quoi faire autrement.

C’est une entrée invalide, pas un défaut dans la logique du programme. Une entrée externe peut contenir n’importe quoi. Le programme doit donc la rejeter avec une raison, comme tout autre enregistrement invalide. Ajoutez cette vérification au début de find_problems, après la chaîne de documentation :

ticket_cleaner/parsing.py (in find_problems)
if not isinstance(raw, dict):
return ["not a record"]

isinstance(raw, dict) vaut True si raw est un dictionnaire. Le return anticipé arrête l’exécution avant tout raw.get. Relancez le programme :

Sortie (premières lignes)
Source: data/broken.json
Row 2 rejected: not a record
3 rows: 2 valid, 1 rejected.

Le service de soutien exporte ses billets de soutien depuis un tableur. Téléchargez ce fichier :

Fenêtre de terminal
curl -o data/tickets-excel.csv https://raw.githubusercontent.com/Nextia-Intelligence/nextia-learning-labs/main/C02/data/tickets-excel.csv

Ouvrez-le dans VS Code. Il semble identique à tickets.csv. Exécutez le programme avec ce fichier (définissez TICKETS_SOURCE=data/tickets-excel.csv comme avant) :

Sortie (dernières lignes)
Row 14 rejected: missing id; missing category
Row 15 rejected: missing id
15 rows: 0 valid, 15 rejected.
Report: reports/summary.json

Il n’y a aucune trace d’appels. Le programme indique que chaque ligne n’a pas d’identifiant, mais vous voyez les identifiants dans le fichier. Suivez le côté droit de l’arbre de décision.

Résultat attendu et résultat réel. Attendu : 9 valides, 6 rejetés, comme pour tickets.csv. Réel : 0 valide, chaque ligne indique missing id (identifiant manquant).

Trouvez la première étape où ils diffèrent. find_problems indique « missing id », alors examinez ce qu’elle reçoit. Vous pouvez ajouter print(raw), mais un point d’arrêt est souvent plus rapide. Il arrête le programme à une ligne, et vous pouvez examiner chaque variable avant de continuer. Ajoutez cette ligne comme première ligne de find_problems, après la chaîne de documentation :

ticket_cleaner/parsing.py (temporary)
breakpoint()

Relancez le programme. Il s’arrête et affiche l’invite du débogueur, (Pdb). Tapez une commande après l’invite et appuyez sur Entrée :

Debugger session
> /Users/amira/projects/ticket-cleaner/ticket_cleaner/parsing.py(19)find_problems()
-> breakpoint()
(Pdb) p raw
{'\ufeffid': 'T-1001', 'status': 'open', 'category': 'billing', 'priority': '2', 'subject': 'Charged twice for March'}
(Pdb) p list(raw)
['\ufeffid', 'status', 'category', 'priority', 'subject']
(Pdb) p raw.get("id")
None
(Pdb) q
Commande Effet
p expression Affiche la valeur de toute expression, avec les variables à cet endroit.
l Affiche le code autour de la ligne actuelle.
n Exécute la ligne suivante, puis s’arrête de nouveau.
c Continue jusqu’au prochain point d’arrêt ou jusqu’à la fin.
q Quitte le programme. Si Python demande Quit anyway? [y/n] (quitter quand même?), tapez y.

Voilà : la première clé n’est pas 'id', mais '\ufeffid'. Un caractère invisible précède id. Supprimez maintenant la ligne breakpoint(). Un point d’arrêt oublié arrête le programme pour chaque utilisateur.

Réduisez l’exemple. Le problème vient-il du fichier ou du code? Un exemple minimal qui échoue est la plus petite entrée qui montre encore le problème. Créez-en un dans Python interactif (python, puis tapez chaque ligne) :

Interactive Python
>>> import csv
>>> with open("tiny.csv", "w", encoding="utf-8-sig") as file:
... file.write("id,status\nT-1,open\n")
...
19
>>> with open("tiny.csv", encoding="utf-8", newline="") as file:
... print(list(csv.DictReader(file)))
...
[{'\ufeffid': 'T-1', 'status': 'open'}]

(19 est le nombre de caractères écrits par write. Le mode interactif l’affiche.) Deux lignes de données, et le problème est toujours là. La seule particularité est encoding="utf-8-sig" lors de l’écriture du fichier. C’est ainsi que les tableurs enregistrent les fichiers CSV. Ils placent trois octets au début du fichier pour dire « ce fichier est en UTF-8 ». Ces octets forment une marque d’ordre des octets (BOM). La lecture avec encoding="utf-8" conserve cette marque sous forme du caractère \ufeff. Il devient alors une partie du nom de la première colonne.

Correction. L’encodage utf-8-sig lit l’UTF-8 et supprime la marque d’ordre des octets, s’il y en a une. Un fichier sans marque se lit comme avant. Modifiez la ligne CSV dans read_rows :

ticket_cleaner/files.py (in read_rows)
with open(path, encoding="utf-8-sig", newline="") as file:

Relancez le programme avec data/tickets-excel.csv. Le résultat est 15 rows: 9 valid, 6 rejected. (15 lignes : 9 valides, 6 rejetées), comme pour tickets.csv. Supprimez tiny.csv.

Faites les deux analyses sur votre ordinateur, dans l’ordre.

  1. Créez data/broken.json et exécutez le programme avec ce fichier. Lisez la trace d’appels de bas en haut. Notez le type d’erreur et le dernier cadre dans votre code.
  2. Ajoutez la vérification isinstance à find_problems. Relancez le programme. La ligne 2 est rejetée avec not a record (ce n’est pas un enregistrement).
  3. Téléchargez data/tickets-excel.csv et exécutez le programme avec ce fichier. Notez le résultat attendu et le résultat réel.
  4. Ajoutez breakpoint() à find_problems. Exécutez le programme et utilisez p raw et p list(raw). Quittez avec q.
  5. Supprimez la ligne breakpoint().
  6. Créez l’exemple minimal qui échoue dans Python interactif.
  7. Changez l’encodage pour utf-8-sig et relancez le programme. Le résultat est 9 valides et 6 rejetés.
  8. Supprimez TICKETS_SOURCE (unset TICKETS_SOURCE ou Remove-Item Env:TICKETS_SOURCE). Relancez le programme avec le fichier par défaut pour vérifier qu’il fonctionne encore.

Dans certains pays, les tableurs séparent les valeurs avec ; plutôt qu’avec ,. Créez data/tickets-semicolon.csv avec ce texte :

data/tickets-semicolon.csv
id;status;category;priority;subject
T-4001;open;billing;2;Wrong invoice total
T-4002;closed;login;3;Locked out

Exécutez le programme avec ce fichier. Utilisez l’arbre de décision : notez le résultat attendu et le résultat réel. Trouvez la première étape où ils diffèrent et créez un exemple minimal qui échoue. Vous n’avez pas à le corriger. Écrivez deux phrases : la cause et ce qu’une correction devrait faire.

Montrer la cause

Les deux lignes sont rejetées avec missing id; missing status; missing category; missing priority (identifiant, statut, catégorie et priorité manquants). p list(raw) à un point d’arrêt montre une seule clé : 'id;status;category;priority;subject'. csv.DictReader sépare les valeurs avec , par défaut. Il lit donc chaque ligne comme une seule colonne. Une correction doit indiquer le séparateur au lecteur : csv.DictReader(file, delimiter=";"). Un programme pourrait aussi le détecter, mais il lui faudrait une règle pour les fichiers contenant les deux caractères. Pour ticket-cleaner, rejeter le fichier avec un message clair suffit, et chaque ligne est déjà signalée.

Erreur courante

Modifier le code jusqu'à ce que l'erreur disparaisse

Symptôme : après plusieurs petites modifications, l’erreur disparaît, mais vous ne savez pas pourquoi. Ou une autre erreur apparaît à un endroit qui fonctionnait avant.

Cause : les modifications étaient des suppositions, pas des réponses aux faits. Certaines cachent seulement le symptôme, par exemple raw = raw or {} pour la ligne None. L’erreur disparaît, mais un échec clair devient un résultat trompeur. La ligne est maintenant rejetée avec missing id; missing status; missing category; missing priority. Le vrai problème, une valeur null dans le fichier, reste caché.

Correction :

  1. Annulez les modifications faites au hasard. Si vous utilisez Git, git diff les montre et git restore les supprime.
  2. Revenez à l’arbre de décision et recueillez un fait à la fois. Par exemple : la dernière ligne de la trace d’appels, la valeur d’une variable ou la plus petite entrée.
  3. Modifiez le code seulement quand vous pouvez expliquer la cause en une phrase.

Vérifiez votre compréhension

Une trace d'appels contient six cadres. Les deux derniers sont dans httpx. Où commencez-vous à chercher la cause?

Afficher la réponse

Au dernier cadre dans votre code, le troisième à partir du bas. Cette ligne a appelé httpx avec une valeur ou à un moment que httpx ne pouvait pas gérer. Par exemple, une URL avec une faute de frappe. Lisez d’abord la dernière ligne de la trace d’appels pour connaître le type d’erreur et le message. Remontez ensuite jusqu’à votre code.

Vérifiez votre compréhension

Pourquoi créer un exemple minimal qui échoue si vous voyez déjà le problème dans le fichier complet?

Afficher la réponse

Il prouve la cause. Avec 15 lignes et 5 modules, beaucoup de choses pourraient être en cause. Avec deux lignes de CSV et un seul open, il ne reste que l’encodage. Cet exemple devient aussi le test de non-régression dans les prochaines leçons. C’est une petite entrée qui échoue avant la correction et réussit après.

Récapitulatif

  • Lisez une trace d’appels de bas en haut : la dernière ligne donne l’erreur, et le dernier cadre dans votre code indique où.
  • Pour un résultat incorrect sans erreur, comparez le résultat attendu et le résultat réel. Trouvez la première étape où ils diffèrent.
  • breakpoint() arrête le programme. p affiche une valeur, n exécute la ligne suivante, c continue et q quitte. Supprimez le point d’arrêt après utilisation.
  • Un exemple minimal qui échoue élimine tout ce qui n’est pas la cause.
  • Une entrée externe peut contenir n’importe quoi. Rejetez un enregistrement invalide avec une raison; ne le cachez pas.

Ensuite, vous ferez gérer les erreurs attendues par le programme avec des messages clairs et des codes de sortie. Vous utiliserez aussi la journalisation pour enregistrer ce qu’il fait.

Leçon suivante : Exceptions et journalisation