1.3.4 · D2Python Intermediate

Visual walkthrough — Context managers — with statement, `__enter__` - `__exit__`

2,130 words10 min readBack to topic

This is the companion visual walkthrough to the parent topic. Every arrow you see in the code, we'll first draw as a picture.


Step 1 — The problem: two things that must always come in pairs

WHAT. Some resources come in pairs of actions: open a file → close it; grab a lock → release it; start a timer → stop it. The first action acquires, the second releases.

WHY. If you acquire but forget to release, the resource leaks. A never-closed file eats an OS handle; a never-released lock freezes your whole program (a deadlock — two pieces of code each waiting forever for the other).

PICTURE. Think of a door. Walking in = acquire. Walking out = release. The danger is a door you walked through but never walked back out of.

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

Step 2 — The naive fix, and the hole in it

WHAT. The obvious idea: release right after you're done.

f = open("data.txt")    # acquire
data = f.read()         # use it
f.close()               # release

WHY it's not enough. Look at the middle line. If f.read() raises an error, Python jumps past f.close() — the release never runs. So the "obvious" version leaks the file precisely when something goes wrong, which is the worst time.

PICTURE. The error is a trapdoor in the middle of the room. You fall through it and never reach the exit door. The file stays open forever.

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

Step 3 — try / finally: a release that cannot be skipped

WHAT. Python has a tool built for exactly this: try / finally. Whatever happens in the try block, the finally block runs on the way out.

f = open("data.txt")     # acquire (outside try — if this fails, nothing to close)
try:
    data = f.read()      # use it — may raise
finally:
    f.close()            # release — runs no matter what

Term by term:

  • try: — "attempt this, and be ready for it to blow up."
  • finally: — "no matter how you leave the try block (normally, or by exception, or by return), run this first."

WHY this shape. f.close() in finally is a promise. The trapdoor from Step 2 no longer helps the file escape cleanup — even falling through it routes you through finally.

PICTURE. Now the exit door is placed so that every path — the normal walk-out and the trapdoor — funnels through it before you leave the building.

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

Step 4 — Naming the two halves: __enter__ and __exit__

WHAT. We give the pair of actions official method names, so any object can play this game — not just files.

Look at __exit__'s three arguments — this is the "crash report" Python hands the cleanup code:

If the block finished without an error, all three are None. That is how __exit__ knows whether anything went wrong.

WHY methods and not just two functions. Because now the acquire/release pair travels inside one object. You hand that object to with once; it carries both halves with it.

PICTURE. The context-manager object is a little box with two buttons: a green ENTER button (setup) and a red EXIT button (cleanup, which also receives the crash report).

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

Step 5 — Assembling with: the exact de-sugaring

WHAT. Now we glue Steps 3 and 4 together. Here is the full translation of

with EXPR as VAR:
    BODY

into plain try/finally — this is the definition of what with does:

mgr = EXPR                       # (a) build the context-manager object
VAR = mgr.__enter__()            # (b) acquire; return value → the `as` variable
try:
    BODY                         # (c) your code runs here
except BaseException:            # (d) something raised inside BODY
    if not mgr.__exit__(*sys.exc_info()):   # (e) hand it the crash report
        raise                    # (f) re-raise unless __exit__ said "swallow it"
else:                            # (g) BODY finished cleanly
    mgr.__exit__(None, None, None)          # (h) release; no error → three Nones

Line by line, matched to the pictures we already built:

  • (a) evaluate EXPR once → the box from Step 4.
  • (b) press the green button. Whatever it returns is what as VAR binds — not the box itself (a classic trap; see the mistake below).
  • (c)–(d) if BODY blows up, we catch every exception (BaseException is the root of all of them).
  • (e) press the red button, feeding it the crash report. sys.exc_info() is exactly the tuple (exc_type, exc_value, traceback).
  • (f) __exit__'s return value is a yes/no flag: "should I swallow this error?". if not (...) means: if it did not say yes, re-raise and let the error keep travelling.
  • (g)–(h) no error path: still press the red button, but the crash report is all None.

WHY this exact shape. It guarantees __exit__ runs on every exit path (the funnel from Step 3), and it lets cleanup decide the error's fate (the return-value flag).

PICTURE. Two roads leave BODY — the calm road (no error) and the storm road (error). Both roads pass through the red EXIT button before leaving; only the storm road carries a crash report.

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

Step 6 — The return-value flag decides the error's fate

WHAT. Zoom into line (e)–(f). The single most misunderstood rule of context managers lives here:

WHY None propagates. A method with no explicit return returns None, and None is falsy. So if not mgr.__exit__(...) becomes if not Noneif True → we raise. Doing your cleanup does NOT hide the error — you must explicitly return True to hide it. This is a safety feature: silent error-swallowing is a top bug source.

PICTURE. The red button is also a gate. return True closes the gate — the error is trapped inside and vanishes. return None/False leaves the gate open — the error walks straight out and up the call stack.

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

Step 7 — The degenerate & edge cases (never leave a gap)

We must show every path a reader could hit.

Case A — BODY finishes normally. Road (g)→(h): __exit__(None, None, None). Cleanup still runs.

Case B — BODY raises, __exit__ returns falsy. Road (d)→(e)→(f): cleanup runs, then the error re-raises. Normal, expected behaviour.

Case C — BODY raises, __exit__ returns True. Road (d)→(e): cleanup runs, error suppressed, program continues after the with.

Case D — __enter__ itself raises (acquire fails). Look at line (b): it is before the try. So __exit__ never runs — and correctly so: you never acquired the resource, so there is nothing to release. You can't close a file you never opened.

Case E — EXPR isn't a context manager. No __enter__/__exit__AttributeError at line (a)/(b), before BODY ever runs.

Case F — @contextmanager with the wrong number of yields. The generator must ==yield exactly once==. Zero yields → RuntimeError: didn't yield. Two yields → RuntimeError: generator didn't stop. The single yield is the boundary between the two roads.

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

The one-picture summary

Everything above, compressed: the box has two buttons; with presses green, runs your body, then always presses red — and the red button's return value decides whether an error escapes.

Figure — Context managers — with statement, `__enter__`  -  `__exit__`
Recall Feynman: the whole walkthrough in plain words

Some things come in pairs — open/close, lock/unlock. If you forget the second half, the program leaks or freezes. Writing the release yourself is risky because an error can jump past it. Python's finally fixes that: it runs on every way out of a block. We wrap that pattern into an object with two buttons — a green "set up" button (__enter__, whose result becomes your as variable) and a red "clean up" button (__exit__, which is always pressed and is handed a crash report of Type/Value/Traceback). The with statement just presses green, runs your code, and guarantees it presses red on the way out — whether your code finished calmly or crashed. The red button can return True to swallow the crash, or None/False to let it fly on. And if the green button itself fails, red is never pressed, because you never actually acquired anything to release.


Recall

Which de-sugared line binds the as variable?
VAR = mgr.__enter__() — the return value of __enter__
Does __exit__ run if __enter__ raises?
No — __enter__ is before the try, so nothing was acquired to release
__exit__ returns None after an error — what happens?
The error propagates (None is falsy → the if not re-raises)
What are sys.exc_info()'s three parts?
exc_type, exc_value, traceback — the crash report given to __exit__
How many times must a @contextmanager generator yield?
Exactly once — the yield splits setup from cleanup

Connections

  • Exception handling — try-except-finally — the try/finally engine underneath every step here.
  • Generators and yield — the single-yield split of Case F.
  • Decorators@contextmanager is a decorator around a generator.
  • File I/O — the canonical acquire/release pair (open/close).
  • Threading and lockswith lock: guarantees the release in Step 1.
  • Dunder methods__enter__/__exit__ belong to this protocol family.