UdelaR  ·  Máster en Análisis de Datos

Introducción a Python

Clase 8 — Errores, excepciones, debugging y logging

Asignatura  Introducción a Python Profesor  Daniel Miles Touya Universidad  UdelaR-Uvigo
Parte I

El sistema de errores de Python

Errores como objetos: la jerarquía de excepciones teoría

En la Clase 6a establecimos que todo en Python es un objeto. Los errores no son una excepción — son objetos de clases que heredan de BaseException. Cuando Python encuentra un problema durante la ejecución, crea un objeto de la clase de error correspondiente y lo lanza — raise. Si nadie lo captura, el programa termina y Python imprime el traceback.

Que los errores sean objetos tiene consecuencias concretas: se pueden capturar, inspeccionar, almacenar, pasar como argumentos y crear nuevos tipos propios. Es el mismo mecanismo de objetos que hemos visto en toda la clase.

— La jerarquía de excepciones —

BaseException ← raíz de todo
├── Exception ← errores que se deben capturar
├── ValueError — valor incorrecto para el tipo
├── TypeError — tipo incorrecto
├── KeyError — clave no existe en dict
├── IndexError — índice fuera de rango
├── AttributeError — atributo no existe
├── NameError — nombre no definido
└── UnboundLocalError — variable local sin valor
├── FileNotFoundError — fichero no existe
├── ModuleNotFoundError — módulo no encontrado
├── ZeroDivisionError — división por cero
└── StopIteration — iterador agotado
├── SystemExit ← sys.exit() — no capturar
└── KeyboardInterrupt ← Ctrl+C — no capturar
# Los errores son objetos — tienen tipo
try:
    1 / 0
except ZeroDivisionError as e:
    print(type(e))          # 
    print(e.args)           # ('division by zero',)
    print(str(e))           # 'division by zero'
    print(repr(e))          # ZeroDivisionError('division by zero')

print("---")

# La jerarquía — isinstance funciona con errores
try:
    1 / 0
except ZeroDivisionError as e:
    print(isinstance(e, ZeroDivisionError))  # True
    print(isinstance(e, ArithmeticError))    # True — padre
    print(isinstance(e, Exception))          # True — abuelo
    print(isinstance(e, BaseException))      # True — raíz
    print(isinstance(e, ValueError))         # False — rama distinta

print("---")

# Ver la jerarquía completa de un error
print(ZeroDivisionError.__mro__)
# (, ,
#  , , )

— Los errores más frecuentes en código de IA —

# TypeError — operación sobre un tipo incorrecto
try:
    resultado = "precio: " + 42
except TypeError as e:
    print(f"TypeError: {e}")
# can only concatenate str (not "int") to str

try:
    resultado = len(42)
except TypeError as e:
    print(f"TypeError: {e}")
# object of type 'int' has no len()

print("---")

# ValueError — tipo correcto, valor incorrecto
try:
    n = int("no soy un número")
except ValueError as e:
    print(f"ValueError: {e}")
# invalid literal for int() with base 10: 'no soy un número'

try:
    import math
    math.sqrt(-1)
except ValueError as e:
    print(f"ValueError: {e}")
# math domain error

print("---")

# KeyError — clave no existe en el diccionario
datos = {"nombre": "Ana", "edad": 25}
try:
    valor = datos["salario"]
except KeyError as e:
    print(f"KeyError: {e}")   # 'salario'

print("---")

# AttributeError — el objeto no tiene ese atributo
try:
    x = 42
    x.append(1)   # int no tiene append
except AttributeError as e:
    print(f"AttributeError: {e}")
# 'int' object has no attribute 'append'

print("---")

# IndexError — índice fuera de rango
lista = [1, 2, 3]
try:
    valor = lista[10]
except IndexError as e:
    print(f"IndexError: {e}")
# list index out of range

print("---")

# FileNotFoundError — fichero no existe
try:
    with open("fichero_inexistente.csv") as f:
        contenido = f.read()
except FileNotFoundError as e:
    print(f"FileNotFoundError: {e}")
# [Errno 2] No such file or directory: 'fichero_inexistente.csv'

print("---")

# AttributeError en pandas — muy frecuente en código de IA
import pandas as pd
df = pd.DataFrame({"ventas": [1, 2, 3]})
try:
    df.columna_inexistente
except AttributeError as e:
    print(f"AttributeError: {e}")

— Guía rápida: error → causa más probable —

Error Causa más probable Dónde buscar
TypeError Mezcla de tipos incompatibles La operación marcada en el traceback
ValueError Conversión fallida, valor fuera de rango El dato que se intenta convertir o usar
KeyError Clave no existe en dict o columna en DataFrame El nombre de la clave entre comillas
AttributeError Typo en nombre de método, tipo equivocado El objeto y el atributo que se intenta acceder
IndexError Lista más corta de lo esperado El índice y la longitud real
NameError Variable no definida, typo en nombre Dónde se define la variable
FileNotFoundError Ruta incorrecta, directorio de trabajo La ruta exacta y os.getcwd()
  1. Identifica el tipo de error que produce cada fragmento antes de ejecutarlo y explica por qué:
    # A
    datos = [1, 2, 3]
    datos[5]
    
    # B
    int("3.14")
    
    # C
    42.upper()
    
    # D
    d = {"a": 1}
    d["b"]
    
    # E
    len(42)
  2. ¿Por qué isinstance(e, Exception) devuelve True para casi cualquier error pero no para KeyboardInterrupt ni SystemExit? ¿Qué consecuencia práctica tiene eso?
  3. ¿Cuál es la diferencia entre TypeError y ValueError? Escribe un ejemplo que produzca cada uno con la misma función int().
  4. Este código de IA falla. Identifica el tipo de error y la causa antes de ejecutar:
    import pandas as pd
    
    df = pd.DataFrame({
        "Ventas": [100, 200, 300],
        "Precio": [1.5, 2.0, 2.5]
    })
    
    total = df["ventas"].sum()   # ← ¿qué error?
    print(total)
Respuesta orientativa
a

A: IndexError — la lista tiene 3 elementos (índices 0, 1, 2) y se intenta acceder al índice 5.

B: ValueError — el tipo es correcto (str), pero "3.14" no es un entero válido. Habría que hacer int(float("3.14")).

C: AttributeError — int no tiene el método upper(), que es de str.

D: KeyError — la clave "b" no existe en el diccionario.

E: TypeError — len() requiere un objeto con longitud (lista, string, dict...) y 42 es un entero que no la tiene.

b

KeyboardInterrupt y SystemExit heredan directamente de BaseException — no de Exception. Esto es deliberado: son señales del sistema que indican que el programa debe terminar. Si heredaran de Exception, un except Exception genérico las capturaría y el programa no podría terminar con Ctrl+C ni con sys.exit(). La consecuencia práctica: nunca uses except BaseException — siempre except Exception como mínimo.

c

TypeError: el tipo del argumento es incorrecto — int() no puede operar sobre ese tipo.

ValueError: el tipo es correcto — str es un tipo válido para int() — pero el valor concreto no puede convertirse.

# TypeError — tipo incorrecto
try:
    int([1, 2, 3])   # lista — no es un tipo válido
except TypeError as e:
    print(f"TypeError: {e}")

# ValueError — tipo correcto, valor incorrecto
try:
    int("abc")   # str es válido pero "abc" no es un entero
except ValueError as e:
    print(f"ValueError: {e}")
d

KeyError: 'ventas'. El DataFrame tiene una columna llamada "Ventas" con V mayúscula — Python y pandas son case-sensitive. df["ventas"] con v minúscula no existe. La corrección: df["Ventas"].sum() o normalizar los nombres al crear el DataFrame con str.lower().

El error de mayúsculas en pandas es uno de los más frecuentes en código de IA — la IA no sabe cómo están nombradas tus columnas. El patrón correcto en un pipeline de datos es normalizar los nombres de columnas al inicio: df.columns = df.columns.str.lower().
Leer un traceback: el mapa del fallo leer

Un traceback es el registro de la pila de llamadas — el stack de la Clase 6b — en el momento en que ocurrió el error. Muestra exactamente qué funciones estaban activas, en qué fichero, en qué línea y qué código se estaba ejecutando cuando todo falló.

El traceback es la herramienta más importante para entender un error — pero hay que saber leerlo en el orden correcto. La mayoría de los principiantes lo leen de arriba a abajo y se pierden en las primeras líneas. La clave es leerlo de abajo a arriba: el error concreto está abajo, la causa raíz está arriba.

— Anatomía de un traceback —

Traceback (most recent call last):
File "main.py", line 12, in <module>
resultado = procesar(datos) ← quién llamó
File "main.py", line 7, in procesar
return calcular(datos["valor"]) ← quién llamó a quién
File "main.py", line 3, in calcular
return x * 2 ← dónde ocurrió el error
TypeError: unsupported operand type(s) for *: 'str' and 'int' ← el error concreto — leer aquí primero
# El código que produce el traceback anterior
def calcular(x):
    return x * 2   # ← línea 3 — aquí ocurre el error

def procesar(datos):
    return calcular(datos["valor"])   # ← línea 7

datos = {"valor": "cien"}   # ← string en lugar de número

try:
    resultado = procesar(datos)   # ← línea 12
except TypeError as e:
    print(f"TypeError: {e}")
    # El traceback muestra el camino completo:
    # main.py:12 → procesar → main.py:7 → calcular → main.py:3
    # La causa raíz: datos["valor"] es "cien" — un string

— El protocolo de tres pasos para leer un traceback —

Paso 1 — última línea

Lee la última línea primero. Es el error concreto — tipo y mensaje. TypeError, ValueError... Identifica de qué tipo es y qué dice el mensaje.

Paso 2 — penúltimo bloque

Lee el bloque inmediatamente anterior al error. Es la línea de tu código donde ocurrió el problema — fichero, número de línea y código exacto.

Paso 3 — subir si es necesario

Si la causa no está clara, sube un nivel. ¿Quién llamó a esa función? ¿Qué datos le pasó? La causa raíz suele estar en los niveles superiores.

— Tracebacks en pandas: más largos pero igual de legibles —

Los tracebacks de pandas y numpy pueden ser muy largos — 20 o 30 líneas — porque pasan por muchas capas internas de la librería. El protocolo es el mismo: ignorar las líneas que apuntan a ficheros dentro de pandas/ o numpy/ y buscar las líneas que apuntan a tu propio código. La causa del error siempre está en tu código, no en pandas.

import pandas as pd

df = pd.DataFrame({
    "ventas": [100, 200, "trescientos"],  # ← el problema
    "precio": [1.5, 2.0, 2.5]
})

try:
    # La suma fallará porque "trescientos" es un string
    total = df["ventas"].sum()
    media = df["ventas"].mean()
    print(media)
except TypeError as e:
    print(f"TypeError: {e}")
    print("Causa: hay un string en la columna 'ventas'")
    print(f"dtypes:\n{df.dtypes}")

print("---")

# Cómo identificar el problema en una columna mixta
df2 = pd.DataFrame({
    "ventas": [100, 200, "trescientos", 400, None]
})

print("dtype de ventas:", df2["ventas"].dtype)
# object — pandas detectó tipos mixtos

# Encontrar las filas problemáticas
def es_numerico(x):
    try:
        float(x)
        return True
    except (TypeError, ValueError):
        return False

mask = ~df2["ventas"].apply(es_numerico)
print("Filas no numéricas:")
print(df2[mask])

— Tracebacks anidados: cuando un error causa otro —

import traceback

# Python 3 muestra la cadena de errores
# "During handling of the above exception, another exception occurred"

def cargar_datos(ruta):
    try:
        with open(ruta) as f:
            return f.read()
    except FileNotFoundError as e:
        # Intentamos una ruta alternativa — también falla
        raise ValueError(f"No se pudo cargar: {ruta}") from e

try:
    cargar_datos("fichero_inexistente.csv")
except ValueError as e:
    print(f"ValueError: {e}")
    print(f"Causado por: {e.__cause__}")
    # __cause__ — el error original que causó este

print("---")

# Obtener el traceback como string — útil para logging
try:
    1 / 0
except ZeroDivisionError:
    tb_string = traceback.format_exc()
    print("Traceback como string:")
    print(tb_string)
El modelo mental del traceback: es el stack de la Clase 6b impreso en el momento del fallo. Cada bloque es un frame — una función que estaba activa. El frame más reciente está al final — más cerca del error. El frame más antiguo está al principio — donde empezó la cadena de llamadas. Lee de abajo a arriba: primero el error, luego la línea, luego quién la llamó si hace falta.
  1. Lee este traceback y responde: ¿qué tipo de error es, en qué línea ocurrió, cuál es la causa raíz y cómo lo corregirías?
    Traceback (most recent call last):
      File "analisis.py", line 18, in <module>
        resultado = pipeline(df)
      File "analisis.py", line 12, in pipeline
        df = normalizar(df)
      File "analisis.py", line 6, in normalizar
        return (df["ventas"] - df["ventas"].mean()) / df["ventas"].std()
    KeyError: 'ventas'
  2. ¿Por qué los tracebacks de pandas son más largos que los de código Python puro? ¿Dónde debes buscar la causa del error en un traceback largo de pandas?
  3. ¿Qué significa e.__cause__ en un traceback anidado? ¿Cuándo aparece el mensaje "During handling of the above exception, another exception occurred"?
  4. Este código genera un error. Sin ejecutarlo, predice el traceback completo: tipo de error, mensaje, fichero y línea:
    def media(datos):
        return sum(datos) / len(datos)
    
    def analizar(fichero):
        datos = []
        return media(datos)
    
    analizar("ventas.csv")
Respuesta orientativa
a

Tipo de error: KeyError. Línea donde ocurrió: línea 6 en normalizar. La causa raíz: el DataFrame no tiene una columna llamada "ventas" — probablemente se llama distinto, por ejemplo "Ventas" con V mayúscula. Cómo corregirlo: verificar con df.columns cómo se llaman las columnas reales y ajustar el nombre en normalizar — o normalizar los nombres al inicio del pipeline con df.columns.str.lower().

b

Los tracebacks de pandas son largos porque las operaciones de pandas pasan por muchas capas internas — validación de tipos, selección de implementación, operaciones numpy. Cada una añade frames al stack. La causa del error siempre está en el código del usuario — las líneas que apuntan a ficheros dentro de site-packages/pandas/ o numpy/ son las capas internas y generalmente no son la causa. Busca las líneas que apuntan a tus propios ficheros.

c

e.__cause__ es el error original que causó el error actual — aparece cuando se usa raise NuevoError from error_original. Python preserva la cadena de causalidad. El mensaje "During handling of the above exception, another exception occurred" aparece cuando ocurre un segundo error mientras se estaba manejando el primero — por ejemplo dentro de un bloque except. Es una señal de que el código de manejo de errores tiene también un error.

d

ZeroDivisionError: division by zero. analizar llama a media con una lista vacía. sum([]) devuelve 0 y len([]) devuelve 0, así que se intenta calcular 0 / 0.

Traceback (most recent call last):
  File "script.py", line 8, in <module>
    analizar("ventas.csv")
  File "script.py", line 6, in analizar
    return media(datos)
  File "script.py", line 2, in media
    return sum(datos) / len(datos)
ZeroDivisionError: division by zero

La causa raíz: datos = [] en analizar — la función no carga ningún dato del fichero, solo inicializa una lista vacía y la pasa directamente.

Parte II

try / except / else / finally

try / except / else / finally: la mecánica completa teoría

try/except es el mecanismo de Python para manejar errores en tiempo de ejecución — errores que no se pueden detectar leyendo el código sino solo cuando se ejecuta. Un fichero que no existe, un dato que llega en formato inesperado, una conexión que falla.

La filosofía de Python es EAFP — Easier to Ask Forgiveness than Permission: intenta hacer la operación y maneja el error si ocurre, en lugar de comprobar todas las condiciones antes de intentarlo. Es lo opuesto al estilo LBYL — Look Before You Leap — que usa muchos if preventivos.

— El flujo de ejecución paso a paso —

try:
código que puede fallar ← se ejecuta siempre
except TipoError as e:
manejar el error ← solo si hay error del tipo indicado
else:
código sin error ← solo si NO hubo error
finally:
limpieza ← SIEMPRE — con o sin error
# El caso básico — try + except
try:
    resultado = 10 / 0
except ZeroDivisionError:
    print("Error: división por cero")
# El programa continúa — el error fue manejado
print("El programa sigue")

print("---")

# Capturar el objeto error con 'as e'
try:
    valor = int("no es número")
except ValueError as e:
    print(f"tipo:    {type(e).__name__}")   # ValueError
    print(f"mensaje: {e}")                  # invalid literal...
    print(f"args:    {e.args}")             # ('invalid literal...',)

print("---")

# else — solo si NO hubo error
def dividir(a, b):
    try:
        resultado = a / b
    except ZeroDivisionError:
        print("No se puede dividir por cero")
    else:
        # Este bloque solo se ejecuta si try tuvo éxito
        print(f"Resultado: {resultado}")
        return resultado

dividir(10, 2)   # Resultado: 5.0
dividir(10, 0)   # No se puede dividir por cero

print("---")

# finally — siempre se ejecuta
def leer_fichero(ruta):
    fichero = None
    try:
        fichero = open(ruta)
        contenido = fichero.read()
        return contenido
    except FileNotFoundError as e:
        print(f"Fichero no encontrado: {e}")
        return None
    finally:
        # Se ejecuta siempre — con o sin error
        # Garantiza que el fichero se cierra
        if fichero:
            fichero.close()
            print("Fichero cerrado")

leer_fichero("existente.txt")
leer_fichero("inexistente.txt")

— Múltiples except: el orden importa —

Se pueden encadenar varios bloques except para manejar distintos tipos de error de forma distinta. Python los evalúa en orden — el primero que coincida se ejecuta y los demás se ignoran. La regla: siempre de más específico a menos específico. Si pones except Exception primero, captura todo y los bloques siguientes nunca se ejecutan.

def convertir_y_dividir(texto, divisor):
    try:
        numero = int(texto)
        resultado = numero / divisor
        return resultado

    except ValueError:
        print(f"'{texto}' no es un número entero válido")
        return None

    except ZeroDivisionError:
        print("El divisor no puede ser cero")
        return None

    except TypeError as e:
        print(f"Tipo incorrecto: {e}")
        return None

print(convertir_y_dividir("10",  2))   # 5.0
print(convertir_y_dividir("abc", 2))   # ValueError
print(convertir_y_dividir("10",  0))   # ZeroDivisionError
print(convertir_y_dividir("10", "x"))  # TypeError

print("---")

# Capturar múltiples tipos en un solo except
def procesar(valor):
    try:
        return int(valor) * 2
    except (ValueError, TypeError) as e:
        print(f"Error de conversión: {e}")
        return 0

print(procesar("5"))     # 10
print(procesar("abc"))   # Error de conversión
print(procesar(None))    # Error de conversión

print("---")

# EAFP vs LBYL — la filosofía Python

# LBYL — comprobar antes (no pythónico)
def obtener_valor_lbyl(d, clave):
    if isinstance(d, dict) and clave in d:
        return d[clave]
    return None

# EAFP — intentar y manejar (pythónico)
def obtener_valor_eafp(d, clave):
    try:
        return d[clave]
    except (KeyError, TypeError):
        return None

datos = {"a": 1, "b": 2}
print(obtener_valor_lbyl(datos, "a"))   # 1
print(obtener_valor_eafp(datos, "c"))   # None
El antipatrón más peligroso — capturar todo y silenciar:
# ❌ Nunca hagas esto
try:
    resultado = procesar(datos)
except:
    pass   # silencia TODOS los errores — incluido KeyboardInterrupt

# ❌ Tampoco esto
try:
    resultado = procesar(datos)
except Exception:
    pass   # silencia el error — el programa sigue como si nada
