Hasta ahora las excepciones nos llegaban: las lanzaba float(), open(), json.load(). Pero el módulo 5 escondía un adelanto que pasó casi desapercibido: el __post_init__ de Libro y las properties de Producto ya hacían raise ValueError(...) cuando alguien intentaba un precio negativo. Entonces fue un acto de fe; hoy se convierte en decisión de diseño. En esta lección aprenderás a lanzar excepciones con intención: cuándo una situación merece un raise y cuándo basta con devolver None, cómo escribir mensajes que ahorren una sesión de depuración, cómo relanzar lo que no te toca manejar y cómo encadenar con raise ... from e para no perder el rastro del error original. El ejemplo central será la función más delicada de Papyrus: vender(), la que toca el stock y la caja.
Contenido
raise: lanzar una excepción a propósito- El mensaje importa: errores accionables
- ¿Lanzar o devolver
None? El criterio del contrato - Guard clauses: validar en la puerta
vender(titulo, unidades): el ejemplo central- Relanzar (
raisea secas) y encadenar (raise ... from e) - Revisitando el módulo 5: properties y
__post_init__con vocabulario completo
raise: lanzar una excepción a propósito
La sentencia raise crea y lanza una excepción, exactamente igual que lo haría Python por su cuenta:
def aplicar_descuento(precio, porcentaje):
if not 0 <= porcentaje <= 1:
raise ValueError(f"porcentaje debe estar entre 0 y 1, no {porcentaje}")
return precio * (1 - porcentaje)Anatomía: raise + una instancia de excepción (se construye como cualquier objeto: clase y argumentos, normalmente el mensaje). A partir de ahí, el mecanismo es el de 07-01: la ejecución se interrumpe, la excepción se propaga hacia arriba, y o alguien la atrapa con el try/except de 07-02 o el programa termina con su traceback.
¿Y qué tipo lanzar? Mientras no tengamos excepciones propias (07-04), se eligen las integradas respetando su significado, el mismo de la tabla de 07-01:
| Situación | Tipo adecuado |
|---|---|
El valor no vale, aunque el tipo sea correcto (porcentaje=3) |
ValueError |
El tipo no es el esperado (unidades="dos") |
TypeError |
| Se buscó una clave/elemento que no está | KeyError / LookupError |
| La operación no es válida en el estado actual del objeto | RuntimeError (o una propia, en 07-04) |
Lanzar un ValueError cuando el problema es de tipo despista a quien depure. El tipo de la excepción es parte del mensaje.
El mensaje importa: errores accionables
Compara estos dos lanzamientos ante el mismo problema:
raise ValueError("error en unidades") # ❌ ¿qué error? ¿qué valor llegó?
raise ValueError(f"unidades debe ser un entero positivo, no {unidades!r}") # ✅Un buen mensaje responde tres preguntas: qué se esperaba, qué llegó y — si no es obvio — qué hacer al respecto. El !r en la f-string (equivale a aplicar repr()) muestra el valor con sus comillas y su tipo visible: no '3' delata que llegó un string "3" en vez del entero 3, un matiz que no 3 ocultaría. Recuerda 07-01: el mensaje del raise es exactamente lo que aparecerá en la última línea del traceback, la primera que leerá quien depure. Escríbelo para esa persona — que dentro de tres meses serás tú.
¿Lanzar o devolver None? El criterio del contrato
Esta es la decisión de diseño central de la lección, y el precio_socio() del ejercicio 3 de 07-02 la dejó abierta. La brújula es la idea de contrato: toda función promete algo ("dame un título y unidades válidas y descontaré stock"). Entonces:
- Lanza una excepción cuando el que llama rompe el contrato — pide algo absurdo o imposible: vender −3 unidades, un precio negativo, un código de socio con formato inválido. Eso es un error de programación o de uso y debe hacer ruido.
- Devuelve
Nonecuando la ausencia es una respuesta legítima a una pregunta bien formulada: "¿tenéis Rayuela?" — no tenerlo no es ningún error, es información.
| Criterio | Devolver None |
Lanzar excepción |
|---|---|---|
| ¿La situación es un resultado normal del negocio? | Sí: "no lo tenemos" es una respuesta | No: es una violación del contrato |
| ¿Quién tiene la culpa? | Nadie: la pregunta era válida | El llamador: pidió algo inválido |
| ¿Qué hará el llamador, casi siempre? | Comprobar y seguir (if libro is None:) |
No debería ocurrir; si ocurre, mejor ruido que silencio |
| ¿Riesgo si se ignora? | Bajo: None explota pronto y cerca (AttributeError) |
Alto: continuar con stock negativo corrompe datos |
| Ejemplo Papyrus | buscar_libro("Rayuela") → None |
vender("Hamlet", -3) → ValueError |
El caso canónico de la columna izquierda es buscar_libro(), que formalizamos así:
def buscar_libro(catalogo, titulo):
"""Devuelve el Libro correspondiente al título, o None si no está.
Que un libro no esté en el catálogo NO es un error: es la respuesta
a la pregunta '¿lo tenéis?'. Por eso None, y no una excepción.
"""
return catalogo.get(titulo.strip().casefold())dict.get() (módulo 4) devuelve None cuando la clave no existe: encaja como un guante con este contrato. Quien llama debe comprobar el resultado — if libro is None: — y decidir. Cuidado, eso sí, con el None que viaja sin comprobarse: acabará en un AttributeError: 'NoneType' object has no attribute 'precio' dos funciones más lejos. Por eso en 07-04 crearemos una hermana estricta, obtener_libro(), que lanza en vez de devolver None, y veremos que ambas pueden convivir: cada una sirve a un tipo de llamador distinto.
Guard clauses: validar en la puerta
Una guard clause (cláusula de guarda) es una comprobación al principio de la función que lanza inmediatamente si un argumento no cumple el contrato. Es LBYL del bueno — el que 07-01 declaró legítimo: validar datos que ya tienes en la mano, sin ventana temporal ni mundo exterior de por medio.
def registrar_socio(nombre, codigo):
# --- guardas: la puerta de entrada ---
if not nombre.strip():
raise ValueError("el nombre del socio no puede estar vacío")
if not codigo.strip():
raise ValueError("el código del socio no puede estar vacío")
# --- a partir de aquí, terreno seguro ---
...Ventajas de validar en la puerta y no a mitad de función:
- Fallar pronto (fail fast): la excepción salta en la primera línea, con el dato recién llegado, no veinte líneas después cuando ya es difícil saber de dónde vino el valor malo. Un stock negativo detectado al vender es un misterio; detectado al entrar, es un traceback que señala al culpable.
- El cuerpo respira: superadas las guardas, el resto de la función puede asumir datos válidos sin
ifanidados. - El contrato queda escrito: las guardas documentan, mejor que cualquier comentario, qué acepta la función.
vender(titulo, unidades): el ejemplo central
Juntemos todo en la función más sensible de Papyrus. Vender toca el stock y la caja: aquí un error silencioso cuesta dinero, así que el contrato se defiende con raise:
IVA_LIBROS = 0.04
def vender(catalogo, titulo, unidades):
"""Descuenta stock y devuelve el importe con IVA de la venta.
Lanza ValueError si las unidades no son válidas o si la venta
es imposible (título desconocido o stock insuficiente).
"""
# Guardas sobre los argumentos: el llamador rompe el contrato
if not isinstance(unidades, int):
raise TypeError(f"unidades debe ser int, no {type(unidades).__name__}")
if unidades <= 0:
raise ValueError(f"unidades debe ser positivo, no {unidades}")
# Guardas sobre el estado: la venta pedida es imposible
libro = buscar_libro(catalogo, titulo)
if libro is None:
raise ValueError(f"'{titulo}' no está en el catálogo")
if libro.stock < unidades:
raise ValueError(
f"stock insuficiente de '{libro.titulo}': "
f"quedan {libro.stock}, se pidieron {unidades}"
)
# Terreno seguro: ejecutar la venta
libro.stock -= unidades
return round(libro.precio * unidades * (1 + IVA_LIBROS), 2)Decisiones de diseño, una a una:
unidades <= 0esValueError, no unreturn None: pedir −3 ejemplares no es "una respuesta legítima del negocio", es un llamador equivocado (o un bug aguas arriba). Ruido, no silencio.vender()usabuscar_libro()por dentro y traduce suNonea excepción. Es un patrón elegante: la función tolerante responde preguntas; la función que ejecuta acciones convierte la ausencia en violación de contrato, porque vender lo que no existe sí es un error.- El mensaje de stock lleva los tres números que importan: qué libro, cuánto queda, cuánto se pidió. En 07-04 esos tres datos ascenderán a atributos de una excepción propia (
StockInsuficienteError), para que el código — no solo el humano — pueda leerlos. - La mutación va al final. Todas las guardas se evalúan antes de tocar
libro.stock. Si algo falla, el catálogo queda intacto: nada de ventas a medias.
Con datos canónicos: vender(catalogo, "Hamlet", 2) → los 6 de stock pasan a 4 y devuelve 9.95 * 2 * 1.04 = 20.70. vender(catalogo, "Hamlet", 99) → ValueError: stock insuficiente de 'Hamlet': quedan 6, se pidieron 99 (nada, ni el 6, ha cambiado).
Relanzar (raise a secas) y encadenar (raise ... from e)
A veces atrapas una excepción pero descubres que no te toca manejarla del todo. Dos herramientas:
raise a secas dentro de un except relanza la misma excepción, con su traceback original intacto. Útil para reaccionar de paso (avisar, registrar, limpiar) sin apropiarte del error:
try:
importe = vender(catalogo, titulo, unidades)
except ValueError:
print("Venta fallida; el catálogo NO se ha modificado.")
raise # que decida quien está más arribaraise NuevaExcepcion(...) from e lanza una excepción distinta encadenada a la original. Es la herramienta para traducir errores técnicos de bajo nivel al idioma del que llama, sin destruir la evidencia:
def cargar_catalogo():
ruta = BASE / "datos" / "catalogo.json"
try:
with open(ruta, encoding="utf-8") as f:
lista = json.load(f)
except json.JSONDecodeError as e:
raise RuntimeError(
f"catálogo ilegible en {ruta}; restaura una copia de copias/"
) from e
return {l["titulo"].strip().casefold(): Libro(**l) for l in lista}El traceback resultante muestra las dos excepciones, unidas por la línea The above exception was the direct cause of the following exception: — arriba el JSONDecodeError con la línea exacta del JSON roto, debajo el RuntimeError con el consejo de restaurar la copia. Quien llama maneja un error de alto nivel; quien depura conserva la causa raíz. (Nota para comparar: en 07-02 este caso lo resolvimos con SystemExit(1), decisión de programa-terminal; from e es la versión de biblioteca, que informa sin decidir por el llamador. En 07-04, RuntimeError cederá el sitio a una excepción propia.)
¿Y qué pasa si dentro de un except lanzas sin from? Python encadena igualmente, pero con el rótulo During handling of the above exception, another exception occurred: — que sugiere un accidente dentro del manejador, no una traducción deliberada. from e declara la intención; úsalo siempre que traduzcas.
| Herramienta | Qué hace | Cuándo usarla |
|---|---|---|
raise Tipo("msg") |
Lanza una excepción nueva | Violación de contrato detectada por ti (guardas) |
raise a secas (en except) |
Relanza la actual, traceback intacto | Reaccionar de paso sin apropiarte del error |
raise Tipo("msg") from e |
Lanza otra, encadenando la causa | Traducir un error técnico al idioma del llamador |
Revisitando el módulo 5: properties y __post_init__ con vocabulario completo
Cerremos el círculo. Esto escribimos en el módulo 5, entonces como receta:
@dataclass
class Libro:
titulo: str
precio: float
stock: int = 0
def __post_init__(self):
if self.precio < 0:
raise ValueError(f"el precio no puede ser negativo: {self.precio}")
if self.stock < 0:
raise ValueError(f"el stock no puede ser negativo: {self.stock}")Con el vocabulario de hoy, cada pieza tiene nombre: __post_init__ es una guard clause del constructor — valida en la puerta, falla pronto, y garantiza el invariante de que no puede existir un Libro con precio negativo en toda Papyrus. Lanza ValueError porque el tipo es correcto y el valor no. El setter de la property precio de Producto hace lo mismo en cada asignación posterior: el contrato no se firma solo al nacer, se defiende toda la vida del objeto. Y esto explica algo de 07-02: cuando cargar_catalogo() reconstruye con Libro(**datos), un JSON con "precio": -5 dispara este ValueError automáticamente — la validación del módulo 5 protege también la frontera de la persistencia del módulo 6, gratis. Los sistemas bien diseñados encajan así.
Errores Comunes y Consejos
- Devolver códigos de error (
-1,False,"") en vez de lanzar. El error viaja disfrazado de dato y explota lejos, ilegible. En Python, contrato roto = excepción; ausencia legítima =Nonedocumentado. - Lanzar para todo, incluso para ausencias normales. El extremo opuesto: si
buscar_libro()lanzara por cada título no encontrado, consultar el catálogo se llenaría detry/excepttriviales. Aplica la tabla del contrato, no un reflejo. - Atrapar tu propia excepción dos líneas después. Si lanzas y atrapas en la misma función, casi seguro que sobraba el
raise: era un simpleif.raisees para cruzar fronteras de responsabilidad. - Perder la causa original al traducir.
except JSONDecodeError: raise RuntimeError("catálogo ilegible")sinfrom edeja la traducción con aspecto de accidente. Confrom ela cadena queda explícita y completa. - Mensajes sin el dato culpable.
raise ValueError("unidades inválidas")obliga a depurar;raise ValueError(f"unidades debe ser positivo, no {unidades!r}")se arregla de un vistazo. - Consejo: escribe primero la docstring con el contrato ("lanza X si..."), después las guardas que lo hacen cumplir. Si no sabes qué prometer, aún no sabes qué lanzar.
- Consejo: mutar el estado siempre después de todas las guardas. Una función que valida a medias y modifica a medias deja datos corruptos, que son peores que cualquier excepción.
Ejercicios
reponer(catalogo, titulo, unidades). Escribe la función que suma stock a un libro existente. Contrato:TypeErrorsiunidadesno esint;ValueErrorsiunidades <= 0;ValueErrorsi el título no está en el catálogo (Ana no repone lo que no ha dado de alta). Devuelve el stock resultante. Compruébala reponiendo 5 ejemplares de Fausto (que M6 dejó en 5): debe devolver 10 y los casos inválidos deben lanzar con mensajes que incluyan el dato culpable.- ¿
Noneoraise? Decide y justifica en una frase, usando la tabla del contrato: (a)buscar_socio(codigo)cuando"LUIS-001"no está ensocios.json; (b) la misma función cuando recibecodigo=""; (c)precio_con_iva(precio)cuandoprecioes negativo; (d)ultima_venta()cuandoventas.csvestá vacío tras un día sin ventas. - Traducir con
from. Escribecargar_socios()que leadatos/socios.jsony, si el JSON está corrupto, lanceRuntimeError("fichero de socios ilegible; restaura una copia")encadenado al error original confrom. Si el archivo no existe, debe devolver[](¿por qué esa asimetría entre ambos fallos?).
Soluciones
-
def reponer(catalogo, titulo, unidades): """Suma unidades al stock de un libro ya catalogado. Lanza TypeError/ValueError si se viola el contrato. Devuelve el stock resultante. """ if not isinstance(unidades, int): raise TypeError(f"unidades debe ser int, no {type(unidades).__name__}") if unidades <= 0: raise ValueError(f"unidades debe ser positivo, no {unidades}") libro = buscar_libro(catalogo, titulo) if libro is None: raise ValueError(f"'{titulo}' no está en el catálogo; dalo de alta primero") libro.stock += unidades return libro.stockreponer(catalogo, "Fausto", 5)→ 10. La estructura calca la devender(): guardas de argumentos, guarda de estado, mutación al final. Cuando dos funciones comparten contrato tan de cerca, están pidiendo una jerarquía de excepciones común — 07-04. -
(a)
None: preguntar por un socio que no existe es una consulta legítima; la ausencia es información ("no es socio → sin descuento"). (b)raise ValueError: un código vacío no es una pregunta válida sino un llamador roto — guard clause. La misma función puede así devolverNoney lanzar: responden a situaciones distintas. (c)raise ValueError: un precio negativo viola el contrato; devolverNonedejaría pasar el bug en silencio. (d)None(o[]si devuelve lista): un día sin ventas es un resultado normal del negocio, no un error — que el cierre de caja muestre 0.00 €, no un traceback. -
def cargar_socios(): ruta = BASE / "datos" / "socios.json" try: with open(ruta, encoding="utf-8") as f: return json.load(f) except FileNotFoundError: return [] except json.JSONDecodeError as e: raise RuntimeError("fichero de socios ilegible; restaura una copia") from eLa asimetría es la de 07-02, ahora con nombre de contrato: archivo ausente es un estado legítimo (aún no hay socios: lista vacía, como el
Nonede la tabla); archivo corrupto significa que hay datos pero no podemos leerlos — devolver[]los daría por perdidos en silencio y el siguiente guardado los machacaría. Contrato roto por el mundo exterior: excepción, traducida al idioma de Papyrus con la causa encadenada.
Conclusión
raise ha convertido a Papyrus de reactiva en asertiva: sus funciones ya no solo encajan errores ajenos, sino que defienden contratos propios. Tienes el criterio (None para ausencias legítimas como buscar_libro(), excepción para contratos rotos como vender() con unidades negativas), la técnica (guard clauses que fallan pronto y mutan tarde, mensajes con el dato culpable en !r) y las herramientas de propagación (raise a secas para no apropiarte del error, raise ... from e para traducir sin destruir la evidencia). Hasta las validaciones del módulo 5 han revelado su verdadero nombre: guardas de constructor que protegen invariantes. Pero fíjate en una grieta: vender() lanza ValueError tanto por "unidades negativas" como por "título desconocido" como por "stock insuficiente" — y quien atrape ese ValueError no puede distinguirlos sin leer el mensaje con los ojos. El código merece algo mejor que analizar strings. La próxima lección crea la jerarquía de excepciones propia de Papyrus — ErrorPapyrus, LibroNoEncontradoError, StockInsuficienteError — con atributos que las máquinas puedan leer, no solo los humanos.
Curso de Programación en Python
Módulo 1: Introducción a Python
- Introducción a Python
- Configuración del Entorno de Desarrollo
- Sintaxis de Python y Tipos de Datos Básicos
- Variables y Constantes
- Entrada y Salida Básica
- Entornos Virtuales y Gestión de Paquetes
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for y while
- Herramientas de Control de Flujo
- Comprensiones de Listas
Módulo 3: Funciones y Módulos
- Definición de Funciones
- Argumentos de Función
- Funciones Lambda
- Módulos y Paquetes
- Visión General de la Biblioteca Estándar
Módulo 4: Estructuras de Datos
Módulo 5: Programación Orientada a Objetos
Módulo 6: Manejo de Archivos
- Lectura y Escritura de Archivos
- Trabajo con Archivos CSV
- Manejo de Datos JSON
- Operaciones con Archivos y Directorios
Módulo 7: Manejo de Errores y Excepciones
- Introducción a las Excepciones
- Manejo de Excepciones
- Lanzamiento de Excepciones
- Excepciones Personalizadas
- Buenas Prácticas y Registro de Errores con logging
Módulo 8: Temas Avanzados
- Anotaciones de Tipos (type hints)
- Decoradores
- Generadores
- Administradores de Contexto
- Concurrencia: Hilos y Procesos
- Asyncio para Programación Asíncrona
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con unittest
- Pruebas con pytest
- Desarrollo Guiado por Pruebas
- Técnicas de Depuración
- Uso de pdb para Depuración
Módulo 10: Desarrollo Web con Python
- Introducción al Desarrollo Web
- Fundamentos del Framework Flask
- Construcción de APIs REST con Flask
- Introducción a Django
- Construcción de Aplicaciones Web con Django
Módulo 11: Ciencia de Datos con Python
- Introducción a la Ciencia de Datos
- NumPy para Computación Numérica
- Pandas para Manipulación de Datos
- Matplotlib para Visualización de Datos
- Introducción al Aprendizaje Automático con scikit-learn
