Whetstone.
The Idioms You Will Meetwith, yield, and the two protocols underneath
Module 4 · Lesson 214 min

with, yield, and the two protocols underneath

Two pieces of syntax that are really protocols. Both appear everywhere in the code you are about to read, and both are things you can implement on your own types with two methods.

with: guaranteed cleanup, owned by the resource

The form you already recognise
with open("data.json") as f:
    payload = json.load(f)
# f is closed here, no matter how we left the block

with x as y: calls x.__enter__() and binds its return value to y. On leaving the block, by any path at all, it calls x.__exit__(exc_type, exc_value, traceback).

Every path: normal completion, return, break, or an unhandled exception. It is try/finally with the cleanup living on the object instead of at every call site.

You will meet these constantly:

Where you will see it
with open(path) as f: ...                          # files
with httpx.Client() as client: ...                 # connection pooling
with Session(engine) as session: ...               # database transactions
with tempfile.TemporaryDirectory() as d: ...       # scratch space, cleaned up
with lock: ...                                     # threading locks
with pytest.raises(ValueError): ...                # asserting a raise

That last one is a nice illustration of how general the protocol is: pytest.raises uses __exit__ to inspect the exception and fail the test if the expected one did not appear. __exit__ can return a truthy value to suppress the exception entirely, which is a real power and one almost nothing should use.

Writing one

The class form and the generator form
class Timer:
    def __enter__(self):
        self.start = time.monotonic()
        return self
    def __exit__(self, *exc):
        self.elapsed = time.monotonic() - self.start
        return False          # do not suppress

# or, far more often:
from contextlib import contextmanager

@contextmanager
def timer():
    start = time.monotonic()
    try:
        yield
    finally:
        print(f"took {time.monotonic() - start:.2f}s")

The generator form is the one you will read. Before the yield is setup, the yielded value is what as binds, after the yield is teardown. The try/finally is not optional decoration: without it, an exception in the block propagates straight out of the yield and the teardown never runs.

You can also stack them, and a trailing comma is legal in the parenthesised form:

Multiple managers, one block
with open(src) as fin, open(dst, "w") as fout:
    fout.write(fin.read())

yield: functions that pause

A generator
def read_lines(path):
    with open(path) as f:
        for line in f:
            yield line.strip()

A function containing yield is a generator function. Calling it runs none of the body: it builds a generator object and returns immediately. The body advances only when something asks for a value, runs until the next yield, hands the value over, and freezes there with its local state intact.

That laziness is the point. read_lines on a four-gigabyte file uses a few kilobytes of memory, and a consumer that stops after ten lines never reads the eleventh.

Consuming one
for line in read_lines("big.log"):     # pulls one at a time
    if "ERROR" in line:
        print(line)

lines = list(read_lines("small.log"))  # forces the whole thing
total = sum(1 for _ in read_lines(p))  # counts without materialising

The generator expression from module 1 is the same object with less ceremony: (x * 2 for x in nums) is lazy, single-use, and exactly what yield produces.

yield from, briefly

Delegating to another iterable
def all_lines(paths):
    for p in paths:
        yield from read_lines(p)

yield from delegates to another iterable and yields everything it produces. Without it you would write a nested loop that re-yields each item, which is what people do before they learn this exists.

Where you will meet both in agent code

Streaming is generators, and resource lifetimes are context managers.

Both, in the shape you will actually see
for chunk in graph.stream(state, config):   # a generator: chunks as they arrive
    render(chunk)

with checkpointer_context() as saver:       # a context manager: connection lifetime
    graph = builder.compile(checkpointer=saver)

The streaming APIs across LangChain and LangGraph are generators (and their async equivalents, which is the next lesson). Every connection-holding object in the ecosystem is a context manager, and using one outside a with is the standard way to leak a Postgres connection.

The iterator protocol underneath

Both of these are just dunders, which is the module 1 lesson arriving with consequences.

  • for x in thing calls thing.__iter__() to get an iterator, then __next__() repeatedly until StopIteration.
  • with thing calls __enter__ and __exit__.

Implement the methods and your own class works with the syntax. There is nothing to inherit and nothing to declare, which is why the standard library composes as freely as it does: zip, enumerate, sorted, sum, any, all and every function in itertools work on anything iterable, including things written after they were.

Where this goes

One idiom left, and it is the one with the most surface area: async. The syntax is identical to what you already write and the runtime underneath is meaningfully different from Node’s.

Practice

Try it yourself

Quiz

Leaving a with block via an exception

Code inside a with open(path) as f: block raises an exception that nothing catches. What happens to the file?

  1. AIt is closed only if that with statement is itself inside a try with a matching finally
  2. BIt stays open, since the exception unwinds the frame before the block's cleanup can be reached
  3. CIt is closed, but only when the garbage collector reclaims it, which is not deterministic timing
  4. DIt is closed. __exit__ runs on every exit path, including exceptions, before one propagates
Show answer

Correct answer: D — It is closed. __exit__ runs on every exit path, including exceptions, before one propagates

__exit__ is guaranteed on every exit path: normal completion, return, break, and an exception. It receives the exception details and can suppress the exception by returning true, though almost none do. This is precisely the guarantee try/finally gives you, packaged so the resource itself owns its cleanup rather than every call site remembering to write it. The garbage-collector-timing option describes CPython's refcounting, which does often close files promptly and is exactly why forgetting with appears to work in testing and then fails under load or on another implementation.

Quiz

When a generator body starts running

A function contains a yield. You call it and assign the result to a variable, but do not iterate it. How much of the body has executed?

  1. ANone. Calling it returns a generator object and the body runs only when iteration requests the first value
  2. BEverything up to the first yield, which is evaluated eagerly so the first value is ready on demand
  3. CThe whole body, with each yielded value collected into an internal buffer for the consumer to drain
  4. DNone, and the body is discarded entirely unless the generator is iterated within the same scope
Show answer

Correct answer: A — None. Calling it returns a generator object and the body runs only when iteration requests the first value

Nothing runs. Calling a generator function builds a generator object and returns immediately; the body advances only when something asks for a value, and it pauses again at each yield. This is why a generator whose first statement is expensive setup does that work at first-iteration time rather than at call time, which relocates where an exception surfaces and is the most common source of confusion about them. The eager-up-to-the-first-yield option is a very reasonable guess about how laziness might be implemented, and it is what a checked-eagerly design would do.

Quiz

Iterating a generator twice

You iterate a generator to build a list, then iterate the same generator object again in a second loop. What does the second loop see?

  1. AThe same items again, because iteration restarts from the beginning on each new for statement
  2. BNothing. The generator is exhausted, and iterating it again yields no items and raises no error
  3. CA RuntimeError naming the exhausted generator, which is what makes the mistake easy to catch
  4. DThe same items, but only if the underlying source is a list rather than a computed sequence
Show answer

Correct answer: B — Nothing. The generator is exhausted, and iterating it again yields no items and raises no error

A generator is a one-shot cursor, and re-iterating an exhausted one is silently empty. The silence is what makes this expensive: your second loop simply does not run, no error appears, and the symptom is missing output somewhere downstream. It is also why a function returning a generator and a function returning a list are not interchangeable even though both work in a for loop, and why sum(gen) followed by len(list(gen)) gives you a total and then zero.

Recall

Writing a context manager with a generator

The shortcut that removes the class ceremony, and the reason the two protocols end up in the same lesson.

How do you write a context manager without defining __enter__ and __exit__, and where exactly do setup, the block body, and teardown live?

Reveal answer

Decorate a generator function with @contextlib.contextmanager. Everything before the single yield is setup and runs on entry; the yielded value is what as binds; everything after the yield is teardown and runs on exit. To guarantee teardown when the block raises, the yield must sit inside a try with a finally, otherwise an exception propagating out of the block skips everything after the yield. That try-yield-finally shape is the whole idiom, and it is worth recognising that a generator is being used here as a coroutine that suspends exactly once, not as a sequence.

Check

Spot the resource that is not managed

Find a Python file that opens a file, a socket, a database session or an HTTP client. Check whether the acquisition is inside a with.

You should see

You can say for each one whether cleanup is guaranteed on the exception path, and if it is not, you can name what would still be held open when something upstream raises.

Sign in to track your progress →