The previous lesson ended by pointing at a crack: sell() raises ValueError for three different reasons — invalid units, unknown title, insufficient stock — and whoever catches it can't tell them apart without reading the message "with their eyes". Worse still: if the till code catches except ValueError: to handle a sold-out book, it will also catch, by accident, the ValueError of any random bug in the vicinity. The solution is the same one Python uses for itself: custom exception classes. Just as FileNotFoundError tells your except exactly what happened with the file, InsufficientStockError will tell Papyrus's till exactly what happened with the sale — and will carry inside, as attributes, the title, the amount requested and the amount available, ready for code to act on without parsing strings. In this lesson we'll build Papyrus's error hierarchy, house it in its own module errors.py and integrate it into the functions you already know.

Contents

  1. Why create your own exceptions
  2. The minimal form: inheriting from Exception
  3. The Papyrus hierarchy: PapyrusError and its daughters
  4. Enriching with attributes and __str__
  5. Catching at the base vs catching at the leaf
  6. get_book() vs find_book(): strict and tolerant coexist
  7. Integration into sell() and the errors.py module

Why create your own exceptions

Three reasons, from most practical to most profound:

  • The caller's except distinguishes YOUR error from the rest. except InsufficientStockError: catches exactly "we're out of stock" and lets any accidental ValueError through (a conversion bug, a badly parsed date). With built-in exceptions for everything, your business error handling and Python's bugs travel down the same pipe.
  • The error's data travels as attributes, not embedded in a string. In 07-03, the stock message carried "the three numbers that matter"; now the code will be able to read e.available and offer Julia the 6 copies that are left, instead of slicing up a message with fingers crossed.
  • The hierarchy documents your domain. A glance at errors.py will tell you what can go wrong in Papyrus, just as a glance at the 07-01 table tells you what can go wrong in Python.

The minimal form: inheriting from Exception

A custom exception is a normal class (module 5) that inherits from Exception. The minimal version doesn't even need a body:

class PapyrusError(Exception):
    """Base error for the Papyrus bookshop."""

With this alone it can already be raised and caught:

raise PapyrusError("something is wrong in the shop")
Traceback (most recent call last):
  ...
PapyrusError: something is wrong in the shop

How does it work without writing a single method? Pure inheritance, the module 5 kind: Exception already knows how to receive a message in the constructor, store it, and display itself in the traceback. Your subclass inherits it all; for now it only contributes a new name that except clauses can distinguish. Two universal style rules: inherit from Exception (not from BaseException — remember from 07-01 that that level is for KeyboardInterrupt and company) and end the name in Error when it represents an error, as ValueError and FileNotFoundError do.

The Papyrus hierarchy: PapyrusError and its daughters

Just as Python hangs KeyError and IndexError off LookupError, Papyrus defines a common root and hangs its concrete business errors from it:

graph TD
    EX[Exception] --> EP[PapyrusError]
    EP --> LNE[BookNotFoundError<br/>attribute: title]
    EP --> SIE[InsufficientStockError<br/>attributes: title, requested, available]
    EP --> SVE[InvalidMemberError<br/>attribute: code]

The base PapyrusError is almost never raised directly: its job is to group. Thanks to 07-02's matching rule (an except catches the class and its subclasses), except PapyrusError: means "any business error of the shop", while each leaf keeps its identity for whoever needs precision.

Enriching with attributes and __str__

The full version of the three leaves, with machine-readable data:

class PapyrusError(Exception):
    """Base error for the Papyrus bookshop."""


class BookNotFoundError(PapyrusError):
    """A book was requested that isn't in the catalog."""

    def __init__(self, title):
        super().__init__(f"'{title}' is not in the catalog")
        self.title = title


class InsufficientStockError(PapyrusError):
    """More units were requested than are available."""

    def __init__(self, title, requested, available):
        super().__init__(title)  # the detailed message is composed by __str__
        self.title = title
        self.requested = requested
        self.available = available

    def __str__(self):
        return (f"insufficient stock of '{self.title}': "
                f"{self.available} left, {self.requested} requested")


class InvalidMemberError(PapyrusError):
    """The member code doesn't exist or has an invalid format."""

    def __init__(self, code):
        super().__init__(f"invalid member code: {code!r}")
        self.code = code

Let's take the techniques apart — all of them from module 5, applied to a new case:

  • A custom __init__ with domain parameters. BookNotFoundError("Moby-Dick") is built from the relevant datum, not from a message pre-cooked by the caller. Less repetition, always-consistent messages.
  • super().__init__(message) delegates to Exception so the standard machinery (the traceback text, print(e)) keeps working. Don't skip it: an exception that doesn't call super().__init__ loses behaviors others expect.
  • The attributes (self.title, self.requested, self.available, self.code) are the payload: an except that catches them can compute with them.
  • __str__ (the same dunder from M5) controls how the exception is displayed in the traceback and via print(e). InsufficientStockError uses it to compose the message from its attributes: a single source of truth, so message and data can never disagree.

And this is how the payload is exploited from the caller:

try:
    amount = sell(catalog, "Hamlet", 99)
except InsufficientStockError as e:
    print(f"Only {e.available} of '{e.title}' left.")
    if e.available > 0:
        answer = input(f"Sell those {e.available}? (y/n) ")

Not one sliced string: e.available is an int ready to compare. This was impossible with the ValueError of 07-03.

Catching at the base vs catching at the leaf

The hierarchy gives the caller a precision dial. Compare the two extremes:

# At the leaf: the till wants to react differently to each situation
try:
    amount = sell(catalog, title, units)
except BookNotFoundError as e:
    print(f"We don't have '{e.title}'. Shall we order it from the distributor?")
except InsufficientStockError as e:
    print(f"Only {e.available} left; adjust the sale?")

# At the base: the main menu just wants the shop to stay up
try:
    run_option(option)
except PapyrusError as e:
    print(f"Operation not completed: {e}")
    # and back to the menu, with the shop intact
Catching at... Catches When it's the right choice
The leaf (InsufficientStockError) Only that exact situation When each error has a different reaction (the till)
The base (PapyrusError) Any Papyrus business error, present or future At the boundary: main menu, application loop
Exception Everything, including bugs unrelated to the business Almost never; only at the outermost boundary, and logging (07-05)

The "or future" nuance is gold: when Papyrus gets a website in module 10 and DuplicateOrderError(PapyrusError) appears, the menu's except PapyrusError: will handle it without touching a line. The hierarchy is an extensible contract. And 07-02's ordering rule still applies: leaves before the base, or the base will eat everything and the leaves will be dead code.

get_book() vs find_book(): strict and tolerant coexist

In 07-03 the strict sister of find_book() was promised. Here it is, and the complete pair illustrates that returning None and raising don't compete: they serve different callers.

def find_book(catalog, title):
    """Return the Book, or None if it isn't there. Absence = legitimate answer."""
    return catalog.get(title.strip().casefold())


def get_book(catalog, title):
    """Return the Book, or raise BookNotFoundError if it isn't there.

    For callers that NEED the book: without it, they can't go on.
    """
    book = find_book(catalog, title)
    if book is None:
        raise BookNotFoundError(title)
    return book
find_book() get_book()
If the book isn't there Returns None Raises BookNotFoundError
Question it answers "Do you have it?" "Give it to me, I need it"
Typical caller Counter queries, checks sell(), restock(), reports
Caller's obligation Check is None Nothing, or an except where appropriate

Notice that the strict one is built on top of the tolerant one: one line that translates absence into an exception. It's the same pattern from the standard library that you already use without thinking: catalog["x"] raises KeyError (strict) and catalog.get("x") returns None (tolerant). Papyrus now speaks that same language. The criterion from 07-03's table decides which one to use at each point in the code; having both isn't duplication, it's giving each caller the contract it needs.

Integration into sell() and the errors.py module

First, a home for the exceptions. Module 3 taught us to split code into modules; custom exceptions deserve their own, because they're imported both by whoever raises and by whoever catches:

papyrus/
├── data/             (catalog.json, sales.csv, members.json, config.json, ...)
├── backups/
├── reports/
├── errors.py         ← the complete hierarchy: ONLY exceptions
├── models.py         (Book, Product, Member...)
├── warehouse.py      (load/save catalog, find/get, sell, restock)
└── register.py       (the program Ana runs)

errors.py contains the four classes above and nothing else. That way, warehouse.py does from errors import BookNotFoundError, InsufficientStockError to raise them, and register.py imports the ones it catches — without the register having to import the whole warehouse just to know its errors, and with no risk of circular imports.

And the new sell(), with each situation speaking its own language:

from errors import InsufficientStockError
# BookNotFoundError is raised by get_book(); sell just lets it through

def sell(catalog, title, units):
    """Deduct stock and return the VAT-inclusive amount.

    Raises TypeError/ValueError if the arguments violate the contract,
    BookNotFoundError if the title doesn't exist,
    InsufficientStockError if there aren't enough units.
    """
    if not isinstance(units, int):
        raise TypeError(f"units must be int, not {type(units).__name__}")
    if units <= 0:
        raise ValueError(f"units must be positive, not {units}")

    book = get_book(catalog, title)             # raises BookNotFoundError
    if book.stock < units:
        raise InsufficientStockError(book.title, units, book.stock)

    book.stock -= units
    return round(book.price * units * (1 + BOOK_VAT), 2)

Compare it with the 07-03 version and look at the final division of labor:

  • TypeError/ValueError remain for the arguments: negative units are a programming error, universal, not a bookshop-business concept. The built-ins express that better than any custom class.
  • The domain situations — book that doesn't exist, stock running short — use the Papyrus hierarchy, with attributes.
  • sell() has shrunk: the existence guard now lives in get_book(), and the exception it raises passes cleanly through sell() (the propagation from 07-01 working in our favor). One function, one responsibility.

With the canonical figures: sell(catalog, "The Odyssey", 9) raises InsufficientStockError("The Odyssey", 9, 4) — and the till's except can offer the 4 that remain by reading e.available. sell(catalog, "Moby-Dick", 1) raises BookNotFoundError with e.title == "Moby-Dick", ready for the "shall we order it?" message.

Common Mistakes and Tips

  • Inheriting from BaseException. Reserved for the system (Ctrl+C, SystemExit). Your own errors hang off Exception — normally off your own base class.
  • Forgetting super().__init__(...). The exception "works" but its standard message shows up empty or odd in tracebacks and logs. Always delegate to the mother class.
  • Putting the data only in the string. raise PapyrusError(f"{n} left") forces the caller to slice text. Data goes in attributes; let __str__ compose the text from them.
  • One exception class per raise. The opposite extreme of having none: fifty classes nobody can tell apart. Create an exception when some real except will treat it differently; if no one would distinguish it from its sister, it's dead weight.
  • Catching at the base and acting as if it were a leaf. Inside except PapyrusError as e: there is no guaranteed e.available (it could be an InvalidMemberError). If you need a leaf's attributes, catch the leaf.
  • Tip: name with the SomethingError convention and write a one-line docstring in each class: errors.py becomes, for free, the catalog of "what can go wrong" in your system.
  • Tip: when in doubt between built-in and custom, ask yourself who will catch it. An except in your application with business logic? Custom. A generic value/type error that any Python function might raise? Built-in.

Exercises

  1. apply_member_discount(catalog, title, code, members). Using the complete hierarchy, write the function that returns the member-discounted price (MEMBER_DISCOUNT = 0.05, rounded to 2 decimals): it must raise InvalidMemberError if code isn't in the members list (use ["LUIS-001", "MARTA-002", "PAU-003"]), and BookNotFoundError if the title doesn't exist (hint: don't raise it yourself — let get_book() do the work). Verify that ("Hamlet", "MARTA-002") returns 9.45 and that "JULIA-999" raises with e.code == "JULIA-999".
  2. The counter that never goes down. Write a while True counter loop that asks for title and units (with 07-02's ask_int()), calls sell() and: on BookNotFoundError offers to order it; on InsufficientStockError offers to sell the remaining e.available if they are > 0; on any other PapyrusError shows the message and carries on; and exits cleanly when the title is "done". Why can't the order of those three except clauses be any other?
  3. A new leaf without touching the menu. Add to errors.py the exception InvalidPriceError(PapyrusError) with attributes title and price and its own __str__, meant for when a price update brings a negative price. Explain in one sentence why the main menu's except PapyrusError: handles it without being modified, and how this relates to module 5's polymorphism.

Solutions

  1. MEMBER_DISCOUNT = 0.05
    
    def apply_member_discount(catalog, title, code, members):
        if code not in members:
            raise InvalidMemberError(code)
        book = get_book(catalog, title)   # raises BookNotFoundError if missing
        return round(book.price * (1 - MEMBER_DISCOUNT), 2)
    

    apply_member_discount(catalog, "Hamlet", "MARTA-002", members)9.45 (the 9.83 in the canonical member-price table also includes the 4% VAT; here we apply the discount only). With "JULIA-999" an InvalidMemberError: invalid member code: 'JULIA-999' fires and e.code equals "JULIA-999" — Julia and Omar are customers, not members. The member guard goes first: validate the argument before touching the catalog, fail fast (07-03).

  2. while True:
        title = input("\nTitle (or 'done'): ").strip()
        if title.casefold() == "done":
            break
        units = ask_int("Units: ")
        try:
            amount = sell(catalog, title, units)
        except BookNotFoundError as e:
            print(f"We don't have '{e.title}'. Shall we order it from the distributor?")
        except InsufficientStockError as e:
            if e.available > 0:
                print(f"Only {e.available} of '{e.title}' left. Adjust the sale.")
            else:
                print(f"'{e.title}' is sold out.")
        except PapyrusError as e:
            print(f"Operation not completed: {e}")
        else:
            print(f"Sale completed: {amount:.2f} EUR (VAT included)")
    

    The order is mandatory by 07-02's rule applied to our hierarchy: PapyrusError is the mother of the other two; if it came first, it would catch everything and the leaves would be dead code. From specific to general — now with classes you designed yourself. The else keeps the success message out of the try, as 07-02 dictates.

  3. class InvalidPriceError(PapyrusError):
        """A price update brings an unacceptable price."""
    
        def __init__(self, title, price):
            super().__init__(title)
            self.title = title
            self.price = price
    
        def __str__(self):
            return f"invalid price for '{self.title}': {self.price}"
    

    The menu handles it without changes because except PapyrusError: catches the class and all its subclasses, including the ones that didn't exist yet when the menu was written. It's the same polymorphism from module 5 — code written against the base class that works with any daughter — applied to the error channel: the exception hierarchy is an extensible interface.

Conclusion

Papyrus now has its own vocabulary of errors: PapyrusError as the grouping root, and hanging from it BookNotFoundError (with title), InsufficientStockError (with title, requested, available) and InvalidMemberError (with code) — all housed in errors.py, with attributes that code reads without slicing strings and a __str__ that composes the message from a single source of truth. You've seen the precision dial (catch at the leaf in the till, at the base in the menu, and how polymorphism makes future leaves handled for free) and the deliberate coexistence of the tolerant find_book() and the strict get_book(), mirroring the dictionaries' get()/[] duo. The error system is complete... for development. But when the shop runs for real — Ana at the counter, the program up for hours — the warning print() calls will vanish when the terminal closes, and all the evidence with them. The module's final lesson brings the production piece: the logging module, with levels, format, the papyrus.log file and the priceless logger.exception(); and it closes with the ten best-practice rules that sum up all of module 7.

Python Programming Course

Module 1: Introduction to Python

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved