Cuando escribes with open("file.txt") as f:, Python no está ejecutando magia interna reservada para el intérprete. Está llamando a dos métodos ordinarios del modelo de datos: __enter__ y __exit__. Cualquier objeto que los implemente se convierte en un context manager completo, con exactamente las mismas garantías que open, threading.Lock o cualquier otra pieza de la biblioteca estándar.
El mecanismo es directo: al entrar en el bloque with, Python llama a obj.__enter__() y asigna su valor de retorno a la variable que aparece después de as. Nota que lo que se asigna no es el objeto en sí, sino lo que __enter__ devuelva explícitamente. A veces es self, a veces es algo distinto, como cuando open() devuelve el file object desde __enter__ en lugar del propio file wrapper.
Al salir del bloque —ya sea por ejecución normal, por return, break, o por una excepción— Python llama siempre a __exit__(self, exc_type, exc_val, exc_tb). Si la salida fue limpia, los tres argumentos llegan como None. Si hubo una excepción, llevan su tipo, valor y traceback respectivamente. Aquí viene la parte con más consecuencias: si __exit__ retorna un valor truthy, Python considera la excepción suprimida y continúa la ejecución normalmente. Si retorna None o False, la excepción se propaga. Esto no es un detalle menor —un return True accidental en __exit__ puede tragarse excepciones reales y hacer que el código falle en silencio.
Este protocolo es funcionalmente equivalente a envolver todo el bloque en un try/finally, pero con la ventaja de que la lógica de limpieza vive encapsulada en el objeto que gestiona el recurso, no dispersa en el código del llamador. Es el patrón correcto para locks, conexiones de red, transacciones de base de datos, o cualquier recurso que necesite liberarse aunque el código intermedio explote.
import time
import logging
from typing import Optional
logging.basicConfig(level=logging.INFO, format="%(message)s")
logger = logging.getLogger(__name__)
class Timer:
"""Mide el tiempo de ejecución de un bloque de código."""
def __init__(self, label: str) -> None:
self.label = label
self.elapsed: float = 0.0
self._start: Optional[float] = None
def __enter__(self) -> "Timer":
self._start = time.perf_counter()
return self # esto es lo que recibe la cláusula `as`
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
self.elapsed = time.perf_counter() - self._start # type: ignore[operator]
status = "ERROR" if exc_type is not None else "OK"
logger.info("[%s] %.4fs — %s", self.label, self.elapsed, status)
return False # no suprimimos excepciones: deben propagarse
class ManagedTransaction:
"""Simula una transacción de base de datos con commit/rollback automático."""
def __init__(self, connection) -> None:
self._conn = connection
self._committed = False
def __enter__(self) -> "ManagedTransaction":
self._conn.begin()
return self
def commit(self) -> None:
self._conn.commit()
self._committed = True
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
if exc_type is not None:
# Hubo una excepción: deshacemos los cambios
self._conn.rollback()
logger.info("Transacción revertida por %s", exc_type.__name__)
elif not self._committed:
# El bloque terminó limpio pero nadie llamó a commit: rollback defensivo
self._conn.rollback()
logger.info("Transacción revertida: commit nunca fue llamado")
return False # dejamos que cualquier excepción siga subiendo
# ── Uso ──────────────────────────────────────────────────────────────────
class FakeConnection:
def begin(self): logger.info("BEGIN")
def commit(self): logger.info("COMMIT")
def rollback(self): logger.info("ROLLBACK")
def process_data(items: list[int]) -> list[int]:
with Timer("process_data") as t:
result = [x * 2 for x in items if x > 0]
# t.elapsed ya está disponible aquí porque __exit__ se ejecutó antes de continuar
logger.info("Tiempo disponible fuera del bloque: %.4fs", t.elapsed)
return result
def transfer_funds(conn: FakeConnection, amount: float) -> None:
with ManagedTransaction(conn) as tx:
if amount <= 0:
raise ValueError(f"Monto inválido: {amount}")
logger.info("Transfiriendo %.2f", amount)
tx.commit()
if __name__ == "__main__":
process_data([3, -1, 7, 0, 2])
conn = FakeConnection()
logger.info("--- Transferencia válida ---")
transfer_funds(conn, 100.0)
logger.info("--- Transferencia inválida ---")
try:
transfer_funds(conn, -50.0)
except ValueError as e:
logger.info("Capturado: %s", e)
Desglose del protocolo en acción
En Timer.__enter__, registramos el tiempo de inicio y devolvemos self. Esa decisión —devolver self en lugar de None— es lo que hace útil la cláusula as t: el llamador obtiene una referencia al mismo Timer y puede leer t.elapsed después de que el bloque termine. Si devolvieras None, as t funcionaría sintácticamente pero t sería None y no podrías leer nada.
En Timer.__exit__, calculamos el tiempo transcurrido antes de cualquier otra cosa. Incluso si el bloque lanzó una excepción, queremos el tiempo real. Luego retornamos False explícitamente. Podría omitirse —Python trata None igual que False aquí— pero ser explícito elimina ambigüedad y deja clara la intención a quien lea el código después.
ManagedTransaction ilustra el patrón más común en producción: __enter__ establece el recurso, __exit__ aplica lógica condicional dependiendo de si exc_type es None o no. El rollback defensivo cuando not self._committed cubre el caso donde el bloque termina sin error pero sin commit explícito —una fuente clásica de pérdida de datos silenciosa en código de base de datos.
La secuencia garantizada es: __enter__ → cuerpo del bloque → __exit__ (siempre). Esto es lo que da al protocolo su equivalencia con try/finally. Incluso si el proceso recibe una señal o hay un sys.exit(), Python intentará ejecutar __exit__ antes de terminar, salvo un SIGKILL o un crash del intérprete.
Errores que debes conocer
Error: Retornar True desde __exit__ inadvertidamente supprime excepciones reales, haciendo que errores críticos pasen desapercibidos.
# ❌ Wrong
def __exit__(self, exc_type, exc_val, exc_tb):
self._release()
return True # suprime CUALQUIER excepción, incluyendo errores inesperados
# ✅ Right
def __exit__(self, exc_type, exc_val, exc_tb):
self._release()
return False # las excepciones siguen propagándose normalmente
Solo retorna True cuando tienes la intención explícita de suprimir excepciones de un tipo concreto; en ese caso, comprueba exc_type antes de decidir.
Error: Asumir que as x recibe el objeto, cuando recibe el valor de retorno de __enter__.
# ❌ Wrong
class Resource:
def __enter__(self):
self.connect()
# sin return explícito → retorna None
with Resource() as r:
r.read() # AttributeError: 'NoneType' object has no attribute 'read'
# ✅ Right
class Resource:
def __enter__(self):
self.connect()
return self # o el objeto interno que el llamador necesita usar
__enter__ debe retornar explícitamente lo que el código dentro del bloque va a necesitar; el valor por defecto None casi nunca es lo correcto.
Error: Olvidar que __exit__ recibe tres argumentos además de self, causando un TypeError en tiempo de ejecución solo cuando hay una excepción.
# ❌ Wrong
def __exit__(self): # falta exc_type, exc_val, exc_tb
self._cleanup()
# ✅ Right
def __exit__(self, exc_type, exc_val, exc_tb):
self._cleanup()
Python siempre pasa exactamente tres argumentos al salir del bloque; la firma es parte del contrato del protocolo y no es opcional.
N° 91