Cuando silencias un error sin registrarlo ni manejarlo, el programa continúa con un estado incorrecto. El fallo real aparecerá más tarde, en un lugar completamente distinto, y será casi imposible de depurar. Como mínimo imprime el error o guárdalo en un log.
  1. ¿Qué imprime este código? Traza la ejecución paso a paso:
    def test(x):
        try:
            resultado = 10 / x
            print(f"resultado: {resultado}")
        except ZeroDivisionError:
            print("división por cero")
        else:
            print("sin error")
        finally:
            print("finally")
    
    test(2)
    print("---")
    test(0)
  2. ¿Por qué este código es peligroso? ¿Qué errores podría estar silenciando? ¿Cómo lo corregirías?
    def cargar_csv(ruta):
        try:
            import pandas as pd
            df = pd.read_csv(ruta)
            return df
        except:
            return None
  3. ¿Cuándo tiene sentido usar else en un try/except? ¿Por qué es mejor poner el código de éxito en else que al final del try?
  4. Escribe una función leer_entero que pida un número entero al usuario, maneje el caso en que escriba algo que no sea un entero con ValueError, y use finally para imprimir siempre cuántos intentos hizo.
Respuesta orientativa
a

test(2): el try ejecuta sin error, imprime resultado: 5.0. El else se ejecuta — imprime sin error. El finally siempre — imprime finally.

test(0): el try lanza ZeroDivisionError — el print de resultado no se ejecuta. El except captura el error e imprime división por cero. El else no se ejecuta — hubo error. El finally siempre — imprime finally.

b

El except: sin tipo captura absolutamente todo — incluyendo KeyboardInterrupt, SystemExit y MemoryError. Podría estar silenciando que el fichero no existe, que tiene un formato incorrecto, que no hay memoria suficiente, o que el usuario presionó Ctrl+C. El programa devuelve None silenciosamente en todos estos casos. La corrección:

def cargar_csv(ruta):
    try:
        import pandas as pd
        df = pd.read_csv(ruta)
        return df
    except FileNotFoundError:
        print(f"Fichero no encontrado: {ruta}")
        return None
    except Exception as e:
        print(f"Error inesperado cargando {ruta}: {e}")
        return None
c

else tiene sentido cuando quieres distinguir claramente entre el código que puede fallar y el código que solo se ejecuta si tuvo éxito. Es mejor que poner el código de éxito al final del try porque si ese código adicional también lanza una excepción, el except la capturaría — aunque el error no tenga nada que ver con la operación que querías proteger. Con else el código de éxito no está protegido por el except — los errores ahí se propagan normalmente.

d
def leer_entero(mensaje="Introduce un entero: "):
    intentos = 0
    while True:
        try:
            intentos += 1
            valor = int(input(mensaje))
            return valor
        except ValueError:
            print("Eso no es un entero. Inténtalo de nuevo.")
        finally:
            print(f"Intentos realizados hasta ahora: {intentos}")

# n = leer_entero("¿Cuántas filas quieres ver? ")
Nota sobre finally en bucles: el finally se ejecuta en cada iteración — también cuando el try tiene éxito y se ejecuta el return. En ese caso Python ejecuta el finally antes de devolver el valor.
raise, excepciones propias y el context manager teoría

Hasta ahora hemos capturado errores que lanza Python. Pero también podemos lanzar errores nosotros mismos con raise — cuando detectamos que algo está mal antes de que Python lo detecte. Un argumento inválido, un dato fuera de rango, un estado inconsistente. Lanzar un error explícito con un mensaje claro es siempre mejor que dejar que Python lance un error críptico más adelante.

— raise: lanzar un error explícito —

# raise lanza un objeto excepción
def calcular_media(datos):
    if not datos:
        raise ValueError("La lista no puede estar vacía")
    if not all(isinstance(x, (int, float)) for x in datos):
        raise TypeError("Todos los elementos deben ser numéricos")
    return sum(datos) / len(datos)

try:
    calcular_media([])
except ValueError as e:
    print(f"ValueError: {e}")

try:
    calcular_media([1, 2, "tres"])
except TypeError as e:
    print(f"TypeError: {e}")

print(calcular_media([1, 2, 3, 4, 5]))   # 3.0

print("---")

# raise sin argumentos — relanza el error activo
def procesar_con_log(datos):
    try:
        resultado = calcular_media(datos)
        return resultado
    except ValueError as e:
        print(f"[LOG] Error en procesar_con_log: {e}")
        raise   # ← relanza el mismo error

try:
    procesar_con_log([])
except ValueError as e:
    print(f"Capturado arriba: {e}")

— raise ... from: preservar la cadena de causalidad —

Cuando capturamos un error y lanzamos otro diferente, podemos preservar el error original como causa usando raise Nuevo from original. Esto muestra ambos errores en el traceback — el original y el nuevo — lo que facilita enormemente el debugging. La IA lo usa frecuentemente para convertir errores técnicos en errores más descriptivos.

def cargar_configuracion(ruta):
    try:
        with open(ruta) as f:
            import json
            return json.load(f)
    except FileNotFoundError as e:
        # Convertimos en un error más descriptivo
        # pero preservamos el original como causa
        raise RuntimeError(
            f"No se encontró la configuración en '{ruta}'. "
            f"Crea el fichero antes de ejecutar."
        ) from e
    except json.JSONDecodeError as e:
        raise ValueError(
            f"El fichero '{ruta}' no tiene JSON válido: {e}"
        ) from e

try:
    config = cargar_configuracion("config.json")
except RuntimeError as e:
    print(f"RuntimeError: {e}")
    print(f"Causado por: {e.__cause__}")

print("---")

# raise ... from None — suprimir la cadena
# cuando el error original no aporta información útil
def obtener_columna(df, nombre):
    try:
        return df[nombre]
    except KeyError:
        columnas = list(df.columns)
        raise KeyError(
            f"Columna '{nombre}' no existe. "
            f"Columnas disponibles: {columnas}"
        ) from None   # ← suprime el error original

— Excepciones propias: mensajes de error específicos —

Crear tipos de excepción propios permite dar nombres precisos a los errores de tu dominio — DatosInvalidosError, ColumnaFaltanteError, ValorFueraDeRangoError. Quien use tu código puede capturar exactamente el error que le interesa sin capturar otros errores no relacionados. Es exactamente el mismo mecanismo que la jerarquía de errores de Python — heredar de Exception.

# Excepciones propias — heredan de Exception
class DatosInvalidosError(Exception):
    """Error para datos que no cumplen los requisitos."""
    pass

class ColumnaFaltanteError(Exception):
    """Error cuando falta una columna requerida."""
    def __init__(self, columna, disponibles):
        self.columna = columna
        self.disponibles = disponibles
        super().__init__(
            f"Columna '{columna}' no encontrada. "
            f"Disponibles: {disponibles}"
        )

class ValorFueraDeRangoError(Exception):
    """Error cuando un valor está fuera del rango esperado."""
    def __init__(self, valor, minimo, maximo):
        self.valor = valor
        self.minimo = minimo
        self.maximo = maximo
        super().__init__(
            f"Valor {valor} fuera de rango [{minimo}, {maximo}]"
        )

print("---")

# Usarlas en código de análisis
import pandas as pd

def validar_dataframe(df, columnas_requeridas, rango_ventas=(0, 10000)):
    """Valida que un DataFrame cumple los requisitos."""
    for col in columnas_requeridas:
        if col not in df.columns:
            raise ColumnaFaltanteError(col, list(df.columns))

    if "ventas" in df.columns:
        minimo, maximo = rango_ventas
        fuera = df[
            (df["ventas"] < minimo) | (df["ventas"] > maximo)
        ]
        if not fuera.empty:
            valores = fuera["ventas"].tolist()
            raise ValorFueraDeRangoError(valores, minimo, maximo)

    return True

df_ok = pd.DataFrame({
    "producto": ["A", "B"],
    "ventas":   [100, 200]
})

df_mal = pd.DataFrame({
    "producto": ["A", "B"],
    "ventas":   [100, 99999]   # fuera de rango
})

df_sin_col = pd.DataFrame({
    "producto": ["A", "B"]
    # falta 'ventas'
})

for nombre, df in [("ok", df_ok), ("mal", df_mal), ("sin_col", df_sin_col)]:
    try:
        validar_dataframe(df, ["producto", "ventas"])
        print(f"{nombre}: ✓ válido")
    except ColumnaFaltanteError as e:
        print(f"{nombre}: ColumnaFaltanteError — {e}")
    except ValorFueraDeRangoError as e:
        print(f"{nombre}: ValorFueraDeRangoError — {e}")

— with: el context manager como alternativa a try/finally —

El patrón try/finally para garantizar que un recurso se libera — un fichero, una conexión — es tan frecuente que Python tiene una sintaxis especial para él: el context manager con la palabra clave with. Garantiza que el recurso se cierra correctamente tanto si el bloque termina con éxito como si lanza una excepción.

# Sin with — hay que gestionar el cierre manualmente
fichero = None
try:
    fichero = open("datos.txt", "w")
    fichero.write("hola")
finally:
    if fichero:
        fichero.close()   # ← hay que recordarlo

# Con with — más limpio y más seguro
with open("datos.txt", "w") as f:
    f.write("hola")
# f.close() se llama automáticamente — con o sin error

print("---")

# with y try/except combinados — el patrón más completo
def leer_csv_seguro(ruta):
    """Lee un CSV manejando los errores más frecuentes."""
    try:
        import pandas as pd
        with open(ruta) as f:   # ← verifica que existe
            pass
        df = pd.read_csv(ruta)
        if df.empty:
            raise DatosInvalidosError(f"El fichero '{ruta}' está vacío")
        return df
    except FileNotFoundError:
        print(f"Fichero no encontrado: {ruta}")
        return None
    except DatosInvalidosError as e:
        print(f"Datos inválidos: {e}")
        return None
    except Exception as e:
        print(f"Error inesperado: {type(e).__name__}: {e}")
        return None

print(leer_csv_seguro("datos.csv"))
print(leer_csv_seguro("inexistente.csv"))

print("---")

# Múltiples context managers en una línea
with open("entrada.txt", "w") as entrada, \
     open("salida.txt",  "w") as salida:
    entrada.write("datos de entrada")
    salida.write("datos de salida")
# Ambos ficheros se cierran correctamente
Cuándo usar try/except y cuándo no: úsalo cuando el error es esperable y manejable — un fichero que puede no existir, un dato que puede tener formato incorrecto, una conexión que puede fallar. No lo uses como sustituto de validación explícita — si puedes comprobar una condición con if antes de la operación, hazlo. No lo uses para silenciar errores — si capturas un error, haz algo con él: regístralo, informa al usuario o devuelve un valor por defecto claramente documentado.
  1. ¿Cuál es la diferencia entre raise ValueError("mensaje") y raise ValueError("mensaje") from e? ¿Cuándo usarías cada uno?
  2. Crea una excepción propia NotaInvalidaError que reciba la nota y muestre un mensaje indicando que debe estar entre 0 y 10. Úsala en una función registrar_nota(nota) que la lance si la nota está fuera de rango.
  3. ¿Por qué es mejor usar with open(...) que abrir el fichero manualmente con f = open(...) y cerrarlo con f.close()? ¿Qué pasa si hay un error antes de f.close() sin usar with?
  4. Este código de IA tiene tres problemas de manejo de errores. Identifícalos y corrígelos:
    def analizar(ruta, columna):
        try:
            import pandas as pd
            df = pd.read_csv(ruta)
            media = df[columna].mean()
            maximo = df[columna].max()
            print(f"media: {media}, máximo: {maximo}")
            return media, maximo
        except:
            return None, None
Respuesta orientativa
a

raise ValueError("mensaje") lanza un error nuevo sin conexión con ningún error anterior. Si se produce dentro de un except, Python mostrará ambos errores con el mensaje "During handling of the above exception" — conexión implícita.

raise ValueError("mensaje") from e establece explícitamente que el nuevo error fue causado por e. El traceback muestra la cadena completa de forma clara. from None suprime el error original cuando no aporta información útil. Usa from e cuando el error original ayuda a entender el nuevo, y from None cuando solo añade ruido.

b
class NotaInvalidaError(Exception):
    """Error cuando una nota está fuera del rango [0, 10]."""
    def __init__(self, nota):
        self.nota = nota
        super().__init__(
            f"Nota inválida: {nota}. "
            f"Debe estar entre 0 y 10."
        )

def registrar_nota(nota):
    """Registra una nota validando que esté en [0, 10]."""
    if not isinstance(nota, (int, float)):
        raise TypeError(f"La nota debe ser numérica, no {type(nota).__name__}")
    if not (0 <= nota <= 10):
        raise NotaInvalidaError(nota)
    print(f"Nota {nota} registrada correctamente")
    return nota

try:
    registrar_nota(8.5)   # ✓
    registrar_nota(11)    # NotaInvalidaError
except NotaInvalidaError as e:
    print(f"Error: {e}")
except TypeError as e:
    print(f"TypeError: {e}")
c

Sin with, si ocurre un error después de open() y antes de f.close(), el fichero queda abierto indefinidamente — hasta que el proceso termina o el garbage collector lo cierra. En sistemas con muchos ficheros abiertos simultáneamente esto puede agotar los descriptores de fichero del sistema operativo. with garantiza el cierre en cualquier caso — es equivalente a un try/finally con f.close() en el finally.

d

Tres problemas: except: sin tipo captura todo incluyendo KeyboardInterrupt. No hay logging — el error desaparece silenciosamente. Devuelve None, None sin que el llamante sepa qué falló.

def analizar(ruta, columna):
    try:
        import pandas as pd
        df = pd.read_csv(ruta)
        media = df[columna].mean()
        maximo = df[columna].max()
        print(f"media: {media:.2f}, máximo: {maximo}")
        return media, maximo
    except FileNotFoundError:
        print(f"Fichero no encontrado: {ruta}")
        return None, None
    except KeyError:
        print(f"Columna '{columna}' no existe")
        return None, None
    except Exception as e:
        print(f"Error inesperado: {type(e).__name__}: {e}")
        return None, None
Parte III

Debugging: encontrar el problema

Las herramientas del debugging: de más simple a más potente práctica

Debugging es el proceso de encontrar por qué el código no hace lo que debería hacer. Hay dos tipos de problema: el código lanza un error — y el traceback apunta al lugar — o el código funciona sin errores pero produce resultados incorrectos — el caso más difícil.

Las herramientas van de lo más simple a lo más potente. La mayoría de los problemas se resuelven con print estratégico. Cuando eso no es suficiente existen herramientas más potentes que permiten pausar el programa e inspeccionar el estado en cualquier punto.

— print estratégico: la herramienta más usada —

El print de debugging no es solo print(variable). Un print bien colocado muestra el nombre de la variable, su tipo, su valor y en qué punto del código se encuentra. La diferencia entre un print útil y uno inútil está en la información que incluye.

import pandas as pd
import numpy as np

# ❌ Print inútil — no dice nada
datos = [1, 2, None, 4, "cinco"]
print(datos)   # [1, 2, None, 4, 'cinco'] — ¿y?

print("---")

# ✓ Print útil — nombre, tipo, valor, contexto
print(f"[DEBUG] datos: type={type(datos).__name__}, len={len(datos)}, valor={datos}")

print("---")

# Print en puntos clave de un pipeline
def pipeline_ventas(df, debug=False):
    """Procesa un DataFrame de ventas."""

    if debug:
        print(f"\n[1] Entrada: {df.shape}, dtypes:\n{df.dtypes}")

    # Paso 1: limpiar nulos
    df = df.dropna()
    if debug:
        print(f"[2] Tras dropna: {df.shape}")

    # Paso 2: filtrar ventas positivas
    df = df[df["ventas"] > 0]
    if debug:
        print(f"[3] Tras filtro ventas>0: {df.shape}")

    # Paso 3: calcular ingreso
    df["ingreso"] = df["ventas"] * df["precio"]
    if debug:
        print(f"[4] Tras calcular ingreso:\n{df.head()}")

    return df

df = pd.DataFrame({
    "ventas": [100, None, -5, 200, 150],
    "precio": [1.5, 2.0, 1.0, 2.5, 1.8]
})

# Sin debug
resultado = pipeline_ventas(df)
print(f"\nResultado final: {resultado.shape}")

print("---")

# Con debug — activo solo cuando se investiga
resultado = pipeline_ventas(df, debug=True)

— assert: verificar suposiciones —

assert condición, mensaje verifica que una suposición es cierta en ese punto del código. Si la condición es falsa, lanza AssertionError con el mensaje. Es más conciso que if not condición: raise para verificaciones internas que solo importan en desarrollo. En producción se pueden desactivar todos los assert ejecutando Python con python -O script.py.

import pandas as pd

# assert — verificar suposiciones en puntos clave
def normalizar(datos):
    assert len(datos) > 0, "datos no puede estar vacío"
    assert all(isinstance(x, (int, float)) for x in datos), \
        "todos los elementos deben ser numéricos"

    maximo = max(datos)
    minimo = min(datos)

    assert maximo != minimo, \
        "todos los valores son iguales — no se puede normalizar"

    resultado = [(x - minimo) / (maximo - minimo) for x in datos]

    # Verificar postcondición — el resultado es correcto
    assert min(resultado) == 0.0, "el mínimo normalizado debe ser 0"
    assert max(resultado) == 1.0, "el máximo normalizado debe ser 1"

    return resultado

print(normalizar([1, 2, 3, 4, 5]))   # [0.0, 0.25, 0.5, 0.75, 1.0]

try:
    normalizar([])
except AssertionError as e:
    print(f"AssertionError: {e}")

try:
    normalizar([5, 5, 5])
except AssertionError as e:
    print(f"AssertionError: {e}")

print("---")

# assert en pipelines de datos
def verificar_dataframe(df, nombre="df"):
    """Verificaciones rápidas sobre un DataFrame."""
    assert isinstance(df, pd.DataFrame), \
        f"{nombre} debe ser un DataFrame"
    assert not df.empty, \
        f"{nombre} no puede estar vacío"
    assert df.isnull().sum().sum() == 0, \
        f"{nombre} contiene {df.isnull().sum().sum()} nulos"
    print(f"✓ {nombre}: {df.shape}, sin nulos")
    return df

df_ok = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
df_nulos = pd.DataFrame({"a": [1, None], "b": [3, 4]})

verificar_dataframe(df_ok, "df_ventas")

try:
    verificar_dataframe(df_nulos, "df_nulos")
except AssertionError as e:
    print(f"AssertionError: {e}")

— pdb y breakpoint(): pausar el programa e inspeccionar —

Cuando print no es suficiente — porque hay demasiadas variables o el problema ocurre dentro de una librería — el debugger permite pausar el programa en cualquier punto e inspeccionar el estado completo: variables, stack, tipos. breakpoint() es la forma moderna de activarlo — disponible desde Python 3.7.

# breakpoint() — pausa el programa aquí
# y abre el prompt interactivo de pdb

def calcular_estadisticas(datos):
    media = sum(datos) / len(datos)
    varianza = sum((x - media)**2 for x in datos) / len(datos)
    desv = varianza ** 0.5
    # breakpoint()  ← descomenta para pausar aquí
    return {"media": media, "varianza": varianza, "desv": desv}

resultado = calcular_estadisticas([1, 2, 3, 4, 5])
print(resultado)

— Comandos esenciales de pdb —

Comando Acción Cuándo usarlo
n next — ejecuta la línea siguiente sin entrar en funciones Avanzar paso a paso
s step — entra dentro de la función llamada Inspeccionar el interior de una función
c continue — ejecuta hasta el siguiente breakpoint Saltar al siguiente punto de interés
p expr print — evalúa e imprime una expresión Ver el valor de cualquier variable
pp expr pretty print — imprime con formato legible Ver estructuras complejas como dicts o DataFrames
l list — muestra el código alrededor de la línea actual Ver el contexto del código
w where — muestra el stack completo de llamadas Ver desde dónde se llegó aquí
u / d up/down — sube o baja un nivel en el stack Inspeccionar variables de la función llamante
q quit — sale del debugger y termina el programa Salir

— Debugging en Jupyter: %debug y otros trucos —

# En Jupyter, después de un error ejecuta %debug
# para abrir el debugger en el punto del fallo
#
# Celda 1:
# def calcular(x):
#     return 1 / x
#
# calcular(0)   ← ZeroDivisionError
#
# Celda 2:
# %debug        ← abre pdb en el punto del error

# Otras magic commands útiles en Jupyter
# %pdb on/off   — activa pdb automáticamente en cada error
# %xmode        — cambia el formato del traceback
# %xmode Verbose — traceback con valores de variables
# %xmode Minimal — traceback mínimo

