1.3.6 · D4Python Intermediate

Exercises — Raising exceptions — raise, custom exception classes

2,630 words12 min readBack to topic

Quick vocabulary refresher so nothing is used before it's named:

  • raise ::: the statement that throws (propagates) an exception object up the call stack.
  • except ::: the block that catches a matching exception.
  • .args ::: the tuple where an exception stores the message you passed it.
  • __cause__ ::: the attribute raise B from A sets to Aexplicit chaining you asked for.
  • __context__ ::: the attribute Python sets automatically to whatever exception was already being handled when a new one is raised — implicit chaining you get for free.

Level 1 — Recognition

L1.1

What does each line do — and does it actually throw anything?

raise ValueError("bad")     # (a)
raise ValueError            # (b)
ValueError("bad")           # (c)
return ValueError("bad")    # (d)
Recall Solution
  • (a) Throws a ValueError instance whose .args is ("bad",). str(e)"bad".
  • (b) Throws a ValueError; Python instantiates the class with no args, so .args is () and str(e) is "".
  • (c) Builds an exception object and immediately throws it away. Nothing is raised. No error, no effect.
  • (d) Returns the object to the caller as an ordinary value. Nothing is raised. The caller receives an exception object and keeps going.

Only (a) and (b) actually raise. Make ≠ raise.

L1.2

Given e = KeyError("k"), what is type(e).__mro__[1]? (The MRO is the inheritance chain; index 1 is the direct-ish parent chain.) Which built-in class does KeyError ultimately inherit from that we're told never to inherit directly?

Recall Solution

KeyErrorLookupErrorExceptionBaseExceptionobject. So type(e).__mro__ is (KeyError, LookupError, Exception, BaseException, object) and index 1 is LookupError. The class at the top we must never inherit from directly is ==BaseException== (see Exception Hierarchy & BaseException).


Level 2 — Application

L2.1

Write a function withdraw(balance, amount) that returns the new balance, but raises ValueError if amount is negative or exceeds balance. Show what withdraw(100, 30), withdraw(100, -5), and withdraw(100, 150) do.

Recall Solution
def withdraw(balance, amount):
    if amount < 0:
        raise ValueError("amount must be non-negative")
    if amount > balance:
        raise ValueError("insufficient funds")
    return balance - amount
  • withdraw(100, 30) → returns 70 (valid path).
  • withdraw(100, -5) → raises ValueError("amount must be non-negative").
  • withdraw(100, 150) → raises ValueError("insufficient funds").

Why raise instead of returning -1? Returning a fake number lets the caller keep spending money that isn't there. Raising forces a decision (see the parent's withdraw intuition).

L2.2

Trace the output:

def a(): raise ValueError("boom")
def b(): a()
def c():
    try:
        b()
    except ValueError as e:
        print("caught:", e)
        return "done"
print(c())
Recall Solution

a raises. b has no try, so the exception skips the rest of b and rises. c's try catches it in the except ValueError block. Output:

caught: boom
done

The exception lands at the first matching except walking up the call stack (see Try-Except-Finally).


Level 3 — Analysis

L3.1

This custom exception has a bug. What prints, and why is it wrong?

class InsufficientFundsError(Exception):
    def __init__(self, balance, requested):
        self.balance = balance
        self.requested = requested
        self.shortfall = requested - balance
 
try:
    raise InsufficientFundsError(50, 80)
except InsufficientFundsError as e:
    print("args:", e.args)
    print("shortfall:", e.shortfall)
Recall Solution

Output:

args: (50, 80)
shortfall: 30

Here's the subtle bug: because __init__ accepts positional arguments and never calls super().__init__(...), Python's default BaseException.__init__ still captured those positional args, so .args becomes (50, 80) — a confusing pair, not a readable sentence. str(e) then prints "(50, 80)", which looks like data, not a message. The custom attribute e.shortfall still works (80 - 50 = 30) because that code ran. Fix: call super().__init__(f"tried {requested}, only {balance} available") explicitly so .args holds one clean message string instead of the raw constructor arguments (see Classes and __init__).

L3.2

For each block below, decide whether the ZeroDivisionError is caught. Explain.

# Block A
try:
    1 / 0
except ArithmeticError:
    print("A caught")
 
# Block B
try:
    1 / 0
except ValueError:
    print("B caught")

ZeroDivisionError inherits: ZeroDivisionError → ArithmeticError → Exception.

Recall Solution
  • Block A: caught, prints A caught. An except catches the named class and all its subclasses. ZeroDivisionError is a ArithmeticError, so the parent-class handler catches it.
  • Block B: not caught. ZeroDivisionError is not a ValueError (different branch of the tree). The exception escapes and would print a traceback / crash.

Rule: an except X catches X and everything below X in the hierarchy — nothing above, nothing sideways. The figure below turns this into a picture:

Figure — Raising exceptions — raise, custom exception classes

Read the figure alongside Block A and Block B: the orange arrow shows the catch travelling down the tree from ArithmeticError to the raised ZeroDivisionError (that's Block A succeeding). The magenta ValueError box hangs off a sideways branch — no path connects it to the raised error, which is exactly why Block B misses.


Level 4 — Synthesis

L4.1

Write parse_port(text) that converts a string to an int port number. If the text isn't an integer, raise a domain-level ConfigError chained from the original ValueError so the traceback keeps the cause. Then show that parse_port("abc") sets __cause__ correctly.

Recall Solution
class ConfigError(Exception):
    pass
 
def parse_port(text):
    try:
        return int(text)
    except ValueError as e:
        raise ConfigError(f"bad port: {text!r}") from e

Checking the chain:

try:
    parse_port("abc")
except ConfigError as err:
    print(type(err).__name__)              # ConfigError
    print(str(err))                         # bad port: 'abc'
    print(type(err.__cause__).__name__)     # ValueError

Output:

ConfigError
bad port: 'abc'
ValueError

raise ... from e sets err.__cause__ = e, so the traceback shows "The above exception was the direct cause..." — the low-level ValueError stays visible while the caller catches the friendly ConfigError.

L4.2

Complete a log-and-re-raise wrapper. It should record the failure but not swallow it. Fill the blank and state what run() ultimately does.

messages = []
def log(m): messages.append(m)
 
def risky():
    raise IOError("disk gone")
 
def run():
    try:
        risky()
    except IOError:
        log("disk problem")
        ______     # fill this line
Recall Solution

The blank is a bare raise:

    except IOError:
        log("disk problem")
        raise            # re-raise the current exception, keep original traceback

After run():

  • messages == ["disk problem"] (exactly one entry — logged once).
  • The IOError("disk gone") still propagates out of run(), so a caller must handle it or the program crashes.

A bare raise inside an except re-throws the exception currently being handled with its traceback intact — perfect for "peek, log, then pass it on" (see Logging in Python).

L4.3 — implicit __context__ vs explicit __cause__

Both blocks raise a new error while handling an old one. What is err.__cause__ and err.__context__ in each?

# Block P — no "from"
try:
    int("abc")
except ValueError:
    raise RuntimeError("wrapper P")
 
# Block Q — with "from"
try:
    int("abc")
except ValueError as e:
    raise RuntimeError("wrapper Q") from e
Recall Solution
  • Block P (implicit): because a RuntimeError was raised while a ValueError was being handled, Python automatically records the old one in err.__context__. So err.__context__ is the ValueError, but err.__cause__ is None. The traceback prints "During handling of the above exception, another exception occurred."
  • Block Q (explicit): raise ... from e sets err.__cause__ to the ValueError and err.__context__ is also set to it. The traceback prints the stronger "The above exception was the direct cause of the following exception."

The difference in one line: __context__ is set for you whenever one exception interrupts the handling of another (implicit chaining); __cause__ is set by you with from to declare "this was deliberately caused by that" (explicit chaining). To hide an accidental context you can write raise RuntimeError(...) from None, which sets __cause__ = None and suppresses the context in the printout.


Level 5 — Mastery

L5.1

Design a small exception family for a banking module and use it. Requirements:

  1. A base BankError(Exception).
  2. InsufficientFundsError(BankError) that carries balance, requested, and a computed shortfall, with a readable message via super().__init__.
  3. A withdraw that raises it.
  4. A caller that catches the base BankError (proving subclasses are caught) and reads the structured shortfall. Show the printed output for withdraw(50, 80).
Recall Solution
class BankError(Exception):
    """Base for all banking errors."""
    pass
 
class InsufficientFundsError(BankError):
    def __init__(self, balance, requested):
        self.balance = balance
        self.requested = requested
        self.shortfall = requested - balance
        super().__init__(
            f"tried {requested}, only {balance} available (short {self.shortfall})"
        )
 
def withdraw(balance, amount):
    if amount > balance:
        raise InsufficientFundsError(balance, amount)
    return balance - amount
 
try:
    withdraw(50, 80)
except BankError as e:                 # base class catches the subclass
    print("caught:", e)
    print("shortfall:", e.shortfall)

Output:

caught: tried 80, only 50 available (short 30)
shortfall: 30

Why a base class? Catching BankError handles every banking error in one net, while callers who care about the specific case can still write except InsufficientFundsError. The subclass is caught by the base because except X catches all subclasses of X. The figure below draws this family and the single catch-all net:

Figure — Raising exceptions — raise, custom exception classes

In the figure, trace the magenta arrows: every child error points up to BankError, so the single except BankError net (labelled on the left) scoops up any of them. InsufficientFundsError (orange, bottom-left) is the one we actually raise here; the greyed FrozenAccountError shows how a future sibling automatically joins the same net for free — that's the payoff of designing a base class.

L5.2

You want a cheap internal invariant check that becomes a ValueError-like failure — but only during development. Which tool, and how does it relate to raise? Contrast it with raise ValueError.

Recall Solution

Use the assert statement: assert amount >= 0, "amount must be non-negative".

  • It is syntactic sugar for "if the condition is false, raise AssertionError(msg)."
  • It is meant for internal invariants / bugs, not for validating user input.
  • Crucially, Python can strip all asserts when run with -O (optimised mode) — so asserts are not guaranteed to run in production.

Contrast: raise ValueError(...) always runs and is the right choice for untrusted input (a user typing a negative amount). assert is for "this should be impossible if my code is correct" checks you're willing to disable in production.

Rule of thumb: validate input with raise; check your own assumptions with assert.


Wrap-up recall

Recall One line: when do I

raise vs return? return when there's a meaningful answer; raise when continuing would mean a bug or invalid state.

Recall Does

except Parent catch Child exceptions? Yes — an except catches the named class and all its subclasses (but never siblings or ancestors).

Recall

__cause__ vs __context__? __context__ is set automatically when one exception is raised while another is being handled (implicit chaining). __cause__ is set by you with raise B from A to declare a deliberate cause (explicit chaining). raise B from None suppresses the printed context.

Recall Bare

raise vs raise e vs raise X from e? Bare raise re-throws the current exception with original traceback. raise e re-throws but resets the traceback to this line (usually undesirable). raise X from e translates to a new type while chaining the cause via __cause__.


Connections