Gestion des erreurs et exceptions#

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

La hiérarchie des exceptions#

Python dispose d’un système d’exceptions structuré en une hiérarchie de classes. Comprendre cette hiérarchie est indispensable pour écrire des gestionnaires d’erreurs précis et pour créer ses propres exceptions de façon cohérente.

Au sommet se trouve BaseException, la classe mère de toutes les exceptions. Elle a quatre sous-classes directes :

  • SystemExit : levée par sys.exit(). Hérite de BaseException et non de Exception afin que les blocs except Exception ne l’attrapent pas accidentellement.

  • KeyboardInterrupt : levée quand l’utilisateur appuie sur Ctrl+C.

  • GeneratorExit : levée quand un générateur ou une coroutine est fermé via .close().

  • Exception : la classe mère de toutes les exceptions applicatives. C’est de celle-ci que dérivent toutes les exceptions que vous utiliserez au quotidien.

La quasi-totalité des exceptions que l’on rencontre en pratique héritent de Exception. En voici les plus importantes :

Classe

Situation typique

ValueError

Argument du bon type mais valeur invalide (int("abc"))

TypeError

Opération sur le mauvais type ("a" + 1)

KeyError

Clé absente dans un dictionnaire

IndexError

Indice hors bornes dans une séquence

AttributeError

Accès à un attribut inexistant

NameError

Utilisation d’une variable non définie

OSError / IOError

Erreurs liées au système d’exploitation (fichiers, réseau)

FileNotFoundError

Fichier introuvable (sous-classe d”OSError)

PermissionError

Droits insuffisants (sous-classe d”OSError)

StopIteration

Signal de fin d’un itérateur

RuntimeError

Erreur générique à l’exécution

NotImplementedError

Méthode abstraite non implémentée

ArithmeticError

Classe mère des erreurs arithmétiques

ZeroDivisionError

Division par zéro (sous-classe d”ArithmeticError)

OverflowError

Dépassement de capacité numérique

MemoryError

Mémoire insuffisante

RecursionError

Profondeur de récursion maximale dépassée

ImportError

Échec d’importation d’un module

ModuleNotFoundError

Module introuvable (sous-classe d”ImportError)

AssertionError

Assertion assert échouée

Hide code cell source

fig, ax = plt.subplots(figsize=(14, 10))
ax.set_xlim(0, 14)
ax.set_ylim(0, 11)
ax.axis('off')
ax.set_title("Hiérarchie des exceptions Python (simplifiée)", fontsize=14,
             fontweight='bold', pad=12)

c_root   = '#8e44ad'
c_base   = '#c0392b'
c_exc    = '#2980b9'
c_std    = '#27ae60'
c_leaf   = '#16a085'
c_sys    = '#e67e22'

def noeud(ax, x, y, w, h, couleur, texte, fontsize=8.5):
    rect = patches.FancyBboxPatch(
        (x - w/2, y - h/2), w, h,
        boxstyle="round,pad=0.1", linewidth=1.8,
        edgecolor=couleur, facecolor=couleur, alpha=0.85
    )
    ax.add_patch(rect)
    ax.text(x, y, texte, ha='center', va='center',
            fontsize=fontsize, fontweight='bold', color='white')

def lien(ax, x1, y1, x2, y2):
    ax.plot([x1, x2], [y1, y2], color='#95a5a6', lw=1.5, zorder=0)

# Racine
noeud(ax, 7, 10.3, 2.8, 0.7, c_root, "BaseException", fontsize=9.5)

# Enfants directs de BaseException
noeud(ax, 2.0, 8.8, 2.4, 0.65, c_sys,  "SystemExit")
noeud(ax, 4.8, 8.8, 2.8, 0.65, c_sys,  "KeyboardInterrupt")
noeud(ax, 7.8, 8.8, 2.4, 0.65, c_sys,  "GeneratorExit")
noeud(ax, 11.0, 8.8, 2.4, 0.7, c_exc,  "Exception", fontsize=9)

for xc in [2.0, 4.8, 7.8, 11.0]:
    lien(ax, 7, 9.95, xc, 9.12)

