Esta es la lección de saldar la deuda más antigua del curso. Desde el módulo 6 escribes with ruta.open() as f: aceptando dos promesas: "el archivo se cierra solo, pase lo que pase" y "algún día veremos cómo funciona por dentro". El módulo 7 añadió la pista clave — el try/finally garantiza la limpieza incluso con excepciones — y la lección anterior te enseñó un with dentro de un generador. Hoy se cierra el círculo: el protocolo __enter__/__exit__ que hay detrás de cada with, y con él construiremos administradores de contexto propios para Papyrus, incluida una TransaccionCatalogo que deja el catálogo a salvo si una venta se rompe a medias.

Contenido

  1. Qué hace realmente el with
  2. El protocolo: __enter__ y __exit__
  3. Reimplementar (conceptualmente) el with de archivos
  4. Un administrador propio como clase: TransaccionCatalogo
  5. __exit__ y las excepciones: propagar o suprimir
  6. contextlib.@contextmanager: la versión generador
  7. Otros usos: cronometrar un bloque, cambiar de directorio
  8. Clase vs @contextmanager: tabla de decisión

Qué hace realmente el with

Empecemos por lo que el with no es: no es un bloque mágico "para archivos". Es azúcar sintáctico sobre un patrón try/finally que ya sabrías escribir a mano con lo aprendido en 07-02:

# Lo que escribes:
with (BASE / "datos" / "catalogo.json").open(encoding="utf-8") as f:
    datos = f.read()

# Lo que Python ejecuta (equivalente esencial):
f = (BASE / "datos" / "catalogo.json").open(encoding="utf-8")
f_ctx = f.__enter__()          # preparar el contexto; su retorno va al "as"
try:
    datos = f_ctx.read()
finally:
    f.__exit__(None, None, None)   # limpiar SIEMPRE: con éxito o con excepción

Cualquier objeto que implemente esos dos métodos dunder — y ya sabes de dunders por el módulo 5 — puede usarse con with. Se le llama administrador de contexto (context manager): administra la entrada y la salida de un contexto, garantizando la salida.

El protocolo: __enter__ y __exit__

Método Cuándo se llama Qué hace Qué devuelve
__enter__(self) Al entrar en el with Adquirir/preparar el recurso Lo que quieras ligar al as (a menudo self)
__exit__(self, exc_type, exc_val, exc_tb) Al salir, siempre Liberar/limpiar True → suprime la excepción; False/None → la propaga

Los tres argumentos de __exit__ describen qué pasó dentro del bloque:

  • Si el bloque terminó bien: los tres son None.
  • Si el bloque lanzó: exc_type es la clase de la excepción (p. ej. StockInsuficienteError), exc_val la instancia (con sus atributos titulo, solicitado, disponible de 07-04) y exc_tb el traceback.

Es decir: __exit__ es un finally con superpoderes — no solo se ejecuta siempre, sino que además sabe si hubo excepción y cuál.

Reimplementar el with de archivos

Para fijar el concepto, escribamos nuestro propio "open" conceptual (el de verdad está en C, pero el protocolo es idéntico):

class Archivo:
    """Reimplementación didáctica del context manager de open()."""

    def __init__(self, ruta, modo="r", encoding="utf-8"):
        self.ruta = ruta
        self.modo = modo
        self.encoding = encoding
        self.f = None

    def __enter__(self):
        print(f"[enter] abriendo {self.ruta}")
        self.f = open(self.ruta, self.modo, encoding=self.encoding)
        return self.f                       # esto es lo que recibe el "as f"

    def __exit__(self, exc_type, exc_val, exc_tb):
        print(f"[exit] cerrando {self.ruta} (excepción: {exc_type})")
        self.f.close()                      # pase lo que pase
        return False                        # no suprimimos nada: que se propague

with Archivo(BASE / "datos" / "registro_altas.txt") as f:
    print(f.readline())

Si dentro del bloque algo lanza, verás [exit] cerrando ... (excepción: <class '...'>) antes de que la traza llegue a tu pantalla: la limpieza ocurrió, y la excepción siguió su camino hacia las anillas de 07-04. Exactamente las dos promesas del with, ahora sin misterio.

TransaccionCatalogo: el caso real de Papyrus

Ahora un administrador que resuelve un problema de verdad. vender() descuenta stock y luego se registra la venta y se guarda el catálogo. ¿Qué pasa si Ana vende un lote de tres títulos a Marta y el segundo lanza StockInsuficienteError? El primero ya se descontó: el catálogo queda a medias, en un estado que no corresponde a ninguna venta real. Queremos semántica de transacción: o todo, o nada.

import copy
import logging

logger = logging.getLogger(__name__)

class TransaccionCatalogo:
    """Si el bloque falla, restaura el catálogo a su estado inicial y relanza."""

    def __init__(self, catalogo: dict):
        self.catalogo = catalogo
        self.copia = None

    def __enter__(self):
        self.copia = copy.deepcopy(self.catalogo)   # foto del estado inicial
        return self.catalogo                        # se trabaja sobre el original

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is not None:                    # algo se rompió dentro
            self.catalogo.clear()
            self.catalogo.update(self.copia)        # restaurar la foto
            logger.warning("transacción revertida por %s: %s",
                           exc_type.__name__, exc_val)
        return False                                # False → la excepción se propaga

