Errors, properly all 3 written
Raising and catching exceptions on purpose, writing your own, and the with statement that guarantees cleanup.
22 Exceptions: raise, try, except, else, finally
An exception is how Python reports that something cannot be done. Lesson 3 covered reading one; this covers raising them deliberately and catching them precisely. The discipline that matters is narrowness: catch the specific thing you can actually handle, and let everything else travel.
Raising on purpose
def set_age(age):
if not isinstance(age, int):
raise TypeError(f"age must be an int, got {type(age).__name__}")
if age < 0:
raise ValueError(f"age cannot be negative, got {age}")
return age
print("valid:", set_age(30))
seen = []
for bad in ["thirty", -5]:
try:
set_age(bad)
except (TypeError, ValueError) as exc:
seen.append(type(exc).__name__)
print(f"{type(exc).__name__}: {exc}")
test("a valid value passes through", set_age(30), 30)
test("the wrong type raises TypeError and the wrong value ValueError",
seen, ["TypeError", "ValueError"])
What it printed, when the build ran it
valid: 30 TypeError: age must be an int, got str ValueError: age cannot be negative, got -5 ok a valid value passes through ok the wrong type raises TypeError and the wrong value ValueError
Which exception to raise. TypeError when the argument is the wrong
kind of thing; ValueError when the type is right but the value is not;
KeyError and IndexError for lookups you implement yourself. Matching the
built-in meanings means callers who already know Python already know what your function does.
Catching precisely
def parse_port(text):
try:
port = int(text)
except ValueError:
return None # this one we can handle
return port
print("parse_port('8080') =", parse_port("8080"))
print("parse_port('abc') =", parse_port("abc"))
# Several types in one except, as a tuple.
def read_first(data):
try:
return data[0]
except (IndexError, KeyError, TypeError) as exc:
return f"unavailable ({type(exc).__name__})"
print()
for value in [[1, 2], [], {"a": 1}, 42]:
print(f" {repr(value):>10} -> {read_first(value)}")
test("a handled error returns the fallback", parse_port("abc"), None)
test("and a good value passes through", parse_port("8080"), 8080)
test("a tuple of types catches any of them", read_first([]), "unavailable (IndexError)")
test("including a different one", read_first(42), "unavailable (TypeError)")
What it printed, when the build ran it
parse_port('8080') = 8080
parse_port('abc') = None
[1, 2] -> 1
[] -> unavailable (IndexError)
{'a': 1} -> unavailable (KeyError)
42 -> unavailable (TypeError)
ok a handled error returns the fallback
ok and a good value passes through
ok a tuple of types catches any of them
ok including a different one
The bare except, and why it is a mistake
A bare except: catches everything — including
KeyboardInterrupt when you press Ctrl-C and SystemExit when the program is
trying to shut down. It turns a program you cannot interrupt into a bug you cannot find.
# What a bare except really catches.
def swallow_everything():
try:
raise KeyboardInterrupt("user pressed Ctrl-C")
except: # never write this
return "swallowed the interrupt"
print(swallow_everything())
# 'except Exception' is the broad-but-defensible version: KeyboardInterrupt and
# SystemExit inherit from BaseException, NOT Exception, so they pass through.
def swallow_errors_only():
try:
raise KeyboardInterrupt("user pressed Ctrl-C")
except Exception:
return "swallowed"
try:
swallow_errors_only()
except KeyboardInterrupt:
print("except Exception let the interrupt through, as it should")
print()
print("KeyboardInterrupt is an Exception? ", issubclass(KeyboardInterrupt, Exception))
print("KeyboardInterrupt is a BaseException?", issubclass(KeyboardInterrupt, BaseException))
test("a bare except catches even KeyboardInterrupt", swallow_everything(), "swallowed the interrupt")
test("KeyboardInterrupt does not inherit from Exception", issubclass(KeyboardInterrupt, Exception), False)
test("but it does from BaseException", issubclass(KeyboardInterrupt, BaseException), True)
What it printed, when the build ran it
swallowed the interrupt except Exception let the interrupt through, as it should KeyboardInterrupt is an Exception? False KeyboardInterrupt is a BaseException? True ok a bare except catches even KeyboardInterrupt ok KeyboardInterrupt does not inherit from Exception ok but it does from BaseException
The hierarchy is the point. BaseException is the root;
Exception sits under it and is the parent of everything you would normally catch.
KeyboardInterrupt, SystemExit and GeneratorExit deliberately
sit outside Exception, precisely so that except Exception does
not swallow them. Never write a bare except:; write except Exception: if
you genuinely need breadth, and log what you caught.
else: the clause that keeps the try narrow
Code in the try block is code whose exceptions you are catching. Putting the
follow-on work in there too means you might catch an error from the wrong line. The
else clause runs only if nothing was raised, which lets the try hold only
the risky line.
records = {"ada": "36"}
# Too much in the try: the ValueError could come from either line.
def loose(key):
try:
raw = records[key]
return int(raw) * 2 # if THIS raises, we blame the lookup
except KeyError:
return "no such record"
# Narrow try, with the follow-on work in else.
def tight(key):
try:
raw = records[key]
except KeyError:
return "no such record"
else:
return int(raw) * 2 # only runs if the lookup succeeded
print("loose('ada'):", loose("ada"))
print("tight('ada'):", tight("ada"))
print("tight('bob'):", tight("bob"))
# With bad data, the difference shows: the error is no longer mistakenly
# attributable to the lookup.
records["bad"] = "not a number"
try:
tight("bad")
except ValueError as exc:
print()
print("the ValueError now escapes clearly:", exc)
test("both work on good data", loose("ada"), tight("ada"))
test("and both handle a missing key", tight("bob"), "no such record")
What it printed, when the build ran it
loose('ada'): 72
tight('ada'): 72
tight('bob'): no such record
the ValueError now escapes clearly: invalid literal for int() with base 10: 'not a number'
ok both work on good data
ok and both handle a missing key
finally: runs whatever happens
log = []
def process(fail):
log.append("open")
try:
if fail:
raise RuntimeError("something broke")
log.append("work")
return "ok"
except RuntimeError:
log.append("handled")
return "recovered"
finally:
log.append("close") # runs on every path
success_result = process(False)
success_log = list(log)
print("success path:", success_result, success_log)
log.clear()
failure_result = process(True)
failure_log = list(log)
print("failure path:", failure_result, failure_log)
# It even runs when the exception is NOT handled.
log.clear()
def unhandled():
try:
raise KeyError("k")
finally:
log.append("cleanup ran anyway")
try:
unhandled()
except KeyError:
pass
print()
print("with an uncaught exception:", log)
test("finally ran on the success path", success_log, ["open", "work", "close"])
test("and on the handled-error path", failure_log, ["open", "handled", "close"])
test("and on the uncaught-exception path", log, ["cleanup ran anyway"])
What it printed, when the build ran it
success path: ok ['open', 'work', 'close'] failure path: recovered ['open', 'handled', 'close'] with an uncaught exception: ['cleanup ran anyway'] ok finally ran on the success path ok and on the handled-error path ok and on the uncaught-exception path
There is one genuinely surprising thing about finally: a return inside it
replaces whatever the function was already returning — and silently discards an in-flight
exception.
def sneaky():
try:
return "from try"
finally:
return "from finally" # overrides the return above
def worse():
try:
raise ValueError("this will vanish")
finally:
return "the exception was discarded"
print("return in finally wins:", sneaky())
print("and it swallows exceptions:", worse())
print()
print("Neither of these should ever be written. Use finally for cleanup only.")
test("a return in finally replaces the earlier one", sneaky(), "from finally")
test("and it silently discards a live exception", worse(), "the exception was discarded")
What it printed, when the build ran it
return in finally wins: from finally and it swallows exceptions: the exception was discarded Neither of these should ever be written. Use finally for cleanup only. ok a return in finally replaces the earlier one ok and it silently discards a live exception example.py:5: SyntaxWarning: 'return' in a 'finally' block return "from finally" # overrides the return above example.py:11: SyntaxWarning: 'return' in a 'finally' block return "the exception was discarded"
Re-raising, and adding context
Catching an exception to log it and then letting it continue is common. A bare raise
re-raises the current exception with its original traceback intact — which is what you want, because
it preserves where the problem actually happened.
log = []
def risky():
return 1 / 0
def with_logging():
try:
return risky()
except ZeroDivisionError:
log.append("division failed, re-raising")
raise # same exception, original traceback
try:
with_logging()
except ZeroDivisionError as exc:
print("caught again at the top:", type(exc).__name__, "-", exc)
print("log:", log)
# Adding notes (3.11+) attaches context without changing the type.
def annotated():
try:
return 1 / 0
except ZeroDivisionError as exc:
exc.add_note("while computing the average of an empty list")
raise
notes = []
try:
annotated()
except ZeroDivisionError as exc:
notes = list(exc.__notes__)
print()
print("the note travels with it:", notes)
test("a bare raise re-raises the same type", log, ["division failed, re-raising"])
test("add_note attaches the context to the exception",
notes, ["while computing the average of an empty list"])
What it printed, when the build ran it
caught again at the top: ZeroDivisionError - division by zero log: ['division failed, re-raising'] the note travels with it: ['while computing the average of an empty list'] ok a bare raise re-raises the same type ok add_note attaches the context to the exception
Several exceptions at once: ExceptionGroup
Since 3.11 a single raise can carry several unrelated exceptions, and except* handles
each kind separately. This exists for concurrent code, where five tasks can fail five different ways
simultaneously — lesson 43 uses it properly.
def validate(record):
problems = []
if "name" not in record:
problems.append(KeyError("name is required"))
if record.get("age", 0) < 0:
problems.append(ValueError("age cannot be negative"))
if problems:
raise ExceptionGroup("validation failed", problems)
return "valid"
handled = []
try:
validate({"age": -1})
except* KeyError as group:
handled.append(("missing field", len(group.exceptions)))
except* ValueError as group:
handled.append(("bad value", len(group.exceptions)))
print("both problems were reported, not just the first:")
for kind, count in handled:
print(f" {kind}: {count}")
print()
print("a valid record:", validate({"name": "Ada", "age": 30}))
test("both branches ran", len(handled), 2)
test("each saw its own exception type", sorted(k for k, _ in handled), ["bad value", "missing field"])
test("and a valid record raises nothing", validate({"name": "Ada"}), "valid")
What it printed, when the build ran it
both problems were reported, not just the first: missing field: 1 bad value: 1 a valid record: valid ok both branches ran ok each saw its own exception type ok and a valid record raises nothing
Errors should not pass silently
# The anti-pattern: catching and doing nothing.
def silent(values):
total = 0
for v in values:
try:
total += int(v)
except ValueError:
pass # the bad data vanished without trace
return total
# Better: keep the good data AND report what was skipped.
def reported(values):
total, skipped = 0, []
for v in values:
try:
total += int(v)
except ValueError:
skipped.append(v)
return total, skipped
data = ["1", "2", "oops", "4"]
print("silent: ", silent(data), " <- looks like a complete answer")
print("reported:", reported(data), "<- and you know what was dropped")
test("both total the usable values", silent(data), reported(data)[0])
test("only the second reports the problem", reported(data)[1], ["oops"])
What it printed, when the build ran it
silent: 7 <- looks like a complete answer reported: (7, ['oops']) <- and you know what was dropped ok both total the usable values ok only the second reports the problem
What to take from this lesson
- Raise
TypeErrorfor the wrong kind of thing andValueErrorfor the wrong value. - Catch specific types. Never
except:— useexcept Exception:if you need breadth, and log what you caught. - Keep the
tryblock down to the risky line; put the follow-on work inelse. finallyalways runs — use it for cleanup only, because areturnin it discards both the earlier return and any live exception.- A bare
raisepreserves the original traceback;add_noteattaches context. except passis how data disappears. Report what you skipped.
23 Your own exception types, and when to bother
Defining an exception type is three lines, so the interesting question is when to. The test is not "is this a special error" — it is "would a caller want to catch this failure and not others". If yes, it needs a name. If no, a built-in says it better.
The minimum
class ValidationError(Exception):
"""Raised when input does not meet the required shape."""
def check(age):
if age < 0:
raise ValidationError(f"age cannot be negative, got {age}")
return age
print("valid:", check(30))
try:
check(-1)
except ValidationError as exc:
print("caught:", type(exc).__name__, "-", exc)
# It is an Exception like any other, with the usual behaviour for free.
print()
print("is an Exception:", issubclass(ValidationError, Exception))
print("its message: ", ValidationError("boom").args)
test("the custom type is raised and caught", check(30), 30)
test("it inherits from Exception", issubclass(ValidationError, Exception), True)
test("and carries its message in args", ValidationError("boom").args, ("boom",))
What it printed, when the build ran it
valid: 30
caught: ValidationError - age cannot be negative, got -1
is an Exception: True
its message: ('boom',)
ok the custom type is raised and caught
ok it inherits from Exception
ok and carries its message in args
Inherit from Exception, not BaseException. Anything
under BaseException but outside Exception is understood to mean "stop the
program" — that is where KeyboardInterrupt and SystemExit live. An
application error inheriting from BaseException would slip through every
except Exception in the codebase, which is the opposite of useful.
A base class per library, which is the real pattern
Give your module or package one base exception, and derive everything from it. Callers then have a choice of precision: catch one specific failure, or catch anything your code raises, without knowing your internals.
class StorageError(Exception):
"""Base for every error this module raises."""
class NotFound(StorageError):
"""The requested key does not exist."""
class PermissionDenied(StorageError):
"""The caller may not access this key."""
class Corrupted(StorageError):
"""The stored value could not be decoded."""
DB = {"a": "1", "secret": "2", "bad": "not-a-number"}
def fetch(key, admin=False):
if key not in DB:
raise NotFound(f"no such key: {key!r}")
if key == "secret" and not admin:
raise PermissionDenied(f"{key!r} requires admin")
try:
return int(DB[key])
except ValueError as exc:
raise Corrupted(f"{key!r} is not a number") from exc
print("fetch('a') =", fetch("a"))
# A caller who cares about one case can catch just that one.
try:
fetch("nope")
except NotFound as exc:
print("specific:", exc)
# A caller who just wants to survive catches the base class.
print()
for key in ["nope", "secret", "bad"]:
try:
fetch(key)
except StorageError as exc:
print(f" {type(exc).__name__:17} {exc}")
test("the happy path works", fetch("a"), 1)
test("every error shares the base class",
all(issubclass(e, StorageError) for e in (NotFound, PermissionDenied, Corrupted)), True)
test("so one except clause catches all of them",
isinstance(NotFound("x"), StorageError), True)
test("and admin access changes the outcome", fetch("secret", admin=True), 2)
What it printed, when the build ran it
fetch('a') = 1
specific: no such key: 'nope'
NotFound no such key: 'nope'
PermissionDenied 'secret' requires admin
Corrupted 'bad' is not a number
ok the happy path works
ok every error shares the base class
ok so one except clause catches all of them
ok and admin access changes the outcome
Carrying data, not just a message
An exception is an object, so it can hold whatever the handler needs. This is much better than forcing callers to parse your error message, which is what they will otherwise do.
class RetryableError(Exception):
def __init__(self, message, *, retry_after, attempts):
super().__init__(message) # keeps str(exc) working
self.retry_after = retry_after
self.attempts = attempts
def call_service(attempt):
if attempt < 3:
raise RetryableError(
"service unavailable",
retry_after=attempt * 2,
attempts=attempt,
)
return "succeeded"
for attempt in range(1, 4):
try:
result = call_service(attempt)
except RetryableError as exc:
# The handler reads structured fields instead of parsing text.
print(f"attempt {exc.attempts}: {exc} -- wait {exc.retry_after}s")
else:
print(f"attempt {attempt}: {result}")
test("str() still gives the message", str(RetryableError("x", retry_after=1, attempts=1)), "x")
test("and the structured data is reachable",
RetryableError("x", retry_after=4, attempts=2).retry_after, 4)
test("the third attempt succeeds", call_service(3), "succeeded")
What it printed, when the build ran it
attempt 1: service unavailable -- wait 2s attempt 2: service unavailable -- wait 4s attempt 3: succeeded ok str() still gives the message ok and the structured data is reachable ok the third attempt succeeds
Call super().__init__(message). Skip it and
str(exc) stops being the message: with positional arguments you get the whole
argument tuple printed back at you, and with keyword-only arguments you get an empty string — so
the exception appears as a blank line in every log and traceback it reaches. Both are shown
below.
class Untidy(Exception):
def __init__(self, message, code):
self.message = message # super() never called
self.code = code
class Silent(Exception):
def __init__(self, *, message, code): # keyword-only, and no super()
self.message = message
self.code = code
class Correct(Exception):
def __init__(self, message, code):
super().__init__(message)
self.code = code
for label, exc in [
("Untidy", Untidy("something failed", 500)),
("Silent", Silent(message="something failed", code=500)),
("Correct", Correct("something failed", 500)),
]:
print(f" {label:8} str() -> {str(exc)!r:25} args -> {exc.args!r}")
print()
print("Untidy prints the whole args tuple; Silent prints nothing at all;")
print("only Correct shows the message a reader wanted.")
test("positional args survive as a tuple even without super()", str(Untidy("x", 1)), "('x', 1)")
test("keyword-only args with no super() leave str() empty", str(Silent(message="x", code=1)), "")
test("calling super() gives the clean message", str(Correct("x", 1)), "x")
What it printed, when the build ran it
Untidy str() -> "('something failed', 500)" args -> ('something failed', 500)
Silent str() -> '' args -> ()
Correct str() -> 'something failed' args -> ('something failed',)
Untidy prints the whole args tuple; Silent prints nothing at all;
only Correct shows the message a reader wanted.
ok positional args survive as a tuple even without super()
ok keyword-only args with no super() leave str() empty
ok calling super() gives the clean message
Translating an exception with raise ... from
Lesson 3 showed the two connector sentences a chained traceback can carry. Here you choose which
one your code produces. raise X from exc says "this was caused by that" and keeps the
original for whoever debugs it.
class ConfigError(Exception):
pass
def load_port(raw):
try:
return int(raw["port"])
except KeyError as exc:
raise ConfigError("config is missing 'port'") from exc
load_port({})
The traceback it really produced, when the build ran it
Traceback (most recent call last):
File "example.py", line 6, in load_port
return int(raw["port"])
~~~^^^^^^^^
KeyError: 'port'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "example.py", line 10, in <module>
load_port({})
~~~~~~~~~^^^^
File "example.py", line 8, in load_port
raise ConfigError("config is missing 'port'") from exc
ConfigError: config is missing 'port'
Without from, raising inside an except block still shows both — but as
"during handling of the above exception", which reads as an accident rather than a deliberate
translation. And from None suppresses the original entirely, which is right when the
internal cause would only confuse the caller.
class ConfigError(Exception):
pass
def implicit(raw):
try:
return int(raw["port"])
except KeyError:
raise ConfigError("missing 'port'") # no 'from'
def explicit(raw):
try:
return int(raw["port"])
except KeyError as exc:
raise ConfigError("missing 'port'") from exc # deliberate
def suppressed(raw):
try:
return int(raw["port"])
except KeyError:
raise ConfigError("missing 'port'") from None # hide the internals
observed = {}
for name, func in [("implicit", implicit), ("explicit", explicit), ("suppressed", suppressed)]:
try:
func({})
except ConfigError as exc:
cause = type(exc.__cause__).__name__ if exc.__cause__ else None
context = type(exc.__context__).__name__ if exc.__context__ else None
observed[name] = (cause, context)
print(f" {name:11} __cause__={cause!s:10} __context__={context}")
print()
print("__cause__ is set only by 'from'. __context__ is set automatically either way.")
test("a plain raise records context but no cause", observed["implicit"], (None, "KeyError"))
test("from exc sets the cause explicitly", observed["explicit"], ("KeyError", "KeyError"))
test("from None clears the cause", observed["suppressed"][0], None)
What it printed, when the build ran it
implicit __cause__=None __context__=KeyError explicit __cause__=KeyError __context__=KeyError suppressed __cause__=None __context__=KeyError __cause__ is set only by 'from'. __context__ is set automatically either way. ok a plain raise records context but no cause ok from exc sets the cause explicitly ok from None clears the cause
When not to define one
Use a built-in when a built-in fits. A function given a negative age should raise
ValueError, not NegativeAgeError. A missing dict key should be
KeyError. Callers already know how to handle those, and a type nobody will ever catch
specifically is just noise. Define your own when a caller needs to distinguish your failure
— typically at a library or subsystem boundary — and not before.
# Over-engineered: three types nobody will catch separately.
class AgeTooSmallError(Exception): pass
class AgeTooLargeError(Exception): pass
class AgeNotIntegerError(Exception): pass
def over_engineered(age):
if not isinstance(age, int):
raise AgeNotIntegerError(age)
if age < 0:
raise AgeTooSmallError(age)
if age > 150:
raise AgeTooLargeError(age)
return age
# The version anyone can use, with the same information in the message.
def sensible(age):
if not isinstance(age, int):
raise TypeError(f"age must be an int, got {type(age).__name__}")
if not 0 <= age <= 150:
raise ValueError(f"age must be 0-150, got {age}")
return age
raised = []
for value in [-1, 200, "x"]:
try:
sensible(value)
except (TypeError, ValueError) as exc:
raised.append(type(exc).__name__)
print(f" {type(exc).__name__}: {exc}")
print()
print("Same diagnosis, built-in types, and no new vocabulary for callers to learn.")
test("the sensible version accepts a valid age", sensible(30), 30)
test("and reports the failures with built-in types",
raised, ["ValueError", "ValueError", "TypeError"])
What it printed, when the build ran it
ValueError: age must be 0-150, got -1 ValueError: age must be 0-150, got 200 TypeError: age must be an int, got str Same diagnosis, built-in types, and no new vocabulary for callers to learn. ok the sensible version accepts a valid age ok and reports the failures with built-in types
What to take from this lesson
- Define an exception when a caller needs to catch that failure specifically. Otherwise use a built-in.
- Inherit from
Exception, neverBaseException. - Give a library one base exception and derive the rest from it, so callers can be precise or broad as they choose.
- Put structured data on the exception rather than making callers parse the message — and call
super().__init__(message). raise X from excfor a deliberate translation;from Noneto hide an internal cause.
24 with: context managers and guaranteed cleanup
A file you opened must be closed. A lock you took must be released. A
transaction you began must be committed or rolled back. In every case the cleanup has to happen even
if the code in between raises, and with is how Python makes that guarantee
structural rather than something you remember to do.
The problem with solves
from pathlib import Path
Path("data.txt").write_text("line one\nline two\n")
# Without any protection: an exception between open and close leaks the handle.
f = open("data.txt")
first = f.readline()
f.close()
print("manual:", first.strip())
# The correct manual version is try/finally, which is verbose enough that
# people skip it -- which is exactly why 'with' exists.
f = open("data.txt")
try:
first = f.readline()
finally:
f.close()
print("try/finally:", first.strip())
# with does the same thing, and cannot be forgotten.
with open("data.txt") as f:
first = f.readline()
print("with:", first.strip())
# The file is closed on the way out, whatever happened.
print()
print("closed after the with block:", f.closed)
test("all three read the same line", first.strip(), "line one")
test("the file is closed automatically", f.closed, True)
What it printed, when the build ran it
manual: line one try/finally: line one with: line one closed after the with block: True ok all three read the same line ok the file is closed automatically
And the guarantee holds when the body raises, which is the entire point.
from pathlib import Path
Path("data.txt").write_text("content\n")
handle = None
try:
with open("data.txt") as f:
handle = f
raise RuntimeError("something went wrong mid-read")
except RuntimeError as exc:
print("the exception still propagated:", exc)
print("but the file was closed on the way out:", handle.closed)
test("the exception was not swallowed", handle is not None, True)
test("and cleanup happened anyway", handle.closed, True)
What it printed, when the build ran it
the exception still propagated: something went wrong mid-read but the file was closed on the way out: True ok the exception was not swallowed ok and cleanup happened anyway
Two reasons this matters more than it looks. An unclosed file may not be flushed
to disk, so data you "wrote" can be missing. And every operating system limits how many files a
process may have open, so a leak in a loop eventually fails with
OSError: too many open files — far from the code that caused it.
Several at once, and nesting
from pathlib import Path
Path("in.txt").write_text("alpha\nbeta\n")
# One with statement, two managers. Both are closed, in reverse order.
with open("in.txt") as src, open("out.txt", "w") as dst:
for line in src:
dst.write(line.upper())
print("copied and upper-cased:", Path("out.txt").read_text().strip().split("\n"))
# Parenthesised form, which is the readable way when there are several (3.10+).
with (
open("in.txt") as a,
open("out.txt") as b,
):
print("both open at once:", a.readline().strip(), "/", b.readline().strip())
test("the output file was written and closed", Path("out.txt").read_text(), "ALPHA\nBETA\n")
What it printed, when the build ran it
copied and upper-cased: ['ALPHA', 'BETA'] both open at once: alpha / ALPHA ok the output file was written and closed
Writing one as a class
Any object with __enter__ and __exit__ works with with.
__enter__'s return value is what as binds; __exit__ is called on
the way out and is told what went wrong, if anything.
class Timer:
"""Measures how long its block took."""
def __init__(self, label):
self.label = label
self.events = []
def __enter__(self):
self.events.append("enter")
return self # this is what 'as' receives
def __exit__(self, exc_type, exc_value, traceback):
self.events.append("exit")
self.failed = exc_type is not None
# Returning None (or False) means "I did not handle it, let it travel".
return None
with Timer("import") as t:
t.events.append("body")
print("events:", t.events)
print("failed:", t.failed)
# __exit__ is told exactly what happened.
seen = {}
class Watcher:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
seen["type"] = exc_type.__name__ if exc_type else None
seen["message"] = str(exc_value) if exc_value else None
return None
try:
with Watcher():
raise ValueError("the body failed")
except ValueError:
pass
print()
print("what __exit__ was told:", seen)
test("enter, body, exit ran in that order", t.events, ["enter", "body", "exit"])
test("a clean block reports no failure", t.failed, False)
test("__exit__ receives the exception type", seen["type"], "ValueError")
test("and its message", seen["message"], "the body failed")
What it printed, when the build ran it
events: ['enter', 'body', 'exit']
failed: False
what __exit__ was told: {'type': 'ValueError', 'message': 'the body failed'}
ok enter, body, exit ran in that order
ok a clean block reports no failure
ok __exit__ receives the exception type
ok and its message
The trap: returning True from __exit__
A truthy return from __exit__ means "I have handled this exception; do not propagate
it". That is occasionally what you want and usually a bug — the failure disappears and the code after
the block runs as though nothing happened.
class Swallows:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
return True # "handled" -- the exception vanishes
class Propagates:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
return None # the correct default
reached = []
with Swallows():
raise ValueError("this will disappear")
reached.append("after Swallows") # this line RUNS
try:
with Propagates():
raise ValueError("this one escapes")
except ValueError:
reached.append("caught from Propagates")
print("what happened:", reached)
print()
print("The first block raised and the program carried on regardless.")
test("returning True swallowed the exception", "after Swallows" in reached, True)
test("returning None let it propagate", "caught from Propagates" in reached, True)
What it printed, when the build ran it
what happened: ['after Swallows', 'caught from Propagates'] The first block raised and the program carried on regardless. ok returning True swallowed the exception ok returning None let it propagate
Return None from __exit__ unless you mean it. Falling off
the end of the method does this for you. The only legitimate reason to return True is a
manager whose stated job is suppressing a specific exception — and the standard library already has
contextlib.suppress for that, which is clearer than writing your own.
Writing one as a generator, which is usually shorter
@contextmanager turns a generator into a context manager. Everything before the
yield is setup, the yielded value is what as binds, and everything after is
cleanup — put in a finally so it runs even when the body raises.
from contextlib import contextmanager
log = []
@contextmanager
def tracked(name):
log.append(f"open {name}")
resource = name.upper()
try:
yield resource # the body runs here
finally:
log.append(f"close {name}") # always runs
with tracked("db") as handle:
log.append(f"body has {handle}")
success_log = list(log)
print("\n".join(success_log))
# And on the failing path.
log.clear()
try:
with tracked("net"):
raise RuntimeError("boom")
except RuntimeError:
pass
failure_log = list(log)
print()
print("on failure:", failure_log)
test("setup, body and cleanup ran in order",
success_log, ["open db", "body has DB", "close db"])
test("cleanup ran even though the body raised", failure_log, ["open net", "close net"])
What it printed, when the build ran it
open db body has DB close db on failure: ['open net', 'close net'] ok setup, body and cleanup ran in order ok cleanup ran even though the body raised
Without the try/finally, the cleanup is not guaranteed.
If the body raises, the exception is thrown back in at the yield, and anything after it
is skipped unless it is protected. This is the one mistake that makes a
@contextmanager quietly useless.
from contextlib import contextmanager
log = []
@contextmanager
def unprotected(name):
log.append("open")
yield name
log.append("close") # skipped if the body raises
@contextmanager
def protected(name):
log.append("open")
try:
yield name
finally:
log.append("close")
try:
with unprotected("x"):
raise RuntimeError("boom")
except RuntimeError:
pass
unprotected_log = list(log)
print("unprotected:", unprotected_log, "<- never closed")
log.clear()
try:
with protected("x"):
raise RuntimeError("boom")
except RuntimeError:
pass
protected_log = list(log)
print("protected: ", protected_log)
test("without finally, cleanup is skipped on failure", unprotected_log, ["open"])
test("with finally, it always runs", protected_log, ["open", "close"])
What it printed, when the build ran it
unprotected: ['open'] <- never closed protected: ['open', 'close'] ok without finally, cleanup is skipped on failure ok with finally, it always runs
The ones in the standard library worth knowing
from contextlib import suppress, redirect_stdout
from pathlib import Path
import io
# suppress: the honest version of try/except/pass, for when absence is fine.
Path("exists.txt").write_text("x")
with suppress(FileNotFoundError):
Path("exists.txt").unlink()
with suppress(FileNotFoundError):
Path("never-existed.txt").unlink() # no error, by design
print("both unlink attempts completed; the file is gone:", not Path("exists.txt").exists())
# redirect_stdout: capture printed output, which is how you test a function
# that prints instead of returning.
buffer = io.StringIO()
with redirect_stdout(buffer):
print("this went into the buffer")
captured = buffer.getvalue().strip()
print("captured:", repr(captured))
# A context manager can also be used without 'with', by calling the methods
# directly -- which is what a decorator-based one does under the hood.
print()
print("suppress is itself a class:", type(suppress(ValueError)).__name__)
test("suppress ignored the missing file", Path("never-existed.txt").exists(), False)
test("and the present one was really deleted", Path("exists.txt").exists(), False)
test("redirect_stdout captured the print", captured, "this went into the buffer")
What it printed, when the build ran it
both unlink attempts completed; the file is gone: True captured: 'this went into the buffer' suppress is itself a class: suppress ok suppress ignored the missing file ok and the present one was really deleted ok redirect_stdout captured the print
suppress deserves a caution of its own: it is except: pass with better
syntax, and it has the same risk. Use it when absence genuinely does not matter, and never around a
block big enough to hide a second, unrelated failure.
from contextlib import suppress
# Too broad: the KeyError from the second line is hidden too.
data = {"a": 1}
result = None
with suppress(KeyError):
result = data["a"]
result = data["missing"] + data["also_missing"]
print("result after a too-broad suppress:", result, "<- partial work, no complaint")
# Narrow: only the one lookup that is allowed to fail.
value = None
with suppress(KeyError):
value = data["missing"]
print("narrow suppress leaves value as:", value)
test("the broad version silently kept partial work", result, 1)
test("the narrow one left the value unset", value, None)
What it printed, when the build ran it
result after a too-broad suppress: 1 <- partial work, no complaint narrow suppress leaves value as: None ok the broad version silently kept partial work ok the narrow one left the value unset
What to take from this lesson
withis a reusabletry/finally: it guarantees cleanup even when the body raises.- A context manager is any object with
__enter__and__exit__;__enter__'s return value is whatasbinds. - Return
Nonefrom__exit__. ReturningTrueswallows the exception. @contextmanageris usually shorter — but theyieldmust be inside atry/finallyor cleanup is skipped on failure.suppressis tidier thanexcept: passand carries the same danger. Keep it narrow.