Retour au cours

backend / python

Context managers : with et contextlib

Leçon 161 exercice

Explication

Ce que vous allez apprendre

  • Comprendre pourquoi with garantit qu'une ressource sera libérée, même en cas d'erreur
  • Implémenter __enter__ et __exit__ pour rendre une classe compatible avec with
  • Transformer une fonction génératrice en context manager avec @contextlib.contextmanager
  • Utiliser contextlib.suppress pour ignorer proprement une exception attendue
  • Gérer un nombre dynamique de ressources ouvertes avec ExitStack

Dans quel contexte ?

Un développeur ouvre une connexion à une base de données dans une fonction de traitement par lot, sans utiliser with. Le jour où une ligne de données invalide déclenche une exception au milieu du traitement, la connexion reste ouverte indéfiniment, jusqu'à épuiser le pool de connexions disponibles du serveur de base de données. Cette leçon montre comment un context manager garantit la fermeture de la connexion, que le traitement réussisse ou échoue.

Garantir qu'un nettoyage aura toujours lieu

Chaque ressource externe qu'on ouvre (fichier, connexion réseau, verrou) doit être libérée à un moment donné, que le traitement se soit bien passé ou qu'une erreur soit survenue en cours de route. Écrire ce nettoyage à la main dans un bloc finally à chaque fois serait répétitif et facile à oublier. Le context manager, utilisé via with, encapsule cette responsabilité une fois pour toutes.

Le protocole enter/exit

Un objet devient utilisable avec with s'il implémente deux méthodes : __enter__, appelée à l'ouverture du bloc (et dont la valeur de retour est ce qu'on récupère après as), et __exit__, appelée systématiquement à la sortie, même en cas d'exception. __exit__ reçoit d'ailleurs le détail de l'exception éventuelle, et peut choisir de la laisser se propager ou de l'absorber en retournant True.

Piège fréquent

Si __exit__ retourne True, l'exception survenue dans le bloc with est supprimée silencieusement et le programme continue comme si de rien n'était. C'est parfois voulu, mais souvent source de bugs invisibles : ne retournez True que pour des types d'exception précis et volontairement gérés.

Un raccourci pour éviter d'écrire une classe entière

Créer une classe complète juste pour un context manager simple est souvent excessif. contextlib.contextmanager permet d'écrire la même logique sous forme de fonction génératrice : tout ce qui précède le yield joue le rôle de __enter__, tout ce qui suit joue le rôle de __exit__. C'est un pont naturel avec la leçon précédente sur les générateurs.

Des outils prêts à l'emploi

contextlib fournit des context managers déjà écrits pour des besoins courants : suppress pour ignorer élégamment certaines exceptions, ExitStack pour gérer un nombre variable de ressources ouvertes dynamiquement, ou nullcontext pour rendre un context manager optionnel sans dupliquer le code appelant.

OutilRemplaceAvantage
@contextmanagerUne classe avec __enter__/__exit__Syntaxe courte, basée sur yield
contextlib.suppress(Err)try/except Err: passPlus lisible pour ignorer une erreur connue
ExitStack()Plusieurs with imbriqués à nombre fixeGère un nombre dynamique de ressources

Commandes & code

Context managers : with et contextlib

Garantir l'acquisition/libération propre de ressources (fichiers, verrous, connexions).

python
# Le protocole : __enter__ / __exit__
class ConnexionBDD:
    def __init__(self, dsn):
        self.dsn = dsn
        self.connectee = False

    def __enter__(self):
        print(f"Connexion a {self.dsn}")
        self.connectee = True
        return self          # valeur assignee apres "as"

    def __exit__(self, exc_type, exc_value, traceback):
        print("Fermeture de la connexion")
        self.connectee = False
        # retourner True supprimerait l'exception (a utiliser avec precaution)
        return False

with ConnexionBDD("postgres://localhost") as conn:
    print(f"Connectee : {conn.connectee}")
    # meme si une exception survient ici, __exit__ est garanti d'etre appele

# __exit__ recoit le detail de l'exception si elle survient
class GestionErreurs:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        if exc_type is ValueError:
            print(f"ValueError interceptee et loguee : {exc_value}")
            return True         # supprime l'exception, le programme continue
        return False             # laisse propager les autres types d'exception

with GestionErreurs():
    raise ValueError("Oups")
print("On arrive ici car ValueError a ete supprimee")

# contextlib.contextmanager : ecrire un context manager avec une simple fonction generatrice
from contextlib import contextmanager

@contextmanager
def chronometre(nom):
    import time
    debut = time.perf_counter()
    try:
        yield              # tout ce qui est avant = __enter__, apres = __exit__
    finally:
        duree = time.perf_counter() - debut
        print(f"{nom} : {duree:.4f}s")

with chronometre("calcul"):
    somme = sum(range(1_000_000))

# Plusieurs context managers en une seule instruction with
with open("a.txt", "w") as fa, open("b.txt", "w") as fb:
    fa.write("contenu A")
    fb.write("contenu B")

# contextlib.suppress : ignorer certaines exceptions elegamment
from contextlib import suppress

with suppress(FileNotFoundError):
    import os
    os.remove("fichier_qui_n_existe_peut_etre_pas.txt")
# equivalent, mais plus verbeux :
# try:
#     os.remove(...)
# except FileNotFoundError:
#     pass

# ExitStack : gerer un nombre dynamique de context managers
from contextlib import ExitStack

def ouvrir_plusieurs_fichiers(chemins):
    with ExitStack() as stack:
        fichiers = [stack.enter_context(open(c)) for c in chemins]
        # tous les fichiers seront fermes automatiquement, meme en cas d'erreur
        return [f.read() for f in fichiers]

# Context manager reentrant pour changer un etat temporairement (pattern courant)
@contextmanager
def precision_decimale(nb_decimales):
    import decimal
    ancienne_precision = decimal.getcontext().prec
    decimal.getcontext().prec = nb_decimales
    try:
        yield
    finally:
        decimal.getcontext().prec = ancienne_precision   # toujours restaure

# nullcontext : utile pour un context manager optionnel/conditionnel
from contextlib import nullcontext

def traiter(donnees, verrou=None):
    with (verrou if verrou is not None else nullcontext()):
        return sum(donnees)

Résumé

  • __enter__/__exit__ définissent le protocole ; __exit__ retournant True supprime l'exception.
  • @contextmanager transforme une fonction génératrice en context manager (le yield sépare enter/exit).
  • ExitStack gère un nombre dynamique de ressources ; suppress ignore proprement certaines exceptions.

Exercices pratiques

1 disponible
1

Mission : arrêter la fuite de connexions d'un traitement par lot

Objectif : Remplacer une ouverture de connexion sans nettoyage garanti par un vrai context manager, et comprendre le rôle exact de la valeur de retour de __exit__.

Contexte

Une fonction traiter_lot(donnees) ouvre une connexion avec conn = ConnexionBDD("postgres://prod") (sans with), traite les données, puis appelle conn.fermer() à la toute dernière ligne. Le jour où une ligne de données invalide lève une exception au milieu du traitement, fermer() n'est jamais atteint et la connexion reste ouverte, jusqu'à épuiser le pool de connexions du serveur.

Tu dois corriger ce code avec with, comprendre précisément ce qui se passerait si __exit__ retournait True par erreur, puis convertir un context manager en une fonction génératrice avec @contextmanager.

Résoudre l’exercice →