Saltar a contenido

1.11 · Excepciones y logging

Objetivos. Al terminar este capítulo podrás: distinguir errores de sintaxis, errores de ejecución y errores de lógica; capturar excepciones concretas con try/except; usar else, finally y raise; y registrar información útil con el módulo logging.

Evidencia de logro. Mejorarás el asistente de notas de 1.10 para que valide entradas, informe de los fallos esperables sin ocultarlos y deje un registro mínimo de las operaciones importantes.

Contexto y motivación

Un programa real recibe datos incompletos, ficheros que no existen y servicios que pueden fallar. Una excepción es la señal que Python utiliza para indicar que la ejecución normal no puede continuar en ese punto.

Manejar una excepción no significa poner try alrededor de todo. Significa decidir qué errores son previsibles, responder a ellos de forma útil y dejar que los errores inesperados conserven su información para poder corregirlos.

Vocabulario

Término Significado
Excepción Objeto que describe un fallo durante la ejecución
try Bloque cuya ejecución puede producir una excepción
except Respuesta para una excepción concreta
raise Instrucción que lanza una excepción de forma intencionada
else Bloque que se ejecuta si el try termina sin error
finally Bloque que se ejecuta siempre, haya error o no
Traceback Rastro con la llamada y la línea donde falló el programa
Logging Registro estructurado de eventos del programa

Prerrequisitos

1.10 · Archivos, funciones y condicionales. En 1.10 ya viste FileNotFoundError de manera puntual; aquí construiremos el modelo completo.

1. Tres clases de problemas

No todos los fallos se solucionan igual:

  • Sintaxis: Python no puede interpretar el programa (if x = 1:).
  • Ejecución: el programa empieza, pero una operación falla (1 / 0).
  • Lógica: el programa termina, pero calcula algo incorrecto (por ejemplo, sumar una propina dos veces).

El try/except sirve para errores de ejecución, no para ocultar errores de sintaxis ni para detectar automáticamente una lógica equivocada.

1.1 Leer un traceback

Cuando una excepción no se captura, Python detiene el programa y muestra un traceback: el rastro de llamadas que llevó hasta el fallo. Por ejemplo, este programa:

def precio_con_iva(texto):
    precio = float(texto)
    return precio * 1.21

precio_con_iva("doce")

Salida esperada (error):

ValueError: could not convert string to float: 'doce'

En la terminal, la salida completa se parece a esto (las rutas y los números de línea dependen de tu fichero):

Traceback (most recent call last):
  File "precios.py", line 5, in <module>
    precio_con_iva("doce")
  File "precios.py", line 2, in precio_con_iva
    precio = float(texto)
ValueError: could not convert string to float: 'doce'

Se lee de abajo arriba:

  1. La última línea dice qué pasó: el tipo de excepción (ValueError) y un mensaje que suele incluir el valor problemático ('doce').
  2. Justo encima está la línea de tu código que falló (precio = float(texto)), con el fichero y el número de línea.
  3. Más arriba aparece quién llamó a esa función (línea 5), y así hasta el principio del programa.

Empieza siempre por la última línea: casi siempre basta para entender el error, y el resto te dice dónde buscarlo.

1.2 Capturar una excepción

# La división provoca ZeroDivisionError.
try:
    resultado = 10 / 0
except ZeroDivisionError:
    resultado = None

print(resultado)

Salida esperada:

None

La excepción interrumpe el bloque try y Python continúa en el except. Si no hubiera un except compatible, el programa terminaría mostrando un traceback.

2. Capturar la excepción adecuada

Captura la excepción más específica que sepas tratar:

def convertir_entero(texto):
    try:
        return int(texto)
    except ValueError:
        return None

for entrada in ["42", "no es un número"]:
    valor = convertir_entero(entrada)
    print(entrada, "->", valor)

Salida esperada:

42 -> 42
no es un número -> None

int() puede lanzar ValueError cuando el texto no representa un entero. La función convierte ese caso conocido en None, que el código que llama puede comprobar explícitamente.

No ocultes todos los errores

except Exception: captura casi cualquier excepción y puede esconder un bug. Úsalo solo en una frontera bien definida, donde vayas a registrar el error y decidir qué respuesta devolver. No uses un except: vacío.

2.1 Varias excepciones y orden

def dividir(texto_a, texto_b):
    try:
        return float(texto_a) / float(texto_b)
    except ValueError:
        return "Alguno de los valores no es numérico"
    except ZeroDivisionError:
        return "No se puede dividir entre cero"

print(dividir("10", "2"))
print(dividir("diez", "2"))
print(dividir("10", "0"))

Salida esperada:

5.0
Alguno de los valores no es numérico
No se puede dividir entre cero

Los except se prueban de arriba abajo. Una excepción hija debe aparecer antes que su clase base; por ejemplo, FileNotFoundError antes que OSError. También puedes agrupar excepciones que reciben exactamente la misma respuesta:

try:
    valor = int("3.5")
except (ValueError, TypeError):
    valor = None

print(valor)

Salida esperada:

None

3. else y finally

else separa el código que depende de que la operación haya salido bien; finally se reserva para una limpieza que debe ocurrir siempre.

def mostrar_division(a, b):
    try:
        resultado = a / b
    except ZeroDivisionError:
        print("Error: divisor cero")
    else:
        print("Resultado:", resultado)
    finally:
        print("Fin de la operación")

mostrar_division(10, 2)
mostrar_division(10, 0)

Salida esperada:

Resultado: 5.0
Fin de la operación
Error: divisor cero
Fin de la operación

Para cerrar ficheros debes preferir with, que ya ofrece una limpieza segura. finally resulta útil cuando una librería o un recurso necesita un cierre manual, o para mostrar que una operación terminó.

4. Inspeccionar y relanzar errores

Durante el diagnóstico puede interesar conservar el mensaje original:

def cargar_entero(texto):
    try:
        return int(texto)
    except ValueError as error:
        print(f"Entrada inválida: {error}")
        raise

try:
    cargar_entero("3.5")
except ValueError:
    print("La persona debe introducir un entero")

Salida esperada:

Entrada inválida: invalid literal for int() with base 10: '3.5'
La persona debe introducir un entero

raise sin expresión vuelve a lanzar la excepción actual. Es útil cuando una capa registra o añade contexto, pero la capa superior todavía debe decidir qué hacer.

4.1 Crear una excepción propia

Una excepción propia da un nombre al contrato que se ha incumplido. Aquí solo necesitas copiarla tal cual; qué es una class y por qué se hereda de ValueError se explica en 1.13 · Programación orientada a objetos.

class NotaFueraDeRangoError(ValueError):
    """La nota no está entre 0 y 10."""


def validar_nota(nota):
    if not 0 <= nota <= 10:
        raise NotaFueraDeRangoError(f"Nota inválida: {nota}")
    return nota

for nota in [8.5, 12]:
    try:
        print("Válida:", validar_nota(nota))
    except NotaFueraDeRangoError as error:
        print("Revisar:", error)

Salida esperada:

Válida: 8.5
Revisar: Nota inválida: 12

Hereda de ValueError porque el problema es el valor recibido. Así quien conozca ValueError puede tratar también este caso, y quien necesite distinguirlo puede capturar NotaFueraDeRangoError.

5. Logging: registrar sin llenar la pantalla

print() es útil para una demostración; logging permite filtrar niveles, identificar el módulo y dirigir los mensajes a la consola o a un fichero.

import logging

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)

logger.debug("Detalle que no aparece con nivel INFO")
logger.info("Se ha cargado el asistente de notas")
logger.warning("El fichero de notas todavía no existe")
logger.info("Se han cargado %d %s", 3, "notas")

Salida esperada:

INFO: Se ha cargado el asistente de notas
WARNING: El fichero de notas todavía no existe
INFO: Se han cargado 3 notas

basicConfig configura el registro una sola vez: level fija el nivel mínimo que se muestra y format decide el aspecto de cada línea (%(levelname)s es el nivel y %(message)s, el mensaje). Si se llama de nuevo cuando ya está configurado, no tiene efecto; en un cuaderno, reinicia el kernel si necesitas cambiarlo.

Por defecto, logging escribe en stderr, el canal de errores de la terminal. En VS Code suele verse junto a la salida normal, pero algunos entornos lo presentan por separado; que no aparezca en stdout no significa que el logger no se haya ejecutado.

Los niveles habituales, de menor a mayor gravedad, son DEBUG, INFO, WARNING, ERROR y CRITICAL. El nivel configurado decide qué mensajes se muestran.

Un registrador (logger) es el objeto que lleva los mensajes a un destino concreto. La práctica habitual es crear uno por módulo con logging.getLogger(__name__), donde __name__ es una variable que Python define sola en cada fichero: contiene el nombre del módulo, así que el mensaje sale etiquetado con el fichero que lo ha producido. Es el mismo __name__ que viste en el bloque if __name__ == "__main__": de 1.1.

Observa la última línea del ejemplo: el mensaje usa %d (un entero) y %s (un texto) en lugar de una f-string, y los valores se pasan como argumentos aparte. El motivo es que el registro difiere el formateo: si el nivel configurado descarta el mensaje, el texto nunca se construye. Con una f-string el texto se formatea siempre, aunque nadie vaya a verlo. Para mensajes sencillos la diferencia es mínima, pero el patrón con %s es el recomendado en la documentación oficial y conviene reconocerlo desde el principio.

5.1 Registrar una excepción con traceback

Dentro de un except, logger.exception() incluye automáticamente el traceback:

try:
    int("3.5")
except ValueError:
    logger.exception("No se pudo convertir la entrada")

Salida esperada:

ERROR: No se pudo convertir la entrada
Traceback (most recent call last):
  File "...", line N, in <module>
    int("3.5")
ValueError: invalid literal for int() with base 10: '3.5'

El bloque está marcado como de solo sintaxis porque las líneas del traceback dependen del fichero y del número de línea, y no se pueden comparar literalmente. Lo importante son las dos primeras y la última: ERROR: con tu mensaje, Traceback, y el tipo de excepción con su detalle original. El registro conserva el tipo y el contexto del error, que es justo lo que un print no daría.

Ampliación: loguru

Existe una librería externa llamada loguru con una API más cómoda que la de logging. No forma parte del curso: logging está en la biblioteca estándar, no añade ninguna dependencia al proyecto y sirve para todo lo que vas a necesitar aquí. Si algún día comparas ambas, será después de dominar esta.

Aplicación práctica: robustecer las notas

La ausencia del fichero es un caso normal la primera vez; un JSON corrupto es un error que conviene comunicar y registrar:

import json
import logging
from pathlib import Path

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)
RUTA = Path("practica/ud1_archivos/notas.json")


def plural_notas(cantidad):
    return "nota" if cantidad == 1 else "notas"


def cargar_notas(ruta=RUTA):
    try:
        with ruta.open(encoding="utf-8") as fichero:
            notas = json.load(fichero)
    except FileNotFoundError:
        logger.info("No existe %s; se empieza con una lista vacía", ruta.as_posix())
        return []
    except json.JSONDecodeError as error:
        logger.error("El JSON de %s no es válido", ruta.as_posix())
        raise ValueError(f"El JSON de {ruta.as_posix()} no es válido") from error
    else:
        if not isinstance(notas, list) or not all(isinstance(nota, str) for nota in notas):
            logger.error("El JSON de %s no contiene una lista de textos", ruta.as_posix())
            raise ValueError(f"Los datos de {ruta.as_posix()} no tienen el formato esperado")
        logger.info("Se han cargado %d %s", len(notas), plural_notas(len(notas)))
        return notas


def guardar_notas(notas, ruta=RUTA):
    ruta.parent.mkdir(parents=True, exist_ok=True)
    with ruta.open("w", encoding="utf-8") as fichero:
        json.dump(notas, fichero, ensure_ascii=False, indent=2)
    logger.info("Se han guardado %d %s", len(notas), plural_notas(len(notas)))


notas = cargar_notas(RUTA)
notas.append("Revisar excepciones")
guardar_notas(notas, RUTA)

# La demo se limpia a sí misma para que pueda repetirse con la misma salida.
# Coméntala si quieres observar la persistencia en la segunda ejecución.
RUTA.unlink()

Salida esperada:

