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

  1. Por qué crear excepciones propias
  2. La forma mínima: heredar de Exception
  3. La jerarquía de Papyrus: ErrorPapyrus y sus hijas
  4. Enriquecer con atributos y __str__
  5. Capturar por la base vs capturar por la hoja
  6. obtener_libro() vs buscar_libro(): estricta y tolerante conviven
  7. Integración en vender() y el módulo errores.py

Por qué crear excepciones propias

Tres razones, de más práctica a más profunda:

  • El except del llamador distingue TU error del resto. except StockInsuficienteError: atrapa exactamente "no queda género" y deja pasar cualquier ValueError accidental (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.disponible y 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.py dirá 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:

class ErrorPapyrus(Exception):
    """Error base de la librería Papyrus."""

Con esto ya se puede lanzar y atrapar:

raise ErrorPapyrus("algo va mal en la tienda")
Traceback (most recent call last):
  ...
ErrorPapyrus: algo va mal en la tienda

¿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 = codigo

Desmontemos 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 en Exception para que el mecanismo estándar (el texto del traceback, print(e)) siga funcionando. No lo omitas: una excepción que no llama a super().__init__ pierde comportamientos que otros esperan.
  • Los atributos (self.titulo, self.solicitado, self.disponible, self.codigo) son la carga útil: el except que 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 hacer print(e). StockInsuficienteError lo 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 libro
buscar_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/ValueError siguen 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 en obtener_libro(), y la excepción que esta lanza atraviesa vender() 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 de Exception — 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ún except real 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 existe e.disponible garantizado (podría ser un SocioInvalidoError). Si necesitas los atributos de una hoja, captura la hoja.
  • Consejo: nombra con la convención AlgoError y escribe una docstring de una línea en cada clase: errores.py se 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 except de 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

  1. 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 lanzar SocioInvalidoError si codigo no está en la lista socios (usa ["LUIS-001", "MARTA-002", "PAU-003"]), y LibroNoEncontradoError si el título no existe (pista: no la lances tú — deja que obtener_libro() trabaje). Comprueba que ("Hamlet", "MARTA-002") devuelve 9.45 y que "JULIA-999" lanza con e.codigo == "JULIA-999".
  2. El mostrador que no se cae. Escribe un bucle while True de mostrador que pida título y unidades (con el pedir_entero() de 07-02), llame a vender() y: ante LibroNoEncontradoError proponga encargarlo; ante StockInsuficienteError ofrezca vender las e.disponible restantes si son > 0; ante cualquier otro ErrorPapyrus muestre el mensaje y siga; y salga limpiamente cuando el título sea "fin". ¿Por qué el orden de esos tres except no puede ser otro?
  3. Nueva hoja sin tocar el menú. Añade a errores.py la excepción PrecioInvalidoError(ErrorPapyrus) con atributos titulo y precio y un __str__ propio, pensada para cuando una actualización de tarifas trae un precio negativo. Explica en una frase por qué el except ErrorPapyrus: del menú principal la maneja sin modificarse, y qué relación guarda esto con el polimorfismo del módulo 5.

Soluciones

  1. 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" salta SocioInvalidoError: código de socio inválido: 'JULIA-999' y e.codigo vale "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).

  2. 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: ErrorPapyrus es 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ú. El else mantiene el mensaje de éxito fuera del try, como manda 07-02.

  3. 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

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados