Clase 8 — Errores, excepciones, debugging y logging
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 —
# 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() |
# A
datos = [1, 2, 3]
datos[5]
# B
int("3.14")
# C
42.upper()
# D
d = {"a": 1}
d["b"]
# E
len(42)
isinstance(e, Exception)
devuelve True
para casi cualquier error
pero no para
KeyboardInterrupt
ni SystemExit?
¿Qué consecuencia práctica
tiene eso?
TypeError
y ValueError?
Escribe un ejemplo
que produzca cada uno
con la misma función
int().
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)
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.
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.
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}")
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().
df.columns = df.columns.str.lower().
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 —
# 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)
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'
e.__cause__
en un traceback anidado?
¿Cuándo aparece
el mensaje
"During handling of
the above exception,
another exception occurred"?
def media(datos):
return sum(datos) / len(datos)
def analizar(fichero):
datos = []
return media(datos)
analizar("ventas.csv")
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().
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.
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.
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.
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 —
# 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
# ❌ 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.
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)
def cargar_csv(ruta):
try:
import pandas as pd
df = pd.read_csv(ruta)
return df
except:
return None
else
en un try/except?
¿Por qué es mejor poner
el código de éxito en
else
que al final del
try?
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.
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.
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
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.
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? ")
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
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.
raise ValueError("mensaje")
y
raise ValueError("mensaje") from e?
¿Cuándo usarías cada uno?
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.
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?
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
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.
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}")
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.
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
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}")
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.
assert condición
y
if not condición: raise ValueError?
¿Cuándo usarías cada uno?
def procesar(df):
resultado = df.groupby("categoria")["ventas"].sum()
print(resultado) # ← mejorar este print
return resultado
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))
debug=False
como parámetro de función
es mejor que poner
y quitar prints
manualmente?
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.
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
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.
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.
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")
)
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%
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)}")
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?
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%
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"
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.
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
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())
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.
DEBUG
y el handler tiene nivel
WARNING?
logging.getLogger(__name__)
que llamar directamente a
logging.info()?
¿Qué ventaja da en un proyecto
con varios módulos?
"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.
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.
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.
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
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.
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")
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.
logger.error("mensaje")
y
logger.exception("mensaje")?
¿Cuándo usarías cada uno?
@log_llamada
usa
func.__module__
para obtener el logger
en lugar de crear uno fijo?
¿Qué ventaja da eso?
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
RotatingFileHandler
y por qué es mejor
que un
FileHandler
normal en producción?
¿Qué significa
backupCount=3?
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ó.
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.
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
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.