Exceptions et journalisation
- Leçon 14
- 45 min
- Module 5 : Débogage et fiabilité
Leçon 14 sur 18 · Python pour l'ingénierie de l'IA en pratiqueExceptions et journalisation
1. Bases de l'ordinateur et de l'environnement
2. Valeurs et flux de contrôle
3. Fonctions et structure du programme
4. Fichiers, données et services externes
5. Débogage et fiabilité
6. Du carnet Jupyter au projet facile à maintenir
Ce que vous saurez faire
Faites en sorte que ticket-cleaner échoue correctement. Un fichier manquant ou un serveur en panne doit produire une ligne claire et le code de sortie 1. Il ne doit pas produire une longue trace d’appels. Un bogue dans votre code doit toujours afficher sa trace d’appels complète pour vous aider à le trouver. Chaque enregistrement rejeté va dans un journal dont vous pouvez ajuster le niveau sans changer le code.
Carnet JupyterFaites cette leçon dans un carnet Jupyter
Le carnet (notebook) Jupyter contient toutes les étapes, tâches et vérifications de cette leçon, avec les instructions. Ses premières cellules créent le dossier ticket-cleaner dans son état 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 là-bas.
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 main
Ce que vous devez savoir
- Lire une trace d’appels, dans Lire les erreurs : erreur 1.
- Les codes de sortie, dans Les fichiers et le terminal.
- Les codes d’état et
raise_for_status(), dans Utiliser un client HTTP simple.
Ce qu’il vous faut sur votre ordinateur (seulement pour le parcours local; le carnet et les encadrés de cette page n’en ont pas besoin)
- Les deux corrections de Lire les erreurs : exercices guidés :
find_problemsrejette une valeur qui n’est pas un dictionnaire (Erreur 1). Etread_rowslit les fichiers CSV avecutf-8-sig(Erreur 2). Si ces corrections manquent, refaites cette section. Vous pouvez aussi copier les fichiers de la cellule de préparation du carnet de cette leçon. Elle contient tous les fichiers dans leur état au début de cette leçon. TICKETS_SOURCEn’est pas définie dans votre terminal. Supprimez-la avecunset TICKETS_SOURCE(macOS et Linux) ouRemove-Item Env:TICKETS_SOURCE(PowerShell). Consultez Configuration et secrets : variables d’environnement.data/tickets.csvetdata/tickets.json. Vous les avez téléchargés dans Lire et écrire des fichiers : obtenir le fichier de données. Si un fichier manque ou a changé, téléchargez-le de nouveau avec la même commandecurl. Les sommes de contrôle se trouvent dans la fiche du jeu de données.- L’environnement
.venvdu projet, actif dans votre terminal (l’invite commence par(.venv)). Vous l’avez créé dans Isoler les dépendances : exercices guidés. S’il manque ou ne fonctionne plus, supprimez-le et recréez-le à partir du fichier de verrouillage. Consultez Recréer l’environnement à partir de zéro. - httpx 0.28.1 dans cet environnement. Vous l’avez installé dans Isoler les dépendances : exemple détaillé. Vérifiez-le avec
python -c "import httpx; print(httpx.__version__)". S’il manque, exécutezpython -m pip install -r requirements-lock.txt.
Testé avec Python 3.14.6 et httpx 0.28.1 sur macOS, et Pyodide 314.0 (navigateur). Le carnet a été testé dans Jupyter.
Deux sortes d’erreurs
Section intitulée « Deux sortes d’erreurs »Une exception est la façon dont Python signale une erreur : un objet qui décrit ce qui a mal fonctionné. Quand le code lève une exception, Python arrête le déroulement normal et remonte les fonctions appelantes. Si aucune ne la traite, le programme s’arrête et affiche la trace d’appels. Vous en avez vu plusieurs : KeyError, ValueError, FileNotFoundError.
Les erreurs ne sont pas toutes équivalentes. La question est : qui est responsable, et le programme peut-il faire quelque chose d’utile?
| Erreurs prévues | Défauts de programmation (bogues) | |
|---|---|---|
| Exemples | Le fichier n’existe pas. Le serveur est en panne. Le JSON est tronqué. Un enregistrement a une priorité invalide. | Une faute de frappe dans un nom de champ. Une fonction reçoit une liste alors qu’elle attend un billet de soutien. |
| Cause | Le monde extérieur au programme. | Le programme lui-même. |
| Possible dans du code correct? | Oui, à tout moment. | Non. Du code correct n’en contient pas. |
| Ce que fait le programme | Traiter l’erreur : rejeter l’enregistrement, ou s’arrêter avec un message clair et le code de sortie 1. |
Rien. Le laisser planter avec la trace d’appels complète, puis corriger le code. |
ticket-cleaner traite déjà une sorte d’erreur prévue sans exception : un enregistrement invalide devient une entrée Rejected. Cette leçon traite les autres : les erreurs qui empêchent complètement de lire les données d’entrée.
try et except
Section intitulée « try et except »Un bloc try exécute du code qui peut lever une exception. Le bloc except qui suit indique quels types d’exceptions traiter et quoi faire :
try: rows = read_rows(Path("data/missing.csv"))except FileNotFoundError as error: print("Cannot read the file:", error)Si read_rows lève une FileNotFoundError, Python passe au bloc except, et error est l’objet exception. Son texte est le message. Si aucune erreur n’est levée, Python saute le bloc except. Si une exception différente est levée, le bloc except ne l’intercepte pas. Elle remonte à la fonction appelante comme avant.
Les types d’exceptions forment des familles. Un type plus général couvre les types particuliers qui en dépendent :
| Intercepter ce type | Couvre aussi |
|---|---|
OSError |
FileNotFoundError, PermissionError (pas le droit de lire le fichier) et d’autres erreurs du système d’exploitation. |
ValueError |
json.JSONDecodeError (le texte JSON est invalide), et la ValueError que read_rows lève pour un fichier .txt. |
httpx.HTTPError |
httpx.HTTPStatusError (un code 4xx ou 5xx), httpx.ConnectTimeout, httpx.ConnectError. |
Pour intercepter plusieurs types, placez-les dans un tuple : except (OSError, ValueError) as error:.
Exécutez du Python dans votre navigateur
Interceptez seulement les erreurs prévues
"2" fonctionne et "high" est intercepté. None lève une TypeError, que le bloc except ne nomme pas. Le programme s’arrête donc. Ici, None est-il une erreur prévue ou un défaut? Réfléchissez à l’origine de la valeur, puis décidez s’il faut ajouter TypeError.
Tab ajoute quatre espaces. Appuyez sur Esc, puis sur Tab, pour quitter la zone. Ctrl+Enter exécute le code.
Intercepter à la frontière, pas partout
Section intitulée « Intercepter à la frontière, pas partout »Où placer le try? Pas dans read_rows ni dans fetch_rows. Ces fonctions ne peuvent pas savoir ce que veut l’utilisateur : arrêter, essayer un autre fichier ou réessayer plus tard. Elles lèvent une exception et laissent la fonction appelante décider.
Le bon endroit est la frontière du programme : main(), qui connaît toute la situation. Elle peut indiquer ce qui a échoué, avec quelle source, et retourner un code de sortie. Tout ce qui se trouve entre la frontière et la cause reste simple, sans traitement d’erreurs.
Dans Les fichiers et le terminal, le code de sortie indiquait aux autres programmes si une commande avait fonctionné. Maintenant, main() retourne le code de sortie, et la dernière ligne le transmet à Python :
if __name__ == "__main__": raise SystemExit(main())SystemExit est une exception spéciale qui termine le programme avec le code de sortie donné : 0 pour une réussite, 1 pour une erreur.
La journalisation plutôt que print
Section intitulée « La journalisation plutôt que print »Jusqu’ici, le programme utilisait print pour tout. Un journal convient mieux aux messages sur ce que le programme fait, plutôt qu’à son résultat :
- Chaque message a un niveau :
DEBUG(détails pour un développeur),INFO(progression normale),WARNING(situation inhabituelle, mais le programme continue),ERROR(échec). - Vous choisissez le niveau minimal à afficher au démarrage du programme. Avec
WARNING, les messagesDEBUGetINFOsont masqués. AvecINFO, vous en voyez davantage. Le code ne change pas. - Les messages du journal vont à la sortie d’erreur standard (stderr). Ce flux est distinct de la sortie standard (stdout), où va
print. Un autre programme, ou un fichier, peut recevoir le résultat sans les messages du journal.
import logging
logger = logging.getLogger("ticket_cleaner")logging.basicConfig(level="WARNING", format="%(levelname)s %(name)s: %(message)s")
logger.info("reading tickets from %s", "data/tickets.csv") # hidden at WARNINGlogger.warning("row %d rejected: %s", 7, "unknown status 'opne'")WARNING ticket_cleaner: row 7 rejected: unknown status 'opne'getLogger("ticket_cleaner") obtient un objet de journalisation nommé. Ce nom apparaît dans chaque message. basicConfig définit le niveau et le format une seule fois, au démarrage du programme. Les valeurs après le message remplacent %s (texte) et %d (nombre entier), dans l’ordre.
Un bon message de journal contient du contexte : assez de faits pour agir sans consulter le code. row 7 rejected: unknown status 'opne' (ligne 7 rejetée : état inconnu « opne ») indique quel enregistrement et pourquoi. invalid input (entrée invalide) ne le fait pas. Ne consignez jamais un secret : les mêmes règles que pour l’affichage s’appliquent.
Exemple détaillé : count_tickets.py échoue correctement
Section intitulée « Exemple détaillé : count_tickets.py échoue correctement »Amira ajoute log_level aux paramètres. Ainsi, LOG_LEVEL peut changer le niveau sans modifier le code :
@dataclass(frozen=True)class Settings: source: str tickets_url: str api_token: str | None = field(repr=False) log_level: str
def load_settings() -> Settings: """Read the settings from environment variables, with safe defaults.""" return Settings( source=os.environ.get("TICKETS_SOURCE", "data/tickets.csv"), tickets_url=os.environ.get("TICKETS_URL", DEFAULT_URL), api_token=os.environ.get("TICKETS_API_TOKEN") or None, log_level=os.environ.get("LOG_LEVEL", "WARNING").upper(), )Le script intercepte les trois familles d’erreurs prévues au même endroit. Il consigne les enregistrements rejetés et retourne un code de sortie :
import loggingfrom pathlib import Path
import httpx
from ticket_cleaner.config import Settings, load_settingsfrom ticket_cleaner.files import read_rows, write_jsonfrom ticket_cleaner.parsing import split_recordsfrom ticket_cleaner.remote import fetch_rowsfrom ticket_cleaner.report import build_summary
OUTPUT_FILE = Path("reports/summary.json")
logger = logging.getLogger("ticket_cleaner")
def load_rows(settings: Settings) -> list[dict]: """Return raw records from a file, or from the web service for "remote".""" if settings.source == "remote": return fetch_rows(settings.tickets_url, token=settings.api_token) return read_rows(Path(settings.source))
def main() -> int: settings = load_settings() logging.basicConfig( level=settings.log_level, format="%(levelname)s %(name)s: %(message)s" ) logger.info("reading tickets from %s", settings.source)
try: rows = load_rows(settings) except (OSError, ValueError, httpx.HTTPError) as error: logger.error("cannot read the tickets from %s: %s", settings.source, error) return 1
tickets, rejected = split_records(rows) for record in rejected: logger.warning("row %d rejected: %s", record.row, "; ".join(record.problems))
summary = build_summary(tickets, rejected, status="open") write_json(summary, OUTPUT_FILE) logger.info("wrote %s", OUTPUT_FILE) print(f"{len(rows)} rows: {len(tickets)} valid, {len(rejected)} rejected.") return 0
if __name__ == "__main__": raise SystemExit(main())Le bloc try contient une seule ligne : celle qui touche au monde extérieur. split_records, build_summary et write_json sont à l’extérieur. Si l’une de ces fonctions lève une exception, ce n’est pas un problème d’entrée. La trace d’appels doit le montrer.
Voici le résultat dans quatre situations :
| Situation | Sortie | Code de sortie |
|---|---|---|
Par défaut (data/tickets.csv) |
6 lignes WARNING, puis 15 rows: 9 valid, 6 rejected. (15 lignes : 9 valides, 6 rejetées). |
0 |
LOG_LEVEL=info |
Aussi INFO ticket_cleaner: reading tickets from data/tickets.csv (lecture des billets depuis data/tickets.csv) et wrote reports/summary.json (écriture de reports/summary.json). |
0 |
TICKETS_SOURCE=data/missing.csv |
ERROR ticket_cleaner: cannot read the tickets from data/missing.csv: [Errno 2] No such file or directory: 'data/missing.csv' (impossible de lire les billets : fichier ou dossier inexistant). |
1 |
TICKETS_SOURCE=remote et une mauvaise valeur de TICKETS_URL |
ERROR ticket_cleaner: cannot read the tickets from remote: Client error '404 Not Found' for url '...' (impossible de lire les billets distants : erreur client 404, ressource introuvable). |
1 |
Exercices guidés : faire échouer le programme correctement
Section intitulée « Exercices guidés : faire échouer le programme correctement »-
Mettez à jour
config.pyetcount_tickets.pycomme dans l’exemple détaillé. -
Exécutez
python count_tickets.py, puis affichez le code de sortie (echo $?, ou$LASTEXITCODEdans PowerShell). La sortie contient 6 avertissements et les nombres. Le code de sortie est0. -
Définissez
LOG_LEVELàinfo, puis relancez le programme. Deux lignesINFOapparaissent. Supprimez de nouveauLOG_LEVEL. -
Définissez
TICKETS_SOURCEàdata/missing.csv, puis lancez le programme. Vous voyez une ligneERRORet aucune trace d’appels. Le code de sortie est1. -
Observez les deux flux de sortie. Exécutez le programme avec son journal masqué, puis avec son résultat masqué.
2>envoie stderr vers un fichier, et>envoie stdout./dev/null($nulldans PowerShell) est un endroit qui jette tout :Fenêtre de terminal python count_tickets.py 2>/dev/nullpython count_tickets.py >/dev/nullFenêtre de terminal python count_tickets.py 2>$nullpython count_tickets.py >$nullFenêtre de terminal python count_tickets.py 2>/dev/nullpython count_tickets.py >/dev/nullLa première commande affiche seulement
15 rows: 9 valid, 6 rejected.La deuxième affiche seulement les avertissements. -
Supprimez
TICKETS_SOURCE.
À vous : prédire, puis exécuter
Section intitulée « À vous : prédire, puis exécuter »Pour chaque situation, prédisez la dernière ligne de la sortie et le code de sortie. Créez ensuite cette situation, exécutez le programme et comparez. Annulez chaque changement avant de passer au suivant.
- Un fichier JSON tronqué. Créez
data/cut.jsonavec le texte[{"id": 1(sans fin), et définissezTICKETS_SOURCEà ce fichier. - Un bogue : dans
report.py, remplacezticket.categorydanscount_by_categoryparticket.categry. LOG_LEVEL=erroravec le fichier de données normal.
Afficher les réponses
ERROR ticket_cleaner: cannot read the tickets from data/cut.json: Expecting ',' delimiter: line 1 column 10 (char 9)(impossible de lire les billets : séparateur « , » attendu, ligne 1, colonne 10, caractère 9), code de sortie1.json.JSONDecodeErrorest uneValueError, donc le blocexceptl’intercepte. C’est une erreur prévue : un fichier peut être endommagé à tout moment.- Une trace d’appels complète qui se termine par
AttributeError: 'Ticket' object has no attribute 'categry'. Did you mean: 'category'?(l’objet Ticket n’a pas d’attribut categry; vouliez-vous dire category?), code de sortie1. Le blocexceptne l’intercepte pas, carsplit_recordsetbuild_summarysont à l’extérieur du bloctry. De plus,AttributeErrorn’est pas dans le tuple. C’est correct : il s’agit d’un défaut, et la trace d’appels vous montre la ligne à corriger. - Seulement
15 rows: 9 valid, 6 rejected., code de sortie0. Les avertissements sont masqués, carWARNINGest inférieur àERROR. Le résultat s’affiche quand même, carprintne fait pas partie du journal.
Erreur courante
Un except qui intercepte tout et masque un bogue
Symptôme : le programme indique cannot read the tickets (impossible de lire les billets), mais le fichier existe et est correct. Ou le programme n’affiche rien d’utile, et le rapport est ancien.
try: rows = load_rows(settings) tickets, rejected = split_records(rows) summary = build_summary(tickets, rejected, status="open")except Exception: logger.error("cannot read the tickets") return 1Cause : except Exception: intercepte presque toutes les exceptions. Cela inclut les défauts comme une AttributeError causée par une faute de frappe dans build_summary. Le message accuse les données d’entrée. La trace d’appels, qui montrerait la vraie ligne, disparaît. Avec except Exception: pass, l’erreur disparaît complètement.
Correction :
- Placez seulement les lignes qui touchent au monde extérieur dans le bloc
try. - Nommez les types d’exceptions prévus, comme dans l’exemple détaillé.
- Parfois, vous devez tout intercepter au niveau le plus haut d’un programme, par exemple pour consigner l’erreur avant l’arrêt. Utilisez alors
logger.exception("..."), puis terminez avec le code de sortie1. Cette méthode écrit la trace d’appels complète dans le journal.
Vérifiez votre compréhension
Que devrait intercepter main() : FileNotFoundError, KeyError d'un dictionnaire dans report.py, httpx.ConnectTimeout, TypeError d'un mauvais argument dans votre code?
Afficher la réponse
FileNotFoundError et httpx.ConnectTimeout : les deux viennent du monde extérieur au programme et peuvent survenir dans du code correct. La KeyError et la TypeError sont des défauts dans votre code. Ne les interceptez pas. Laissez la trace d’appels montrer où ils se trouvent, puis corrigez le code.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
Vérifiez votre compréhension
Pourquoi le programme affiche-t-il les nombres avec print, mais les lignes rejetées avec logger.warning?
Afficher la réponse
Les nombres sont le résultat demandé par l’utilisateur. Ils vont donc à la sortie standard. Les lignes rejetées sont des messages sur l’exécution, avec un niveau. Elles vont donc au journal (sortie d’erreur standard). L’utilisateur peut alors masquer le journal avec LOG_LEVEL ou l’envoyer vers un autre fichier. Le résultat reste le même.
Votre réponse contient-elle les points principaux de la nôtre ?
Connectez-vous et inscrivez-vous au cours pour enregistrer vos réponses. Chaque vérification compte pour le certificat.
Récapitulatif
- Les erreurs prévues viennent de l’extérieur du programme. Traitez-les. Les défauts sont des bogues dans votre code. Laissez-les faire planter le programme, puis corrigez-les.
try/except SomeError as error:traite une famille d’erreurs. Interceptez les types précis que vous prévoyez, jamais tout.- Interceptez à la frontière, dans
main(), autour des lignes qui touchent au monde extérieur. Retournez1pour une erreur, puis terminez avecraise SystemExit(main()). - Utilisez la journalisation pour les messages sur l’exécution : un niveau, un nom et assez de contexte pour agir. Les résultats vont à stdout, et les journaux (logs) à stderr.
Ensuite, vous transformerez les vérifications de ce module en tests automatiques avec pytest. Un test prouvera notamment que le bogue d’encodage reste corrigé.
Leçon suivante : Des tests ciblés