Context manager et l'instruction with en Python
1. Aperçu
Ouvrir un fichier, se connecter à une base de données ou acquérir un verrou nécessitent tous une étape de nettoyage à la fin. Cette étape doit s’exécuter même si le code au milieu lève une exception. Python résout cela avec les context managers et l’instruction with.
Cet article explique comment with fonctionne en dessous et comment écrire votre propre context manager avec une classe ou avec contextlib. Vous apprendrez également comment gérer les exceptions dans __exit__, comment tester les context managers et les erreurs courantes lors de leur utilisation.
2. Le problème : nettoyer les ressources avec try/finally
Voyons comment lire un fichier sans utiliser with :
f = open("orders.csv", encoding="utf-8")
try:
header = f.readline()
print(header)
finally:
f.close()
Cette approche est correcte mais verbeux. C’est aussi facile à oublier. Si vous mettez open() à l’intérieur du try, et que open() échoue, alors f n’a pas été assigné et le bloc finally lèvera une NameError. L’instruction with encapsule l’intégralité du modèle ci-dessus :
with open("orders.csv", encoding="utf-8") as f:
header = f.readline()
print(header)
print(f.closed)
order_id,customer,amount
True
Quand vous quittez le bloc with, le fichier a été fermé. Cela est vrai que vous quittiez normalement ou avec une exception.
3. Le protocole du context manager
Tout objet ayant les deux méthodes __enter__ et __exit__ peut être utilisé avec with. Conceptuellement, l’instruction :
with EXPR as target:
BODY
est équivalente au code suivant :
manager = EXPR
target = manager.__enter__()
try:
BODY
except BaseException as e:
if not manager.__exit__(type(e), e, e.__traceback__):
raise
else:
manager.__exit__(None, None, None)
Il y a trois points à retenir :
- La valeur assignée à
targetest le résultat de__enter__(), et non l’EXPRelle-même. __exit__est toujours appelé. Lorsqu’une exception est levée, elle reçoit(exc_type, exc_value, traceback). Quand il n’y a pas d’erreur, les trois paramètres sontNone.- Si
__exit__retourne une valeur truthy, l’exception est supprimée. Sinon, l’exception est levée à nouveau.
3.1. Écrire un context manager avec une classe
Le premier exemple est un Timer qui mesure le temps d’exécution d’un bloc de code :
import time
class Timer:
def __init__(self, label: str):
self.label = label
self.elapsed: float | None = None
def __enter__(self):
self._start = time.perf_counter()
return self
def __exit__(self, exc_type, exc, tb):
self.elapsed = time.perf_counter() - self._start
print(f"[{self.label}] {self.elapsed:.3f}s")
return False
with Timer("somme_commandes") as t:
total = sum(range(5_000_000))
print(t.elapsed > 0)
[somme_commandes] 0.081s
True
Explication détaillée :
__enter__enregistre l’heure de début et retourneself. Ainsitest exactement l’objetTimer.__exit__calcule le temps écoulé, l’affiche à l’écran, puis retourneFalse. Les exceptions éventuelles ne seront pas supprimées.- La variable
treste accessible après le blocwith. C’est parce quewithne crée pas une nouvelle portée.
3.2. Gérer les exceptions dans __exit__
Les context managers sont les plus utiles quand vous avez besoin de réagir différemment selon que le bloc de code réussit ou échoue. L’exemple ci-dessous est une transaction simple sur un registre (ledger) :
class Transaction:
def __init__(self, ledger: list):
self.ledger = ledger
self.pending: list = []
def __enter__(self):
return self.pending
def __exit__(self, exc_type, exc, tb):
if exc_type is None:
self.ledger.extend(self.pending)
print(f"commit {len(self.pending)} enregistrements")
else:
print(f"rollback en raison de {exc_type.__name__}: {exc}")
return False
ledger = []
with Transaction(ledger) as tx:
tx.append({"order_id": 1, "amount": 250_000})
tx.append({"order_id": 2, "amount": 120_000})
try:
with Transaction(ledger) as tx:
tx.append({"order_id": 3, "amount": 90_000})
raise ValueError("solde insuffisant")
except ValueError:
pass
print(len(ledger))
commit 2 enregistrements
rollback en raison de ValueError: solde insuffisant
2
Dans le deuxième bloc with, la commande n° 3 n’a pas été enregistrée dans le ledger. Puisque __exit__ retourne False, la ValueError est toujours levée et l’appelant décide comment la gérer.
Remarque : Ne retournez
Truedepuis__exit__que si vous intentionnellement voulez supprimer l’exception. Une méthode__exit__qui retourne accidentellementselfou un autre objet truthy fera disparaître l’erreur sans aucune trace.
4. Plus concis avec @contextmanager
Écrire une classe entière juste pour une étape de configuration et de nettoyage est assez verbeux. Le module contextlib fournit le décorateur @contextmanager pour transformer un générateur en context manager :
from contextlib import contextmanager
class PaymentService:
def connect(self):
print("ouvrir la connexion à la passerelle de paiement")
def charge(self, customer: str, amount: int):
print(f"débiter {amount} du compte de {customer}")
def disconnect(self):
print("fermer la connexion")
@contextmanager
def payment_session(service: PaymentService):
service.connect()
try:
yield service
finally:
service.disconnect()
with payment_session(PaymentService()) as payment:
payment.charge("Lan", 300_000)
ouvrir la connexion à la passerelle de paiement
débiter 300000 du compte de Lan
fermer la connexion
Voici comment le décorateur mappe le générateur vers le protocole du context manager :
- Le code avant
yieldjoue le rôle de__enter__. - La valeur
yielded est la valeur assignée à la variable aprèsas. - Le code après
yieldjoue le rôle de__exit__. Si le blocwithlève une exception, cette exception est levée à nouveau au niveau deyield.
Puisque l’exception apparaît directement à yield, le bloc try/finally autour de yield est obligatoire. Sans lui, disconnect() ne s’exécuterait pas en cas d’erreur.
Pour gérer chaque type d’erreur séparément, vous pouvez ajouter except :
@contextmanager
def safe_charge(order_id: int):
try:
yield
except ConnectionError as e:
print(f"commande {order_id}: erreur réseau, nouvelle tentative ultérieurement ({e})")
with safe_charge(42):
raise ConnectionError("timeout")
print("continuer le traitement")
commande 42: erreur réseau, nouvelle tentative ultérieurement (timeout)
continuer le traitement
Le générateur capture l’erreur mais ne la relève pas, donc l’exception est supprimée. Ce résultat équivaut à ce que __exit__ retourne True.
5. Les utilitaires intégrés dans contextlib
5.1. suppress : ignorer une exception spécifique
import os
from contextlib import suppress
with suppress(FileNotFoundError):
os.remove("cache_orders.tmp")
print("terminé")
terminé
Le code ci-dessus est plus concis et plus explicite qu’un try: ... except FileNotFoundError: pass.
5.2. ExitStack : nombre de ressources inconnu à l’avance
Supposons que vous ayez besoin de fusionner plusieurs fichiers de factures, mais que le nombre de fichiers ne soit connu qu’à l’exécution :
from contextlib import ExitStack
from pathlib import Path
def merge_invoices(paths: list[Path], output: Path) -> None:
with ExitStack() as stack:
files = [stack.enter_context(open(p, encoding="utf-8")) for p in paths]
with open(output, "w", encoding="utf-8") as out:
for f in files:
out.write(f.read())
for i in range(3):
Path(f"invoice_{i}.txt").write_text(f"facture {i}\n", encoding="utf-8")
merge_invoices([Path(f"invoice_{i}.txt") for i in range(3)], Path("all.txt"))
print(Path("all.txt").read_text(encoding="utf-8"))
facture 0
facture 1
facture 2
enter_context appelle __enter__ et mémorise le __exit__ correspondant. Quand ExitStack se termine, les fichiers sont fermés dans l’ordre inverse d’ouverture. Si le troisième fichier échoue à s’ouvrir, les deux premiers fichiers sont toujours fermés correctement.
5.3. closing et nullcontext
closing(obj)transforme un objet ayant seulement une méthodeclose()en context manager.nullcontext(value)est un context manager qui ne fait rien. C’est utile quand un paramètre peut ou ne peut pas être un context manager :
from contextlib import nullcontext
import threading
def update_stock(product: dict, qty: int, lock: threading.Lock | None = None):
with lock if lock is not None else nullcontext():
product["stock"] -= qty
product = {"sku": "AO-01", "stock": 10}
update_stock(product, 3)
update_stock(product, 2, threading.Lock())
print(product["stock"])
5
6. Plusieurs context managers dans une seule instruction with
Vous pouvez déclarer plusieurs context managers dans une seule instruction with. À partir de Python 3.10, la syntaxe utilisant des parenthèses permet une meilleure mise en forme :
with (
open("orders.csv", encoding="utf-8") as src,
open("orders_backup.csv", "w", encoding="utf-8") as dst,
):
dst.write(src.read())
Cette syntaxe est équivalente à deux blocs with imbriqués. src est ouvert en premier et fermé en dernier.
7. Async context manager
Avec le code asyncio, le protocole correspondant utilise __aenter__ et __aexit__, utilisés avec async with. La version générateur est @asynccontextmanager :
import asyncio
from contextlib import asynccontextmanager
@asynccontextmanager
async def order_queue(name: str):
print(f"connexion à la file {name}")
await asyncio.sleep(0.01)
try:
yield [f"order-{i}" for i in range(3)]
finally:
await asyncio.sleep(0.01)
print(f"fermeture file {name}")
async def main():
async with order_queue("payments") as messages:
for m in messages:
print("traiter", m)
asyncio.run(main())
connexion à la file payments
traiter order-0
traiter order-1
traiter order-2
fermeture file payments
Remarque : Utiliser
withrégulier avec un async context manager lèvera uneTypeError(ouAttributeErrorsur les versions plus anciennes de Python). L’inverse est aussi vrai. Ces deux protocoles sont distincts.
8. Tester les context managers
Les context managers doivent être testés à la fois pour le chemin réussi et le chemin d’erreur. Installez d’abord pytest :
pip install pytest
Mettez la classe Transaction de la section 3.2 dans un fichier transaction.py, puis écrivez les tests :
# test_transaction.py
import pytest
from transaction import Transaction
def test_commit_when_no_error():
ledger = []
with Transaction(ledger) as tx:
tx.append({"order_id": 1, "amount": 100})
assert ledger == [{"order_id": 1, "amount": 100}]
def test_rollback_and_propagate_error():
ledger = []
with pytest.raises(ValueError, match="solde"):
with Transaction(ledger) as tx:
tx.append({"order_id": 2, "amount": 999})
raise ValueError("solde insuffisant")
assert ledger == []
pytest -q
.. [100%]
2 passed in 0.01s
Le deuxième test vérifie deux choses à la fois : les données sont annulées, et l’exception n’est pas supprimée. pytest.raises lui-même est aussi un context manager, un exemple familier du sujet de cet article.
9. Erreurs courantes et quand ne pas les utiliser
Oublier try/finally autour de yield. C’est l’erreur la plus courante avec @contextmanager :
@contextmanager
def broken_session(service):
service.connect()
yield service
service.disconnect() # ne s'exécute pas si le bloc with lève une exception
Utiliser une ressource après la fin du bloc with. La variable existe toujours, mais la ressource a été libérée :
with open("orders.csv", encoding="utf-8") as f:
pass
f.read()
ValueError: I/O operation on closed file.
Faire yield plusieurs fois dans un générateur. Le générateur utilisé avec @contextmanager doit yield exactement une fois. Si vous faites yield plus d’une fois, Python lève RuntimeError: generator didn't stop.
Réutiliser un context manager créé avec @contextmanager. Cet objet ne peut être utilisé qu’une seule fois. Appeler with une deuxième fois sur le même objet lèvera une erreur. Chaque fois que vous l’utilisez, appelez à nouveau la fonction pour créer un nouvel objet.
Abus d’utilisation. Vous ne devriez pas utiliser de context managers dans les cas suivants :
- La ressource doit persister plus longtemps qu’un bloc de code, par exemple, un pool de connexions qui existe pendant toute la durée de vie de l’application. Dans ce cas, gérez-le dans la couche de démarrage et d’arrêt de l’application.
- La logique n’a pas de paire « ouvrir/fermer » clairement définie. Une fonction ordinaire serait plus lisible.
- Vous voulez ignorer toutes les exceptions avec
suppress(Exception). Cela cache les bugs exactement commeexcept: pass.
Remarque : Ne comptez pas sur le garbage collector ou
__del__pour fermer les ressources. Le moment où ils s’exécutent n’est pas garanti, en particulier sur les implémentations comme PyPy. Utilisezwith.
10. Conclusion
Les context managers encapsulent le setup et le teardown au même endroit, et garantissent que le teardown s’exécute toujours, même en cas d’exception. Vous avez appris :
- Le protocole
__enter__et__exit__, et la signification de la valeur retournée par__exit__. - Comment écrire de façon concise avec
@contextmanager, avectry/finallyautour deyieldobligatoire. - Les utilitaires
suppress,ExitStack,closing,nullcontext, ainsi que leurs versions async. - Comment tester à la fois la branche commit et la branche rollback avec pytest.
Chaque fois que votre code a une paire « acquérir-puis-libérer » (fichiers, verrous, transactions, connexions), envisagez de l’encapsuler dans un context manager.