Lesson 12 / 16
Exceptions
An exception is an instance of a class, and catching looks at the class lineage: eleven events and seven catch classes match in 28 of 77 pairs, Exception catches ten events and swallows three program defects, and BaseException catches all eleven and swallows the exit request too.
Contents
Every protocol measured up to this point followed the expected path. for asked the
iterator for something and got it, len returned a number, sorted gave back a sorted
list. Syntax called, a special method answered, a value came back. So what happens when
a protocol fails?
Python’s answer already showed up in the previous lesson’s loop measurement: on a
three-item object, the fourth __next__ call returns no value, it raises
StopIteration. What ends the loop is not a return value, it is an exception. This
lesson looks at that machinery itself. The concept of an exception and error
handling were built in the Programming Fundamentals course; the concept was
established there, what is measured here is the machinery Python realizes it with: an
exception is an object, the object has a class, and catching looks at that class’s
lineage.
An Exception Is an Instance of a Class
What is raised is not a message, it is an object. The raise statement takes an
object, the except clause takes a class, and the two are compared with
isinstance. This one sentence determines the rest of the lesson: because except
writes a class name, all of that class’s subclasses get caught too.
The lineage starts from two ends. Every exception class derives from BaseException.
Right below it stands Exception, and everything expected to be handled inside the
program descends from there. Below Exception sit intermediate classes —
ArithmeticError, LookupError, OSError — and their leaves are concrete classes like
ZeroDivisionError, KeyError, FileNotFoundError.
These intermediate classes are not empty decoration. Catching a leaf catches only that leaf; catching an intermediate class catches every leaf beneath it. How many it catches is a measurable number, and that number is exactly what this lesson measures.
The Measurement’s Setup
The measurement builds eleven events: small functions each of which really raises an exception. Against them sit seven catch classes — two leaves, three intermediate classes, and two roots. Every event is tried against every catch class; the result is a 77-pair table.
The events split into three tags, and the tag is the oracle: because we built the
setup ourselves, we know which event is really a domain error, which is a program
defect, which is an interrupt. A domain error is something expected to be
handled — a missing key, an unparseable string, a file that cannot be found. A program
defect is a mistake in the code itself, and it needs not to be handled but to escape.
An interrupt is a request to end the program, and it must not be swallowed.
StopIteration is a fourth tag: not an error, part of the protocol.
The measurement’s assumptions:
- EF1 — All eleven events are really run, and the object they raise is caught and kept; no class name is written by hand — every class name in the table is read from the raised object.
- EF2 — The tag is an oracle and comes from the setup itself; it is not derived by looking at the message or the class name.
- EF3 — A pair “matching” means the catch class covers the event’s object through
isinstance; this is exactly the test theexceptclause performs. - EF4 — The lineage chain is read from the class’s resolution order;
objectis not counted, because every class descends from it and it is not distinctive. - EF5 — The measurement is not a performance measurement; what is counted is the number of events caught, not time.
Measurement — the Catch Matrix
"""Exception hierarchy: catching which class also catches which events.""" import math LOG = [] class Tracker: """The core of the shared setup; only the iteration part is used here.""" def __init__(self, items=(1, 2, 3)): self.items = list(items) def __iter__(self): LOG.append("__iter__") self._i = 0 return self def __next__(self): LOG.append("__next__") if self._i >= len(self.items): raise StopIteration value = self.items[self._i] self._i += 1 return value def zero_division(): return 1 // 0 def overflowing_power(): return math.exp(10000) def missing_key(): return {"north": 1}["slope"] def overflowing_index(): return [1, 2, 3][9] def unconvertible_string(): return int("north") def missing_file(): return open("missing/measurement.txt") def exhausted_iterator(): return next(iter(Tracker(()))) def wrong_type(): return len(5) def undefined_name(): return unknown_name def missing_attribute(): return (1).north def exit_request(): raise SystemExit EVENTS = ( ("zero division", zero_division, "domain"), ("overflowing power", overflowing_power, "domain"), ("missing key", missing_key, "domain"), ("overflowing index", overflowing_index, "domain"), ("unconvertible string", unconvertible_string, "domain"), ("missing file", missing_file, "domain"), ("exhausted iterator", exhausted_iterator, "protocol"), ("wrong type", wrong_type, "defect"), ("undefined name", undefined_name, "defect"), ("missing attribute", missing_attribute, "defect"), ("exit request", exit_request, "interrupt"), ) CATCHES = ( ("ZeroDivisionError", ZeroDivisionError), ("ArithmeticError", ArithmeticError), ("LookupError", LookupError), ("ValueError", ValueError), ("OSError", OSError), ("Exception", Exception), ("BaseException", BaseException), ) def throw(function): try: function() except BaseException as e: return e return None print("event class place in the hierarchy") for name, function, _ in EVENTS: s = type(throw(function)) chain = " < ".join(k.__name__ for k in s.__mro__ if k is not object) print(f" {name:21s} {s.__name__:18s} {chain}") print() print("catch class catches domain protocol defect interrupt escapes") for cname, cls in CATCHES: counts = {"domain": 0, "protocol": 0, "defect": 0, "interrupt": 0} for name, function, kind in EVENTS: if isinstance(throw(function), cls): counts[kind] += 1 caught = sum(counts.values()) print(f" {cname:18s} {caught:7d} {counts['domain']:5d} {counts['protocol']:9d}" f" {counts['defect']:6d} {counts['interrupt']:8d} {len(EVENTS) - caught:6d}") print() matches = sum(isinstance(throw(f), cls) for _, f, _ in EVENTS for _, cls in CATCHES) print(f"events {len(EVENTS)} | catch classes {len(CATCHES)} | " f"pairs {len(EVENTS) * len(CATCHES)} | matching pairs {matches}") for kind in ("defect", "interrupt"): items_list = [name for name, _, k in EVENTS if k == kind] print(f"{kind} {len(items_list)}: {', '.join(items_list)}") LOG.clear() throw(exhausted_iterator) print(f"empty Tracker: {LOG.count('__iter__')} times __iter__, " f"{LOG.count('__next__')} times __next__ — that one call returned no value, " f"it raised StopIteration")
event class place in the hierarchy zero division ZeroDivisionError ZeroDivisionError < ArithmeticError < Exception < BaseException overflowing power OverflowError OverflowError < ArithmeticError < Exception < BaseException missing key KeyError KeyError < LookupError < Exception < BaseException overflowing index IndexError IndexError < LookupError < Exception < BaseException unconvertible string ValueError ValueError < Exception < BaseException missing file FileNotFoundError FileNotFoundError < OSError < Exception < BaseException exhausted iterator StopIteration StopIteration < Exception < BaseException wrong type TypeError TypeError < Exception < BaseException undefined name NameError NameError < Exception < BaseException missing attribute AttributeError AttributeError < Exception < BaseException exit request SystemExit SystemExit < BaseException catch class catches domain protocol defect interrupt escapes ZeroDivisionError 1 1 0 0 0 10 ArithmeticError 2 2 0 0 0 9 LookupError 2 2 0 0 0 9 ValueError 1 1 0 0 0 10 OSError 1 1 0 0 0 10 Exception 10 6 1 3 0 1 BaseException 11 6 1 3 1 0 events 11 | catch classes 7 | pairs 77 | matching pairs 28 defect 3: wrong type, undefined name, missing attribute interrupt 1: exit request empty Tracker: 1 times __iter__, 1 times __next__ — that one call returned no value, it raised StopIteration
Reading the Numbers
The upper table gives the lineage and shows the depth is not fixed. ValueError
reaches the root in two steps, FileNotFoundError in three. The difference between
them is not a detail: every intermediate step corresponds to an except clause that
would catch at that step.
The lower table counts the cost of this. Catching ZeroDivisionError matches 1
event and misses 10. One step up, ArithmeticError matches 2 — zero division
and the overflowing power. LookupError also matches 2, the missing key and the
overflowing index. The way to write a single except line that handles two separate
events is to write their common ancestor.
The OSError row shows 1, because only one event in the setup descends from it.
The number is the setup’s count, not the class’s scope: many leaves sit beneath
OSError, and all of them would land on this row. The lesson writes the count it
itself measured.
What a Broad Catch Swallows
The two root rows are the table’s central finding. Catching Exception matches 10
events and misses only 1. 6 of these ten are domain errors — exactly what is
expected to be handled. But the same row also swallows 3 program defects: wrong
type, undefined name, missing attribute. None of these three come from user input or
the file system; they come from a mistake in the code itself. A block written as
except Exception catches these too, silently, and the program keeps running in its
broken form.
It also swallows 1 protocol event: StopIteration. The last line states where this
comes from: on an empty Tracker, __iter__ was called 1 time, __next__ 1
time, and that single call raised StopIteration instead of returning a value — one
more than the item count. A broad catch can mistake a loop’s normal end for an error
and fall into its own branch.
BaseException goes one step further: it matches all 11 events, missing 0. The
one extra thing it catches is the exit request, and this is the lesson’s sharpest
number. Exception misses it — missing it is by design. A request to end the
program is not a domain error and is not something to be handled; writing except BaseException means swallowing that request too. This is the only difference between
the two root rows, and in the table it stands as 11 − 10 = 1.
The pattern is this: as the catch class climbs, the events it matches grow, but only part of what it matches is something that should be handled. 28 of 77 pairs match, and 21 of these 28 matches come from the two root rows. Narrowing means shrinking what is matched and zeroing out what is swallowed.
Re-Raising and the Cause Chain
Catching an exception does not mean it has to be handled. Catching it, logging it, and then re-raising it is a common need, and Python offers a few distinct forms for this. What differs between the forms is the identity of the object that escapes and whether the inner object survives.
Every exception object carries two fields. __context__ points to the other
exception if this one was born while that other one was being handled, and the
interpreter sets this up on its own. __cause__, by contrast, is only filled in when
from is written, and it is the declaration “this exception’s cause is that one.”
from None suppresses the context.
- EF6 — Five re-raising forms are produced from the same source; because the source produces a new object on every call, the forms do not share each other’s object.
- EF7 — Whether the objects are the same is tested with
is; no identity number is printed. - EF8 — The chain length is counted by following unsuppressed context and cause links; because no exception escapes in the swallowing form, its length is zero.
- EF9 — The branch table runs the three states separately, and whether each branch ran is read from a log placed inside the branch; the order is not guessed, it is measured.
"""Re-raising and the chain: which object escapes, does the inner one survive.""" STEPS = [] def source(): """Produces a domain error; a new object forms on every call.""" return {"north": 1}["slope"] def bare(): try: source() except KeyError: raise def wrap(): try: source() except KeyError: raise RuntimeError("could not read the measurement") def with_cause(): try: source() except KeyError as e: raise RuntimeError("could not read the measurement") from e def suppress(): try: source() except KeyError: raise RuntimeError("could not read the measurement") from None def swallow(): try: source() except KeyError: return None FORMS = (("bare raise", bare), ("wrapping", wrap), ("with from", with_cause), ("with from None", suppress), ("swallowing", swallow)) print(f"{'form':<17s}{'escapes as':<15s}{'cause':<14s}" f"{'context':<14s}chain") for name, function in FORMS: try: function() except BaseException as exc: escapes = type(exc).__name__ cause = type(exc.__cause__).__name__ if exc.__cause__ else "-" context = type(exc.__context__).__name__ if exc.__context__ else "-" length, cur = 0, exc while cur is not None: length += 1 cur = cur.__cause__ or (None if cur.__suppress_context__ else cur.__context__) else: escapes = cause = context = "-" length = 0 print(f" {name:<15s}{escapes:<15s}{cause:<14s}{context:<14s}{length}") print() try: try: source() except KeyError as e: inner = e raise except KeyError as exc: print("does a bare raise send the same object out:", exc is inner) try: try: source() except KeyError as e: inner = e raise RuntimeError("could not read the measurement") from e except RuntimeError as exc: print("with from, is the outgoing object the same:", exc is inner) print("with from, is the cause the same object:", exc.__cause__ is inner) print() BRANCHES = ("try", "except", "else", "finally") print(f"{'state':<16s}" + "".join(f"{b:<9s}" for b in BRANCHES)) for state, function in (("no error", lambda: 1), ("caught", source), ("uncaught", lambda: 1 // 0)): STEPS.clear() try: try: STEPS.append("try") function() except KeyError: STEPS.append("except") else: STEPS.append("else") finally: STEPS.append("finally") except ZeroDivisionError: pass print(f" {state:<14s}" + "".join(f"{('ran' if b in STEPS else 'skipped'):<9s}" for b in BRANCHES))
form escapes as cause context chain bare raise KeyError - - 1 wrapping RuntimeError - KeyError 2 with from RuntimeError KeyError KeyError 2 with from None RuntimeError - KeyError 1 swallowing - - - 0 does a bare raise send the same object out: True with from, is the outgoing object the same: False with from, is the cause the same object: True state try except else finally no error ran skipped ran ran caught ran ran skipped ran uncaught ran skipped skipped ran
What the Chain Costs
Bare raise sends KeyError out, and the line below states it is the same
object. Because no new object is produced, the chain is 1. Catching and
re-raising loses no information.
The wrapping form sends RuntimeError out; __cause__ is empty but __context__
holds the KeyError. The chain is 2. Nobody wrote this link — it formed on its own
because the exception was born while another exception was being handled.
In the form written with from, __cause__ is filled and the chain is again 2.
The difference is in the output’s lower lines: the escaping object is not the same
as the inner one, but __cause__ points to the same object. Wrapping and writing
from give the same chain length; where they part ways is whether the link was
declared or formed on its own.
from None brings the chain down to 1. __context__ is still filled, but
because it is suppressed it does not enter the count — someone looking from outside
cannot see the KeyError. For a boundary that wants to hide a domain error, the right
tool is one used knowing the inner detail is lost.
The swallowing form gives 0: nothing escapes at all. Combined with the broad catch from the previous section, this zero is dangerous — this is exactly where the three program defects got swallowed.
The last table gives which of the four branches ran in which state. try ran in all
three states; finally ran in all three too — even while an uncaught exception was
escaping. else only ran when no exception occurred, except only when one was
caught. Keeping the source call inside try and moving the success path into
else keeps the except clause from accidentally catching too much.
Summary
- An exception is an object, the object has a class, and
excepttakes a class; catching covers all of that class’s subclasses. - Eleven events and seven catch classes produce 77 pairs, and 28 of them match;
leaf classes match 1–2 events, while
Exceptionmatches 10 andBaseExceptionmatches 11. - Catching
Exceptionswallows 6 domain errors alongside 3 program defects and 1 protocol event;BaseExceptionadditionally swallows the exit request. - A bare
raisesends the same object out (chain 1); wrapping and writingfrommake the chain 2,from Nonebrings it down to 1, swallowing to 0. finallyruns in all three states,elseonly when no exception occurs; keeping the success path insideelsenarrows theexceptclause’s scope.
Next Step
Every class in this lesson came ready-made: KeyError, ValueError, OSError. The
measurement could group them by common ancestor because the lineage was already built.
But what if what is raised is the domain’s own error — a measurement device
reporting an out-of-range value, a field missing from a record? Then it is us who write
the class, and where we hang it in the lineage decides which of the later catches will
match. The next lesson measures a custom exception class’s place in the hierarchy and
what that place costs in catching behavior.
To keep your progress and take notes, Log in
My notes
Log in to take notes.