backend / python
Context managers : with et contextlib
Explication
Ce que vous allez apprendre
- Comprendre pourquoi
withgarantit qu'une ressource sera libérée, même en cas d'erreur - Implémenter
__enter__et__exit__pour rendre une classe compatible avecwith - Transformer une fonction génératrice en context manager avec
@contextlib.contextmanager - Utiliser
contextlib.suppresspour 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.
| Outil | Remplace | Avantage |
|---|---|---|
@contextmanager | Une classe avec __enter__/__exit__ | Syntaxe courte, basée sur yield |
contextlib.suppress(Err) | try/except Err: pass | Plus lisible pour ignorer une erreur connue |
ExitStack() | Plusieurs with imbriqués à nombre fixe | Gère un nombre dynamique de ressources |
Commandes & code
Context managers : with et contextlib
Garantir l'acquisition/libération propre de ressources (fichiers, verrous, connexions).
# 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__retournantTruesupprime l'exception.@contextmanagertransforme une fonction génératrice en context manager (leyieldsépare enter/exit).ExitStackgère un nombre dynamique de ressources ;suppressignore proprement certaines exceptions.
Exercices pratiques
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.