deepcopy (primo profundo del copy que rozamos en M4) fotografía catálogo y libros; restaurar mutando con clear()/update() — en vez de reasignar — mantiene válida cualquier otra referencia al mismo diccionario. Su uso en el mostrador:

from almacen import vender
from errores import ErrorPapyrus

lote = [("Hamlet", 2), ("La Odisea", 9), ("Fausto", 1)]   # La Odisea: solo hay 4

try:
    with TransaccionCatalogo(catalogo) as cat:
        for titulo, unidades in lote:
            vender(cat, titulo, unidades)
except ErrorPapyrus as e:
    print(f"Venta cancelada, el catálogo está intacto: {e}")

Secuencia: se venden 2 Hamlet (stock 6 → 4)... y La Odisea con 9 unidades pedidas y 4 disponibles lanza StockInsuficienteError. __exit__ la ve llegar, restaura la foto — Hamlet vuelve a 6 — deja constancia a WARNING en papyrus.log, y devuelve False: la excepción sigue subiendo hasta el except ErrorPapyrus del mostrador, que informa con la calma de 07-04. Nadie perdió datos; nadie silenció nada.

Propagar o suprimir: el retorno de __exit__

El valor devuelto por __exit__ es un interruptor delicado:

  • False (o None, que es lo que devuelve un método sin return): la excepción se propaga. Es lo correcto en el 95 % de los casos — el administrador limpia, no decide.
  • True: la excepción se suprime, como si el bloque hubiera ido bien. Solo tiene sentido cuando suprimir es el servicio que ofreces (p. ej. contextlib.suppress(FileNotFoundError), que la biblioteca estándar ya trae hecho).

Un return True descuidado es el except: pass de 07-04 disfrazado de elegancia. Si dudas, False.

contextlib.@contextmanager: la versión generador

Escribir una clase entera para "haz algo antes, haz algo después" puede ser mucha ceremonia. contextlib ofrece un atajo que reúne las dos lecciones anteriores — un decorador aplicado a un generador:

from contextlib import contextmanager

@contextmanager
def transaccion_catalogo(catalogo: dict):
    copia = copy.deepcopy(catalogo)      # ── esto hace de __enter__
    try:
        yield catalogo                   # ── aquí se ejecuta el bloque del with
    except Exception:
        catalogo.clear()
        catalogo.update(copia)           # ── esto hace de __exit__ con excepción
        raise                            # relanzar = "return False" de la clase

La correspondencia exacta:

  • Todo lo anterior al yield es el __enter__.
  • El valor del yield es lo que recibe el as.
  • El generador queda pausado en el yield (tal cual lo viste en 08-03) mientras se ejecuta el cuerpo del with.
  • Si el bloque lanza, la excepción aparece en el punto del yield — por eso se envuelve en try/except (o try/finally si solo hay que limpiar).
  • Reanudar y terminar sin más equivale a propagar normalidad; raise propaga la excepción; atraparla sin relanzar equivale al return True.
with transaccion_catalogo(catalogo) as cat:
    vender(cat, "Hamlet", 2)

El mismo comportamiento que la clase, en un tercio de líneas.

Otros usos: no solo recursos

Cualquier patrón "preparar / deshacer" encaja. Dos ejemplos útiles en Papyrus:

import time
from contextlib import contextmanager

@contextmanager
def cronometro(etiqueta: str):
    """El primo de bloque del @cronometrar de 08-02: mide un TROZO de código."""
    inicio = time.perf_counter()
    try:
        yield
    finally:
        logger.info("%s: %.3f s", etiqueta, time.perf_counter() - inicio)

with cronometro("cierre + informe"):
    total = cierre_de_caja(BASE / "datos" / "ventas.csv")
    generar_informe(total)
import os
from pathlib import Path

@contextmanager
def en_directorio(ruta: Path):
    """Cambia el cwd temporalmente y SIEMPRE vuelve (útil en informes/)."""
    anterior = Path.cwd()
    os.chdir(ruta)
    try:
        yield ruta
    finally:
        os.chdir(anterior)    # aunque generar el informe explote

Fíjate en que cronometro hace con un bloque lo que @cronometrar hacía con una función: decoradores y context managers son las dos caras de "envolver código".

Clase vs @contextmanager

Criterio Clase (__enter__/__exit__) @contextmanager
Líneas de código Más (ceremonia de clase) Menos (un generador)
Estado complejo o métodos extra Natural (atributos, métodos) Incómodo
Reutilizable/configurable con herencia No aplica
Distinguir tipos de excepción en la salida exc_type explícito except TipoX: dentro del generador
Legibilidad para casos simples Correcta Excelente
Ejemplo idóneo TransaccionCatalogo con opciones cronometro, en_directorio

