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
- Why create your own exceptions
- The minimal form: inheriting from
Exception - The Papyrus hierarchy:
PapyrusErrorand its daughters - Enriching with attributes and
__str__ - Catching at the base vs catching at the leaf
get_book()vsfind_book(): strict and tolerant coexist- Integration into
sell()and theerrors.pymodule
Why create your own exceptions
Three reasons, from most practical to most profound:
- The caller's
exceptdistinguishes YOUR error from the rest.except InsufficientStockError:catches exactly "we're out of stock" and lets any accidentalValueErrorthrough (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.availableand 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.pywill 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:
With this alone it can already be raised and caught:
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 = codeLet'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 toExceptionso the standard machinery (the traceback text,print(e)) keeps working. Don't skip it: an exception that doesn't callsuper().__init__loses behaviors others expect.- The attributes (
self.title,self.requested,self.available,self.code) are the payload: anexceptthat catches them can compute with them. __str__(the same dunder from M5) controls how the exception is displayed in the traceback and viaprint(e).InsufficientStockErroruses 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 bookfind_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/ValueErrorremain 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 inget_book(), and the exception it raises passes cleanly throughsell()(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 offException— 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 realexceptwill 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 guaranteede.available(it could be anInvalidMemberError). If you need a leaf's attributes, catch the leaf. - Tip: name with the
SomethingErrorconvention and write a one-line docstring in each class:errors.pybecomes, 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
exceptin your application with business logic? Custom. A generic value/type error that any Python function might raise? Built-in.
Exercises
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 raiseInvalidMemberErrorifcodeisn't in thememberslist (use["LUIS-001", "MARTA-002", "PAU-003"]), andBookNotFoundErrorif the title doesn't exist (hint: don't raise it yourself — letget_book()do the work). Verify that("Hamlet", "MARTA-002")returns9.45and that"JULIA-999"raises withe.code == "JULIA-999".- The counter that never goes down. Write a
while Truecounter loop that asks for title and units (with 07-02'sask_int()), callssell()and: onBookNotFoundErroroffers to order it; onInsufficientStockErroroffers to sell the remaininge.availableif they are > 0; on any otherPapyrusErrorshows the message and carries on; and exits cleanly when the title is"done". Why can't the order of those threeexceptclauses be any other? - A new leaf without touching the menu. Add to
errors.pythe exceptionInvalidPriceError(PapyrusError)with attributestitleandpriceand its own__str__, meant for when a price update brings a negative price. Explain in one sentence why the main menu'sexcept PapyrusError:handles it without being modified, and how this relates to module 5's polymorphism.
Solutions
-
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"anInvalidMemberError: invalid member code: 'JULIA-999'fires ande.codeequals"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). -
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:
PapyrusErroris 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. Theelsekeeps the success message out of thetry, as 07-02 dictates. -
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
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
