1.3.4 · D4Python Intermediate

Exercises — Context managers — with statement, `__enter__` - `__exit__`

2,039 words9 min readBack to topic

This page assumes only the parent note. If a term feels new, it was built there — go back, then return.

Figure — Context managers — with statement, `__enter__`  -  `__exit__`

The figure above is the mental model for every exercise: with de-sugars into a try/finally, and __exit__ sits in the finally so it always fires. Keep glancing at it.


Level 1 — Recognition

Can you name the pieces and read them off a snippet?

Recall Solution 1.1

(a) The object returned by open("a.txt") — a file object. (b) f is the return value of __enter__. For files, __enter__ returns the same file object, so f is the file. (c) __exit__ — it calls .close() on the file when the block is left. Why: always ask "what does __enter__ return?" — that is the as variable. Here it happens to be the manager itself, but that is a coincidence of the file class.

Recall Solution 1.2

Order: exc_type, exc_value, traceback (mnemonic TVT). With no exception, all three are None, None, None. Why: the three args are the "crash report". No crash → nothing to report → three Nones.


Level 2 — Application

Predict the exact output.

Recall Solution 2.1
A
B
C

Why: __enter__ runs first (prints A), then the body (B), then __exit__ on the way out (C). "ENTER sets, EXIT resets."

Recall Solution 2.2
enter
body
exit
caught

Why: the body raises after body, so never is skipped. But __exit__ lives in the finally half of the de-sugaring, so exit still prints. __exit__ returns False, so the ValueError propagates out and is caught by the outer except, printing caught.


Level 3 — Analysis

Reason about return values and swallowing.

Recall Solution 3.1

after does not print — the KeyError propagates and crashes the program (unless caught elsewhere). Why: __exit__ returned None. None is falsy, and the de-sugaring says re-raise unless __exit__ returned a truthy value. Only an explicit return True (or any truthy value) swallows the error. Returning None — the default when you write no return — lets it propagate.

Recall Solution 3.2

(a) x equals 42 — the yielded value becomes the as variable. (b)

setup
body 42
cleanup

(c) No. The manager is a generator object created by band(); x is the integer 42. They are different objects — this is exactly the L1 trap in action. Why: with @contextmanager, code before yield is __enter__, the yielded value is what __enter__ returns, and code after yield is __exit__.


Level 4 — Synthesis

Build a working manager from scratch.

Recall Solution 4.1
class ignore:
    def __init__(self, *exc):
        self.exc = exc
    def __enter__(self):
        return self
    def __exit__(self, et, ev, tb):
        return et is not None and issubclass(et, self.exc)

Output: still here prints. Why each line:

  • et is not None — an exception only occurred if et (the type) is set; if it's None we must return falsy so a clean exit isn't wrongly reported as "suppressed".
  • issubclass(et, self.exc)True only for the types we asked to ignore. For those, __exit__ returns True → the with swallows the error and execution continues to print.
  • A different error (say PermissionError) makes issubclass return False → propagates.
Recall Solution 4.2
import time
from contextlib import contextmanager
 
@contextmanager
def timer():
    tic = time.perf_counter()
    box = {}                      # mutable container we can fill later
    try:
        yield box                 # caller reads box["elapsed"] AFTER the block
    finally:
        box["elapsed"] = time.perf_counter() - tic
 
with timer() as t:
    sum(range(1_000_000))
print(t["elapsed"])              # e.g. 0.01

Why a plain number fails: if you yield 0.0, the caller binds t = 0.0 at enter time, before any work has run. You cannot go back and change the integer the caller already holds. A mutable container (dict/list) lets __exit__-side code fill in the true elapsed value after the body finishes, and the caller — holding the same object — sees the update. Why finally: so box["elapsed"] is set even if the body raises.


Level 5 — Mastery

Compose managers and reason about nesting order.

Recall Solution 5.1
enter 1
enter 2
body
exit 2
exit 1

M(2)'s __exit__ runs first. Managers unwind last-in, first-out (LIFO), like nested boxes: the inner one opened last, so it closes first. Why: with M(1), M(2): is sugar for nesting with M(1): with M(2):. The inner block's finally fires before control returns to the outer block's finally.

Recall Solution 5.2
pass sees ValueError
swallow sees ValueError
done

done prints. Why: the ValueError first hits the inner Pass.__exit__, which sees it but returns False → the error keeps propagating outward. It then reaches the outer Swallow.__exit__, which returns True → the error is suppressed there. Both __exit__s ran (LIFO), and because the outermost one swallowed the exception, execution resumes and done prints. Key insight: an exception "walks outward" through each enclosing __exit__ until one returns truthy. If none does, it escapes the whole with stack.


Recall Self-check summary
  • __enter__ return value → as variable ::: yes
  • __exit__ truthy return → suppress ::: yes
  • None return → propagate ::: yes
  • Managers unwind LIFO ::: yes

Connections

  • Exception handling — try-except-finally — every exercise here is really a try/finally in disguise.
  • Generators and yield — behind Exercises 3.2 and 4.2.
  • Decorators@contextmanager is the decorator doing the work.
  • File I/O — the open(...) in Exercises 1.1 and 4.1.
  • Threading and locks — the LIFO unwinding of Exercise 5.1 is why with lock: nesting is safe.
  • Dunder methods__enter__/__exit__ are protocol dunders.