Módulo 1 · Lección 51 de 59
Lanzar tus propias excepciones
Cómo avisar con raise de que algo va mal, elegir el tipo adecuado, crear excepciones propias y encadenarlas sin perder información.
Hasta ahora las excepciones te venían dadas: las lanzaba Python cuando int() no podía convertir o cuando un fichero no estaba. Pero tus propias funciones también pueden encontrarse con datos imposibles, y entonces tienes que decidir qué hacer.
Imagina una función que aplica un descuento y recibe un porcentaje de 150. Eso no tiene sentido. Tienes tres opciones: devolver None, imprimir un mensaje y seguir, o lanzar una excepción. Solo la tercera es buena, y vale la pena ver por qué.
Por qué no devolver None ni imprimir#
# opción mala 1: devolver None
def calcular_descuento(precio, porcentaje):
if not 0 <= porcentaje <= 100:
return None
return precio * (1 - porcentaje / 100)Quien llame a esta función recibirá None y, si no lo comprueba, el error saldrá tres funciones más allá con un críptico TypeError: unsupported operand type(s) for +: 'NoneType' and 'float', muy lejos del sitio donde estaba el problema de verdad. Además, None es ambiguo: puede significar "el dato era inválido", "no encontré nada" o "no aplica".
# opción mala 2: imprimir y seguir
def calcular_descuento(precio, porcentaje):
if not 0 <= porcentaje <= 100:
print("Porcentaje incorrecto")
return precioPeor todavía. El programa continúa con un precio sin descontar como si nada hubiera pasado, el mensaje se pierde entre las cien líneas que imprime el programa, y si esa función se usa algún día en una web o en una aplicación con ventanas, no hay ninguna pantalla donde ese print se vea.
Una función no debe decidir qué hacer ante un error que no es suyo. Su trabajo es avisar, en voz alta y con detalle, y dejar que quien la llamó decida.
raise#
def calcular_descuento(precio: float, porcentaje: float) -> float:
"""Aplica un descuento en tanto por ciento a un precio."""
if precio < 0:
raise ValueError(f"El precio no puede ser negativo: {precio}")
if not 0 <= porcentaje <= 100:
raise ValueError(f"El porcentaje debe estar entre 0 y 100, no {porcentaje}")
return round(precio * (1 - porcentaje / 100), 2)
print(calcular_descuento(120, 25))
print(calcular_descuento(120, 150))90.0
Traceback (most recent call last):
File "/Users/erwin/curso-python/descuentos.py", line 11, in <module>
print(calcular_descuento(120, 150))
~~~~~~~~~~~~~~~~~~^^^^^^^^^^
File "/Users/erwin/curso-python/descuentos.py", line 6, in calcular_descuento
raise ValueError(f"El porcentaje debe estar entre 0 y 100, no {porcentaje}")
ValueError: El porcentaje debe estar entre 0 y 100, no 150La sintaxis es raise TipoDeError("mensaje"). Ese raise detiene la función inmediatamente, como un return pero por la puerta de atrás, y la excepción sube por la cadena de llamadas hasta que alguien la capture con un except o hasta que llegue arriba y pare el programa.
Fíjate en lo que has ganado. El traceback tiene dos escalones: la línea que llamó mal y la línea que se quejó. El mensaje dice qué valor llegó (150) y qué se esperaba. Y quien use tu función puede decidir:
try:
precio_final = calcular_descuento(120, 150)
except ValueError as error:
print(f"No he podido aplicar el descuento: {error}")
precio_final = 120Elegir el tipo adecuado#
Python tiene una excepción para cada situación, y usar la que toca hace que quien llame a tu función pueda capturarla con precisión.
| Situación | Excepción |
|---|---|
| El valor no sirve, aunque el tipo sea correcto | ValueError |
| El tipo de dato no es el que se esperaba | TypeError |
| Falta una clave o una posición | KeyError, IndexError |
| La operación no se puede hacer en este momento | RuntimeError |
| Es una función que aún no has escrito | NotImplementedError |
La distinción entre las dos primeras es la que más se confunde:
def repetir_mensaje(texto: str, veces: int) -> str:
"""Repite un texto un número de veces, separado por espacios."""
if not isinstance(texto, str):
raise TypeError(f"Se esperaba una cadena, ha llegado {type(texto)}")
if veces < 1:
raise ValueError(f"Hay que repetir al menos una vez, no {veces}")
return " ".join([texto] * veces)TypeError es para "esto no es la clase de cosa que necesito": me has dado un número donde iba texto. ValueError es para "esto es la clase de cosa correcta, pero este ejemplar concreto no vale": me has dado un entero, como pedía, pero vale -3.
Tus propias excepciones#
A veces ninguna excepción de Python describe bien tu problema. Que una cuenta no tenga saldo suficiente no es un ValueError: es algo específico de tu programa, y quieres que quien lo use pueda capturarlo por separado.
Crear una excepción propia son dos líneas:
class SaldoInsuficiente(Exception):
"""El saldo de la cuenta no llega para hacer la operación."""Esa es la receta completa. El nombre que elijas, la palabra Exception entre paréntesis, y un docstring que explique cuándo se lanza. No hace falta escribir nada más dentro.
Con la excepción creada, se lanza y se captura igual que cualquier otra:
class SaldoInsuficiente(Exception):
"""El saldo de la cuenta no llega para hacer la operación."""
def retirar(saldo: float, cantidad: float) -> float:
if cantidad <= 0:
raise ValueError(f"La cantidad debe ser positiva, no {cantidad}")
if cantidad > saldo:
raise SaldoInsuficiente(f"Intentas sacar {cantidad} € y solo tienes {saldo} €")
return saldo - cantidad
try:
nuevo_saldo = retirar(50, 80)
except SaldoInsuficiente as error:
print(f"Operación rechazada: {error}")
except ValueError as error:
print(f"Datos incorrectos: {error}")
print(retirar(50, 20))Operación rechazada: Intentas sacar 80 € y solo tienes 50 €
30Y si nadie la captura, se comporta como cualquier excepción de Python, con tu nombre en la última línea. Este sería el mismo fichero con el try quitado y una llamada suelta a retirar(50, 80) en la línea 13:
Traceback (most recent call last):
File "/Users/erwin/curso-python/banco.py", line 13, in <module>
retirar(50, 80)
~~~~~~~^^^^^^^^
File "/Users/erwin/curso-python/banco.py", line 9, in retirar
raise SaldoInsuficiente(f"Intentas sacar {cantidad} € y solo tienes {saldo} €")
SaldoInsuficiente: Intentas sacar 80 € y solo tienes 50 €Esa última línea, SaldoInsuficiente: Intentas sacar 80 € y solo tienes 50 €, es documentación gratis para quien se encuentre el fallo dentro de seis meses.
Relanzar con raise a secas#
A veces quieres enterarte de un fallo, hacer algo, y dejar que siga su camino igualmente. Un raise sin nada detrás, dentro de un except, relanza la misma excepción que acabas de capturar:
from datetime import datetime
def anotar_fallo(mensaje: str) -> None:
momento = datetime.now().strftime("%H:%M:%S")
print(f"[{momento}] REGISTRO: {mensaje}")
def cargar_datos(ruta: str) -> str:
try:
with open(ruta, encoding="utf-8") as fichero:
return fichero.read()
except FileNotFoundError:
anotar_fallo(f"no se ha encontrado {ruta}")
raise
cargar_datos("clientes.csv")[14:04:33] REGISTRO: no se ha encontrado clientes.csv
Traceback (most recent call last):
File "/Users/erwin/curso-python/carga.py", line 18, in <module>
cargar_datos("clientes.csv")
~~~~~~~~~~~~^^^^^^^^^^^^^^^^
File "/Users/erwin/curso-python/carga.py", line 11, in cargar_datos
with open(ruta, encoding="utf-8") as fichero:
~~~~^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: 'clientes.csv'La hora del registro será la de tu ejecución. Lo importante es que el traceback señala la línea del open() original, no la del raise: la excepción conserva intacta su historia. Si en vez de raise a secas escribieras raise FileNotFoundError(...), crearías una excepción nueva y perderías el rastro.
Este patrón es el de "anota y deja pasar": tu función no sabe cómo resolver el problema, pero sí quiere dejar constancia de que ocurrió.
Encadenar con raise ... from#
El otro caso es cuando sí quieres cambiar la excepción, traduciendo un fallo técnico de bajo nivel a uno que tenga sentido en tu programa. Ahí entra from:
class ConfiguracionInvalida(Exception):
"""El fichero de configuración no se puede usar."""
def leer_puerto(texto: str) -> int:
"""Convierte el puerto leído del fichero de configuración."""
try:
return int(texto)
except ValueError as error:
raise ConfiguracionInvalida(f"El puerto '{texto}' no es un número") from error
leer_puerto("ocho mil")Traceback (most recent call last):
File "/Users/erwin/curso-python/configuracion.py", line 8, in leer_puerto
return int(texto)
ValueError: invalid literal for int() with base 10: 'ocho mil'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "/Users/erwin/curso-python/configuracion.py", line 13, in <module>
leer_puerto("ocho mil")
~~~~~~~~~~~^^^^^^^^^^^^
File "/Users/erwin/curso-python/configuracion.py", line 10, in leer_puerto
raise ConfiguracionInvalida(f"El puerto '{texto}' no es un número") from error
ConfiguracionInvalida: El puerto 'ocho mil' no es un númeroSalen dos tracebacks unidos por la frase The above exception was the direct cause of the following exception. Arriba, la causa técnica: int() no pudo con ese texto. Abajo, lo que significa en tu programa: la configuración no vale. Quien lo lea tiene las dos capas de información, y eso es exactamente lo que quieres cuando algo falla en producción a las tres de la mañana.
| Forma | Qué hace |
|---|---|
raise | Relanza la misma excepción, sin tocar nada |
raise OtroError("mensaje") from error | Lanza una nueva conservando la causa original |
raise OtroError("mensaje") | Lanza una nueva; la causa original se pierde de vista |
Cuándo lanzar y cuándo no#
Lanzar una excepción es lo correcto cuando tu función no puede cumplir lo que promete: le han dado datos imposibles, falta un recurso, el estado no es el esperado.
No lances una excepción para algo que forma parte del funcionamiento normal. Buscar un cliente en una lista y no encontrarlo no es un error: es un resultado posible, y devolver None ahí está bien. La diferencia está en si la situación era previsible y aceptable, o si de verdad hace imposible continuar.