The with statement — guaranteed cleanup
with open(path) as f: opens a file and guarantees it's closed when the block exits — exception or not. The mechanism: the object after with is a context manager, and Python calls its __enter__ on entry and __exit__ on exit. Cleanup is the killer use case, but it's not the only one — anything where you need "set up, then tear down regardless" can be a context manager.
The protocol — __enter__ returns the resource, __exit__ cleans up
A class becomes a context manager by defining __enter__(self) (returns the value bound by as) and __exit__(self, exc_type, exc_value, tb) (cleanup; receives exception info if there was one). If __exit__ returns truthy, the exception is suppressed; if falsy or None, it propagates.
Multiple contexts in one with
with open(a) as fa, open(b) as fb: opens both, closes both. The grammar handles arbitrary nesting flat. Order matters — they're entered left-to-right, exited right-to-left. If the second open raises, the first file is still cleanly closed.
What context managers replace
Anything that used to be try: ... finally: cleanup(). File handles, locks, database transactions, temporary chdir, redirecting stdout, opening connections. The finally pattern is correct; the context manager pattern is just a more idiomatic, more reusable, less error-prone way to spell it.
try: ... finally: cleanup() — and the cleanup logic is reusable — write a context manager. The next caller (which might be future-you) gets the cleanup automatically without having to remember.