Regla práctica: empieza con @contextmanager; pásate a la clase cuando necesites estado, configuración o una jerarquía.

Errores Comunes y Consejos

  • Olvidar el try alrededor del yield en un @contextmanager: si el bloque lanza, tu código de limpieza no se ejecuta — el generador muere en el yield. El try/finally (o try/except + raise) no es opcional: es el equivalente del __exit__.
  • return True (o atrapar sin relanzar) por accidente: suprime excepciones que el mostrador esperaba. Síntoma típico: "la venta falló pero no saltó ningún error". Revisa el retorno de __exit__ antes que nada.
  • Hacer trabajo pesado en __init__ en vez de __enter__: TransaccionCatalogo(catalogo) sin with no debería fotografiar nada todavía. __init__ guarda parámetros; __enter__ adquiere.
  • Copiar superficialmente cuando hay objetos anidados: dict(catalogo) copia el diccionario pero no los Libro; la reversión dejaría stocks corruptos. Para transacciones, copy.deepcopy.
  • Consejo: si escribes try/finally alrededor de un recurso más de una vez, es la señal de que ahí vive un context manager con nombre propio.
  • Consejo: se pueden encadenar en un solo with: with transaccion_catalogo(cat) as c, cronometro("venta"): — se salen en orden inverso al que se entró.

Ejercicios

  1. Escribe como clase el administrador RegistroBloque(nombre) que al entrar escriba en el log INICIO <nombre> y al salir FIN <nombre> (ok) o FIN <nombre> (error: <ExcTipo>) según haya habido excepción, propagándola siempre. Pruébalo envolviendo una venta que lance StockInsuficienteError.
  2. Reescribe RegistroBloque con @contextmanager (llámalo registro_bloque). ¿Qué construcción sustituye a la comprobación if exc_type is not None?
  3. Escribe copia_de_seguridad(ruta) con @contextmanager: antes del bloque copia el archivo a copias/<nombre>.bak (usa shutil.copy2, de M6); si el bloque falla, restaura el .bak sobre el original y relanza; si va bien, no hace nada más. Úsalo alrededor de guardar_catalogo.

Soluciones

  1. class RegistroBloque:
        def __init__(self, nombre: str):
            self.nombre = nombre
    
        def __enter__(self):
            logger.info("INICIO %s", self.nombre)
            return self
    
        def __exit__(self, exc_type, exc_val, exc_tb):
            if exc_type is None:
                logger.info("FIN %s (ok)", self.nombre)
            else:
                logger.error("FIN %s (error: %s)", self.nombre, exc_type.__name__)
            return False    # propagar siempre
    
    with RegistroBloque("venta lote Marta"):
        vender(catalogo, "La Odisea", 9)    # lanza StockInsuficienteError
    

    En papyrus.log queda INICIO + FIN ... (error: StockInsuficienteError), y la excepción sigue hacia el mostrador.

  2. @contextmanager
    def registro_bloque(nombre: str):
        logger.info("INICIO %s", nombre)
        try:
            yield
            logger.info("FIN %s (ok)", nombre)
        except Exception as e:
            logger.error("FIN %s (error: %s)", nombre, type(e).__name__)
            raise
    

    La comprobación if exc_type is not None se convierte en la estructura try/except: el camino sin excepción sigue tras el yield; el camino con excepción entra por el except — y el raise final preserva la propagación.

  3. import shutil
    from contextlib import contextmanager
    
    @contextmanager
    def copia_de_seguridad(ruta: Path):
        respaldo = BASE / "copias" / (ruta.name + ".bak")
        shutil.copy2(ruta, respaldo)
        try:
            yield ruta
        except Exception:
            shutil.copy2(respaldo, ruta)   # restaurar el original
            raise
    
    with copia_de_seguridad(BASE / "datos" / "catalogo.json") as ruta:
        guardar_catalogo(catalogo, ruta)
    

    Es la TransaccionCatalogo llevada al disco: la misma idea (foto → intentar → restaurar si falla → relanzar) aplicada a un archivo en vez de a un diccionario.

Conclusión

Promesa saldada: el with era un protocolo de dos dunders — __enter__ prepara, __exit__ limpia siempre y decide con su retorno si la excepción se propaga (casi siempre False) — montado sobre el mismo try/finally de 07-02. Papyrus ganó TransaccionCatalogo, que garantiza ventas de todo-o-nada, y las versiones @contextmanager demostraron que decoradores (08-02) y generadores (08-03) no eran temas sueltos: aquí trabajan juntos. Con esto, el código de Papyrus es expresivo y eficiente... pero sigue haciendo una cosa cada vez: si consultar el precio de un libro a una distribuidora tarda dos segundos, consultar a tres tarda seis, con el programa parado esperando. Las dos últimas lecciones del módulo atacan esa espera: primero con hilos y procesos — y una conversación honesta sobre el famoso GIL — y después con asyncio.

Curso de Programación en Python

Módulo 1: Introducción a Python

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados