Historia de la pregunta
Antes de que Python 2.5 introdujera la declaración with a través de PEP 343, la gestión de recursos requería bloques explícitos de try/finally esparcidos por todas partes en las bases de código. Si bien era funcional, este patrón era verboso y propenso a errores en escenarios simples de adquisición y liberación de recursos. El módulo contextlib fue introducido para reducir este código repetitivo permitiendo a los desarrolladores escribir administradores de contexto como funciones generadoras, usando el decorador @contextmanager para transformar generadores con apariencia secuencial en objetos que satisfacen el protocolo de gestión de contexto.
El problema
Una función generadora implementa nativamente el protocolo de iterador (__iter__, __next__), no el protocolo de administrador de contexto (__enter__, __exit__). El desafío fundamental radica en unir estos protocolos distintos: al entrar en un bloque with, el código de configuración antes del yield debe ejecutarse; al salir, el código de limpieza después del yield debe ejecutarse independientemente de las excepciones. Además, las excepciones generadas dentro del bloque with deben ser inyectadas de nuevo en la generadora en el exacto punto de suspensión yield, permitiendo que la propia lógica de manejo de excepciones de la generadora ejecute operaciones de limpieza.
La solución
El decorador envuelve la función generadora en una clase GeneratorContextManager (implementada en C en el moderno CPython). Cada invocación crea un nuevo iterador generador. El método __enter__ llama a next() en este iterador, ejecutando la función hasta la declaración yield, y devuelve el valor arrojado para ser vinculado a la variable as. El método __exit__ recibe detalles de la excepción; si no ocurrió ninguna excepción, llama a next() nuevamente para reanudar y agotar la generadora. Si ocurrió una excepción, llama al método throw() de la generadora, inyectando la excepción en el punto yield suspendido. Esto permite que los bloques except o finally de la generadora manejen la limpieza. Si throw() devuelve normalmente (excepción atrapada), __exit__ devuelve True para suprimir la excepción; de lo contrario, se propaga.
from contextlib import contextmanager @contextmanager def managed_connection(): conn = create_connection() try: print("Conexión establecida") yield conn except NetworkError: conn.rollback() raise finally: conn.close() print("Conexión cerrada") with managed_connection() as c: c.query("SELECT * FROM data")
Descripción del problema: Un servicio de procesamiento de datos de alto rendimiento necesitaba manejar archivos de desbordamiento temporales cuando los búferes en memoria excedían los límites. La implementación heredada duplicaba la lógica de creación y eliminación de archivos en 12 módulos de procesamiento diferentes, lo que llevaba a fugas de descriptores de archivos durante condiciones de error en los casos límites y complicaba el mantenimiento.
Soluciones consideradas:
Bloques manuales de try/finally fueron el enfoque inicial. Cada sitio de uso envolvía operaciones de archivo en try/finally explícitos para asegurar que se llamara a os.unlink(). Esto ofrecía un control de flujo explícito con cero sobrecarga de abstracción, pero resultó ser verboso con ocho líneas por sitio de uso y altamente propenso a errores. A veces, los desarrolladores colocaban la lógica de limpieza en el bloque finally equivocado, y modificar el comportamiento de manera consistente a través de todos los módulos era arduo cuando se añadían requisitos de registro.
Se consideró un administrador de contexto basado en clases como una alternativa reutilizable. Una clase TempSpillFile implementaría __enter__ para crear el archivo y __exit__ para eliminarlo. Si bien era reutilizable y seguía el protocolo estándar, la definición de la clase separaba visualmente la configuración de la limpieza a través de muchas líneas, perjudicando la legibilidad. También requería quince líneas de código repetitivo para lo que conceptualmente era un ciclo de vida de recurso simple, oscureciendo la lógica real.
El enfoque de generador con @contextmanager fue la opción final. Una función generadora temp_spill_file() crearía el archivo, lo arrojaría y utilizaría try/finally para su eliminación. Esto minimizó la duplicación de código y mantuvo la configuración y limpieza adyacentes en el código fuente, aprovechando la sintaxis familiar de manejo de excepciones. Sin embargo, imponía una limitación de uso único y el punto de suspensión yield podría confundir a los desarrolladores que esperaban una ejecución sincrónica.
Solución elegida y resultado: Se seleccionó el enfoque @contextmanager porque minimizó la duplicación de código mientras maximizaba la claridad durante las revisiones de código. La proximidad de la lógica de adquisición y liberación hizo que el ciclo de vida del recurso fuera inmediatamente obvio. La refactorización redujo el código de gestión de recursos de noventa y seis líneas a doce líneas en toda la base de código. El análisis estático confirmó cero fugas de descriptores de archivo durante el trimestre posterior de uso en producción.
¿Cómo maneja GeneratorContextManager las excepciones que ocurren durante la fase de configuración (antes del yield) frente a la fase de limpieza (después del yield)?
Si ocurre una excepción antes del yield en el generador, el generador nunca se suspende; __enter__ propaga esta excepción de inmediato y __exit__ nunca se invoca. Si ocurre una excepción dentro del bloque with (después del yield), el generador se suspende. Luego, __exit__ llama a generator.throw(exc_type, exc_val, exc_tb), lo que reanuda el generador en la línea del yield con la excepción activa. Esto permite que los propios bloques except o finally del generador se ejecuten. Los candidatos a menudo pasan por alto que throw() en realidad reanuda la ejecución y que la excepción se considera que ocurre en la expresión yield desde la perspectiva del generador.
¿Por qué obliga un generador decorado con contextmanager a un único punto de yield, y qué error específico ocurre si se viola esta restricción?
El protocolo del administrador de contexto asume una única entrada y salida. Si el generador arroja una segunda vez, ya sea porque __exit__ llama a next() (sin excepción) y el generador arroja de nuevo en lugar de retornar, o porque se llama a throw() y el generador maneja la excepción y luego arroja de nuevo, el GeneratorContextManager genera RuntimeError con el mensaje "el generador no se detuvo". Esto ocurre porque la máquina de estados espera que el generador esté agotado después de la limpieza. Los candidatos frecuentemente confunden esto con la iteración estándar donde múltiples yield son válidos, sin darse cuenta de que el yield actúa como un límite de suspensión/reanudación para el contexto, no como una secuencia de producción de valores.
¿En qué circunstancias el método __exit__ de un GeneratorContextManager suprime una excepción lanzada en el bloque with, y cómo interactúa esto con el manejo de excepciones del generador?
__exit__ suprime la excepción (devuelve True) solo si la excepción inyectada a través de throw() es capturada dentro del generador y el generador alcanza su fin (lanza StopIteration) sin volver a lanzar la excepción o lanzar una nueva. Si el generador captura la excepción y permite que la llamada a throw() regrese normalmente, __exit__ interpreta esto como un manejo exitoso y devuelve True. Si el generador no captura la excepción, throw() la propaga, y __exit__ devuelve None (falsy), permitiendo que la excepción se propague. Los candidatos a menudo pasan por alto que simplemente tener un try/except dentro del generador no es suficiente; la excepción debe ser capturada específicamente de la llamada a throw() y no volver a lanzarse, y que se requiere un return explícito o caer al final después de atrapar para la supresión.