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:
Salida esperada (error):
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:
- La última línea dice qué pasó: el tipo de excepción (
ValueError) y un mensaje que suele incluir el valor problemático ('doce'). - 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. - 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:
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:
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:
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:
Salida esperada:
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:
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:
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:
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 erroren elraiseenlaza el error nuevo con el que lo ha provocado. ElValueErrorque ve quien llama al programa dice «el JSON está corrupto» y además, al mostrar la traza, indica que la causa fue unJSONDecodeError. Si un día escribes elraisesinfrom 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):allla recorre y devuelveTruesolo 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
Exceptionoexcept:y continuar como si nada hubiera pasado. - Poner
except ValueErrordespués deexcept Exception, de modo que nunca se alcanza. - Usar
raisefuera de unexcepto 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 unexcept: no habrá un traceback útil. - Confundir
loggingcon validación: registrar un dato inválido no lo convierte en válido.
Práctica de transferencia¶
- Escribe
convertir_porcentaje(texto), que devuelva unfloatentre 0 y 100. Convierte elValueErrordefloat()en un mensaje claro y lanza tambiénValueErrorsi el número queda fuera del rango. - Añade
elseyfinallya una función que lea un fichero y muestra cuándo la lectura ha terminado. - Define una excepción
ConfiguracionInvalidaErrorpara rechazar una configuración sin camponombre. - Cambia los
printde la práctica del asistente de notas por un logger con nivelesINFO,WARNINGyERROR. - 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.
- Una función de carga que distinga fichero inexistente, JSON inválido y datos correctos.
- Una validación propia para una nota o una operación de la aplicación.
- Logging con al menos un mensaje
INFO, unoWARNINGoERRORy una comprobación de salida. - 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.