Context manager y la sentencia with en Python
1. Descripción general
Abrir un archivo, conectarse a una base de datos o mantener un bloqueo requieren un paso de limpieza al final. Este paso debe ejecutarse incluso si el código intermedio lanza una excepción. Python resuelve esto con context manager y la sentencia with.
Este artículo presenta cómo funciona with bajo el capó y cómo escribir tus propios context managers usando clases o contextlib. También aprenderás cómo manejar excepciones en __exit__, cómo hacer pruebas de context managers y los errores comunes al usarlos.
2. El problema: limpiar recursos con try/finally
Veamos cómo leer un archivo sin usar with:
f = open("orders.csv", encoding="utf-8")
try:
header = f.readline()
print(header)
finally:
f.close()
Esta forma es correcta pero verbosa. También es fácil de olvidar. Si colocas open() dentro del try, y open() falla, entonces f no se asignará y el bloque finally lanzará NameError. La sentencia with encapsula todo el patrón anterior:
with open("orders.csv", encoding="utf-8") as f:
header = f.readline()
print(header)
print(f.closed)
order_id,customer,amount
True
Cuando salimos del bloque with, el archivo ha sido cerrado. Esto es cierto tanto si salimos normalmente como si hay una excepción.
3. Protocolo de context manager
Cualquier objeto con dos métodos __enter__ y __exit__ se puede usar con with. Conceptualmente, la sentencia:
with EXPR as target:
BODY
es equivalente al siguiente código:
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)
Hay tres puntos a recordar:
- El valor asignado a
targetes el resultado de__enter__(), no la propiaEXPR. __exit__siempre se llama. Cuando hay una excepción, recibe(exc_type, exc_value, traceback). Cuando no hay error, los tres parámetros sonNone.- Si
__exit__devuelve un valor truthy, la excepción es tragada. De lo contrario, la excepción se lanza nuevamente.
3.1. Escribir un context manager con una clase
El primer ejemplo es un Timer que mide el tiempo de ejecución de un bloque de código:
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("calcular_total_ordenes") as t:
total = sum(range(5_000_000))
print(t.elapsed > 0)
[calcular_total_ordenes] 0.081s
True
Explicación de cada parte:
__enter__registra el momento de inicio y devuelveself. De esta forma,tes el objetoTimer.__exit__calcula el tiempo, imprime en pantalla y devuelveFalse. Las excepciones si las hay no serán atrapadas.- La variable
tsigue siendo utilizable después del bloquewith. La razón es quewithno crea un nuevo alcance.
3.2. Manejar excepciones en __exit__
El context manager es más efectivo cuando necesitas reaccionar diferentemente según si el bloque de código es exitoso o falla. El siguiente ejemplo es una transacción simple en un libro mayor (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)} registros")
else:
print(f"rollback debido a {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("saldo insuficiente")
except ValueError:
pass
print(len(ledger))
commit 2 registros
rollback debido a ValueError: saldo insuficiente
2
En el segundo bloque with, la orden número 3 no se registra en ledger. Dado que __exit__ devuelve False, ValueError sigue siendo lanzada y la persona que llama decide cómo manejarla.
Nota: Solo devuelve
Truedesde__exit__cuando intencionalmente quieres tragarte la excepción. Un método__exit__que accidentalmentereturn selfo devuelve otro objeto truthy hará que el error desaparezca sin dejar rastro alguno.
4. Escribir más compacto con @contextmanager
Escribir una clase completa solo para un paso de setup y teardown es bastante largo. El módulo contextlib proporciona el decorador @contextmanager para convertir un generador en un context manager:
from contextlib import contextmanager
class PaymentService:
def connect(self):
print("abriendo conexión de puerta de pago")
def charge(self, customer: str, amount: int):
print(f"deduciendo {amount} de {customer}")
def disconnect(self):
print("cerrando conexión")
@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)
abriendo conexión de puerta de pago
deduciendo 300000 de Lan
cerrando conexión
Cómo el decorador mapea el generador al protocolo de context manager:
- El código antes de
yieldjuega el rol de__enter__. - El valor
yieldes el valor asignado a la variable después deas. - El código después de
yieldjuega el rol de__exit__. Si el bloquewithlanza una excepción, esa excepción se lanza nuevamente en la posiciónyield.
Debido a que la excepción aparece justo en yield, el bloque try/finally alrededor de yield es obligatorio. Sin él, disconnect() no se ejecutará cuando haya un error.
Para manejar tipos específicos de errores por separado, usas except además:
@contextmanager
def safe_charge(order_id: int):
try:
yield
except ConnectionError as e:
print(f"orden {order_id}: error de red, se reintentará después ({e})")
with safe_charge(42):
raise ConnectionError("timeout")
print("continuando el procesamiento")
orden 42: error de red, se reintentará después (timeout)
continuando el procesamiento
El generador atrapa el error pero no lo lanza nuevamente, por lo que la excepción es tragada. Este resultado es equivalente a que __exit__ devuelva True.
5. Utilidades disponibles en contextlib
5.1. suppress: ignorar un tipo específico de excepción
import os
from contextlib import suppress
with suppress(FileNotFoundError):
os.remove("cache_ordenes.tmp")
print("hecho")
hecho
El fragmento anterior es más corto y expresa la intención más claramente que try: ... except FileNotFoundError: pass.
5.2. ExitStack: número desconocido de recursos
Supongamos que necesitas fusionar múltiples archivos de facturas, pero el número de archivos solo se sabe en tiempo de ejecución:
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"factura_{i}.txt").write_text(f"factura {i}\n", encoding="utf-8")
merge_invoices([Path(f"factura_{i}.txt") for i in range(3)], Path("todas.txt"))
print(Path("todas.txt").read_text(encoding="utf-8"))
factura 0
factura 1
factura 2
enter_context llama a __enter__ y recuerda el __exit__ correspondiente. Cuando ExitStack termina, los archivos se cierran en orden inverso al que se abrieron. Si el tercer archivo no se abre, los dos primeros se cierran correctamente de todas formas.
5.3. closing y nullcontext
closing(obj)convierte un objeto que solo tiene un métodoclose()en un context manager.nullcontext(value)es un context manager que no hace nada. Es útil cuando un parámetro puede ser un context manager o no:
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. Múltiples context managers en una sentencia with
Puedes declarar múltiples context managers en la misma sentencia with. A partir de Python 3.10, la sintaxis con paréntesis permite un salto de línea limpio:
with (
open("ordenes.csv", encoding="utf-8") as src,
open("ordenes_backup.csv", "w", encoding="utf-8") as dst,
):
dst.write(src.read())
Esta forma de escribir es equivalente a dos bloques with anidados. src se abre primero y se cierra último.
7. Async context manager
Con código asyncio, el protocolo correspondiente es __aenter__ y __aexit__, usado con async with. La versión generador de esto es @asynccontextmanager:
import asyncio
from contextlib import asynccontextmanager
@asynccontextmanager
async def order_queue(name: str):
print(f"conectando cola {name}")
await asyncio.sleep(0.01)
try:
yield [f"orden-{i}" for i in range(3)]
finally:
await asyncio.sleep(0.01)
print(f"cerrando cola {name}")
async def main():
async with order_queue("pagos") as messages:
for m in messages:
print("procesando", m)
asyncio.run(main())
conectando cola pagos
procesando orden-0
procesando orden-1
procesando orden-2
cerrando cola pagos
Nota: Usar
withnormal con un async context manager causará un errorTypeError(oAttributeErroren versiones antiguas de Python). Lo opuesto también es cierto. Estos dos protocolos están separados entre sí.
8. Probar context managers
Los context managers deben ser probados tanto en la rama de éxito como en la de error. Primero instala pytest:
pip install pytest
Coloca la clase Transaction de la sección 3.2 en un archivo transaction.py, luego escribe la prueba:
# 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="saldo"):
with Transaction(ledger) as tx:
tx.append({"order_id": 2, "amount": 999})
raise ValueError("saldo insuficiente")
assert ledger == []
pytest -q
.. [100%]
2 passed in 0.01s
El segundo test verifica dos cosas a la vez: que los datos se hayan hecho rollback y que la excepción no sea tragada. El propio pytest.raises también es un context manager, un ejemplo familiar del mismo tema de este artículo.
9. Errores comunes y cuándo no usar
Olvidar try/finally alrededor de yield. Este es el error más común con @contextmanager:
@contextmanager
def broken_session(service):
service.connect()
yield service
service.disconnect() # no se ejecuta si el bloque with lanza una excepción
Usar un recurso después de que with termine. La variable aún existe, pero el recurso ha sido liberado:
with open("ordenes.csv", encoding="utf-8") as f:
pass
f.read()
ValueError: I/O operation on closed file.
yield múltiples veces en el generador. El generador usado con @contextmanager debe yield exactamente una vez. Si yield más de una vez, Python lanza RuntimeError: generator didn't stop.
Reutilizar un context manager creado a partir de @contextmanager. Este objeto solo se puede usar una vez. Llamar a with una segunda vez en el mismo objeto fallará. Cada vez que lo uses, llama a la función nuevamente para crear un nuevo objeto.
Abuso. No debes usar context managers en estos casos:
- Los recursos necesitan vivir más tiempo que un bloque de código, por ejemplo, un connection pool que existe durante todo el ciclo de vida de la aplicación. En ese caso, adminístralo en la capa de startup y shutdown de la aplicación.
- La lógica no tiene un par claro de ‘abrir/cerrar’. Una función normal será más fácil de leer.
- Quieres ignorar todas las excepciones usando
suppress(Exception). Esta forma oculta bugs igual queexcept: pass.
Nota: No confíes en el garbage collector o en
__del__para cerrar recursos. El momento en que se ejecutan no está garantizado, especialmente en implementaciones como PyPy. Usawith.
10. Conclusión
Los context managers encapsulan setup y teardown en un lugar y garantizan que teardown siempre se ejecute incluso cuando hay una excepción. Ya has visto:
- El protocolo
__enter__y__exit__, junto con el significado del valor devuelto por__exit__. - Cómo escribir de forma compacta usando
@contextmanager, contry/finallyalrededor deyieldsiendo obligatorio. - Las utilidades
suppress,ExitStack,closing,nullcontext, junto con la versión async. - Cómo probar tanto la rama de commit como la rama de rollback usando pytest.
Cuando tu código tenga un par de ‘tomar y devolver’ (archivo, bloqueo, transacción, conexión), considera envolverlo en un context manager.