print("Comandos de debugging en Jupyter:")
jupyter_cmds = {
    "%debug":       "abre pdb tras un error",
    "%pdb on":      "activa pdb automático en cada error",
    "%xmode Verbose": "traceback con valores de variables",
    "%timeit":      "mide el tiempo de una expresión",
}
for cmd, desc in jupyter_cmds.items():
    print(f"  {cmd:25} → {desc}")

print("---")

# icecream — alternativa moderna a print para debugging
# pip install icecream
# from icecream import ic
#
# def calcular(x, y):
#     resultado = x * y
#     ic(x, y, resultado)   ← imprime nombre y valor automáticamente
#     return resultado
#
# ic(calcular(3, 4))
# → ic| x: 3, y: 4, resultado: 12
# → ic| calcular(3, 4): 12

print("Herramientas de debugging por contexto:")
herramientas = {
    "Script simple":   "print estratégico + assert",
    "Script complejo": "breakpoint() + pdb",
    "Jupyter":         "%debug + %xmode Verbose",
    "VSCode":          "breakpoints visuales en el editor",
    "Producción":      "logging (Parte IV)",
}
for ctx, herramienta in herramientas.items():
    print(f"  {ctx:20} → {herramienta}")
La regla práctica: empieza siempre con print estratégico con el parámetro debug=True — es suficiente para el 80% de los problemas. Añade assert en los puntos críticos del pipeline para verificar suposiciones. Usa breakpoint() cuando necesitas inspeccionar el estado completo del programa en un punto concreto. Reserva el debugger de VSCode para problemas complejos con múltiples ficheros.
  1. ¿Cuál es la diferencia entre assert condición y if not condición: raise ValueError? ¿Cuándo usarías cada uno?
  2. Mejora este print de debugging para que sea más informativo:
    def procesar(df):
        resultado = df.groupby("categoria")["ventas"].sum()
        print(resultado)   # ← mejorar este print
        return resultado
  3. ¿Qué hace exactamente breakpoint()? ¿Dónde lo colocarías en este código para entender por qué total da un valor incorrecto?
    def calcular_total(precios, descuentos):
        precios_desc = [p * (1 - d)
                        for p, d in zip(precios, descuentos)]
        total = sum(precios_desc)
        return total
    
    precios = [100, 200, 150]
    descuentos = [0.1, 0.2]   # ← longitudes distintas
    print(calcular_total(precios, descuentos))
  4. ¿Por qué el patrón debug=False como parámetro de función es mejor que poner y quitar prints manualmente?
Respuesta orientativa
a

assert es para verificaciones de desarrollo — suposiciones sobre el estado interno del programa que deberían ser siempre ciertas. Se pueden desactivar en producción con python -O. No deben usarse para validar datos de entrada del usuario porque en producción estarían desactivados.

if not condición: raise ValueError es para validaciones que deben estar activas siempre — en producción y en desarrollo. Es la herramienta correcta para validar argumentos de funciones públicas y datos de entrada.

b
def procesar(df, debug=False):
    resultado = df.groupby("categoria")["ventas"].sum()
    if debug:
        print(
            f"[procesar] resultado groupby:\n"
            f"  type={type(resultado).__name__}\n"
            f"  shape={resultado.shape}\n"
            f"  dtype={resultado.dtype}\n"
            f"  valores:\n{resultado}"
        )
    return resultado
c

breakpoint() pausa la ejecución y abre el prompt interactivo de pdb donde puedes inspeccionar el estado del programa. El mejor lugar para ponerlo es dentro de calcular_total justo después de calcular precios_desc — para ver qué elementos se procesaron realmente:

def calcular_total(precios, descuentos):
    precios_desc = [p * (1 - d)
                    for p, d in zip(precios, descuentos)]
    # breakpoint()  ← aquí verías que precios_desc
    # tiene solo 2 elementos — zip se detiene
    # en el más corto — descuentos tiene 2
    total = sum(precios_desc)
    return total

El problema es que zip se detiene en el iterable más corto — descuentos tiene solo 2 elementos, así que el precio de 150 no se procesa.

d

El patrón debug=False es mejor porque no modifica el código entre sesiones de debugging — no hay riesgo de olvidar quitar un print antes de pasar a producción. Se activa y desactiva con un parámetro. Documenta explícitamente que la función tiene modo de debugging. Permite activar el debugging en producción de forma controlada sin modificar el código fuente.

El protocolo de debugging: encontrar la causa raíz práctica

Tener herramientas de debugging no es suficiente — hay que saber usarlas con un método. Sin método, el debugging es aleatorio: probar cosas al azar hasta que funciona sin entender por qué. Con método, el debugging es sistemático y eficiente.

El protocolo tiene cuatro pasos: reproducir, aislar, inspeccionar y corregir. Se aplica igual a errores que lanzan excepción y a errores silenciosos donde el código funciona pero produce resultados incorrectos.

— El protocolo de cuatro pasos —

1. Reproducir

Conseguir que el error ocurra de forma consistente. Si no puedes reproducirlo, no puedes debuggearlo. Encuentra el caso mínimo que lo provoca.

2. Aislar

Reducir el código hasta el fragmento mínimo que reproduce el error. Eliminar todo lo que no es necesario para que falle. Divide y vencerás.

3. Inspeccionar

En el fragmento aislado, inspeccionar el estado en el punto de fallo: valores, tipos, shapes. Verificar las suposiciones una a una.

4. Corregir y verificar

Corregir la causa raíz — no el síntoma. Verificar que el caso mínimo ya no falla. Verificar que el resto del código sigue funcionando.

— El protocolo aplicado: error con excepción —

import pandas as pd
import numpy as np

# El código de IA que falla
def analizar_ventas(df):
    return (
        df
        .dropna()
        .groupby("categoria")
        .agg({"ventas": ["mean", "sum"], "precio": "mean"})
        .assign(ingreso_medio=lambda d:
                d[("ventas", "mean")] * d[("precio", "mean")])
        .sort_values(("ventas", "sum"), ascending=False)
    )

df = pd.DataFrame({
    "categoria": ["fruta", "fruta", "cítrico", "exótico"],
    "Ventas":    [150, 80, 120, 200],   # ← Ventas con mayúscula
    "precio":    [1.5, 0.9, 0.8, 1.5]
})

try:
    resultado = analizar_ventas(df)
except KeyError as e:
    print(f"KeyError: {e}")

print("---")

# PASO 1 — REPRODUCIR
# Ya lo tenemos — el KeyError ocurre siempre con estos datos

# PASO 2 — AISLAR
# Descomponer el encadenamiento para ver en qué paso falla
print("Columnas del DataFrame:", df.columns.tolist())
# ['categoria', 'Ventas', 'precio'] — Ventas con V mayúscula

print("---")

# Probar cada paso por separado
paso1 = df.dropna()
print("Paso 1 — dropna:", paso1.shape)

try:
    paso2 = paso1.groupby("categoria").agg(
        {"ventas": ["mean", "sum"], "precio": "mean"}
    )
except KeyError as e:
    print(f"El error está en el groupby: KeyError {e}")
    # ← encontrado: 'ventas' no existe, es 'Ventas'

print("---")

# PASO 3 — INSPECCIONAR
print("Columnas disponibles:", paso1.columns.tolist())
print("dtypes:\n", paso1.dtypes)
# Confirmado: la columna se llama 'Ventas' — mayúscula

print("---")

# PASO 4 — CORREGIR Y VERIFICAR
# Opción A: normalizar nombres al inicio del pipeline
df_corregido = df.copy()
df_corregido.columns = df_corregido.columns.str.lower()
print("Columnas corregidas:", df_corregido.columns.tolist())

resultado = analizar_ventas(df_corregido)
print("\nResultado:")
print(resultado)

— El caso más difícil: errores silenciosos —

Un error silencioso es cuando el código funciona sin lanzar ninguna excepción pero produce resultados incorrectos. Es el tipo de error más difícil porque no hay traceback que apunte al problema. El protocolo es el mismo — pero el primer paso es verificar que el resultado es efectivamente incorrecto comparándolo con un cálculo manual.

import pandas as pd
import numpy as np

# Error silencioso — el código funciona pero el resultado es incorrecto
def calcular_crecimiento(df):
    """Calcula el crecimiento porcentual año a año."""
    df["crecimiento"] = (
        (df["ventas_2024"] - df["ventas_2023"])
        / df["ventas_2023"]   # ← ¿está bien?
    )
    return df

df = pd.DataFrame({
    "region":      ["Norte", "Sur", "Este"],
    "ventas_2023": [1000, 2000, 1500],
    "ventas_2024": [1200, 1800, 1650]
})

resultado = calcular_crecimiento(df)
print(resultado)
# No hay error — pero ¿es correcto el crecimiento?

print("---")

# PASO 1 — REPRODUCIR: calcular a mano para Norte
# Crecimiento Norte = (1200 - 1000) / 1000 = 0.20 = 20%
print(f"Norte manual: {(1200-1000)/1000:.4f}")   # 0.2000 ✓
print(f"Norte código: {resultado.loc[0, 'crecimiento']:.4f}")

# En este caso coincide — el cálculo es correcto
# Pero veamos un caso donde el error silencioso sí existe

print("---")

# Error silencioso real: merge que pierde filas
ventas = pd.DataFrame({
    "id":     [1, 2, 3, 4],
    "ventas": [100, 200, 150, 300]
})
productos = pd.DataFrame({
    "id":      [1, 2, 3],   # ← falta el id 4
    "nombre": ["A", "B", "C"]
})

# inner join por defecto — silenciosamente pierde el id 4
merged = pd.merge(ventas, productos, on="id")
print(f"ventas: {len(ventas)} filas")
print(f"merged: {len(merged)} filas")   # ← 3, no 4
print(f"total ventas original: {ventas['ventas'].sum()}")  # 750
print(f"total ventas merged:   {merged['ventas'].sum()}")   # 450 — incorrecto

print("---")

# PASO 3 — INSPECCIONAR: qué ids se perdieron
ids_originales = set(ventas["id"])
ids_merged = set(merged["id"])
ids_perdidos = ids_originales - ids_merged
print(f"IDs perdidos en el merge: {ids_perdidos}")

# PASO 4 — CORREGIR: usar left join o verificar la completitud
merged_completo = pd.merge(ventas, productos, on="id", how="left")
print(f"\nCon left join: {len(merged_completo)} filas")
print(merged_completo)

