Excepciones y Context Managers
Dominando el manejo de errores y la gestión de recursos en Python
"Los mejores planes de ratones y hombres a menudo salen mal" — Robert Burns. Estas famosas líneas deberían estar grabadas en la mente de todo programador. Incluso si nuestro código es correcto, los errores sucederán. Si no los manejamos apropiadamente, pueden causar que nuestros mejores planes fallen.
Los errores no manejados pueden hacer que el software se bloquee o se comporte incorrectamente. Este capítulo trata sobre los errores y cómo lidiar con lo inesperado. Aprenderemos sobre excepciones, la forma en que Python señala que un error u otro evento excepcional ha ocurrido, y sobre context managers, que proporcionan un mecanismo para encapsular y reutilizar código de manejo de errores.
¿Qué son las Excepciones?
Cuando un error es detectado durante la ejecución, se llama excepción. Las excepciones no son necesariamente letales; de hecho, la excepción StopIteration está profundamente integrada en los mecanismos de generadores e iteradores de Python.
Excepciones Comunes
| Excepción | Descripción |
|---|---|
StopIteration |
Iterador agotado |
IndexError |
Índice fuera de rango en lista |
KeyError |
Clave no encontrada en diccionario |
ValueError |
Valor incorrecto para una operación |
TypeError |
Tipo incorrecto para una operación |
ZeroDivisionError |
División por cero |
Ejemplos de Excepciones
# Generador agotado gen = (n for n in range(2)) next(gen) # 0 next(gen) # 1 next(gen) # StopIteration # Variable no definida print(undefined_name) # NameError # Índice fuera de rango mylist = [1, 2, 3] mylist[5] # IndexError # Clave no encontrada mydict = {"a": "A", "b": "B"} mydict["c"] # KeyError # División por cero 1 / 0 # ZeroDivisionError
Traceback pero continúa ejecutando. Un programa normal o script terminaría inmediatamente si no se hace nada para manejar las excepciones.
1 + "one" print("Esta línea nunca será alcanzada")
File "unhandled.py", line 2, in <module>
1 + "one"
TypeError: unsupported operand type(s) for +: 'int' and 'str'
Lanzar Excepciones
Las excepciones que hemos visto hasta ahora fueron lanzadas por el intérprete de Python cuando detectó un error. Sin embargo, también puedes lanzar excepciones tú mismo, cuando ocurre una situación que tu propio código considera un error.
# Lanzar una excepción manualmente raise NotImplementedError("Me temo que no puedo hacer eso") # Resultado: # Traceback (most recent call last): # File "<stdin>", line 1, in <module> # NotImplementedError: Me temo que no puedo hacer eso
Definir Excepciones Personalizadas
Puedes definir tus propias excepciones personalizadas. Solo necesitas definir una clase que herede de cualquier otra clase de excepción. Todas las excepciones derivan de BaseException; sin embargo, tus excepciones personalizadas deben heredar de Exception.
class ValidationError(Exception): """Excepción personalizada para errores de validación""" pass class NotFoundError(Exception): """Excepción para recursos no encontrados""" pass # Uso def validate_age(age): if not isinstance(age, int): raise ValidationError(f"No es un entero: {age}") if age < 0: raise ValidationError(f"Edad negativa: {age}") return True # Test validate_age(-5) # ValidationError: Edad negativa: -5
Entendiendo Tracebacks
El traceback que Python imprime cuando ocurre una excepción no manejada puede parecer intimidante al principio, pero es muy útil para entender qué causó la excepción. Es como un mapa que muestra el camino a través del código hasta donde ocurrió la excepción.
def squareroot(number): if number < 0: raise ValueError("No se permiten números negativos") return number ** 0.5 def quadratic(a, b, c): d = b**2 - 4 * a * c return ( (-b - squareroot(d)) / (2 * a), (-b + squareroot(d)) / (2 * a) ) # x² + 1 = 0 (no tiene solución real) quadratic(1, 0, 1)
File "trace_back.py", line 16, in <module>
quadratic(1, 0, 1)
^^^^^^^^^^^^^^^^^^
File "trace_back.py", line 11, in quadratic
(-b - squareroot(d)) / (2 * a),
^^^^^^^^^^^^^
File "trace_back.py", line 4, in squareroot
raise ValueError("No se permiten números negativos")
ValueError: No se permiten números negativos
Manejando Excepciones
Para manejar una excepción en Python, usas la sentencia try. Cuando entras en la cláusula try, Python vigilará uno o más tipos de excepciones, y si se lanzan, te permite reaccionar.
Componentes de Try/Except
def try_syntax(numerator, denominator): try: print(f"En el bloque try: {numerator}/{denominator}") result = numerator / denominator except ZeroDivisionError as zde: print(f"Error capturado: {zde}") else: print("El resultado es:", result) return result finally: print("Saliendo (siempre se ejecuta)") print(try_syntax(12, 4)) print("---") print(try_syntax(11, 0))
El resultado es: 3.0
Saliendo (siempre se ejecuta)
3.0
---
En el bloque try: 11/0
Error capturado: division by zero
Saliendo (siempre se ejecuta)
None
Capturar Múltiples Excepciones
# Opción 1: Misma lógica para varias excepciones values = (1, 0) try: q, r = divmod(*values) except (ZeroDivisionError, TypeError) as e: print(type(e), e) # Opción 2: Lógica diferente para cada excepción try: q, r = divmod(*values) except ZeroDivisionError: print("¡Intentaste dividir por cero!") except TypeError as e: print(f"Error de tipo: {e}")
except, coloca las excepciones específicas arriba y las genéricas abajo. Solo UN except handler se ejecuta cuando se lanza una excepción.
Raise From y Exception Chaining
class NotFoundError(Exception): pass vowels = {"a": 1, "e": 5, "i": 9, "o": 15, "u": 21} try: pos = vowels["y"] except KeyError as e: # Encadenamiento deliberado de excepciones raise NotFoundError(*e.args) from e # Si quieres suprimir la excepción original: # raise NotFoundError(*e.args) from None
Añadir Notas a Excepciones (Python 3.11+)
def squareroot(number): if number < 0: raise ValueError("No se permiten números negativos") return number ** 0.5 def quadratic(a, b, c): d = b**2 - 4 * a * c try: return ( (-b - squareroot(d)) / (2 * a), (-b + squareroot(d)) / (2 * a), ) except ValueError as e: e.add_note(f"No se puede resolver {a}x² + {b}x + {c} = 0") raise quadratic(1, 0, 1)
No se puede resolver 1x² + 0x + 1 = 0
Exception Groups (Python 3.11+)
Cuando trabajas con grandes colecciones de datos, puede ser inconveniente detener inmediatamente y lanzar una excepción cuando ocurre un error. A menudo es mejor procesar todos los datos y reportar todos los errores al final.
def validate_age(age): if not isinstance(age, int): raise TypeError(f"No es un entero: {age}") if age < 0: raise ValueError(f"Edad negativa: {age}") def validate_ages(ages): errors = [] for age in ages: try: validate_age(age) except Exception as e: errors.append(e) if errors: raise ExceptionGroup("Errores de validación", errors) # Uso validate_ages([24, -5, "ninety", 30, None])
Manejando con except*
PEP 654 introdujo una nueva variante de try/except que permite manejar sub-excepciones anidadas de tipos particulares dentro de un ExceptionGroup.
try: validate_ages([24, -5, "ninety", 30, None]) except* TypeError as e: print("Tipos inválidos:") print(e.exceptions) except* ValueError as e: print("Valores inválidos:") print(e.exceptions)
(TypeError('No es un entero: ninety'), TypeError('No es un entero: None'))
Valores inválidos:
(ValueError('Edad negativa: -5'),)
try/except normal donde solo se ejecuta UN except clause, con except* cada cláusula que coincida se ejecuta hasta que no queden excepciones sin manejar.
Excepciones: No Solo para Errores
Las excepciones pueden usarse para más que solo errores. Aquí hay un ejemplo elegante de cómo usar excepciones para salir de bucles anidados:
# Forma tradicional (inelegante) n = 100 found = False for a in range(n): if found: break for b in range(n): if found: break for c in range(n): if 42 * a + 17 * b + c == 5096: found = True print(a, b, c) break
# Forma elegante usando excepciones class ExitLoopException(Exception): pass try: n = 100 for a in range(n): for b in range(n): for c in range(n): if 42 * a + 17 * b + c == 5096: raise ExitLoopException(a, b, c) except ExitLoopException as ele: print(ele.args) # (79, 99, 95)
Context Managers
Cuando trabajamos con recursos externos, usualmente necesitamos realizar algunos pasos de limpieza cuando terminamos. Por ejemplo, después de escribir datos en un archivo, necesitamos cerrar el archivo. Los context managers resuelven este problema creando un contexto de ejecución en el cual podemos trabajar con un recurso y realizar automáticamente cualquier limpieza necesaria cuando dejamos ese contexto.
Problema: Sin Context Manager
from decimal import Context, Decimal, getcontext, setcontext one = Decimal("1") three = Decimal("3") # Guardar contexto original orig_ctx = getcontext() ctx = Context(prec=5) setcontext(ctx) # ¿Qué pasa si hay una excepción aquí? print(one / three) # 0.33333 # Restaurar contexto setcontext(orig_ctx) print(one / three) # 0.3333333333333333333333333333
Solución: Con Context Manager
from decimal import Context, Decimal, localcontext one = Decimal("1") three = Decimal("3") with localcontext(Context(prec=5)) as ctx: print(one / three) # 0.33333 # Contexto restaurado automáticamente print(one / three) # 0.3333333333333333333333333333
Múltiples Context Managers
from decimal import Context, Decimal, localcontext one = Decimal("1") three = Decimal("3") # Combinar múltiples context managers (Python 3.10+) with ( localcontext(Context(prec=5)), open("output.txt", "w") as out_file ): out_file.write(f"{one} / {three} = {one / three}\n") # Al salir: archivo cerrado Y contexto decimal restaurado
SyntaxError.
Crear tus Propios Context Managers
Los context managers funcionan a través de dos métodos mágicos: __enter__() se llama justo antes de entrar al cuerpo de la sentencia with y __exit__() se llama al salir del cuerpo de la sentencia with.
Context Manager Basado en Clase
class MyContextManager: def __init__(self): print("MyContextManager init", id(self)) def __enter__(self): print("Entrando al contexto 'with'") return self def __exit__(self, exc_type, exc_val, exc_tb): print(f"{exc_type=} {exc_val=} {exc_tb=}") print("Saliendo del contexto 'with'") return True # Suprime excepciones # Uso with MyContextManager() as mgr: print("Dentro del contexto 'with'") raise Exception("Excepción dentro del 'with'") print("Esta línea nunca se alcanzará") print("Después del contexto 'with'")
Entrando al contexto 'with'
Dentro del contexto 'with'
exc_type=<class 'Exception'> exc_val=Exception("Excepción dentro del 'with'") exc_tb=<traceback object>
Saliendo del contexto 'with'
Después del contexto 'with'
__exit__() retorna True, cualquier excepción lanzada dentro del bloque with será suprimida. Si retorna False, la excepción continuará propagándose.
Context Manager Basado en Generador
El decorador contextmanager del módulo contextlib toma una función generadora y la convierte en un context manager.
from contextlib import contextmanager @contextmanager def my_context_manager(): print("Entrando al contexto 'with'") val = object() print(id(val)) try: yield val except Exception as e: print(f"{type(e)=} {e=}") finally: print("Saliendo del contexto 'with'") print("A punto de entrar al contexto 'with'") with my_context_manager() as val: print("Dentro del contexto 'with'") print(id(val)) raise Exception("Excepción dentro del 'with'") print("Después del contexto 'with'")
Entrando al contexto 'with'
139768531985040
Dentro del contexto 'with'
139768531985040
type(e)=<class 'Exception'> e=Exception("Excepción dentro del 'with'")
Saliendo del contexto 'with'
Después del contexto 'with'
Ejemplo Práctico: Timer Context Manager
Aquí hay un ejemplo práctico de un context manager que mide el tiempo de ejecución de un bloque de código:
import time from contextlib import contextmanager @contextmanager def timer(label: str = "Bloque"): """Context manager para medir tiempo de ejecución""" start = time.perf_counter() try: yield finally: elapsed = time.perf_counter() - start print(f"[{label}] Tiempo: {elapsed:.4f}s") # Uso with timer("Procesamiento de datos"): # Simular trabajo total = sum(range(1_000_000)) with timer("Operación costosa"): time.sleep(0.5)
[Operación costosa] Tiempo: 0.5012s
Context Manager para Manejo de Archivos
from contextlib import contextmanager @contextmanager def managed_file(filename, mode="r"): """Context manager para manejo seguro de archivos""" try: f = open(filename, mode) yield f except FileNotFoundError: print(f"Error: Archivo '{filename}' no encontrado") yield None except PermissionError: print(f"Error: Sin permiso para '{filename}'") yield None finally: if 'f' in locals() and f: f.close() print(f"Archivo '{filename}' cerrado correctamente") # Uso with managed_file("data.txt", "w") as f: if f: f.write("Hola, mundo!")
Resumen de Conceptos
01 // Excepciones
Señales de que algo inesperado ha ocurrido. Pueden ser manejadas con try/except/else/finally.
02 // Raise
Lanza excepciones manualmente cuando tu código detecta una condición de error.
03 // Tracebacks
Mapas que muestran el camino del código hasta donde ocurrió la excepción. Léelos de abajo hacia arriba.
04 // Exception Groups
Contenedores para múltiples excepciones (Python 3.11+). Manéjalas con except*.
05 // Context Managers
Encapsulan setup y cleanup de recursos. Usan __enter__() y __exit__().
06 // @contextmanager
Decorador que convierte funciones generadoras en context managers.
Guías de Programación
except: sin especificar tipo de excepción. Esto capturará incluso excepciones del sistema como SystemExit y KeyboardInterrupt.