INFO: No existe practica/ud1_archivos/notas.json; se empieza con una lista vacía
INFO: Se han guardado 1 nota

Para observar la persistencia, comenta la última línea: en la segunda ejecución el primer mensaje cambia por Se han cargado 1 nota. Esa diferencia es una evidencia observable de que el dato vuelve del disco, y no un error. Los mensajes escriben la ruta con as_posix() para que salga con / en cualquier sistema operativo.

Tres detalles de este bloque que conviene leer despacio:

  • from error en el raise enlaza el error nuevo con el que lo ha provocado. El ValueError que ve quien llama al programa dice «el JSON está corrupto» y además, al mostrar la traza, indica que la causa fue un JSONDecodeError. Si un día escribes el raise sin from error, el mensaje sigue siendo correcto pero se pierde ese rastro, y con él la información de qué estaba mal exactamente.
  • isinstance(notas, list) comprueba el tipo antes de recorrer la lista. Sin esa comprobación, un JSON con "texto" en lugar de [...] haría que el recorrido fallara con un error poco descriptivo.
  • all(isinstance(nota, str) for nota in notas) usa una expresión generadora (1.6 y 1.8): all la recorre y devuelve True solo si todas las notas son textos. Es la forma de validar todos los elementos sin escribir un bucle.

Si el JSON está corrupto o no contiene una lista de textos, se registra el problema y se lanza ValueError; así no se sobrescribe silenciosamente el fichero original.

Errores frecuentes

  • Leer el traceback de arriba abajo y perderse: empieza por la última línea.
  • Capturar Exception o except: y continuar como si nada hubiera pasado.
  • Poner except ValueError después de except Exception, de modo que nunca se alcanza.
  • Usar raise fuera de un except o lanzar una excepción sin explicar el contrato incumplido.
  • Registrar solo «ha fallado» sin incluir la operación, la entrada o el identificador necesario para investigar.
  • Usar logger.exception() fuera de un except: no habrá un traceback útil.
  • Confundir logging con validación: registrar un dato inválido no lo convierte en válido.

Práctica de transferencia

  1. Escribe convertir_porcentaje(texto), que devuelva un float entre 0 y 100. Convierte el ValueError de float() en un mensaje claro y lanza también ValueError si el número queda fuera del rango.
  2. Añade else y finally a una función que lea un fichero y muestra cuándo la lectura ha terminado.
  3. Define una excepción ConfiguracionInvalidaError para rechazar una configuración sin campo nombre.
  4. Cambia los print de la práctica del asistente de notas por un logger con niveles INFO, WARNING y ERROR.
  5. Provoca un JSON corrupto en una copia de prueba y comprueba que se registra el fallo sin confundirlo con la ausencia normal del fichero.

Qué debes comprobar: en el punto 1, al menos tres entradas ("50", "abc" y "150") con respuestas distintas; en el 5, que el mensaje del JSON corrupto es ERROR y el del fichero inexistente es INFO, y por qué tiene sentido esa diferencia.

Producto evaluable

Amplía 08_archivos_log.py con lo siguiente. Este producto es también el mini-reto del bloque 1.10–1.11: un gestor de notas con JSON que no se rompe ante los fallos previsibles.

  1. Una función de carga que distinga fichero inexistente, JSON inválido y datos correctos.
  2. Una validación propia para una nota o una operación de la aplicación.
  3. Logging con al menos un mensaje INFO, uno WARNING o ERROR y una comprobación de salida.
  4. Una celda Markdown que explique qué errores se recuperan y cuáles deberían detener la ejecución.

Criterio de aceptación: el cuaderno ejecuta un caso normal y dos fallos controlados, muestra una respuesta útil y no usa except: vacío.

Formato de entrega: cuaderno marimo ejecutado, con salida esperada, interpretación y conclusión.

Resumen y referencia rápida

Necesitas Patrón
Capturar un error concreto try: ... except ValueError: ...
Código solo si no falla else:
Limpieza siempre finally:
Lanzar un contrato inválido raise ValueError("...")
Excepción propia class MiError(ValueError): ...
Registrar logger.info("...")
Registrar con traceback logger.exception("...") dentro de except

Siguiente: 1.12 · Módulos y paquetes.