Saltar al contenido

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

Ejemplologging: los cinco niveles y para que sirve cada uno
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
Los cinco niveles, y cuándo usar cada uno
NivelSignificaEn un proceso de datos
DEBUGDetalle para depurar«leyendo la fila 47». Apagado en producción
INFOMarcha normal«500 filas leídas», «informe escrito»
WARNINGAlgo no ideal, pero se sigueEl nivel de los datos sucios. «6 filas rechazadas»
ERRORUna operación no se pudo hacer«no se pudo escribir el informe»
CRITICALEl programa no puede seguir«la carpeta de salida no existe»
Por qué no alcanza con 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

EjemploTres formas de informar 500 filas, y solo una sirve
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.

El log dice cuántos; el archivo de rechazos dice cuáles

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

EjemploDos destinos a la vez: consola breve, archivo detallado
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.

Dos detalles que se pagan solos

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

EjemploCuatro antipatrones, y el informe que si sirve
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.
Cuatro maneras de informar mal
AntipatrónPor qué duele
except: passEl 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 secascould not convert string to float: '' no dice qué fila ni qué columna. Con 500 filas y 7 columnas, encontrarla es trabajo manual
Todo en ERRORERROR 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

  1. Cuántas filas entraron. El denominador de todo lo demás.
  2. Cuántas salieron, y qué porcentaje es eso.
  3. El desglose por motivo, con el vocabulario cerrado de la lección 1.5.
  4. Dónde está el detalle: el archivo de rechazos, fila por fila.
  5. El invariante: válidas + rechazadas == entrada, comprobado con un assert sobre 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

(sin salida) el logging no imprime nada

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.

(sin error) cada mensaje sale dos o tres veces

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.

TypeError: not all arguments converted during string formatting

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.

(sin error) el log muestra las horas en otro huso

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.

(sin error) el archivo de rechazos está vacío y sin cabecera

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

logging y el informe
ExpresiónQué haceCuándo
logging.basicConfig(level=..., format=...)Configura el logger raízUna vez, al arrancar
log = logging.getLogger("nombre")Un logger con nombre propioUno por módulo
log.warning("%s de %s", a, b)Mensaje con argumentos perezososSiempre, en vez de f-string
log.exception("...")error() + tracebackDentro de un except
logging.FileHandler(ruta, encoding="utf-8")Manda el log a un archivoProcesos que corren solos
handler.setLevel(...)Filtra por destinoConsola breve, archivo detallado
log.isEnabledFor(logging.DEBUG)¿Vale la pena armar el mensaje?Mensajes caros de construir
Counter[(motivo, columna)]El desgloseEl corazón del informe
assert len(ok) + len(no) == len(entrada)Que no falte ningunaAntes de escribir nada
escritor.writeheader()Cabecera aunque no haya filasSiempre

Ponete a prueba

1. Un archivo de datos trae 6 filas con el importe vacío y el proceso las descarta y sigue. ¿Con qué nivel se registra eso?Nivel 1 · Recordar
2. ¿Cuál de estas cinco cosas no puede faltar en un informe de rechazos?Nivel 1 · Recordar
3. ¿Por qué log.warning("venta %s rechazada", id) y no log.warning(f"venta {id} rechazada")?Nivel 2 · Predecir la salida
4. Un script escribe rechazados.csv solo si hubo rechazos. No hubo ninguno, y el archivo quedó de 0 bytes. ¿Cuál es el problema?Nivel 2 · Predecir la salida
5. Querés que la consola muestre solo lo importante pero que quede un archivo con todo el detalle. ¿Cómo se hace?Nivel 3 · Elegir entre dos casi correctas
6. En un notebook, cada mensaje del log aparece tres veces. ¿Qué pasó?Nivel 3 · Elegir entre dos casi correctas
7. Este proceso informa costo total: 1320349.5 sobre dw_hecho_ventas.csv, y el número es correcto para las filas que sumó. ¿Cuál es el defecto grave?
total = 0.0
for fila in filas:
    try:
        total += float(fila["costo"])
    except ValueError:
        pass
print("costo total:", total)
Nivel 4 · Encontrar el defecto

Prácticas de esta subunidad