Indice / Repaso: archivos y errores / 1.6
Informe de rechazos y logging
Descartar una fila no es el problema. El problema es descartarla sin que quede constancia: un resultado con menos datos de los que dice se ve exactamente igual que uno completo.
Para qué sirve
La lección 1.5 clasificó los defectos. Esta responde qué se hace después: cómo se cuenta, dónde se escribe y quién lo lee. Es la diferencia entre un script que anda en tu máquina y un proceso del que alguien puede fiarse.
Y es corta de aprender: un módulo de la biblioteca estándar, cinco niveles, y una lista de cinco cosas que todo informe tiene que decir.
ELI5Explicado facil
Un análisis de sangre no dice solo «hemoglobina 13,2». Dice también el rango de referencia, la fecha, el método, y si alguna muestra se descartó.
Sin ese contexto el número es indistinguible de uno inventado. Un total calculado sobre 494 de 500 filas necesita exactamente lo mismo.
logging en cinco minutos
import logging
import sys
# format y level se configuran UNA vez, al arrancar el programa.
logging.basicConfig(
level=logging.INFO,
format="%(levelname)-8s %(message)s",
stream=sys.stdout,
)
log = logging.getLogger("ventas")
log.debug("leyendo la fila 47 del archivo")
log.info("archivo leido: 500 filas")
log.warning("3 filas sin costo: se descartan")
log.error("no se pudo escribir el informe")
log.critical("la carpeta de salida no existe: no hay nada que hacer")
print()
print("El DEBUG no aparecio: level=INFO define el piso.")
print("Cambiar ese unico valor apaga o enciende toda la traza fina,")
print("sin tocar ni borrar una sola linea de codigo.")
print()
print("Y esa es la diferencia con print():")
print(" - print va siempre; el nivel se decide una vez y afecta a todo")
print(" - el mensaje trae nivel, y despues fecha, modulo, linea")
print(" - se puede mandar a un archivo sin tocar el codigo que lo emite")
print(" - una biblioteca que hace print le ensucia la salida al que la usa")
print()
# El nivel tambien se puede consultar, para no armar mensajes caros.
print("nivel efectivo:", logging.getLevelName(log.getEffectiveLevel()))
print("¿vale la pena armar el mensaje de debug?", log.isEnabledFor(logging.DEBUG))
INFO archivo leido: 500 filas
WARNING 3 filas sin costo: se descartan
ERROR no se pudo escribir el informe
CRITICAL la carpeta de salida no existe: no hay nada que hacer
El DEBUG no aparecio: level=INFO define el piso.
Cambiar ese unico valor apaga o enciende toda la traza fina,
sin tocar ni borrar una sola linea de codigo.
Y esa es la diferencia con print():
- print va siempre; el nivel se decide una vez y afecta a todo
- el mensaje trae nivel, y despues fecha, modulo, linea
- se puede mandar a un archivo sin tocar el codigo que lo emite
- una biblioteca que hace print le ensucia la salida al que la usa
nivel efectivo: INFO
¿vale la pena armar el mensaje de debug? False
| Nivel | Significa | En un proceso de datos |
|---|---|---|
DEBUG | Detalle para depurar | «leyendo la fila 47». Apagado en producción |
INFO | Marcha normal | «500 filas leídas», «informe escrito» |
WARNING | Algo no ideal, pero se sigue | El nivel de los datos sucios. «6 filas rechazadas» |
ERROR | Una operación no se pudo hacer | «no se pudo escribir el informe» |
CRITICAL | El programa no puede seguir | «la carpeta de salida no existe» |
print
print va siempre y va a un solo lado. Con logging, el piso
de detalle se decide en una línea y afecta a todo el programa; el mensaje trae nivel,
hora y módulo sin que los escribas; y el destino se cambia sin tocar el código que
emite. Además, una biblioteca que hace print le ensucia la salida a quien
la usa - con logging, quien la usa decide si quiere verla.
Cómo se informan 500 filas
import collections
import csv
import logging
import sys
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s",
stream=sys.stdout)
log = logging.getLogger("ventas")
NULOS = {"", "-", "N/A", "NaN", "null"}
with open("dw_hecho_ventas.csv", encoding="utf-8", newline="") as f:
filas = list(csv.DictReader(f))
def defectos(fila):
"""Los motivos por los que la fila no sirve."""
fallas = []
for columna in ("fecha", "cantidad", "costo", "ingreso"):
if fila[columna].strip() in NULOS:
fallas.append(("ausente", columna))
for columna in ("cantidad", "ingreso", "costo"):
crudo = fila[columna].strip()
if crudo in NULOS:
continue
try:
if float(crudo) < 0:
fallas.append(("negativo_incoherente", columna))
except ValueError:
fallas.append(("no_numerico", columna))
return fallas
# ANTIPATRON 1: no informar nada.
buenas = [x for x in filas if not defectos(x)]
print("1. sin informe:", len(buenas), "filas procesadas")
print(" (el que lee esto no tiene forma de saber que faltan",
len(filas) - len(buenas), "filas)")
print()
# ANTIPATRON 2: una linea de log por fila mala.
print(f"2. una linea por rechazo (las primeras 5 de {len(filas) - len(buenas)}):")
mostradas = 0
for fila in filas:
for motivo, columna in defectos(fila):
if mostradas < 5:
log.warning("venta %s: %s en %s", fila["venta_id"], motivo, columna)
mostradas += 1
print(" (con 500 filas y un archivo grande esto son miles de lineas:")
print(" nadie las lee, y las importantes quedan enterradas)")
print()
# LA FORMA QUE SIRVE: agregado al final + el detalle a un archivo.
print("3. resumen agregado:")
conteo = collections.Counter()
rechazadas = []
for fila in filas:
fallas = defectos(fila)
if fallas:
rechazadas.append(fila)
for motivo, columna in fallas:
conteo[(motivo, columna)] += 1
log.info("leidas %d filas de dw_hecho_ventas.csv", len(filas))
log.info("validas %d | rechazadas %d", len(buenas), len(rechazadas))
for (motivo, columna), cuantos in sorted(conteo.items()):
log.warning(" %-22s %-9s %3d", motivo, columna, cuantos)
log.info("el detalle fila por fila va a ventas_rechazadas.csv")
print()
print("El log dice CUANTOS y de que tipo; el archivo de rechazos dice")
print("CUALES. Son dos cosas distintas y ninguna reemplaza a la otra.")
1. sin informe: 494 filas procesadas
(el que lee esto no tiene forma de saber que faltan 6 filas)
2. una linea por rechazo (las primeras 5 de 6):
WARNING venta 91: negativo_incoherente en cantidad
WARNING venta 128: ausente en cantidad
WARNING venta 177: negativo_incoherente en ingreso
WARNING venta 213: ausente en costo
WARNING venta 266: ausente en costo
(con 500 filas y un archivo grande esto son miles de lineas:
nadie las lee, y las importantes quedan enterradas)
3. resumen agregado:
INFO leidas 500 filas de dw_hecho_ventas.csv
INFO validas 494 | rechazadas 6
WARNING ausente cantidad 1
WARNING ausente costo 2
WARNING ausente fecha 1
WARNING negativo_incoherente cantidad 1
WARNING negativo_incoherente ingreso 1
INFO el detalle fila por fila va a ventas_rechazadas.csv
El log dice CUANTOS y de que tipo; el archivo de rechazos dice
CUALES. Son dos cosas distintas y ninguna reemplaza a la otra.
Las tres opciones corren y ninguna falla. La primera oculta que faltan 6 filas; la segunda genera una línea por rechazo, que con un archivo grande son miles y las importantes quedan enterradas; la tercera dice cuántas, de qué tipo, y dónde está el detalle.
Son dos artefactos distintos y ninguno reemplaza al otro. El log lo lee una persona mientras el proceso corre; el archivo de rechazos lo abre después el que tiene que arreglar los datos, y necesita el identificador de cada fila.
Por eso las prácticas del curso piden los dos: la salida limpia y el
*_rechazados.csv con id, columna,
motivo y valor.
Dos destinos, un solo código
import logging
import re
import sys
import tempfile
from pathlib import Path
carpeta = Path(tempfile.mkdtemp())
archivo = carpeta / "proceso.log"
log = logging.getLogger("ventas")
log.setLevel(logging.DEBUG) # el logger deja pasar todo...
consola = logging.StreamHandler(sys.stdout)
consola.setLevel(logging.WARNING) # ...y cada destino filtra lo suyo
consola.setFormatter(logging.Formatter("consola | %(levelname)-8s %(message)s"))
disco = logging.FileHandler(archivo, encoding="utf-8")
disco.setLevel(logging.DEBUG)
disco.setFormatter(logging.Formatter(
"%(asctime)s %(name)s %(levelname)-8s %(message)s",
datefmt="%H:%M:%S"))
log.addHandler(consola)
log.addHandler(disco)
log.debug("abriendo dw_hecho_ventas.csv")
log.info("500 filas leidas")
log.warning("6 filas rechazadas")
log.error("no se pudo convertir el costo de la venta 213")
# exception() = error() + el traceback completo. Solo tiene sentido
# adentro de un except. Lo mandamos solo al archivo: un traceback en la
# consola asusta al que mira, y en el archivo es justo lo que se busca.
log.removeHandler(consola)
try:
int("N/A")
except ValueError:
log.exception("fallo la conversion de la venta 213")
log.addHandler(consola)
print()
print("Arriba salio la consola: solo WARNING y peores, sin traceback.")
print()
print("Y esto es lo que quedo en", archivo.name, "(hora y rutas tapadas")
print("para que la salida de esta pagina no cambie en cada corrida):")
print("-" * 62)
texto = archivo.read_text(encoding="utf-8")
texto = re.sub(r"^\d\d:\d\d:\d\d", "HH:MM:SS", texto, flags=re.M)
texto = re.sub(r'File "[^"]*[\\/]([^"\\/]+)"', r'File ".../\1"', texto)
for linea in texto.splitlines():
print(linea)
print("-" * 62)
print()
print("El mismo codigo produjo las dos salidas. La consola es para la")
print("persona que esta mirando; el archivo, para cuando algo salio mal")
print("y hay que reconstruir que paso una semana despues.")
print()
print("Detalle que ahorra tiempo: los argumentos van SEPARADOS por coma,")
print("no interpolados en la cadena.")
log.warning("venta %s rechazada por %s", 213, "costo ausente")
print(" con %s : el mensaje se arma solo si el nivel lo deja pasar")
print(" con f-string: se arma siempre, aunque despues se descarte")
for h in list(log.handlers):
h.close()
log.removeHandler(h)
consola | WARNING 6 filas rechazadas
consola | ERROR no se pudo convertir el costo de la venta 213
Arriba salio la consola: solo WARNING y peores, sin traceback.
Y esto es lo que quedo en proceso.log (hora y rutas tapadas
para que la salida de esta pagina no cambie en cada corrida):
--------------------------------------------------------------
HH:MM:SS ventas DEBUG abriendo dw_hecho_ventas.csv
HH:MM:SS ventas INFO 500 filas leidas
HH:MM:SS ventas WARNING 6 filas rechazadas
HH:MM:SS ventas ERROR no se pudo convertir el costo de la venta 213
HH:MM:SS ventas ERROR fallo la conversion de la venta 213
Traceback (most recent call last):
File ".../u1-06-e3.py", line 37, in <module>
int("N/A")
~~~^^^^^^^
ValueError: invalid literal for int() with base 10: 'N/A'
--------------------------------------------------------------
El mismo codigo produjo las dos salidas. La consola es para la
persona que esta mirando; el archivo, para cuando algo salio mal
y hay que reconstruir que paso una semana despues.
Detalle que ahorra tiempo: los argumentos van SEPARADOS por coma,
no interpolados en la cadena.
consola | WARNING venta 213 rechazada por costo ausente
con %s : el mensaje se arma solo si el nivel lo deja pasar
con f-string: se arma siempre, aunque despues se descarte
El logger deja pasar todo y cada handler filtra lo suyo: la consola
muestra WARNING para arriba, el archivo guarda desde DEBUG
con hora y nombre de módulo. Cuando algo salga mal una semana después, el archivo es
lo único que va a quedar.
log.exception(...) dentro de un except agrega el traceback
completo al mensaje. Es la única forma de conservar el error técnico sin dejar que el
programa se caiga.
Y los argumentos van separados por coma:
log.warning("venta %s rechazada", id), no una f-string. Con la coma, el
mensaje se arma solo si el nivel lo deja pasar; con f-string se arma siempre, aunque
después se descarte.
Los antipatrones
import collections
import csv
import logging
import sys
import tempfile
from pathlib import Path
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s",
stream=sys.stdout)
log = logging.getLogger("ventas")
NULOS = {"", "-", "N/A", "NaN", "null"}
with open("dw_hecho_ventas.csv", encoding="utf-8", newline="") as f:
filas = list(csv.DictReader(f))
print("ANTIPATRON 1: except: pass")
total = 0.0
for fila in filas:
try:
total += float(fila["costo"])
except ValueError:
pass
print(" costo total:", round(total, 2))
print(" ¿sobre cuantas filas? No hay forma de saberlo desde el resultado.")
print()
print("ANTIPATRON 2: print(error) sin decir donde")
mostrados = 0
for fila in filas:
try:
float(fila["costo"])
except ValueError as error:
if mostrados < 3:
print(" ", error)
mostrados += 1
print(" El mensaje no dice que fila ni que columna: con 500 filas y")
print(" siete columnas, encontrarla es trabajo manual.")
print()
print("ANTIPATRON 3: nivel equivocado")
log.error("costo ausente en la venta 213")
print(" ERROR es 'el programa no pudo seguir'. Un dato sucio en un")
print(" archivo sucio es lo esperable: eso es WARNING.")
print(" Si todo es ERROR, nadie mira los ERROR.")
print()
print("ANTIPATRON 4: informar sin el denominador")
log.warning("6 filas rechazadas")
print(" ¿6 de 10 o 6 de 500? Es la diferencia entre tirar el archivo")
print(" y seguir adelante. Un conteo sin total no informa nada.")
print()
print("=" * 62)
print("EL INFORME QUE SI SIRVE")
print("=" * 62)
def defectos(fila):
fallas = []
for columna in ("fecha", "cantidad", "costo", "ingreso"):
if fila[columna].strip() in NULOS:
fallas.append(("ausente", columna))
for columna in ("cantidad", "ingreso", "costo"):
crudo = fila[columna].strip()
if crudo not in NULOS:
try:
if float(crudo) < 0:
fallas.append(("negativo_incoherente", columna))
except ValueError:
fallas.append(("no_numerico", columna))
return fallas
conteo = collections.Counter()
buenas, rechazadas, malas = [], [], []
for fila in filas:
fallas = defectos(fila)
if fallas:
rechazadas.append(fila)
for motivo, columna in fallas:
conteo[(motivo, columna)] += 1
malas.append({"venta_id": fila["venta_id"], "columna": columna,
"motivo": motivo, "valor": fila[columna]})
else:
buenas.append(fila)
carpeta = Path(tempfile.mkdtemp())
with open(carpeta / "ventas_rechazadas.csv", "w", encoding="utf-8",
newline="") as f:
escritor = csv.DictWriter(
f, fieldnames=["venta_id", "columna", "motivo", "valor"])
escritor.writeheader()
escritor.writerows(malas)
log.info("entrada : %d filas", len(filas))
log.info("validas : %d (%.1f%%)", len(buenas), 100 * len(buenas) / len(filas))
log.info("rechazadas : %d", len(rechazadas))
for (motivo, columna), cuantos in sorted(conteo.items()):
log.warning(" %-22s %-9s %3d", motivo, columna, cuantos)
log.info("detalle : ventas_rechazadas.csv (%d filas)", len(malas))
# Las dos listas se llenaron por separado: que la suma de es una
# comprobacion de verdad, no una tautologia.
assert len(buenas) + len(rechazadas) == len(filas)
log.info("invariante : validas + rechazadas == entrada OK")
print()
print("Tiene las cinco cosas: el total de entrada, el de salida, el")
print("desglose por motivo, el archivo con el detalle, y la comprobacion")
print("de que no se perdio ninguna fila por el camino.")
ANTIPATRON 1: except: pass
costo total: 1320349.5
¿sobre cuantas filas? No hay forma de saberlo desde el resultado.
ANTIPATRON 2: print(error) sin decir donde
could not convert string to float: ''
could not convert string to float: 'N/A'
El mensaje no dice que fila ni que columna: con 500 filas y
siete columnas, encontrarla es trabajo manual.
ANTIPATRON 3: nivel equivocado
ERROR costo ausente en la venta 213
ERROR es 'el programa no pudo seguir'. Un dato sucio en un
archivo sucio es lo esperable: eso es WARNING.
Si todo es ERROR, nadie mira los ERROR.
ANTIPATRON 4: informar sin el denominador
WARNING 6 filas rechazadas
¿6 de 10 o 6 de 500? Es la diferencia entre tirar el archivo
y seguir adelante. Un conteo sin total no informa nada.
==============================================================
EL INFORME QUE SI SIRVE
==============================================================
INFO entrada : 500 filas
INFO validas : 494 (98.8%)
INFO rechazadas : 6
WARNING ausente cantidad 1
WARNING ausente costo 2
WARNING ausente fecha 1
WARNING negativo_incoherente cantidad 1
WARNING negativo_incoherente ingreso 1
INFO detalle : ventas_rechazadas.csv (6 filas)
INFO invariante : validas + rechazadas == entrada OK
Tiene las cinco cosas: el total de entrada, el de salida, el
desglose por motivo, el archivo con el detalle, y la comprobacion
de que no se perdio ninguna fila por el camino.
| Antipatrón | Por qué duele |
|---|---|
except: pass | El resultado no dice sobre cuántas filas se calculó. Es la forma más rápida de publicar un número falso |
print(error) a secas | could not convert string to float: '' no dice qué fila ni qué columna. Con 500 filas y 7 columnas, encontrarla es trabajo manual |
Todo en ERROR | ERROR significa «no se pudo hacer». Un dato sucio en un archivo sucio es lo esperable: es WARNING. Si todo es ERROR, nadie mira los ERROR |
| Contar sin el total | «6 filas rechazadas» ¿de 10 o de 500? Es la diferencia entre tirar el archivo y seguir adelante |
Las cinco cosas que todo informe tiene que decir
- Cuántas filas entraron. El denominador de todo lo demás.
- Cuántas salieron, y qué porcentaje es eso.
- El desglose por motivo, con el vocabulario cerrado de la lección 1.5.
- Dónde está el detalle: el archivo de rechazos, fila por fila.
- El invariante:
válidas + rechazadas == entrada, comprobado con unassertsobre dos listas que se llenaron por separado.
Las cinco caben en seis líneas de código y son lo que separa un resultado publicable de uno que hay que creer de palabra.
Dónde se rompe
Causa: nunca se llamó a basicConfig y el nivel por defecto
del logger raíz es WARNING: los info y
debug se descartan.
Arreglo: logging.basicConfig(level=logging.INFO) una vez, al
arrancar.
Causa: se agregaron handlers más de una vez, o el logger propaga al raíz que ya tiene uno.
Arreglo: configurar los handlers una sola vez, o
log.propagate = False. En un notebook, reejecutar la celda de
configuración es la causa número uno.
Causa: mezclaste los estilos: log.info(f"…{x}", x), o hay
más %s que argumentos.
Arreglo: elegir uno. En logging, el recomendado es
%s con los valores separados por coma.
Causa: asctime usa la hora local de la máquina que
corre.
Arreglo: si el log lo van a leer en otro lado, guardar la fecha en UTC o dejar constancia del huso en el formato.
Causa: no hubo rechazos y se salteó el writeheader() por
escribir solo si la lista tenía algo.
Arreglo: escribir siempre la cabecera. Un archivo con cabecera y cero filas dice «revisé y no encontré nada»; un archivo vacío no dice nada.
Recetario
| Expresión | Qué hace | Cuándo |
|---|---|---|
logging.basicConfig(level=..., format=...) | Configura el logger raíz | Una vez, al arrancar |
log = logging.getLogger("nombre") | Un logger con nombre propio | Uno por módulo |
log.warning("%s de %s", a, b) | Mensaje con argumentos perezosos | Siempre, en vez de f-string |
log.exception("...") | error() + traceback | Dentro de un except |
logging.FileHandler(ruta, encoding="utf-8") | Manda el log a un archivo | Procesos que corren solos |
handler.setLevel(...) | Filtra por destino | Consola breve, archivo detallado |
log.isEnabledFor(logging.DEBUG) | ¿Vale la pena armar el mensaje? | Mensajes caros de construir |
Counter[(motivo, columna)] | El desglose | El corazón del informe |
assert len(ok) + len(no) == len(entrada) | Que no falte ninguna | Antes de escribir nada |
escritor.writeheader() | Cabecera aunque no haya filas | Siempre |