— Debugging de código de IA: los errores más frecuentes —

Error frecuente en IA Síntoma Dónde buscar
Nombres de columna incorrectos KeyError df.columns
Tipos incorrectos en columnas TypeError en operación df.dtypes
Merge que pierde filas Total incorrecto silencioso Comparar len() antes y después
Shape incorrecto en numpy Broadcasting inesperado arr.shape en cada paso
Índice desalineado en pandas NaN inesperados df.index y reset_index()
Variable modificada in-place Datos originales corrompidos Usar df.copy() antes de modificar
# El checklist de inspección para código de IA con pandas
def inspeccionar_df(df, nombre="df", n=3):
    """
    Checklist de inspección rápida de un DataFrame.
    Usar en cada paso del pipeline durante debugging.
    """
    print(f"\n{'='*50}")
    print(f"[{nombre}]")
    print(f"  shape:   {df.shape}")
    print(f"  columns: {df.columns.tolist()}")
    print(f"  dtypes:\n{df.dtypes}")
    print(f"  nulos:   {df.isnull().sum().to_dict()}")
    print(f"  head({n}):\n{df.head(n)}")
    print(f"{'='*50}")
    return df   # devuelve df para encadenar en pipeline

import pandas as pd

df = pd.DataFrame({
    "Ventas":    [150, None, 200],
    "precio":    [1.5, 2.0, 2.5],
    "categoria": ["fruta", "fruta", "exótico"]
})

# Usar en un pipeline para inspeccionar cada paso
resultado = (
    inspeccionar_df(df, "original")
    .pipe(lambda d: d.assign(
        ventas=d["Ventas"]   # normalizar nombre
    ).drop(columns=["Ventas"]))
    .pipe(inspeccionar_df, "renombrado")
    .dropna()
    .pipe(inspeccionar_df, "sin nulos")
)
La regla de oro del debugging: nunca modifiques el código hasta entender la causa raíz. Un cambio que parece arreglar el síntoma pero no la causa esconde el problema y lo hace aparecer más tarde en un lugar distinto. El protocolo es: primero entender por qué falla, luego corregir, luego verificar.
  1. Aplica el protocolo de cuatro pasos a este código que produce un resultado incorrecto. ¿Cuál es la causa raíz? ¿Cómo lo corregirías?
    import pandas as pd
    
    df = pd.DataFrame({
        "mes":    ["ene", "feb", "mar"],
        "ventas": [100, 200, 150]
    })
    
    # Queremos añadir una columna con la diferencia
    # respecto al mes anterior
    df["diferencia"] = df["ventas"].diff()
    df["pct_cambio"] = df["diferencia"] / df["ventas"] * 100
    print(df)
    # feb: diferencia=100, pct_cambio=50.0 — ¿correcto?
    # El pct_cambio de feb debería ser (200-100)/100 = 100%
    # pero el código da 50%
  2. ¿Por qué este merge es un error silencioso? ¿Cómo lo detectarías? ¿Cómo lo corregirías?
    clientes = pd.DataFrame({
        "id":     [1, 2, 3, 4, 5],
        "nombre": ["Ana", "Bob", "Carlos", "Diana", "Eva"]
    })
    pedidos = pd.DataFrame({
        "cliente_id": [1, 2, 1, 3],
        "importe":    [100, 200, 150, 80]
    })
    
    resultado = pd.merge(clientes, pedidos,
                         left_on="id", right_on="cliente_id")
    print(f"clientes: {len(clientes)}")
    print(f"resultado: {len(resultado)}")
  3. ¿Cuál es la diferencia entre un error que lanza excepción y un error silencioso? ¿Cuál es más peligroso en análisis de datos y por qué?
  4. Añade la función inspeccionar_df a este pipeline para encontrar en qué paso se pierden las filas:
    df = pd.DataFrame({
        "ventas":    [100, None, 200, 50, 300],
        "precio":    [1.5, 2.0, None, 1.0, 2.5],
        "categoria": ["A", "A", "B", "B", "A"]
    })
    
    resultado = (
        df
        .dropna()
        .query("ventas > 100")
        .assign(ingreso=lambda d: d["ventas"] * d["precio"])
    )
    print(f"resultado: {len(resultado)} filas")
    # Esperábamos más filas — ¿en qué paso se perdieron?
Respuesta orientativa
a

El problema está en el cálculo de pct_cambio. La fórmula divide la diferencia entre el valor actual en lugar del anterior. Para febrero: 100 / 200 * 100 = 50% — pero debería ser 100 / 100 * 100 = 100%.

# Corrección — dividir por el valor anterior
df["pct_cambio"] = df["ventas"].pct_change() * 100
# pct_change() calcula (actual - anterior) / anterior
print(df)
# feb: 100.0% — correcto
# mar: -25.0% — (150-200)/200 = -25%
b

Es un error silencioso porque el merge no lanza ninguna excepción — simplemente produce un resultado con más filas de las esperadas (4, no 5) porque es un inner join: los clientes 4 y 5 no tienen pedidos y desaparecen. Además el cliente 1 aparece dos veces porque tiene dos pedidos.

Cómo detectarlo: comparar len(clientes) con len(resultado) y verificar que todos los ids están presentes. Cómo corregirlo:

# left join — conserva todos los clientes
resultado = pd.merge(
    clientes, pedidos,
    left_on="id", right_on="cliente_id",
    how="left"
)
print(f"resultado con left join: {len(resultado)} filas")
# Los clientes sin pedidos tendrán NaN en importe

# Verificar que no perdemos clientes
assert set(resultado["id"]) == set(clientes["id"]), \
    "El merge perdió clientes"
c

Un error que lanza excepción detiene el programa e indica exactamente dónde está el problema. Es molesto pero fácil de localizar.

Un error silencioso es más peligroso en análisis de datos porque el código termina con éxito, produce resultados, genera gráficos y tablas — pero todo está mal. Una decisión tomada con datos incorrectos es peor que no tener ningún resultado. El único antídoto es verificar sistemáticamente los resultados intermedios contra cálculos manuales.

d
resultado = (
    df
    .pipe(inspeccionar_df, "original")      # 5 filas
    .dropna()
    .pipe(inspeccionar_df, "tras dropna")   # 3 filas — pierde None en ventas y precio
    .query("ventas > 100")
    .pipe(inspeccionar_df, "tras query")    # 2 filas — pierde ventas=50
    .assign(ingreso=lambda d: d["ventas"] * d["precio"])
    .pipe(inspeccionar_df, "final")
)
# Conclusión: dropna elimina 2 filas (nulos en ventas y precio)
# query elimina 1 fila más (ventas=50)
# quedan 2 filas — correcto dado los datos de entrada
Parte IV

Logging: el debugging en producción

El módulo logging: por qué print no es suficiente teoría

print es perfecto para debugging puntual — pero tiene limitaciones serias en código que va a ejecutarse repetidamente o en producción. No tiene niveles de severidad, no tiene timestamps, no se puede activar o desactivar selectivamente, no se puede redirigir a un fichero sin modificar el código, y no hay forma de saber de dónde vino un mensaje en un programa con varios módulos.

El módulo logging resuelve todos estos problemas. Es el estándar de Python para registrar eventos — la IA lo usa en todo código que va más allá de un script exploratorio.

— El problema concreto de print —

# Con print — limitaciones concretas

# 1. No hay niveles — todo tiene la misma importancia
print("Cargando datos...")      # informativo
print("Fichero no encontrado")   # error grave
print("Media calculada: 3.14")   # debug
# — los tres se ven igual en la consola

# 2. No hay timestamps — ¿cuándo ocurrió esto?
print("Proceso iniciado")
# — sin fecha ni hora

# 3. No se puede desactivar sin modificar el código
# Para quitar todos los prints hay que borrarlos uno a uno

# 4. No se puede redirigir a fichero sin cambiar el código
# python script.py > log.txt — funciona pero pierde la consola

print("---")

# Con logging — todos estos problemas resueltos
import logging

# Configuración básica
logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s — %(levelname)s — %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S"
)

logging.debug("Media calculada: 3.14")     # DEBUG
logging.info("Cargando datos...")           # INFO
logging.warning("Columna con nulos")        # WARNING
logging.error("Fichero no encontrado")      # ERROR
logging.critical("Fallo crítico del sistema") # CRITICAL

— Los cinco niveles de logging —

Nivel Valor Cuándo usarlo En análisis de datos
DEBUG 10 Información detallada para debugging — solo en desarrollo Shapes, tipos, valores intermedios
INFO 20 Progreso normal del programa — activado en producción Ficheros cargados, pasos completados
WARNING 30 Algo inesperado pero el programa continúa Nulos encontrados, valores fuera de rango
ERROR 40 Error grave — una operación falló pero el programa sigue Fichero no cargado, columna no encontrada
CRITICAL 50 Fallo que impide continuar — el programa debe terminar Base de datos no disponible, memoria insuficiente
import logging

# Los niveles son números — solo se muestran los >= nivel configurado
print(f"DEBUG:    {logging.DEBUG}")     # 10
print(f"INFO:     {logging.INFO}")      # 20
print(f"WARNING:  {logging.WARNING}")   # 30
print(f"ERROR:    {logging.ERROR}")     # 40
print(f"CRITICAL: {logging.CRITICAL}")  # 50

print("---")

# Configurar nivel WARNING — solo muestra WARNING, ERROR, CRITICAL
logging.basicConfig(level=logging.WARNING)

logging.debug("esto NO se muestra")
logging.info("esto NO se muestra")
logging.warning("esto SÍ se muestra")
logging.error("esto SÍ se muestra")
logging.critical("esto SÍ se muestra")

— getLogger(__name__): el patrón correcto —

Las llamadas directas como logging.info() usan el logger raíz — un logger global compartido por todos los módulos. En un programa con varios módulos esto crea confusión — no sabes de qué módulo viene cada mensaje.

El patrón correcto es crear un logger por módulo con logging.getLogger(__name__). __name__ es el nombre del módulo actual — así cada mensaje indica exactamente de dónde viene. Es el patrón que usa la IA en código bien estructurado.

import logging

# ❌ Logger raíz — no recomendado en módulos
logging.info("mensaje del logger raíz")