# Enfants directs d'Exception
sous_exc = [
    (2.0, 7.3, "ValueError"),
    (4.0, 7.3, "TypeError"),
    (6.0, 7.3, "KeyError"),
    (8.0, 7.3, "AttributeError"),
    (10.0, 7.3, "NameError"),
    (12.0, 7.3, "StopIteration"),
]
for xs, ys, label in sous_exc:
    noeud(ax, xs, ys, 2.1, 0.6, c_std, label, fontsize=7.5)
    lien(ax, 11.0, 8.45, xs, 7.6)

# OSError
noeud(ax, 3.5, 5.8, 2.0, 0.6, c_std, "OSError")
lien(ax, 11.0, 8.45, 3.5, 6.1)

noeud(ax, 1.5, 4.5, 2.6, 0.58, c_leaf, "FileNotFoundError", fontsize=7)
noeud(ax, 4.2, 4.5, 2.4, 0.58, c_leaf, "PermissionError", fontsize=7)
lien(ax, 3.5, 5.5, 1.5, 4.78)
lien(ax, 3.5, 5.5, 4.2, 4.78)

# ArithmeticError
noeud(ax, 7.5, 5.8, 2.4, 0.6, c_std, "ArithmeticError")
lien(ax, 11.0, 8.45, 7.5, 6.1)
noeud(ax, 6.2, 4.5, 2.5, 0.58, c_leaf, "ZeroDivisionError", fontsize=7)
noeud(ax, 8.9, 4.5, 2.0, 0.58, c_leaf, "OverflowError", fontsize=7)
lien(ax, 7.5, 5.5, 6.2, 4.78)
lien(ax, 7.5, 5.5, 8.9, 4.78)

# ImportError
noeud(ax, 11.5, 5.8, 2.0, 0.6, c_std, "ImportError")
lien(ax, 11.0, 8.45, 11.5, 6.1)
noeud(ax, 11.5, 4.5, 2.8, 0.58, c_leaf, "ModuleNotFoundError", fontsize=7)
lien(ax, 11.5, 5.5, 11.5, 4.78)

# RuntimeError
noeud(ax, 6.0, 2.8, 2.4, 0.58, c_leaf, "RuntimeError")
noeud(ax, 8.8, 2.8, 2.4, 0.58, c_leaf, "RecursionError")
noeud(ax, 11.0, 2.8, 2.4, 0.58, c_leaf, "AssertionError")
for xr in [6.0, 8.8, 11.0]:
    lien(ax, 11.0, 8.45, xr, 3.08)

# Légende
legend_items = [
    (c_root, "Racine absolue"),
    (c_sys,  "Exceptions système"),
    (c_exc,  "Exception (racine applicative)"),
    (c_std,  "Sous-classes intermédiaires"),
    (c_leaf, "Exceptions courantes"),
]
for i, (couleur, label) in enumerate(legend_items):
    rect = patches.FancyBboxPatch((0.2, 2.0 - i * 0.55), 0.5, 0.4,
                                   boxstyle="round,pad=0.05",
                                   facecolor=couleur, edgecolor=couleur, alpha=0.85)
    ax.add_patch(rect)
    ax.text(0.85, 2.2 - i * 0.55, label, fontsize=7.5, va='center')

plt.tight_layout()
plt.show()
_images/e1dca664de5fb9d3d1b1670ea8b5387d91dfe4d05262ee0e280dc9eb6eb2c857.png

try / except / else / finally#

La syntaxe complète du bloc de gestion d’exceptions en Python comprend quatre clauses, chacune avec un rôle distinct :

try:
    # Code susceptible de lever une exception
    ...
except SomeException as e:
    # Exécuté si SomeException (ou une sous-classe) est levée
    ...
except (TypeError, ValueError) as e:
    # Plusieurs types dans un seul except
    ...
else:
    # Exécuté UNIQUEMENT si aucune exception n'a été levée dans try
    ...
finally:
    # Exécuté TOUJOURS, exception ou non
    ...

La clause else est souvent oubliée, mais elle joue un rôle sémantique important : elle sépare le code qui peut échouer du code qui suit le succès. Cela évite d’attraper accidentellement des exceptions levées par le code de traitement post-succès :

def lire_entier(chaine: str) -> int | None:
    try:
        valeur = int(chaine)
    except ValueError:
        print(f"'{chaine}' n'est pas un entier valide.")
        return None
    else:
        # Exécuté seulement si int(chaine) a réussi
        print(f"Conversion réussie : {valeur}")
        return valeur
    finally:
        # Toujours exécuté, utile pour le nettoyage
        print("Fin de lire_entier().")


print(lire_entier("42"))
print()
print(lire_entier("abc"))
Conversion réussie : 42
Fin de lire_entier().
42

'abc' n'est pas un entier valide.
Fin de lire_entier().
None

Remarque 26

La clause finally est garantie de s’exécuter dans tous les cas : après une sortie normale, après un except, après un return, après un break ou un continue dans une boucle, et même après une exception non attrapée. C’est pourquoi elle est idéale pour les opérations de nettoyage (fermeture de fichiers, libération de verrous), bien que les gestionnaires de contexte soient généralement préférables pour cela.

def demonstrer_finally():
    try:
        print("Dans try.")
        return "valeur du try"  # Le finally s'exécute quand même !
    finally:
        print("Dans finally (même après return).")

resultat = demonstrer_finally()
print(f"Résultat : {resultat}")
Dans try.
Dans finally (même après return).
Résultat : valeur du try

On peut empiler plusieurs clauses except pour traiter différentes exceptions de façon spécifique. L’ordre est important : Python teste les clauses de haut en bas et s’arrête à la première correspondance. Il faut donc toujours placer les classes les plus spécifiques avant les classes plus générales :

import json

def charger_config(chemin: str) -> dict:
    try:
        with open(chemin, "r", encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError:
        print(f"Fichier introuvable : {chemin}")
        return {}
    except PermissionError:
        print(f"Droits insuffisants pour lire : {chemin}")
        return {}
    except json.JSONDecodeError as e:
        print(f"JSON invalide dans {chemin} : {e}")
        return {}
    except OSError as e:
        # Plus général : attrape FileNotFoundError et PermissionError
        # si elles n'avaient pas été listées avant
        print(f"Erreur système : {e}")
        return {}


resultat = charger_config("/tmp/config_inexistante.json")
print(f"Résultat : {resultat}")
Fichier introuvable : /tmp/config_inexistante.json
Résultat : {}

Lever une exception#

raise#

L’instruction raise lève une exception. On peut lui passer une instance d’exception ou une classe (Python instancie alors la classe sans arguments) :

def diviser(a: float, b: float) -> float:
    if b == 0:
        raise ValueError("Le diviseur ne peut pas être zéro.")
    return a / b


try:
    resultat = diviser(10, 0)
except ValueError as e:
    print(f"Erreur : {e}")
Erreur : Le diviseur ne peut pas être zéro.

raise from — chaîner les causes#

La syntaxe raise NouvelleException from cause attache l’exception originale comme cause explicite de la nouvelle. C’est une pratique essentielle pour les bibliothèques qui transforment des exceptions bas niveau en abstractions de plus haut niveau, sans perdre le contexte d’origine :

class ErreurConnexion(Exception):
    """Exception de haut niveau pour les erreurs de connexion."""

def connecter_base(url: str) -> None:
    try:
        # Simulons une erreur bas niveau
        raise ConnectionRefusedError(f"Port fermé sur {url}")
    except ConnectionRefusedError as e:
        raise ErreurConnexion(
            f"Impossible de se connecter à la base de données : {url}"
        ) from e


try:
    connecter_base("postgresql://localhost:5432/ma_base")
except ErreurConnexion as e:
    print(f"Erreur : {e}")
    print(f"Cause originale : {e.__cause__}")
Erreur : Impossible de se connecter à la base de données : postgresql://localhost:5432/ma_base
Cause originale : Port fermé sur postgresql://localhost:5432/ma_base

raise nu — re-lever une exception#

À l’intérieur d’un bloc except, un raise sans argument re-lève l’exception courante sans la modifier. C’est utile pour journaliser une erreur et la propager quand même :

import logging

def operation_critique():
    try:
        raise RuntimeError("Quelque chose s'est mal passé.")
    except RuntimeError:
        # On journalise sans avaler l'exception
        print("[LOG] Erreur capturée, propagation en cours...")
        raise  # Re-lève RuntimeError telle quelle


try:
    operation_critique()
except RuntimeError as e:
    print(f"Exception propagée reçue : {e}")
[LOG] Erreur capturée, propagation en cours...
Exception propagée reçue : Quelque chose s'est mal passé.

Définir ses propres exceptions#

La convention Python est de créer une hiérarchie d’exceptions spécifiques à son domaine en héritant de Exception (ou d’une sous-classe appropriée). Les exceptions personnalisées peuvent avoir des attributs supplémentaires pour transporter des informations contextuelles.

class ErreurApplication(Exception):
    """Classe de base pour toutes les exceptions de l'application."""

class ErreurValidation(ErreurApplication):
    """Erreur de validation des données entrantes."""

    def __init__(self, champ: str, valeur, message: str):
        self.champ = champ
        self.valeur = valeur
        super().__init__(f"[{champ}={valeur!r}] {message}")

class ErreurAuthentification(ErreurApplication):
    """Erreur d'authentification."""

    def __init__(self, utilisateur: str, raison: str = ""):
        self.utilisateur = utilisateur
        self.raison = raison
        super().__init__(f"Authentification échouée pour '{utilisateur}'. {raison}")


# Utilisation
def valider_age(age: int) -> None:
    if not isinstance(age, int):
        raise ErreurValidation("age", age, "Doit être un entier.")
    if age < 0 or age > 150:
        raise ErreurValidation("age", age, "Doit être entre 0 et 150.")

try:
    valider_age(-5)
except ErreurValidation as e:
    print(f"Validation échouée — champ : {e.champ}, valeur : {e.valeur}")
    print(f"Message : {e}")
Validation échouée — champ : age, valeur : -5
Message : [age=-5] Doit être entre 0 et 150.

Définition 27 (Hiérarchie d’exceptions métier)

Une hiérarchie d’exceptions métier est un ensemble de classes d’exceptions organisées de façon à refléter le domaine de l’application. La classe racine (par exemple ErreurApplication) permet d’attraper toutes les erreurs de l’application avec un seul except, tandis que les sous-classes permettent un traitement précis de chaque type d’erreur. Cette organisation facilite la maintenance et la lisibilité du code de gestion d’erreurs.

Bonnes pratiques#

Attraper le plus spécifique possible#

Il faut éviter les clauses except Exception ou pire, les except: nus (qui attrapent même KeyboardInterrupt et SystemExit). Attraper une exception trop générale masque des erreurs réelles :

# Mauvaise pratique : attrape tout, même KeyboardInterrupt
try:
    faire_quelque_chose()
except:
    pass  # "Avaler" silencieusement une exception est presque toujours une erreur.

# Bonne pratique
try:
    valeur = int(entree_utilisateur)
except ValueError:
    valeur = 0  # On sait exactement ce que l'on gère.

Ne jamais avaler silencieusement#

Un except SomeException: pass efface silencieusement des erreurs qui pourraient indiquer des bugs sérieux. Si l’on doit ignorer une exception, contextlib.suppress ou au moins un commentaire explicatif est préférable :

import contextlib

# Acceptable : l'intention est claire et documentée
with contextlib.suppress(FileNotFoundError):
    import os
    os.remove("/tmp/fichier_temporaire.txt")

Journalisation avec logging#

La bibliothèque standard logging est bien plus adaptée que print pour enregistrer les erreurs. logging.exception() inclut automatiquement le traceback complet :

import logging

logging.basicConfig(level=logging.DEBUG,
                    format='%(levelname)s: %(message)s')

def operation_avec_log():
    try:
        resultat = 1 / 0
    except ZeroDivisionError:
        logging.exception("Division par zéro lors du calcul.")
        return None

operation_avec_log()
ERROR: Division par zéro lors du calcul.
Traceback (most recent call last):
  File "/tmp/ipykernel_9382/263915616.py", line 8, in operation_avec_log
    resultat = 1 / 0
               ~~^~~
ZeroDivisionError: division by zero

Le module warnings#

Pour les situations qui ne sont pas des erreurs fatales mais méritent l’attention (fonctionnalités dépréciées, comportements ambigus), Python fournit warnings.warn() :

import warnings

def ancienne_fonction(x: int) -> int:
    warnings.warn(
        "ancienne_fonction() est dépréciée, utilisez nouvelle_fonction() à la place.",
        DeprecationWarning,
        stacklevel=2  # Pointe vers l'appelant, pas vers cette ligne
    )
    return x * 2

resultat = ancienne_fonction(5)
print(f"Résultat : {resultat}")
Résultat : 10
/tmp/ipykernel_9382/1552801776.py:11: DeprecationWarning: ancienne_fonction() est dépréciée, utilisez nouvelle_fonction() à la place.
  resultat = ancienne_fonction(5)

ExceptionGroup (Python 3.11+)#

Python 3.11 a introduit ExceptionGroup et la syntaxe except* pour gérer plusieurs exceptions survenues simultanément, un besoin qui émerge naturellement dans les environnements concurrents (tâches asyncio, exécuteurs parallèles) où plusieurs sous-tâches peuvent échouer en même temps.

# Python 3.11+
import asyncio

async def tache(n: int) -> None:
    if n % 2 == 0:
        raise ValueError(f"Valeur paire interdite : {n}")
    await asyncio.sleep(0.01)

async def main():
    async with asyncio.TaskGroup() as tg:
        for i in range(5):
            tg.create_task(tache(i))
    # TaskGroup lève automatiquement un ExceptionGroup
    # si plusieurs tâches échouent.

La syntaxe except* filtre les exceptions d’un groupe par type, permettant de traiter chaque type séparément tout en laissant les autres se propager :

# Création manuelle d'un ExceptionGroup pour la démonstration
groupe = ExceptionGroup(
    "Erreurs de validation",
    [
        ValueError("Âge invalide"),
        TypeError("Type incorrect"),
        ValueError("Nom vide"),
    ]
)

try:
    raise groupe
except* ValueError as eg:
    print(f"ValueError capturées ({len(eg.exceptions)}) :")
    for e in eg.exceptions:
        print(f"  - {e}")
except* TypeError as eg:
    print(f"TypeError capturées : {eg.exceptions}")
ValueError capturées (2) :
  - Âge invalide
  - Nom vide
TypeError capturées : (TypeError('Type incorrect'),)

Remarque 27

ExceptionGroup ne remplace pas la gestion d’exceptions classique : pour les erreurs séquentielles ordinaires, try / except reste la bonne approche. ExceptionGroup est conçu spécifiquement pour les scénarios de concurrence où plusieurs opérations indépendantes s’exécutent en parallèle et peuvent échouer simultanément. La bibliothèque anyio et asyncio.TaskGroup en sont les principaux producteurs.

Résumé#

Ce chapitre a couvert la gestion des erreurs et des exceptions en Python de façon complète :

  • La hiérarchie des exceptions part de BaseException, avec Exception comme racine des exceptions applicatives. SystemExit, KeyboardInterrupt et GeneratorExit héritent directement de BaseException pour ne pas être attrapées accidentellement.

  • La syntaxe try / except / else / finally est la structure principale. else s’exécute uniquement en cas de succès, séparant clairement le code susceptible d’échouer du code de traitement. finally s’exécute toujours.

  • raise lève une exception, raise X from Y chaîne les causes, et raise nu re-lève l’exception courante.

  • Les exceptions personnalisées s’écrivent en héritant d”Exception, avec une hiérarchie reflétant le domaine métier et des attributs portant le contexte de l’erreur.

  • Les bonnes pratiques consistent à attraper le plus spécifique possible, ne jamais avaler silencieusement, journaliser avec logging.exception(), et utiliser warnings.warn() pour les dépréciations.

  • ExceptionGroup et except* (Python 3.11+) permettent de gérer des exceptions multiples survenues simultanément dans un contexte concurrent.

Dans le chapitre suivant, nous aborderons les annotations de types et l’outil mypy, qui permettent de détecter statiquement toute une classe d’erreurs avant même d’exécuter le code.