Context Manager und with-Anweisung in Python
1. Übersicht
Datei öffnen, Datenbankverbindung aufbauen oder einen Lock halten – das erfordert jeweils einen Aufräumschritt am Ende. Dieser Schritt muss laufen, auch wenn der Code dazwischen eine Exception wirft. Python löst das mit Context Managern und der with-Anweisung.
Dieser Artikel zeigt, wie with intern funktioniert und wie du eigene Context Manager mit Klassen oder mit contextlib schreibst. Du wirst auch sehen, wie man Exception in __exit__ behandelt, wie man Context Manager testet und welche Fehler häufig vorkommen.
2. Problem: Ressourcen-Cleanup mit try/finally
Hier ist das traditionelle Vorgehen, um eine Datei zu lesen, ohne with zu verwenden:
f = open("orders.csv", encoding="utf-8")
try:
header = f.readline()
print(header)
finally:
f.close()
Das ist korrekt, aber ausschweifend. Es lässt sich auch leicht vergessen. Wenn du open() in den try-Block stellst und open() fehlschlägt, wird f nie zugewiesen und der finally-Block wirft einen NameError. Die with-Anweisung komprimiert dieses ganze Muster:
with open("orders.csv", encoding="utf-8") as f:
header = f.readline()
print(header)
print(f.closed)
order_id,customer,amount
True
Wenn der with-Block verlassen wird, ist die Datei geschlossen. Das gilt sowohl für einen normalen Ausstieg als auch für eine Exception.
3. Das Context-Manager-Protokoll
Jedes Objekt mit den Methoden __enter__ und __exit__ kann mit with verwendet werden. Konzeptionell ist die Anweisung:
with EXPR as target:
BODY
äquivalent zu:
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)
Drei Punkte sind wichtig:
- Der Wert, der
targetzugewiesen wird, ist das Ergebnis von__enter__(), nichtEXPRselbst. __exit__wird immer aufgerufen. Bei einer Exception erhält sie(exc_type, exc_value, traceback). Ohne Fehler sind alle drei ParameterNone.- Wenn
__exit__einen Wert truthy zurückgibt, wird die Exception unterdrückt. Sonst wird sie weitergegeben.
3.1. Context Manager mit Klassen schreiben
Das erste Beispiel ist ein Timer, der die Ausführungszeit eines Code-Blocks misst:
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("summe_berechnen") as t:
total = sum(range(5_000_000))
print(t.elapsed > 0)
[summe_berechnen] 0.081s
True
Erklärung Teil für Teil:
__enter__speichert die Startzeit und gibtselfzurück. Dadurch isttdasTimer-Objekt selbst.__exit__berechnet die verstrichene Zeit, gibt sie aus und gibtFalsezurück. Eine Exception, falls vorhanden, wird nicht unterdrückt.- Die Variable
tkann nach demwith-Block noch verwendet werden. Der Grund:witherzeugt keinen neuen Scope.
3.2. Exception in __exit__ behandeln
Context Manager zeigen ihre Stärke am deutlichsten, wenn du unterschiedlich reagieren möchtest, je nachdem ob der Block erfolgreich war oder fehlgeschlagen ist. Das folgende Beispiel ist eine einfache Transaction in einem 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"{len(self.pending)} Einträge committed")
else:
print(f"rollback wegen {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("Guthaben reicht nicht aus")
except ValueError:
pass
print(len(ledger))
2 Einträge committed
rollback wegen ValueError: Guthaben reicht nicht aus
2
Im zweiten with-Block wird Bestellung 3 nicht ins ledger geschrieben. Da __exit__ False zurückgibt, wird ValueError immer noch weitergegeben und der Aufrufer kann entscheiden, wie er damit umgeht.
Warnung: Gib nur
Truevon__exit__zurück, wenn du bewusst die Exception unterdrücken möchtest. Eine versehentliche Rückgabe vonselfoder einem anderen truthy-Objekt macht Fehler spurlos verschwinden.
4. Kürzer mit @contextmanager
Eine ganze Klasse nur für Setup und Teardown zu schreiben ist ziemlich aufwändig. Das Modul contextlib stellt den Decorator @contextmanager zur Verfügung, um einen Generator in einen Context Manager umzuwandeln:
from contextlib import contextmanager
class PaymentService:
def connect(self):
print("Zahlungs-Gateway verbunden")
def charge(self, customer: str, amount: int):
print(f"Berechne {amount} für {customer}")
def disconnect(self):
print("Verbindung getrennt")
@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)
Zahlungs-Gateway verbunden
Berechne 300000 für Lan
Verbindung getrennt
Wie der Decorator den Generator zum Context-Manager-Protokoll abbildet:
- Code vor
yieldentspricht__enter__. - Der Wert, der mit
yieldzurückgegeben wird, ist der Wert nachas. - Code nach
yieldentspricht__exit__. Wenn derwith-Block eine Exception wirft, wird diese an der Stelle vonyieldweitergegeben.
Da die Exception direkt bei yield erscheint, ist ein try/finally um yield erforderlich. Ohne ihn wird disconnect() bei Fehlern nicht ausgeführt.
Um verschiedene Fehlertypen unterschiedlich zu behandeln, kannst du zusätzlich except verwenden:
@contextmanager
def safe_charge(order_id: int):
try:
yield
except ConnectionError as e:
print(f"Bestellung {order_id}: Netzwerkfehler, wird später wiederholt ({e})")
with safe_charge(42):
raise ConnectionError("timeout")
print("Verarbeitung läuft weiter")
Bestellung 42: Netzwerkfehler, wird später wiederholt (timeout)
Verarbeitung läuft weiter
Der Generator fängt den Fehler ab, wirft ihn aber nicht weiter, also wird die Exception unterdrückt. Das entspricht der __exit__-Methode, die True zurückgibt.
5. Praktische Tools aus contextlib
5.1. suppress: Eine bestimmte Exception ignorieren
import os
from contextlib import suppress
with suppress(FileNotFoundError):
os.remove("cache_orders.tmp")
print("fertig")
fertig
Das ist kürzer und aussagekräftiger als try: ... except FileNotFoundError: pass.
5.2. ExitStack: Ressourcen mit unbekannter Anzahl
Angenommen, du möchtest mehrere Rechnungsdateien zusammenführen, aber die Anzahl ist erst zur Laufzeit bekannt:
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"rechnung_{i}.txt").write_text(f"Rechnung {i}\n", encoding="utf-8")
merge_invoices([Path(f"rechnung_{i}.txt") for i in range(3)], Path("all.txt"))
print(Path("all.txt").read_text(encoding="utf-8"))
Rechnung 0
Rechnung 1
Rechnung 2
enter_context ruft __enter__ auf und merkt sich das entsprechende __exit__. Wenn ExitStack beendet wird, werden die Dateien in umgekehrter Reihenfolge geschlossen. Falls die dritte Datei nicht geöffnet werden kann, werden die ersten beiden trotzdem korrekt geschlossen.
5.3. closing und nullcontext
closing(obj)wandelt ein Objekt mit nur einerclose()-Methode in einen Context Manager um.nullcontext(value)ist ein Context Manager, der nichts tut. Das ist nützlich, wenn ein Parameter ein Context Manager sein kann oder nicht:
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. Mehrere Context Manager in einer with-Anweisung
Du kannst mehrere Context Manager in einer einzigen with-Anweisung deklarieren. Ab Python 3.10 ermöglicht die Syntax mit Klammern ein sauberes Zeilenumbruch:
with (
open("orders.csv", encoding="utf-8") as src,
open("orders_backup.csv", "w", encoding="utf-8") as dst,
):
dst.write(src.read())
Das ist gleichbedeutend mit zwei verschachtelten with-Blöcken. src wird zuerst geöffnet und zuletzt geschlossen.
7. Asynchrone Context Manager
Mit asyncio-Code ist das entsprechende Protokoll __aenter__ und __aexit__, das mit async with verwendet wird. Die Generator-Version davon ist @asynccontextmanager:
import asyncio
from contextlib import asynccontextmanager
@asynccontextmanager
async def order_queue(name: str):
print(f"Queue {name} verbunden")
await asyncio.sleep(0.01)
try:
yield [f"order-{i}" for i in range(3)]
finally:
await asyncio.sleep(0.01)
print(f"Queue {name} geschlossen")
async def main():
async with order_queue("payments") as messages:
for m in messages:
print("verarbeite", m)
asyncio.run(main())
Queue payments verbunden
verarbeite order-0
verarbeite order-1
verarbeite order-2
Queue payments geschlossen
Warnung: Die Verwendung von
with(stattasync with) mit einem asynchronen Context Manager führt zuTypeError(oderAttributeErrorin älteren Python-Versionen). Umgekehrt genauso. Die beiden Protokolle sind getrennt.
8. Context Manager testen
Context Manager sollten sowohl den erfolgreichen Pfad als auch den Fehlerpfad getestet werden. Installiere zuerst pytest:
pip install pytest
Speichere die Klasse Transaction aus Abschnitt 3.2 in transaction.py, dann schreibe die 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="Guthaben"):
with Transaction(ledger) as tx:
tx.append({"order_id": 2, "amount": 999})
raise ValueError("Guthaben reicht nicht aus")
assert ledger == []
pytest -q
.. [100%]
2 passed in 0.01s
Der zweite Test prüft zwei Dinge gleichzeitig: Die Daten werden zurückgerollt, und die Exception wird nicht unterdrückt. pytest.raises selbst ist auch ein Context Manager – ein bekanntes Beispiel genau dieses Themas.
9. Häufige Fehler und wann man es nicht verwenden sollte
try/finally um yield vergessen. Das ist der häufigste Fehler bei @contextmanager:
@contextmanager
def broken_session(service):
service.connect()
yield service
service.disconnect() # läuft nicht, wenn with eine Exception wirft
Ressource nach dem with verwenden. Die Variable existiert noch, aber die Ressource ist freigegeben:
with open("orders.csv", encoding="utf-8") as f:
pass
f.read()
ValueError: I/O operation on closed file.
Mehrfaches yield im Generator. Ein Generator mit @contextmanager darf genau einmal yield. Bei mehrfachen Yields wirft Python RuntimeError: generator didn't stop.
Context Manager von @contextmanager wiederverwenden. Dieses Objekt kann nur einmal verwendet werden. Ein zweites with auf demselben Objekt schlägt fehl. Jedes Mal, wenn du es brauchst, rufe die Funktion erneut auf, um ein neues Objekt zu erzeugen.
Übergebrauch. Context Manager sollten nicht in diesen Fällen verwendet werden:
- Ressourcen müssen länger als ein Code-Block leben, z.B. ein Connection Pool über die gesamte Lebensdauer der Anwendung. Verwalte sie dann im Startup und Shutdown.
- Die Logik hat kein klares “öffnen/schließen” Paar. Eine gewöhnliche Funktion ist dann lesbarer.
- Du möchtest alle Exceptions mit
suppress(Exception)ignorieren. Das versteckt Bugs genauso wieexcept: pass.
Warnung: Verlasse dich nicht auf Garbage Collection oder
__del__zum Schließen von Ressourcen. Wann diese ausgeführt werden, ist nicht garantiert, besonders auf Implementierungen wie PyPy. Verwendewith.
10. Fazit
Context Manager kapseln Setup und Teardown an einer Stelle ein und garantieren, dass Teardown auch bei Exceptions läuft. Du hast gesehen:
- Das
__enter__und__exit__Protokoll, und was der Rückgabewert von__exit__bedeutet. - Wie man mit
@contextmanagerkürzer schreibt, mittry/finallyumyieldals Erfordernis. - Praktische Tools wie
suppress,ExitStack,closing,nullcontext, und die asynchrone Variante. - Wie man Context Manager mit pytest testet – sowohl Commit- als auch Rollback-Pfad.
Wenn dein Code ein “nimm-dann-gib-zurück” Paar hat (Datei, Lock, Transaction, Verbindung), solltest du überlegen, es in einen Context Manager zu packen.