Skillversitas

Context Managers

A context manager is an object that runs setup code before a with block and teardown code after it - and, crucially, runs the teardown even when the block raises. The with statement is the language's answer to the "open a resource, use it, close it even on error" pattern that try/finally used to spell out by hand. The protocol is two methods, __enter__ and __exit__, and the contextlib module provides decorators that build managers from plain functions. This chapter covers the protocol precisely, the contextlib shortcuts, and the design rules for when a with is the right interface.

The Protocol: __enter__ and __exit__

class ManagedFile:
    def __init__(self, path: str, mode: str = "r"):
        self.path = path
        self.mode = mode

    def __enter__(self):
        # runs before the block; its return value is bound to the `as` target
        self._fh = open(self.path, self.mode)
        print("opened", self.path)
        return self._fh

    def __exit__(self, exc_type, exc, tb):
        # runs on the way out - ALWAYS, whether the block raised or not
        self._fh.close()
        print("closed", self.path)
        if exc_type is not None:
            print(f"suppressed? no - exception {exc_type.__name__} passed through")
        return False            # False/None: do NOT suppress the exception

# the as target is __enter__'s return, not the manager itself
with ManagedFile("notes.txt") as fh:
    data = fh.read()
# fh is closed here, even if fh.read() had raised

The mechanics, precisely: with X() as target: calls target = X().__enter__(), runs the block, then calls X().__exit__(exc_type, exc, tb) with the exception details if the block raised (all three None on the clean path). The as target is whatever __enter__ returns - the manager returns the resource (the file handle) so the block works on the resource, not on the wrapper. The return value of __exit__ is the exception policy: True suppresses the exception (the block's error is swallowed), False or None lets it propagate. That one return is the most dangerous line in the protocol - an __exit__ that returns True by accident turns every error in the block into a silent success, so the default is to return nothing and let errors pass.

contextlib: Managers Without the Boilerplate

from contextlib import contextmanager, ExitStack

@contextmanager
def timed_resource(name: str):
    import time
    start = time.perf_counter()
    print(f"acquire {name}")
    try:
        yield name                 # the value bound to the `as` target
    finally:
        print(f"release {name} after {time.perf_counter() - start:.3f}s")

with timed_resource("db") as res:
    print("using", res)
# acquire db
# using db
# release db after 0.000s

@contextmanager turns a generator function into a context manager: the code before yield is the __enter__ setup, the yielded value is the as target, and the code after it (inside a try/finally) is the __exit__ teardown. The try/finally around the yield is what guarantees the release runs even if the block raised - the generator is suspended at the yield, and the exception is raised into it at that point, which the finally catches. For one-off resources (a timer, a chdir, a temp file, a mock patch) this is the entire implementation - no class, no __enter__/__exit__ pair. ExitStack (also in contextlib) manages dynamic numbers of managers at runtime - entering them in a loop, or conditionally - which the fixed with a, b, c: syntax cannot express.

The Built-in Managers and the Pattern

import io

# files: the canonical use - closed on exit, even on error
with open("data.txt") as f:
    lines = f.readlines()
# f is closed here

# io.StringIO: an in-memory file, same protocol
buf = io.StringIO()
with buf as b:
    b.write("hello\n")
    b.seek(0)
    print(b.read().strip())     # hello

# the pattern generalizes to any acquire/release pair:
# a connection, a lock, a temp dir, a GPU context - anything with a
# resource that must be released even when the work fails

Everything with an acquire/release pair is a context-manager candidate: files, database connections and transactions, locks, temporary directories, subprocesses, thread pools. The built-in open() is the archetype - the with form guarantees the close, which is the whole reason the bare f = open(...) plus a separate f.close() is a bug waiting for an exception between them. The design rule for your own code: if an object hands out a resource that must be released, make the object a context manager so the release is structural (the with guarantees it) rather than procedural (the caller remembers it). The as target being the resource, not the wrapper, is the convention that keeps the block clean - the user works on the connection, not on the manager that owns the connection.

Suppressing: The Deliberate Choice

from contextlib import suppress

# suppress is a context manager that swallows the listed exceptions
import pathlib
with suppress(FileNotFoundError):
    pathlib.Path("maybe.tmp").unlink()
# no error if the file was absent - the expected case

# contrast: a custom __exit__ that returns True does the same, by hand
class Quiet:
    def __enter__(self): return self
    def __exit__(self, *exc): return True   # swallows EVERYTHING - deliberate

# the rule: suppression is a documented policy, not a default.
# "missing file is fine" is suppressible; "the query failed" is not.

Returning True from __exit__ (or using suppress) is exception suppression made explicit - the error does not propagate. It is correct exactly when the error is an expected, handled outcome: the file may not exist, the key may be absent, the connection may already be closed. It is wrong when it hides a real failure: a __exit__ that returns True for every exception converts a crashed block into a quiet no-op, and the bug moves to wherever the missing result is noticed, if ever. The test is the same as the exception chapter's "exceptions are for exceptions" rule, applied to the manager: suppress the case you would otherwise try/except and ignore, and let everything else through.

The Working Rules

  • Guarantee the teardown structurally. If a resource must be released, the release lives in __exit__/finally, never in code the caller must remember to run.
  • Return the resource from __enter__. The as target is what the block uses; returning self is fine when the manager and the resource are the same object, otherwise return the resource.
  • Default to not suppressing. Return None from __exit__; return True only for a named, expected-and-fine failure, and say so in a comment.
  • Use @contextmanager for one-offs. A generator with a try/finally around the yield is a complete manager; reach for the class form when the manager has state or methods beyond the enter/exit pair.
  • Keep the block short and the teardown idempotent. The teardown runs once, on every exit path; write it so that is safe (closing an already-closed file is fine, double-freeing a socket is not).

Summary

A context manager is __enter__ (setup, returns the as target) plus __exit__ (teardown, runs on every exit path, returns True to suppress an exception). The with statement makes the release structural instead of remembered - the guarantee that a bare open/close pair lacks. @contextmanager builds a manager from a generator with a try/finally around the yield for the one-off case, and ExitStack handles dynamic counts. The design rule is the exception rule applied to resources: make anything with an acquire/release pair a context manager, return the resource from __enter__, default to letting exceptions through, and suppress only the failures you have decided are expected-and-fine.