La lección anterior terminó señalando una grieta: vender() lanza ValueError por tres motivos distintos — unidades inválidas, título desconocido, stock insuficiente — y quien lo atrapa no puede distinguirlos sin leer el mensaje "con los ojos". Peor aún: si el código de la caja atrapa except ValueError: para manejar el stock agotado, atrapará también, por accidente, el ValueError de un bug cualquiera que ande cerca. La solución es la misma que Python usa consigo mismo: clases de excepción propias. Igual que FileNotFoundError le dice a tu except exactamente qué pasó con el archivo, StockInsuficienteError le dirá a la caja de Papyrus exactamente qué pasó con la venta — y llevará dentro, como atributos, el título, lo solicitado y lo disponible, listos para que el código actúe sin analizar strings. En esta lección construiremos la jerarquía de errores de Papyrus, la guardaremos en su propio módulo errores.py y la integraremos en las funciones que ya conoces.
Contenido
- Por qué crear excepciones propias
- La forma mínima: heredar de
Exception - La jerarquía de Papyrus:
ErrorPapyrusy sus hijas - Enriquecer con atributos y
__str__ - Capturar por la base vs capturar por la hoja
obtener_libro()vsbuscar_libro(): estricta y tolerante conviven- Integración en
vender()y el móduloerrores.py
Por qué crear excepciones propias
Tres razones, de más práctica a más profunda:
- El
exceptdel llamador distingue TU error del resto.except StockInsuficienteError:atrapa exactamente "no queda género" y deja pasar cualquierValueErroraccidental (un bug de conversión, una fecha mal parseada). Con excepciones integradas para todo, tu manejo de errores de negocio y los bugs de Python viajan por el mismo tubo. - Los datos del error viajan como atributos, no incrustados en un string. En 07-03, el mensaje de stock llevaba "los tres números que importan"; ahora el código podrá leer
e.disponibley ofrecer a Júlia los 6 ejemplares que sí quedan, en lugar de trocear un mensaje con los dedos cruzados. - La jerarquía documenta tu dominio. Un vistazo a
errores.pydirá qué puede salir mal en Papyrus, igual que un vistazo a la tabla de 07-01 dice qué puede salir mal en Python.
La forma mínima: heredar de Exception
Una excepción propia es una clase normal (módulo 5) que hereda de Exception. La versión mínima ni siquiera necesita cuerpo:
Con esto ya se puede lanzar y atrapar:
¿Cómo funciona sin escribir ni un método? Herencia pura, la del módulo 5: Exception ya sabe recibir un mensaje en el constructor, guardarlo, mostrarse en el traceback. Tu subclase lo hereda todo; de momento solo aporta un nombre nuevo que los except pueden distinguir. Dos reglas de estilo universales: heredar de Exception (no de BaseException — recuerda de 07-01 que ese nivel es para KeyboardInterrupt y compañía) y terminar el nombre en Error cuando representa un error, como hacen ValueError o FileNotFoundError.
La jerarquía de Papyrus: ErrorPapyrus y sus hijas
Igual que Python cuelga KeyError e IndexError de LookupError, Papyrus define una raíz común y de ella cuelgan los errores concretos del negocio:
graph TD
EX[Exception] --> EP[ErrorPapyrus]
EP --> LNE[LibroNoEncontradoError<br/>atributo: titulo]
EP --> SIE[StockInsuficienteError<br/>atributos: titulo, solicitado, disponible]
EP --> SVE[SocioInvalidoError<br/>atributo: codigo]
La base ErrorPapyrus no se lanza casi nunca directamente: su oficio es agrupar. Gracias a la regla de coincidencia de 07-02 (un except atrapa la clase y sus subclases), except ErrorPapyrus: significa "cualquier error de negocio de la tienda", mientras cada hoja conserva su identidad para quien necesite afinar.
Enriquecer con atributos y __str__
La versión completa de las tres hojas, con datos legibles por máquina:
class ErrorPapyrus(Exception):
"""Error base de la librería Papyrus."""
class LibroNoEncontradoError(ErrorPapyrus):
"""Se pidió un libro que no está en el catálogo."""
def __init__(self, titulo):
super().__init__(f"'{titulo}' no está en el catálogo")
self.titulo = titulo
class StockInsuficienteError(ErrorPapyrus):
"""Se pidieron más unidades de las disponibles."""
def __init__(self, titulo, solicitado, disponible):
super().__init__(titulo) # el mensaje detallado lo compone __str__
self.titulo = titulo
self.solicitado = solicitado
self.disponible = disponible
def __str__(self):
return (f"stock insuficiente de '{self.titulo}': "
f"quedan {self.disponible}, se pidieron {self.solicitado}")
class SocioInvalidoError(ErrorPapyrus):
"""El código de socio no existe o tiene formato inválido."""
def __init__(self, codigo):
super().__init__(f"código de socio inválido: {codigo!r}")
self.codigo = codigoDesmontemos las técnicas — todas del módulo 5, aplicadas a un caso nuevo:
__init__propio con parámetros de dominio.LibroNoEncontradoError("Rayuela")se construye con el dato relevante, no con un mensaje prefabricado por el llamador. Menos repetición, mensajes siempre coherentes.super().__init__(mensaje)delega enExceptionpara que el mecanismo estándar (el texto del traceback,print(e)) siga funcionando. No lo omitas: una excepción que no llama asuper().__init__pierde comportamientos que otros esperan.- Los atributos (
self.titulo,self.solicitado,self.disponible,self.codigo) son la carga útil: elexceptque la atrape podrá calcular con ellos. __str__(el mismo dunder de M5) controla cómo se muestra la excepción en el traceback y al hacerprint(e).StockInsuficienteErrorlo usa para componer el mensaje a partir de sus atributos: una única fuente de verdad, imposible que mensaje y datos discrepen.
Y así se aprovecha la carga útil desde el llamador:
try:
importe = vender(catalogo, "Hamlet", 99)
except StockInsuficienteError as e:
print(f"Solo quedan {e.disponible} de '{e.titulo}'.")
if e.disponible > 0:
respuesta = input(f"¿Vender esas {e.disponible}? (s/n) ")Ni un solo string troceado: e.disponible es un int listo para comparar. Esto era imposible con el ValueError de 07-03.
Capturar por la base vs capturar por la hoja
La jerarquía da al llamador un dial de precisión. Compara los dos extremos:
# Por la hoja: la caja quiere reaccionar distinto a cada situación
try:
importe = vender(catalogo, titulo, unidades)
except LibroNoEncontradoError as e:
print(f"No tenemos '{e.titulo}'. ¿Lo encargamos a la distribuidora?")
except StockInsuficienteError as e:
print(f"Solo quedan {e.disponible}; ¿ajustamos la venta?")
# Por la base: el menú principal solo quiere que la tienda no se caiga
try:
ejecutar_opcion(opcion)
except ErrorPapyrus as e:
print(f"Operación no realizada: {e}")
# y volvemos al menú, con la tienda intacta| Capturar por... | Atrapa | Cuándo conviene |
|---|---|---|
La hoja (StockInsuficienteError) |
Solo esa situación exacta | Cuando cada error tiene una reacción distinta (la caja) |
La base (ErrorPapyrus) |
Cualquier error de negocio de Papyrus, presente o futuro | En la frontera: menú principal, bucle de la aplicación |
Exception |
Todo, incluidos bugs ajenos al negocio | Casi nunca; solo en la última frontera y registrando (07-05) |
El matiz "o futuro" es oro: cuando en el módulo 10 Papyrus tenga web y aparezca PedidoDuplicadoError(ErrorPapyrus), el except ErrorPapyrus: del menú lo manejará sin tocar una línea. La jerarquía es un contrato extensible. Y sigue vigente la regla de orden de 07-02: las hojas antes que la base, o la base se lo comerá todo y las hojas serán código muerto.
obtener_libro() vs buscar_libro(): estricta y tolerante conviven
En 07-03 quedó prometida la hermana estricta de buscar_libro(). Aquí está, y la pareja completa ilustra que devolver None y lanzar no compiten: sirven a llamadores distintos.
def buscar_libro(catalogo, titulo):
"""Devuelve el Libro, o None si no está. Ausencia = respuesta legítima."""
return catalogo.get(titulo.strip().casefold())
def obtener_libro(catalogo, titulo):
"""Devuelve el Libro, o lanza LibroNoEncontradoError si no está.
Para llamadores que NECESITAN el libro: si no está, no pueden seguir.
"""
libro = buscar_libro(catalogo, titulo)
if libro is None:
raise LibroNoEncontradoError(titulo)
return librobuscar_libro() |
obtener_libro() |
|
|---|---|---|
| Si el libro no está | Devuelve None |
Lanza LibroNoEncontradoError |
| Pregunta que responde | "¿Lo tenéis?" | "Dámelo, lo necesito" |
| Llamador típico | Consulta de mostrador, comprobaciones | vender(), reponer(), informes |
| Obligación del llamador | Comprobar is None |
Nada, o un except donde proceda |
Fíjate en que la estricta se construye encima de la tolerante: una línea que traduce ausencia en excepción. Es el mismo patrón de la biblioteca estándar que ya usas sin pensarlo: catalogo["x"] lanza KeyError (estricto) y catalogo.get("x") devuelve None (tolerante). Papyrus ahora habla ese mismo idioma. El criterio de la tabla de 07-03 decide cuál usar en cada punto del código; tener las dos no es duplicación, es dar a cada llamador el contrato que necesita.
Integración en vender() y el módulo errores.py
Primero, la casa de las excepciones. El módulo 3 enseñó a repartir el código en módulos; las excepciones propias merecen el suyo, porque las importan tanto quien lanza como quien atrapa:
papyrus/
├── datos/ (catalogo.json, ventas.csv, socios.json, config.json, ...)
├── copias/
├── informes/
├── errores.py ← la jerarquía completa: SOLO excepciones
├── modelos.py (Libro, Producto, Socio...)
├── almacen.py (cargar/guardar catálogo, buscar/obtener, vender, reponer)
└── caja.py (el programa que Ana ejecuta)errores.py contiene las cuatro clases de arriba y nada más. Así, almacen.py hace from errores import LibroNoEncontradoError, StockInsuficienteError para lanzarlas, y caja.py importa las que atrapa — sin que caja tenga que importar todo el almacén solo para conocer sus errores, y sin riesgo de imports circulares.
Y la nueva vender(), con cada situación hablando su propio idioma:
from errores import StockInsuficienteError
# LibroNoEncontradoError la lanza obtener_libro(); vender solo la deja pasar
def vender(catalogo, titulo, unidades):
"""Descuenta stock y devuelve el importe con IVA.
Lanza TypeError/ValueError si los argumentos violan el contrato,
LibroNoEncontradoError si el título no existe,
StockInsuficienteError si no hay unidades suficientes.
"""
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 = obtener_libro(catalogo, titulo) # lanza LibroNoEncontradoError
if libro.stock < unidades:
raise StockInsuficienteError(libro.titulo, unidades, libro.stock)
libro.stock -= unidades
return round(libro.precio * unidades * (1 + IVA_LIBROS), 2)Compárala con la versión de 07-03 y observa el reparto final:
TypeError/ValueErrorsiguen para los argumentos: unidades negativas son un error de programación, universal, no un concepto del negocio librero. Las integradas expresan eso mejor que cualquier clase propia.- Las situaciones del dominio — libro inexistente, stock corto — usan la jerarquía de Papyrus, con atributos.
vender()se ha encogido: la guarda de existencia vive ahora enobtener_libro(), y la excepción que esta lanza atraviesavender()limpiamente (la propagación de 07-01 trabajando a favor). Cada función, una responsabilidad.
Con datos canónicos: vender(catalogo, "La Odisea", 9) lanza StockInsuficienteError("La Odisea", 9, 4) — y el except de la caja puede ofrecer los 4 que quedan leyendo e.disponible. vender(catalogo, "Rayuela", 1) lanza LibroNoEncontradoError con e.titulo == "Rayuela", listo para el mensaje de "¿lo encargamos?".
Errores Comunes y Consejos
- Heredar de
BaseException. Reservado para el sistema (Ctrl+C,SystemExit). Lo tuyo cuelga deException— normalmente de tu base propia. - Olvidar
super().__init__(...). La excepción "funciona" pero su mensaje estándar queda vacío o raro en tracebacks y logs. Siempre delega en la madre. - Meter los datos solo en el string.
raise ErrorPapyrus(f"quedan {n}")obliga al llamador a trocear texto. Los datos, a atributos; el texto, que lo componga__str__a partir de ellos. - Una clase de excepción por cada
raise. El extremo opuesto a no tener ninguna: cincuenta clases que nadie distingue. Crea una excepción cuando algúnexceptreal vaya a tratarla distinto; si nadie la distinguiría de su hermana, sobran. - Capturar por la base y actuar como si fuera una hoja. Dentro de
except ErrorPapyrus as e:no existee.disponiblegarantizado (podría ser unSocioInvalidoError). Si necesitas los atributos de una hoja, captura la hoja. - Consejo: nombra con la convención
AlgoErrory escribe una docstring de una línea en cada clase:errores.pyse convierte gratis en el catálogo de "qué puede salir mal" de tu sistema. - Consejo: cuando dudes entre integrada y propia, pregúntate quién atrapará. ¿Un
exceptde tu aplicación con lógica de negocio? Propia. ¿Es un error genérico de valores/tipos que cualquier función de Python podría lanzar? Integrada.
Ejercicios
aplicar_descuento_socio(catalogo, titulo, codigo, socios). Usando la jerarquía completa, escribe la función que devuelve el precio con descuento de socio (DESCUENTO_SOCIO = 0.05, redondeado a 2 decimales): debe lanzarSocioInvalidoErrorsicodigono está en la listasocios(usa["LUIS-001", "MARTA-002", "PAU-003"]), yLibroNoEncontradoErrorsi el título no existe (pista: no la lances tú — deja queobtener_libro()trabaje). Comprueba que("Hamlet", "MARTA-002")devuelve9.45y que"JULIA-999"lanza cone.codigo == "JULIA-999".- El mostrador que no se cae. Escribe un bucle
while Truede mostrador que pida título y unidades (con elpedir_entero()de 07-02), llame avender()y: anteLibroNoEncontradoErrorproponga encargarlo; anteStockInsuficienteErrorofrezca vender lase.disponiblerestantes si son > 0; ante cualquier otroErrorPapyrusmuestre el mensaje y siga; y salga limpiamente cuando el título sea"fin". ¿Por qué el orden de esos tresexceptno puede ser otro? - Nueva hoja sin tocar el menú. Añade a
errores.pyla excepciónPrecioInvalidoError(ErrorPapyrus)con atributostituloyprecioy un__str__propio, pensada para cuando una actualización de tarifas trae un precio negativo. Explica en una frase por qué elexcept ErrorPapyrus:del menú principal la maneja sin modificarse, y qué relación guarda esto con el polimorfismo del módulo 5.
Soluciones
-
DESCUENTO_SOCIO = 0.05 def aplicar_descuento_socio(catalogo, titulo, codigo, socios): if codigo not in socios: raise SocioInvalidoError(codigo) libro = obtener_libro(catalogo, titulo) # lanza LibroNoEncontradoError si falta return round(libro.precio * (1 - DESCUENTO_SOCIO), 2)aplicar_descuento_socio(catalogo, "Hamlet", "MARTA-002", socios)→9.45(los 9.83 de la tabla canónica de precios socio incluyen además el IVA del 4 %; aquí solo aplicamos el descuento). Con"JULIA-999"saltaSocioInvalidoError: código de socio inválido: 'JULIA-999'ye.codigovale"JULIA-999"— Júlia y Omar son clientes, no socios. La guarda del socio va primero: valida el argumento antes de tocar el catálogo, fallar pronto (07-03). -
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: print(f"No tenemos '{e.titulo}'. ¿Lo encargamos a la distribuidora?") except StockInsuficienteError as e: if e.disponible > 0: print(f"Solo quedan {e.disponible} de '{e.titulo}'. Ajusta la venta.") else: print(f"'{e.titulo}' está agotado.") except ErrorPapyrus as e: print(f"Operación no realizada: {e}") else: print(f"Venta realizada: {importe:.2f} € (IVA incluido)")El orden es obligatorio por la regla de 07-02 aplicada a nuestra jerarquía:
ErrorPapyruses la madre de las otras dos; si fuera primero, atraparía todo y las hojas serían código muerto. De lo específico a lo general — ahora con clases que diseñaste tú. Elelsemantiene el mensaje de éxito fuera deltry, como manda 07-02. -
class PrecioInvalidoError(ErrorPapyrus): """Una actualización de tarifas trae un precio inaceptable.""" def __init__(self, titulo, precio): super().__init__(titulo) self.titulo = titulo self.precio = precio def __str__(self): return f"precio inválido para '{self.titulo}': {self.precio}"El menú la maneja sin cambios porque
except ErrorPapyrus:atrapa la clase y todas sus subclases, incluidas las que aún no existían cuando se escribió el menú. Es el mismo polimorfismo del módulo 5 — código escrito contra la clase base que funciona con cualquier hija — aplicado al canal de errores: la jerarquía de excepciones es una interfaz extensible.
Conclusión
Papyrus ya tiene su propio vocabulario de errores: ErrorPapyrus como raíz que agrupa, y colgando de ella LibroNoEncontradoError (con titulo), StockInsuficienteError (con titulo, solicitado, disponible) y SocioInvalidoError (con codigo) — todas en su casa, errores.py, con atributos que el código lee sin trocear strings y un __str__ que compone el mensaje desde una única fuente de verdad. Has visto el dial de precisión (capturar por la hoja en la caja, por la base en el menú, y cómo el polimorfismo hace que las hojas futuras se manejen gratis) y la convivencia con criterio de buscar_libro() tolerante y obtener_libro() estricta, calcando el dúo get()/[] de los diccionarios. El sistema de errores está completo... para el desarrollo. Pero cuando la tienda funcione de verdad — Ana atendiendo, el programa corriendo horas — los print() de aviso desaparecerán al cerrar la terminal, y con ellos toda la evidencia. La última lección del módulo trae la pieza de producción: el módulo logging, con niveles, formato, archivo papyrus.log y el valiosísimo logger.exception(); y cierra con el decálogo de buenas prácticas que resume todo el módulo 7.
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