# ✓ Logger por módulo — patrón correcto
logger = logging.getLogger(__name__)
# __name__ es el nombre del módulo actual
# En un script: '__main__'
# En un módulo importado: el nombre del módulo

print(f"Nombre del logger: {logger.name}")
print(f"Nivel efectivo:    {logger.getEffectiveLevel()}")

print("---")

# Configuración completa con logger propio
import logging

# Crear logger
logger = logging.getLogger("analisis")
logger.setLevel(logging.DEBUG)

# Crear formatter — el formato de cada mensaje
formatter = logging.Formatter(
    fmt="%(asctime)s | %(name)s | %(levelname)s | %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S"
)

# Crear handler de consola
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)
console_handler.setFormatter(formatter)

# Añadir handler al logger
logger.addHandler(console_handler)

# Usar el logger
logger.debug("Iniciando análisis")
logger.info("Cargando datos desde ventas.csv")
logger.warning("Se encontraron 3 valores nulos en 'precio'")
logger.error("No se pudo calcular la media — datos vacíos")

print("---")

# Variables de formato disponibles
variables_formato = {
    "%(asctime)s":    "fecha y hora del mensaje",
    "%(name)s":       "nombre del logger",
    "%(levelname)s":  "nivel — DEBUG, INFO...",
    "%(message)s":    "el mensaje en sí",
    "%(filename)s":   "nombre del fichero",
    "%(lineno)d":     "número de línea",
    "%(funcName)s":   "nombre de la función",
}
for var, desc in variables_formato.items():
    print(f"  {var:20} → {desc}")

— Handlers: dónde van los mensajes —

Un handler define el destino de los mensajes — consola, fichero, red. Un logger puede tener varios handlers simultáneamente: enviar todos los mensajes a un fichero y solo los errores a la consola. Cada handler puede tener su propio nivel y su propio formatter.

import logging

def crear_logger(nombre, fichero_log=None):
    """
    Crea un logger con handler de consola
    y opcionalmente handler de fichero.
    """
    logger = logging.getLogger(nombre)
    logger.setLevel(logging.DEBUG)

    # Evitar añadir handlers duplicados
    if logger.hasHandlers():
        logger.handlers.clear()

    # Formatter común
    fmt = logging.Formatter(
        "%(asctime)s | %(name)s | %(levelname)-8s | %(message)s",
        datefmt="%H:%M:%S"
    )

    # Handler de consola — solo WARNING y superior
    ch = logging.StreamHandler()
    ch.setLevel(logging.WARNING)
    ch.setFormatter(fmt)
    logger.addHandler(ch)

    # Handler de fichero — todo desde DEBUG
    if fichero_log:
        fh = logging.FileHandler(fichero_log, encoding="utf-8")
        fh.setLevel(logging.DEBUG)
        fh.setFormatter(fmt)
        logger.addHandler(fh)
        logger.info(f"Log guardado en: {fichero_log}")

    return logger

# Logger solo consola
log = crear_logger("pipeline")

log.debug("esto NO aparece en consola — nivel WARNING")
log.info("esto NO aparece en consola")
log.warning("esto SÍ aparece en consola")
log.error("esto SÍ aparece en consola")

print("---")

# Logger con fichero — todo va al fichero, solo WARNING+ a consola
log_completo = crear_logger("pipeline_completo", "pipeline.log")

log_completo.debug("shape de entrada: (1000, 5)")
log_completo.info("Paso 1 completado")
log_completo.warning("3 nulos encontrados")
log_completo.error("Error en paso 3")

# Ver el contenido del fichero
import os
if os.path.exists("pipeline.log"):
    with open("pipeline.log") as f:
        print("Contenido del fichero de log:")
        print(f.read())
El modelo mental del logging: el Logger recibe los mensajes y decide si son suficientemente importantes según su nivel. Los Handler deciden dónde enviarlos — consola, fichero. El Formatter decide cómo formatearlos. Un mensaje puede ir a varios destinos simultáneamente con distintos formatos y distintos niveles de filtrado.
  1. ¿Cuál es la diferencia entre el nivel del logger y el nivel del handler? ¿Qué pasa si el logger tiene nivel DEBUG y el handler tiene nivel WARNING?
  2. ¿Por qué es mejor usar logging.getLogger(__name__) que llamar directamente a logging.info()? ¿Qué ventaja da en un proyecto con varios módulos?
  3. Configura un logger llamado "ventas" que muestre en consola solo los mensajes de nivel WARNING o superior, y que guarde todos los mensajes desde DEBUG en un fichero "ventas.log". Usa el formato: HORA | NIVEL | MENSAJE.
  4. ¿En qué nivel registrarías cada uno de estos eventos? Justifica la respuesta:
    — El pipeline ha procesado 1000 filas correctamente.
    — Se encontraron 5 valores nulos en la columna precio.
    — El fichero de datos no existe en la ruta indicada.
    — La media de ventas calculada en el paso 3 es 142.5.
    — La conexión a la base de datos falló y no se puede continuar.
Respuesta orientativa
a

El nivel del logger es el primer filtro — los mensajes por debajo de ese nivel se descartan inmediatamente sin llegar a ningún handler. El nivel del handler es el segundo filtro — de los mensajes que pasan el filtro del logger, el handler solo procesa los que están por encima de su propio nivel.

Si el logger tiene nivel DEBUG y el handler tiene nivel WARNING: el logger deja pasar todos los mensajes, pero el handler solo muestra los de nivel WARNING o superior. Los mensajes DEBUG e INFO llegan al handler pero este los ignora.

b

logging.info() usa el logger raíz — un objeto global compartido por todos los módulos del programa. En un proyecto con varios módulos todos escriben en el mismo lugar sin indicar de dónde viene cada mensaje.

getLogger(__name__) crea un logger con el nombre del módulo actual. Cada mensaje indica su origen exacto — analisis.limpieza, analisis.pipeline. Permite configurar el nivel de cada módulo de forma independiente y filtrar mensajes por módulo en los logs.

c
import logging

logger = logging.getLogger("ventas")
logger.setLevel(logging.DEBUG)

if logger.hasHandlers():
    logger.handlers.clear()

fmt = logging.Formatter(
    "%(asctime)s | %(levelname)-8s | %(message)s",
    datefmt="%H:%M:%S"
)

# Consola — solo WARNING+
ch = logging.StreamHandler()
ch.setLevel(logging.WARNING)
ch.setFormatter(fmt)
logger.addHandler(ch)

# Fichero — todo desde DEBUG
fh = logging.FileHandler("ventas.log", encoding="utf-8")
fh.setLevel(logging.DEBUG)
fh.setFormatter(fmt)
logger.addHandler(fh)

# Prueba
logger.debug("shape: (500, 4)")          # solo fichero
logger.info("Datos cargados")            # solo fichero
logger.warning("5 nulos encontrados")    # consola + fichero
logger.error("Columna no encontrada")    # consola + fichero
d

Pipeline procesó 1000 filas → INFO: progreso normal, útil en producción para confirmar que el proceso avanza.

5 valores nulos en precio → WARNING: algo inesperado que el programa maneja pero que merece atención.

Fichero no existe → ERROR: una operación falló — el fichero no se cargó — pero quizás el programa puede continuar con otros datos.

Media calculada 142.5 → DEBUG: información detallada solo útil durante el desarrollo — no necesaria en producción.

Conexión a BD fallida → CRITICAL: el programa no puede continuar su función principal — es un fallo irrecuperable.

Logging en la práctica: pipelines, decoradores y producción práctica

El logging no es solo para registrar errores. En un pipeline de análisis de datos bien instrumentado, los logs permiten reconstruir exactamente qué ocurrió, cuándo, en qué orden y con qué datos — sin necesidad de ejecutar el código de nuevo. Son la memoria del programa.

— Logging en un pipeline de análisis —

import logging
import pandas as pd
import numpy as np
import time

# Configurar logger del módulo
logger = logging.getLogger("pipeline")
logger.setLevel(logging.DEBUG)

if not logger.hasHandlers():
    fmt = logging.Formatter(
        "%(asctime)s | %(levelname)-8s | %(message)s",
        datefmt="%H:%M:%S"
    )
    ch = logging.StreamHandler()
    ch.setLevel(logging.DEBUG)
    ch.setFormatter(fmt)
    logger.addHandler(ch)

# Pipeline instrumentado con logging
def cargar_datos(ruta):
    logger.info(f"Cargando datos desde: {ruta}")
    try:
        df = pd.read_csv(ruta)
        logger.info(f"Datos cargados: {df.shape[0]} filas, {df.shape[1]} columnas")
        logger.debug(f"Columnas: {df.columns.tolist()}")
        logger.debug(f"Tipos:\n{df.dtypes.to_dict()}")
        return df
    except FileNotFoundError:
        logger.error(f"Fichero no encontrado: {ruta}")
        return None

def limpiar_datos(df):
    logger.info("Iniciando limpieza de datos")
    filas_inicial = len(df)

    nulos = df.isnull().sum()
    cols_con_nulos = nulos[nulos > 0]
    if not cols_con_nulos.empty:
        logger.warning(
            f"Columnas con nulos: {cols_con_nulos.to_dict()}"
        )

    df = df.dropna()
    filas_eliminadas = filas_inicial - len(df)

    if filas_eliminadas > 0:
        pct = filas_eliminadas / filas_inicial * 100
        logger.warning(
            f"Eliminadas {filas_eliminadas} filas con nulos ({pct:.1f}%)"
        )
    else:
        logger.info("Sin valores nulos — datos completos")

    logger.debug(f"Shape tras limpieza: {df.shape}")
    return df

def calcular_estadisticas(df, columna):
    logger.info(f"Calculando estadísticas de '{columna}'")

    if columna not in df.columns:
        logger.error(f"Columna '{columna}' no existe. Disponibles: {df.columns.tolist()}")
        return None

    stats = {
        "media":    df[columna].mean(),
        "mediana":  df[columna].median(),
        "desv":     df[columna].std(),
        "minimo":   df[columna].min(),
        "maximo":   df[columna].max(),
    }

    logger.debug(f"Estadísticas de '{columna}': {stats}")

    outliers = df[
        (df[columna] > stats["media"] + 3 * stats["desv"]) |
        (df[columna] < stats["media"] - 3 * stats["desv"])
    ]
    if not outliers.empty:
        logger.warning(f"Detectados {len(outliers)} outliers en '{columna}'")

    return stats

# Ejecutar el pipeline
np.random.seed(42)
df_test = pd.DataFrame({
    "ventas": np.random.normal(150, 30, 100).tolist() + [999],
    "precio": np.random.uniform(1, 3, 101),
})
df_test.loc[5, "ventas"] = None
df_test.loc[10, "precio"] = None
df_test.to_csv("ventas_test.csv", index=False)

df = cargar_datos("ventas_test.csv")
if df is not None:
    df = limpiar_datos(df)
    stats = calcular_estadisticas(df, "ventas")
    print(f"\nEstadísticas finales: {stats}")

— logging.exception(): registrar el traceback completo —

logger.exception() es equivalente a logger.error() pero añade automáticamente el traceback completo del error al mensaje. Solo funciona dentro de un bloque except. Es la forma correcta de registrar un error inesperado — el log incluye toda la información necesaria para entender qué ocurrió sin necesidad de reproducir el error.

import logging

logger = logging.getLogger("errores")
logger.setLevel(logging.DEBUG)
if not logger.hasHandlers():
    ch = logging.StreamHandler()
    ch.setFormatter(logging.Formatter(
        "%(asctime)s | %(levelname)-8s | %(message)s",
        datefmt="%H:%M:%S"
    ))
    logger.addHandler(ch)

def procesar_seguro(datos):
    """Procesa datos registrando cualquier error con su traceback."""
    try:
        resultado = sum(datos) / len(datos)
        logger.info(f"Procesado correctamente: media={resultado:.2f}")
        return resultado
    except ZeroDivisionError:
        logger.error("Lista vacía — no se puede calcular la media")
        return None
    except TypeError:
        # exception() añade el traceback completo
        logger.exception("Error de tipo inesperado procesando datos")
        return None
    except Exception:
        # Para errores inesperados — siempre exception()
        logger.exception("Error inesperado — contacta con el administrador")
        return None

procesar_seguro([1, 2, 3])
procesar_seguro([])
procesar_seguro([1, "dos", 3])   # TypeError — muestra traceback

— La conexión con la Clase 6d: decoradores con logging real —

En la Clase 6d vimos decoradores como @logger("INFO") que usaban print. Ahora podemos escribir la versión correcta — usando el módulo logging real. El mecanismo es exactamente el mismo — la diferencia es que ahora los mensajes tienen nivel, timestamp y pueden ir a fichero.

import logging
import time
from functools import wraps

# Decorador de logging con el módulo logging real
def log_llamada(nivel=logging.INFO):
    """
    Fábrica de decoradores que registra las llamadas
    usando el módulo logging con el nivel indicado.
    """
    def decorator(func):
        # Logger con el nombre del módulo de la función
        func_logger = logging.getLogger(func.__module__)

        @wraps(func)
        def wrapper(*args, **kwargs):
            func_logger.log(
                nivel,
                f"→ {func.__name__} llamada con args={args}, kwargs={kwargs}"
            )
            inicio = time.time()
            try:
                resultado = func(*args, **kwargs)
                duracion = time.time() - inicio
                func_logger.log(
                    nivel,
                    f"← {func.__name__} completada en {duracion:.4f}s"
                )
                return resultado
            except Exception as e:
                duracion = time.time() - inicio
                func_logger.exception(
                    f"✗ {func.__name__} falló tras {duracion:.4f}s: {e}"
                )
                raise
        return wrapper
    return decorator

# Configurar logging para ver los mensajes
logging.basicConfig(
    level=logging.DEBUG,
    format="%(asctime)s | %(name)s | %(levelname)-8s | %(message)s",
    datefmt="%H:%M:%S"
)

# Usar el decorador
@log_llamada(nivel=logging.INFO)
def cargar_csv(ruta):
    """Carga un CSV y devuelve un DataFrame."""
    import pandas as pd
    return pd.read_csv(ruta)

@log_llamada(nivel=logging.DEBUG)
def calcular_media(datos):
    """Calcula la media de una lista."""
    return sum(datos) / len(datos)

# Las llamadas se registran automáticamente
media = calcular_media([1, 2, 3, 4, 5])
print(f"Media: {media}")

try:
    calcular_media([])   # ZeroDivisionError — se registra con traceback
except ZeroDivisionError:
    pass

— Desarrollo vs producción: configuraciones distintas —

import logging
import os

def configurar_logging(entorno="desarrollo"):
    """
    Configura el logging según el entorno.
    En desarrollo: DEBUG en consola.
    En producción: INFO en fichero + WARNING en consola.
    """
    logger = logging.getLogger("app")
    logger.setLevel(logging.DEBUG)

    if logger.hasHandlers():
        logger.handlers.clear()

    fmt_consola = logging.Formatter(
        "%(levelname)-8s | %(message)s"
    )
    fmt_fichero = logging.Formatter(
        "%(asctime)s | %(name)s | %(levelname)s | %(message)s | %(filename)s:%(lineno)d",
        datefmt="%Y-%m-%d %H:%M:%S"
    )

    if entorno == "desarrollo":
        # Todo en consola — nivel DEBUG
        ch = logging.StreamHandler()
        ch.setLevel(logging.DEBUG)
        ch.setFormatter(fmt_consola)
        logger.addHandler(ch)
        logger.debug("Logging configurado: DESARROLLO (DEBUG)")

    elif entorno == "produccion":
        # Consola: solo WARNING+
        ch = logging.StreamHandler()
        ch.setLevel(logging.WARNING)
        ch.setFormatter(fmt_consola)
        logger.addHandler(ch)

        # Fichero: INFO+ con rotación
        from logging.handlers import RotatingFileHandler
        fh = RotatingFileHandler(
            "app.log",
            maxBytes=5 * 1024 * 1024,   # 5 MB
            backupCount=3,               # máx 3 ficheros de backup
            encoding="utf-8"
        )
        fh.setLevel(logging.INFO)
        fh.setFormatter(fmt_fichero)
        logger.addHandler(fh)
        logger.info("Logging configurado: PRODUCCIÓN (INFO en fichero)")

    return logger

# Leer el entorno de una variable de entorno
entorno = os.environ.get("ENTORNO", "desarrollo")
log = configurar_logging(entorno)

log.debug("Mensaje de debug — solo en desarrollo")
log.info("Aplicación iniciada")
log.warning("Configuración por defecto — revisa las variables de entorno")
log.error("Error simulado para prueba")
El ciclo completo de la Clase 8: los errores son objetos con tipo y jerarquía. El traceback es el stack del momento del fallo. try/except captura y maneja los errores. raise lanza errores con mensajes claros. El debugging con print, assert y pdb localiza la causa raíz. Y el logging registra el comportamiento del programa con niveles, timestamps y destinos configurables — para desarrollo y producción. Todo conectado con los módulos, los decoradores y el paradigma de las clases anteriores.
  1. ¿Cuál es la diferencia entre logger.error("mensaje") y logger.exception("mensaje")? ¿Cuándo usarías cada uno?
  2. ¿Por qué el decorador @log_llamada usa func.__module__ para obtener el logger en lugar de crear uno fijo? ¿Qué ventaja da eso?
  3. Reescribe este código sustituyendo los print por llamadas al módulo logging con el nivel apropiado:
    def procesar_ficheros(carpeta):
        import glob
        ficheros = glob.glob(f"{carpeta}/*.csv")
    
        print(f"Encontrados {len(ficheros)} ficheros")
    
        if not ficheros:
            print("No hay ficheros CSV en la carpeta")
            return []
    
        resultados = []
        for f in ficheros:
            try:
                import pandas as pd
                df = pd.read_csv(f)
                print(f"Cargado {f}: {df.shape}")
                resultados.append(df)
            except Exception as e:
                print(f"Error cargando {f}: {e}")
    
        print(f"Procesados {len(resultados)} ficheros correctamente")
        return resultados
  4. ¿Qué es un RotatingFileHandler y por qué es mejor que un FileHandler normal en producción? ¿Qué significa backupCount=3?
Respuesta orientativa
a

logger.error("mensaje") registra el mensaje con nivel ERROR — sin información adicional sobre el error que pudo haberlo causado.

logger.exception("mensaje") registra el mensaje con nivel ERROR y añade automáticamente el traceback completo del error activo en el bloque except. Solo funciona dentro de un bloque except — fuera de él no hay traceback que añadir. Usa error() para errores esperados y manejados — usa exception() para errores inesperados donde el traceback es necesario para entender qué falló.

b

func.__module__ es el nombre del módulo donde está definida la función decorada — no el módulo donde está el decorador. Si la función cargar_csv está en analisis.datos, el logger se llama analisis.datos — los mensajes identifican exactamente de qué módulo vienen. Si usara un logger fijo todos los mensajes de todas las funciones decoradas irían al mismo logger sin distinción de origen.

c
import logging
import glob
import pandas as pd

logger = logging.getLogger(__name__)

def procesar_ficheros(carpeta):
    ficheros = glob.glob(f"{carpeta}/*.csv")

    logger.info(f"Encontrados {len(ficheros)} ficheros en {carpeta}")

    if not ficheros:
        logger.warning(f"No hay ficheros CSV en: {carpeta}")
        return []

    resultados = []
    for f in ficheros:
        try:
            df = pd.read_csv(f)
            logger.info(f"Cargado '{f}': {df.shape}")
            logger.debug(f"  dtypes: {df.dtypes.to_dict()}")
            resultados.append(df)
        except Exception:
            logger.exception(f"Error cargando '{f}'")

    logger.info(
        f"Procesados {len(resultados)}/{len(ficheros)} ficheros"
    )
    return resultados
d

Un FileHandler normal escribe en un fichero que crece indefinidamente — en un proceso que lleva días o semanas ejecutando, el fichero de log puede llegar a ocupar gigabytes.

Un RotatingFileHandler limita el tamaño del fichero. Cuando el fichero alcanza maxBytes — 5 MB en el ejemplo — lo renombra a app.log.1 y empieza uno nuevo. backupCount=3 significa que mantiene hasta 3 ficheros de backup: app.log, app.log.1, app.log.2, app.log.3. Cuando habría un cuarto, elimina el más antiguo. El espacio en disco queda acotado a maxBytes × (backupCount + 1) — en el ejemplo, 20 MB máximo.