Aller au contenu
English

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

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 JupyterColab · Kaggle · GitHub · en anglais

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 mainEnviron 45 minutes

Ce que vous devez savoir

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)

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.

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.

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.

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.

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 messages DEBUG et INFO sont masqués. Avec INFO, 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 WARNING
logger.warning("row %d rejected: %s", 7, "unknown status 'opne'")
Sortie
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 :

ticket_cleaner/config.py (changes)
@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 :

count_tickets.py
import logging
from pathlib import Path
import httpx
from ticket_cleaner.config import Settings, load_settings
from ticket_cleaner.files import read_rows, write_json
from ticket_cleaner.parsing import split_records
from ticket_cleaner.remote import fetch_rows
from 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 »
  1. Mettez à jour config.py et count_tickets.py comme dans l’exemple détaillé.

  2. Exécutez python count_tickets.py, puis affichez le code de sortie (echo $?, ou $LASTEXITCODE dans PowerShell). La sortie contient 6 avertissements et les nombres. Le code de sortie est 0.

  3. Définissez LOG_LEVEL à info, puis relancez le programme. Deux lignes INFO apparaissent. Supprimez de nouveau LOG_LEVEL.

  4. Définissez TICKETS_SOURCE à data/missing.csv, puis lancez le programme. Vous voyez une ligne ERROR et aucune trace d’appels. Le code de sortie est 1.

  5. 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 ($null dans PowerShell) est un endroit qui jette tout :

    Fenêtre de terminal
    python count_tickets.py 2>/dev/null
    python count_tickets.py >/dev/null

    La première commande affiche seulement 15 rows: 9 valid, 6 rejected. La deuxième affiche seulement les avertissements.

  6. Supprimez TICKETS_SOURCE.

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.

  1. Un fichier JSON tronqué. Créez data/cut.json avec le texte [{"id": 1 (sans fin), et définissez TICKETS_SOURCE à ce fichier.
  2. Un bogue : dans report.py, remplacez ticket.category dans count_by_category par ticket.categry.
  3. LOG_LEVEL=error avec le fichier de données normal.
Afficher les réponses
  1. 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 sortie 1. json.JSONDecodeError est une ValueError, donc le bloc except l’intercepte. C’est une erreur prévue : un fichier peut être endommagé à tout moment.
  2. 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 sortie 1. Le bloc except ne l’intercepte pas, car split_records et build_summary sont à l’extérieur du bloc try. De plus, AttributeError n’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.
  3. Seulement 15 rows: 9 valid, 6 rejected., code de sortie 0. Les avertissements sont masqués, car WARNING est inférieur à ERROR. Le résultat s’affiche quand même, car print ne 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 1

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

  1. Placez seulement les lignes qui touchent au monde extérieur dans le bloc try.
  2. Nommez les types d’exceptions prévus, comme dans l’exemple détaillé.
  3. 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 sortie 1. 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.

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.

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. Retournez 1 pour une erreur, puis terminez avec raise 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