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
with open("data.json") as f:
payload = json.load(f)
# f is closed here, no matter how we left the blockwith 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:
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 raiseThat 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
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:
with open(src) as fin, open(dst, "w") as fout:
fout.write(fin.read())yield: functions that pause
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.
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 materialisingThe 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
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.
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 thingcallsthing.__iter__()to get an iterator, then__next__()repeatedly untilStopIteration.with thingcalls__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.
Try it yourself
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?
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.
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?
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.
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?
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.
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.
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 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.