lunes, 19 de enero de 2026

Aprendiendo Python de 0 a experto - Manejo de Excepciones y Contextos

Excepciones y Context Managers en Python
Python // Chapter 07

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.

MÓDULO_01

¿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

first_examples.py
# 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
El shell de Python es bastante permisivo: muestra el Traceback pero continúa ejecutando. Un programa normal o script terminaría inmediatamente si no se hace nada para manejar las excepciones.
unhandled.py
1 + "one"
print("Esta línea nunca será alcanzada")
python unhandled.py
Traceback (most recent call last):
File "unhandled.py", line 2, in <module>
1 + "one"
TypeError: unsupported operand type(s) for +: 'int' and 'str'
MÓDULO_02

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.

Sintaxis de Raise
raise
ExceptionType
("mensaje")
raising.py
# 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.

Jerarquía de Excepciones
BaseException
Exception
SystemExit
KeyboardInterrupt
ValueError
TypeError
CustomException
custom_exception.py
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
Definir excepciones personalizadas es una práctica común al escribir librerías, ya que ayuda a proteger a los usuarios de los detalles de implementación.
MÓDULO_03

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.

trace_back.py
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)
python trace_back.py
Traceback (most recent call last):
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
Lee los tracebacks de abajo hacia arriba: La última línea muestra el error. Las líneas anteriores muestran la secuencia de llamadas de función que llevaron al punto donde se lanzó la excepción.
MÓDULO_04

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.

Estructura del Try/Except
try
except
else
finally

Componentes de Try/Except

TRY
Código que puede lanzar excepciones
EXCEPT
Código que maneja la excepción
ELSE
Se ejecuta si NO hubo excepción
FINALLY
SIEMPRE se ejecuta, haya o no excepción
try_syntax.py
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))
python try_syntax.py
En el bloque try: 12/4
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

multiple_exceptions.py
# 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}")
Cuando apiles múltiples cláusulas 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

raise_from.py
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+)

exception_notes.py
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)
python exception_notes.py
ValueError: No se permiten números negativos
No se puede resolver 1x² + 0x + 1 = 0
MÓDULO_05

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.

exception_groups.py
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.

except_star.py
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)
Resultado
Tipos inválidos:
(TypeError('No es un entero: ninety'), TypeError('No es un entero: None'))
Valores inválidos:
(ValueError('Edad negativa: -5'),)
A diferencia de un 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.
MÓDULO_06

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:

exit_loops_traditional.py
# 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
exit_loops_elegant.py
# 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)
python exit_loops_elegant.py
(79, 99, 95)
Esta técnica es mucho más elegante: la lógica de escape se maneja completamente con una simple excepción cuyo nombre incluso indica su propósito.
MÓDULO_07

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.

Flujo del Context Manager
Crear recurso
__enter__()
Ejecutar código
__exit__()
Limpieza automática

Problema: Sin Context Manager

without_context_manager.py
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

with_context_manager.py
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

multiple_contexts.py
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
Antes de Python 3.10, rodear múltiples context managers con paréntesis habría resultado en un SyntaxError.
MÓDULO_08

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_context_manager.py
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'")
python class_context_manager.py
MyContextManager init 140340228792272
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'
Si __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.

generator_context_manager.py
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'")
python generator_context_manager.py
A punto de entrar al 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'
Los context managers basados en generador también pueden usarse como decoradores de funciones. Si todo el cuerpo de una función necesita estar dentro de un contexto with, puedes simplemente decorar la función en lugar de añadir un nivel de indentación.
MÓDULO_09

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:

timer_context_manager.py
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)
python timer_context_manager.py
[Procesamiento de datos] Tiempo: 0.0234s
[Operación costosa] Tiempo: 0.5012s

Context Manager para Manejo de Archivos

file_handler.py
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.

BEST_PRACTICES

Guías de Programación

TRY CORTO
Mantén la cláusula try lo más corta posible
EXCEPT ESPECÍFICO
Evita except Exception, sé específico
TESTS
Prueba errores esperados e inesperados
Evita usar except: sin especificar tipo de excepción. Esto capturará incluso excepciones del sistema como SystemExit y KeyboardInterrupt.

Basado en "Learn Python Programming" // 4th Edition // Fabrizio Romano & Heinrich Kruger

Chapter 7: Exceptions and Context Managers