---
jupytext:
  text_representation:
    extension: .md
    format_name: myst
    format_version: 0.13
    jupytext_version: 1.16.0
kernelspec:
  name: python3
  display_name: Python 3
---

# Gestion des erreurs et exceptions

```{code-cell} python
:tags: [hide-input]

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 |

```{code-cell} python
:tags: [hide-input]

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()
```

## `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 :

```python
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 :

```{code-cell} python
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"))
```

```{prf:remark}
:label: remark-13-01
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.
```

```{code-cell} python
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}")
```

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 :

```{code-cell} python
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}")
```

## 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) :

```{code-cell} python
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}")
```

### `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 :

```{code-cell} python
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__}")
```

### `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 :

```{code-cell} python
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}")
```

## 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.

```{code-cell} python
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}")
```

```{prf:definition} Hiérarchie d'exceptions métier
:label: definition-13-01
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 :

```python
# 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 :

```{code-cell} python
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 :

```{code-cell} python
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()
```

### 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()` :

```{code-cell} python
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}")
```

## `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
# 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 :

```{code-cell} python
# 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}")
```

```{prf:remark}
:label: remark-13-02
`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.
