El módulo ha ido dejando avisos por el camino: print("Aviso: no existe catalogo.json..."), print("Fila ignorada..."). Funcionaban en clase, pero imagina la escena real: Ana cierra la tienda un sábado, apaga el ordenador y el lunes el cierre de caja no cuadra. ¿Qué pasó el sábado? Los print() murieron con la terminal; no queda ni rastro. Los avisos por pantalla sirven para quien está mirando; para diagnosticar lo que pasó cuando nadie miraba hace falta un registro persistente: el módulo logging de la biblioteca estándar. En esta última lección del módulo aprenderás sus niveles, su configuración básica hacia datos/papyrus.log, el patrón del logger por módulo y la joya logger.exception(), que guarda el traceback completo en el archivo. Cerraremos con el decálogo de buenas prácticas de todo el módulo 7 y con un caja.py que junta las tres piezas: try/except, excepciones propias y logging.

Contenido

  1. Por qué print() no sirve para diagnosticar
  2. Los cinco niveles: de DEBUG a CRITICAL
  3. logging.basicConfig: formato y salida a archivo
  4. Un logger por módulo: getLogger(__name__)
  5. logger.exception(): el traceback, al log
  6. Qué registrar y qué no
  7. Decálogo del módulo 7 y el caja.py robusto

Por qué print() no sirve para diagnosticar

print() y logging responden a preguntas distintas: print() habla con el usuario que está delante; el log habla con el desarrollador que llegará después. En cuanto el programa vive más de una sesión, print() como herramienta de diagnóstico falla por todos lados:

Necesidad en producción print() logging
Que quede rastro al cerrar la terminal No: se esfuma Sí: archivo persistente
Saber cuándo pasó cada cosa Habría que concatenar la fecha a mano en cada print Timestamp automático en el formato
Distinguir rutina de emergencia Todo se imprime igual Niveles: INFO vs ERROR
Subir/bajar el detalle sin tocar código Añadir/borrar prints por todo el fichero Cambiar level= en un solo sitio
Guardar el traceback de una excepción manejada Copiarlo a mano (nadie lo hace) logger.exception() lo hace solo
Saber qué módulo habló Anónimo El nombre del logger va en cada línea

Ojo al matiz: print() no queda prohibido. El menú de la caja, el "Venta realizada: 20.70 €", los mensajes a Júlia y Omar — eso es interfaz de usuario y seguirá siendo print() (hasta que el módulo 10 lo convierta en web). Lo que emigra al log es la telemetría: avisos, errores, migas de pan para el diagnóstico.

Los cinco niveles: de DEBUG a CRITICAL

Cada mensaje de log lleva un nivel de gravedad. Son cinco, ordenados, y el logger tiene un umbral: solo registra lo que iguala o supera su nivel configurado.

Nivel Cuándo usarlo (criterio Papyrus) Ejemplo
DEBUG Detalle fino para desarrollar; en producción suele ir apagado "clave normalizada: 'la odisea'"
INFO La vida normal de la tienda: hitos que confirman que todo va bien "Catálogo cargado: 4 libros" / "Venta: Hamlet x2, 20.70 €"
WARNING Algo raro pero el programa continúa; alguien debería mirarlo "Fila 7 de ventas.csv ilegible, ignorada"
ERROR Una operación ha fallado; el programa sobrevive pero esa acción no se hizo "No se pudo guardar el catálogo"
CRITICAL El programa no puede continuar "catalogo.json corrupto y sin copias en copias/"

La prueba del algodón para elegir nivel: ¿qué debería hacer quien lo lea? Nada, solo contexto → DEBUG/INFO. Echar un vistazo esta semana → WARNING. Actuar hoy → ERROR. Dejar lo que esté haciendo y correr → CRITICAL. Con el umbral en INFO (típico de producción), los DEBUG se descartan sin coste; bajas a DEBUG cuando persigues un fallo, y el mismo código se vuelve locuaz sin añadir un solo print.

logging.basicConfig: formato y salida a archivo

Una sola llamada, al principio del programa principal y solo una vez, configura el sistema entero:

# caja.py — al arrancar, antes de cualquier otra cosa
import logging
from pathlib import Path

BASE = Path(__file__).parent          # el ancla del módulo 6
(BASE / "datos").mkdir(exist_ok=True)

logging.basicConfig(
    filename=BASE / "datos" / "papyrus.log",
    level=logging.INFO,
    format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
    encoding="utf-8",
)

Pieza a pieza:

  • filename: los mensajes van a datos/papyrus.log en vez de a pantalla. Se abre en modo append (a, del módulo 6): cada arranque suma, no machaca — el historial es justamente el valor.
  • level=logging.INFO: el umbral. DEBUG se descarta; de INFO para arriba, se escribe.
  • format: la plantilla de cada línea. %(asctime)s es la fecha y hora, %(levelname)-8s el nivel alineado a 8 caracteres, %(name)s el logger que habla, %(message)s tu texto. (Es la sintaxis clásica de %, anterior a las f-strings; logging la conserva por historia y rendimiento.)
  • encoding="utf-8": el innegociable del módulo 6 también rige aquí — en el log habrá títulos con tildes.

El resultado, tal como se ve dentro de papyrus.log:

2026-07-13 09:00:12,451 INFO     almacen: Catálogo cargado: 4 libros
2026-07-13 09:14:03,102 INFO     almacen: Venta: Hamlet x2, 20.70 €
2026-07-13 09:31:44,876 WARNING  caja: Fila ignorada en ventas.csv: {'fecha': '2026-07-12', 'titulo': 'Fausto', 'importe': 'gratis'}

Fecha, gravedad, módulo, mensaje con el dato culpable: el lunes por la mañana, Ana (o tú) reconstruye el sábado línea a línea.

Un logger por módulo: getLogger(__name__)

No se llama a logging.info(...) a pelo por todo el código. El patrón profesional crea un logger con nombre en la cabecera de cada módulo:

# almacen.py
import logging

logger = logging.getLogger(__name__)   # nombre: "almacen"

def cargar_catalogo():
    ...
    logger.info("Catálogo cargado: %d libros", len(catalogo))
  • __name__ es la variable que el módulo 3 nos presentó (la del if __name__ == "__main__":): vale "almacen" cuando el módulo se importa. Así, cada línea del log declara su origen — la columna %(name)s del formato — sin esfuerzo.
  • División de poderes: los módulos (almacen.py, errores.py...) emiten con su logger; solo el programa principal configura con basicConfig. Una biblioteca que configurara el logging pisaría la configuración de quien la importa.
  • El estilo %d con argumentos separados (logger.info("...: %d libros", len(catalogo))) deja que logging componga el texto solo si el mensaje pasa el umbral. Con f-strings también funciona y es aceptable mientras aprendes; el estilo diferido es el idiomático.

logger.exception(): el traceback, al log

La joya de la corona. Dentro de un except, logger.exception(...) registra el mensaje a nivel ERROR y añade el traceback completo de la excepción en curso — el informe forense de 07-01, archivado automáticamente:

def guardar_catalogo(catalogo):
    ruta = BASE / "datos" / "catalogo.json"
    try:
        with open(ruta, "w", encoding="utf-8") as f:
            json.dump([asdict(l) for l in catalogo.values()], f,
                      ensure_ascii=False, indent=2)
    except OSError:
        logger.exception("No se pudo guardar el catálogo en %s", ruta)
        raise    # registrar NO es manejar: que decida quien llamó

En papyrus.log aparece la línea ERROR seguida del Traceback (most recent call last): ... íntegro, con fichero, línea y causa. Tres reglas de uso:

  • Solo dentro de un except — es donde existe "la excepción en curso" que adjuntar.
  • Combina perfectamente con el raise a secas de 07-03: registrar en la frontera y relanzar. El log guarda la evidencia; la excepción sigue su viaje hacia quien pueda decidir. Registrar no es manejar.
  • Sustituye al antipatrón definitivo: except OSError: pass era silencio culpable (07-02); except OSError: logger.exception(...) seguido de una decisión consciente (relanzar, valor por defecto, abortar) es ingeniería.

Qué registrar y qué no

Un log útil cuenta la historia operativa del programa. Registra: arranque y parada (con versión y configuración cargada), hitos de negocio a INFO (ventas, altas, reposiciones, cierres de caja), toda anomalía a WARNING+ con el dato culpable ({fila!r}, la ruta, el título), y el traceback vía logger.exception() en cada except fronterizo.

Y lo que no debe entrar, porque el log es un archivo de texto plano que se copia, se comparte y acaba en correos de soporte:

  • Datos personales y sensibles: nada de nombres completos de clientes asociados a sus compras, direcciones, teléfonos, datos bancarios o de tarjetas. "Venta: Hamlet x1, socio MARTA-002" vale (un código interno opaco); "Marta García, tarjeta 4779..." jamás. La legislación de protección de datos (en Europa, el RGPD) convierte esto en obligación legal, no solo en cortesía — y un log no tiene el control de acceso de una base de datos.
  • Secretos: contraseñas, tokens, claves de API. Ni siquiera "para depurar un momento": los logs se archivan y los secretos ahí escritos quedan comprometidos.
  • Ruido: registrar cada iteración de un bucle a INFO entierra la señal. Para eso existe DEBUG, que en producción va apagado.

Decálogo del módulo 7 y el caja.py robusto

Las diez reglas que resumen todo el módulo, cada una con su lección de origen:

  1. Lee el traceback de abajo arriba: tipo y mensaje primero, cadena de llamadas después (07-01).
  2. EAFP frente al mundo exterior: intenta y maneja; Path.exists() era un paliativo con condición de carrera (07-01).
  3. Captura tipos concretos: jamás except: pelado; Exception solo en la última frontera, y registrando (07-02).
  4. try mínimo: solo la línea que puede fallar; else para el camino feliz, finally para la limpieza incondicional (07-02).
  5. No silencies: un error atrapado se cuenta, se registra o se relanza; nunca pass a secas (07-02, 07-05).
  6. Falla pronto: guard clauses en la puerta, mutación del estado solo tras superar todas las guardas (07-03).
  7. Mensajes accionables: qué se esperaba, qué llegó (!r) y, si procede, qué hacer (07-03).
  8. None para ausencias legítimas, excepción para contratos rotos; si conviven, que sea con criterio, como buscar_libro()/obtener_libro() (07-03, 07-04).
  9. Jerarquía propia con atributos: el except distingue tu error del resto y el código lee datos, no strings (07-04).
  10. Log en la frontera: registra con logger.exception() donde manejas, no en cada nivel intermedio — un error, una entrada en el log, no cinco (07-05).

Y el cierre práctico: caja.py juntando las tres piezas del módulo.

# caja.py — el programa que Ana ejecuta cada mañana
import logging
from pathlib import Path

from almacen import cargar_catalogo, guardar_catalogo, vender
from errores import ErrorPapyrus, LibroNoEncontradoError, StockInsuficienteError

BASE = Path(__file__).parent
(BASE / "datos").mkdir(exist_ok=True)
logging.basicConfig(
    filename=BASE / "datos" / "papyrus.log",
    level=logging.INFO,
    format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
    encoding="utf-8",
)
logger = logging.getLogger(__name__)


def pedir_entero(mensaje):                      # la deuda de M1, saldada en 07-02
    while True:
        texto = input(mensaje)
        try:
            return int(texto)
        except ValueError:
            print(f"'{texto}' no es un número entero. Inténtalo de nuevo.")


def main():
    logger.info("Papyrus abre la caja")
    catalogo = cargar_catalogo()                # EAFP por dentro (07-02)

    while True:
        titulo = input("\nTítulo (o 'fin'): ").strip()
        if titulo.casefold() == "fin":
            break
        unidades = pedir_entero("Unidades: ")
        try:
            importe = vender(catalogo, titulo, unidades)
        except LibroNoEncontradoError as e:     # hoja: reacción específica (07-04)
            print(f"No tenemos '{e.titulo}'. ¿Lo encargamos?")
            logger.warning("Venta fallida, título desconocido: %r", e.titulo)
        except StockInsuficienteError as e:     # hoja: atributos legibles (07-04)
            print(f"Solo quedan {e.disponible} de '{e.titulo}'.")
            logger.warning("Stock corto: %s", e)
        except ErrorPapyrus as e:               # base: red del negocio (07-04)
            print(f"Operación no realizada: {e}")
            logger.error("Error de negocio no específico: %s", e)
        else:                                   # camino feliz fuera del try (07-02)
            print(f"Venta realizada: {importe:.2f} € (IVA incluido)")
            logger.info("Venta: %s x%d, %.2f €", titulo, unidades, importe)

    guardar_catalogo(catalogo)
    logger.info("Papyrus cierra la caja")


if __name__ == "__main__":
    try:
        main()
    except Exception:                           # ÚNICA frontera con Exception (regla 3)
        logger.exception("Error no previsto: la caja se cierra de emergencia")
        print("Ha ocurrido un error inesperado. Consulta datos/papyrus.log")
        raise SystemExit(1)

Fíjate en la arquitectura de tres anillos: las hojas de la jerarquía se manejan junto al mostrador (reacción específica, WARNING), la base ErrorPapyrus hace de red del negocio, y un único except Exception en la puerta del programa — con logger.exception() y un mensaje honesto a pantalla — caza lo imprevisto sin silenciarlo. print() para Ana, logging para el diagnóstico: cada mensaje, a su público.

Queda una mención honrada: en sistemas que corren semanas, papyrus.log crece sin límite. El propio logging trae la solución — handlers como RotatingFileHandler, que parte el log por tamaño o por fecha, y configuración desde fichero — pero es tema avanzado que excede lo esencial; te bastará saber que existe y dónde buscarlo (logging.handlers) cuando la tienda lo necesite.

Errores Comunes y Consejos

  • Llamar a basicConfig() en cada módulo. Solo el programa principal configura; los módulos crean su getLogger(__name__) y emiten. Además, basicConfig solo surte efecto la primera vez: las llamadas repetidas se ignoran en silencio y confunden.
  • logger.error(e) dentro del except en lugar de logger.exception(...). Guarda el mensaje pero tira el traceback, que era la mitad valiosa. Dentro de except, casi siempre exception().
  • Registrar y creer que ya está manejado. El log es memoria, no decisión: tras registrar, decide — relanza, aplica un valor por defecto o aborta. Registrar-y-tragar es el antipatrón pass con mejor vestuario.
  • Registrar el mismo error en cada nivel de la pila. Cinco entradas idénticas para un solo fallo ensucian el diagnóstico. Regla 10: se registra en la frontera donde se maneja.
  • Datos sensibles en el log. Revisa cada logger.* como si el archivo fuera a leerlo un desconocido — porque tarde o temprano lo hará. Códigos internos sí; identidades, tarjetas y secretos, nunca.
  • Consejo: durante el desarrollo, cambia el umbral a level=logging.DEBUG y añade DEBUG generosos en la lógica delicada (normalización de claves, conversiones). En producción, umbral a INFO: los DEBUG siguen ahí, dormidos y gratis.
  • Consejo: abre papyrus.log de vez en cuando aunque nada falle. Un log que solo se lee en las emergencias suele descubrirse vacío, mal formateado o mudo justo cuando más falta hace.

Ejercicios

  1. cierre_de_caja() con telemetría. Adapta la versión de 07-02 para que use un logger de módulo: cada fila corrupta a WARNING con la fila completa (%r), el total del día a INFO, y ante FileNotFoundError un INFO ("sin ventas hoy") — ¿por qué INFO y no ERROR? Los print() de resumen para Ana se mantienen: razona qué mensaje pertenece a cada canal.

  2. La copia nocturna que avisa. Envuelve la copia_de_seguridad() del módulo 6 (que copia catalogo.json a copias/catalogo-<fecha>.json con shutil.copy2) para que: registre a INFO el destino si todo va bien; y ante OSError use logger.exception() y relance. Explica por qué relanzar en vez de tragarse el error, siendo "solo" una copia.

  3. Auditoría del decálogo. Este fragmento infringe al menos cuatro reglas del decálogo. Identifícalas por número y reescríbelo:

    def alta_socio(socios, nombre, codigo):
        try:
            logging.basicConfig(level=logging.DEBUG)
            if codigo in socios:
                return False
            socios.append(codigo)
            logging.info("Alta de socio: " + nombre + " con tarjeta 4779-1234-5678-9010")
            return True
        except:
            pass
    

Soluciones

  1. logger = logging.getLogger(__name__)
    
    def cierre_de_caja():
        ruta = BASE / "datos" / "ventas.csv"
        total, ventas_ok, corruptas = 0.0, 0, 0
        try:
            with open(ruta, encoding="utf-8", newline="") as f:
                for fila in csv.DictReader(f):
                    try:
                        total += float(fila["importe"])
                    except ValueError:
                        corruptas += 1
                        logger.warning("Fila ilegible en ventas.csv: %r", fila)
                    else:
                        ventas_ok += 1
        except FileNotFoundError:
            logger.info("Cierre sin ventas: no existe %s", ruta)
            print("Sin ventas registradas hoy.")
            return 0.0
        logger.info("Cierre de caja: %d ventas, %d corruptas, total %.2f €",
                    ventas_ok, corruptas, total)
        print(f"Cierre de caja: {ventas_ok} ventas, total {total:.2f} €")
        return total
    

    INFO y no ERROR porque un día sin ventas es un resultado legítimo del negocio (el criterio de 07-03 aplicado a niveles de log): nadie tiene que "actuar hoy". Canales: el print da a Ana el resumen que necesita en el momento; el log guarda además las filas corruptas exactas y el desglose, que es lo que el diagnóstico del lunes necesitará.

  2. def copia_de_seguridad():
        origen = BASE / "datos" / "catalogo.json"
        destino = BASE / "copias" / f"catalogo-{date.today().isoformat()}.json"
        try:
            shutil.copy2(origen, destino)
        except OSError:
            logger.exception("Copia de seguridad fallida hacia %s", destino)
            raise
        logger.info("Copia de seguridad creada: %s", destino)
    

    Se relanza porque una copia fallida silenciada es una falsa sensación de seguridad: el día que Ana necesite restaurar desde copias/, descubriría que llevan semanas sin crearse. Regla 5 (no silenciar) y el patrón registrar-y-relanzar de 07-03/07-05: el log conserva el traceback y quien llamó decide si abortar el cierre o avisar a gritos.

  3. Infracciones: regla 3 (except: pelado), regla 5 (pass: el error desaparece, y encima la función devuelve None en vez de True/False, un tercer estado fantasma), regla 10 / patrón de configuración (basicConfig dentro de una función de biblioteca, pisando — o siendo ignorada por — la configuración del programa principal), y la norma de datos sensibles (un número de tarjeta escrito en el log). De propina: logger anónimo en vez de getLogger(__name__) y un try que abriga código que no puede fallar de forma esperable (regla 4). Reescritura:

    logger = logging.getLogger(__name__)
    
    def alta_socio(socios, nombre, codigo):
        if not nombre.strip() or not codigo.strip():
            raise ValueError("nombre y código no pueden estar vacíos")   # regla 6
        if codigo in socios:
            logger.info("Alta rechazada, código duplicado: %r", codigo)
            return False
        socios.append(codigo)
        logger.info("Alta de socio: %s", codigo)    # código interno; ni nombre ni tarjeta
        return True
    

Conclusión

El módulo 7 responde, una a una, las preguntas que dejó abierto el cierre del módulo 6. ¿Y si el archivo no está? cargar_catalogo() lo intenta al estilo EAFP y convierte el FileNotFoundError en un catálogo vacío con aviso — sin el paliativo Path.exists() ni su condición de carrera (07-01, 07-02). ¿Y si la copia está corrupta? JSONDecodeError se distingue del archivo ausente y se traduce con raise ... from e sin perder la evidencia (07-02, 07-03). ¿Y si float(fila["precio"]) recibe "gratis"? Un try mínimo salta la fila, la cuenta y la deja escrita a WARNING en papyrus.log (07-02, 07-05). ¿Y si Júlia teclea un precio imposible? Las guard clauses y el __post_init__ de M5 — que ahora sabes leer como contratos — lo rechazan con un mensaje accionable, y pedir_entero() saldó de paso la deuda que int(input()) arrastraba desde el módulo 1 (07-03, 07-02). Por el camino, Papyrus ganó un vocabulario propio de errores — ErrorPapyrus y sus hojas con atributos, en errores.py — y una caja de tres anillos que atrapa lo específico junto al mostrador, lo genérico en la frontera y lo deja todo anotado con logger.exception() (07-04, 07-05). La tienda ya no se cae: se explica. El módulo 8 cambia de tercio: el código de Papyrus es correcto y robusto, y ahora toca hacerlo más expresivo y más eficiente — type hints que documentan los contratos que este módulo defendía a mano, decoradores que envuelven funciones sin repetir código, generadores que procesan ventas sin cargarlas enteras en memoria, y el momento de saldar la promesa pendiente del with: entender los context managers por dentro y, por fin, construir los tuyos.

Curso de Programación en Python

Módulo 1: Introducción a Python

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados