El protocolo `__enter__` / `__exit__` en detalle

Cuando escribes with open("archivo.txt") as f:, Python no está usando ninguna magia especial del intérprete reservada para objetos del sistema. Está llamando a dos métodos del protocolo de contexto: __enter__ y __exit__. Cualquier objeto que los implemente puede participar en un bloque with, en igualdad de condiciones con los objetos de la biblioteca estándar.

El flujo es exactamente este: with expresion as variable evalúa expresion, llama a expresion.__enter__(), y asigna su valor de retorno a variable. Luego ejecuta el cuerpo del bloque. Al salir —sea por ejecución normal, return, break, o una excepción— Python llama a __exit__ siempre, sin excepciones. Ahí está la garantía que no te da el garbage collector.

¿Por qué no confiar en __del__ para cleanup? Porque el GC de CPython usa conteo de referencias como mecanismo primario, y en otras implementaciones (PyPy, Jython) o con ciclos de referencia, el orden y momento de destrucción es no determinístico. Un descriptor de archivo, un lock, una conexión de base de datos: no puedes permitirte esperar a que el GC decida cuándo liberarlos. __exit__ te da el control de vuelta.

La firma de __exit__ es __exit__(self, exc_type, exc_val, exc_tb). Si el bloque terminó sin problemas, los tres argumentos llegan como None. Si hubo una excepción, exc_type es la clase, exc_val la instancia, y exc_tb el traceback. El valor de retorno de __exit__ importa: si es truthy, Python suprime la excepción y la ejecución continúa normalmente después del bloque with. Si es falsy (incluyendo None implícito), la excepción se re-lanza. Esta distinción es donde se cometen más errores.

import threading
import logging

logging.basicConfig(level=logging.DEBUG, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)


class ManagedLock:
    """Lock con logging de adquisición y liberación, y supresión opcional de errores."""

    def __init__(self, name: str, suppress: type[BaseException] | None = None):
        self._lock = threading.Lock()
        self._name = name
        # Si se proporciona, __exit__ suprimirá excepciones de este tipo.
        self._suppress = suppress

    def __enter__(self) -> "ManagedLock":
        logger.debug("Adquiriendo lock '%s'", self._name)
        self._lock.acquire()
        # Retornamos self para que `as variable` reciba el propio objeto.
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: object | None,
    ) -> bool:
        self._lock.release()  # Cleanup garantizado antes de evaluar la excepción.
        logger.debug("Liberando lock '%s'", self._name)

        if exc_type is not None:
            logger.error(
                "Excepción dentro del bloque: %s: %s", exc_type.__name__, exc_val
            )

        # Retornar True solo si la excepción es del tipo que queremos suprimir.
        if self._suppress is not None and exc_type is not None:
            return issubclass(exc_type, self._suppress)

        # None / False → la excepción se propaga.
        return False


# ── Uso 1: ejecución normal ──────────────────────────────────────────────────
lock_a = ManagedLock("recurso-A")

with lock_a as ctx:
    logger.info("Trabajando con el lock. ctx es %r", ctx)
    result = 40 + 2

logger.info("Resultado: %d", result)

# ── Uso 2: excepción propagada ───────────────────────────────────────────────
lock_b = ManagedLock("recurso-B")

try:
    with lock_b:
        raise ValueError("algo salió mal")
except ValueError:
    logger.info("ValueError propagada correctamente")

# ── Uso 3: excepción suprimida ───────────────────────────────────────────────
lock_c = ManagedLock("recurso-C", suppress=KeyError)

with lock_c:
    raise KeyError("clave ausente")  # __exit__ retorna True → suprimida

logger.info("Ejecución continúa después del bloque con excepción suprimida")

Desglose del protocolo

__enter__ retorna self, no None. Esto es una decisión deliberada: la variable del as recibe lo que __enter__ devuelve, no el objeto sobre el que se llama. open() retorna el propio file object desde __enter__. Aquí retornamos self para que el código dentro del bloque pueda acceder a métodos del ManagedLock si los necesita. Hay casos donde retornar otra cosa tiene sentido —una conexión de base de datos podría retornar un cursor— pero son la minoría.

El orden en __exit__ importa. Fíjate que self._lock.release() ocurre antes de evaluar si hay excepción o si suprimirla. Esto es intencional: el cleanup debe ejecutarse independientemente de lo que decidamos hacer con la excepción. Si hubiéramos puesto el release() al final y alguna lógica de evaluación lanzara su propia excepción, el lock quedaría atrapado.

issubclass en lugar de == o isinstance. Cuando chequeamos si suprimir, usamos issubclass(exc_type, self._suppress) porque queremos que subclases del tipo también queden suprimidas, igual que haría un bloque except KeyError. El uso de isinstance(exc_val, self._suppress) también funciona y es idiomáticamente equivalente aquí, pero trabajar con exc_type directamente es más explícito sobre la intención.

El valor de retorno falsy explícito. El return False al final no es estrictamente necesario —Python asume None si no hay return— pero lo hace legible: quien lee el código entiende que la no-supresión es la decisión activa, no un olvido.

Cleanup determinístico vs. GC. Sin el protocolo de contexto, liberar el lock dependería de que el objeto ManagedLock sea destruido. Con referencias circulares o bajo PyPy, eso podría no ocurrir en el momento que esperas. Con __exit__, la liberación ocurre exactamente al salir del bloque with, en el orden inverso si anidas varios contextos —lo que hace que la composición de recursos sea predecible.

Errores que debes conocer

Error: retornar True incondicionalmente en __exit__, suprimiendo todas las excepciones sin distinción y haciendo que los bugs pasen silenciosamente.

# ❌ Wrong
def __exit__(self, exc_type, exc_val, exc_tb):
    self._lock.release()
    return True  # Suprime TODO, incluso SystemExit y KeyboardInterrupt.

# ✅ Right
def __exit__(self, exc_type, exc_val, exc_tb):
    self._lock.release()
    if self._suppress and exc_type is not None:
        return issubclass(exc_type, self._suppress)
    return False

Solo suprimir lo que explícitamente decides suprimir; cualquier otra cosa debe propagarse.


Error: hacer el cleanup dentro de un if exc_type is None:, pensando que solo aplica cuando no hay error, cuando en realidad el cleanup debe ser incondicional.

# ❌ Wrong
def __exit__(self, exc_type, exc_val, exc_tb):
    if exc_type is None:
        self._lock.release()  # Si hubo excepción, el lock jamás se libera.

# ✅ Right
def __exit__(self, exc_type, exc_val, exc_tb):
    self._lock.release()  # Siempre, pase lo que pase.
    if exc_type is not None:
        logger.error(...)

__exit__ se llama en ambos casos precisamente para que el cleanup sea incondicional; colocar una guarda sobre él deshace la garantía central del protocolo.


Error: asumir que as variable recibe el objeto original cuando __enter__ retorna otra cosa.

# ❌ Wrong
class Connection:
    def __enter__(self):
        return self._cursor  # Retorna el cursor, no la conexión.

with Connection() as conn:
    conn.close()  # AttributeError: cursor no tiene .close() de Connection.

# ✅ Right
with Connection() as cursor:  # Nombrar la variable según lo que __enter__ realmente retorna.
    cursor.execute("SELECT 1")

El nombre de la variable as debe reflejar lo que __enter__ devuelve, que puede diferir del objeto sobre el que se ejecuta el bloque with.

121

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio