Iterators
presented generators as a way to produce values lazily: a
function containing yield, driven by a
for loop that takes one value at a time. That is
half of what a generator does.
The other half is the return path. yield is an
expression, so a generator can receive a value as well as hand
one out, and it can return a final result when it finishes. Used
that way, a generator is not a sequence but a conversation. It
states what it needs, pauses, and continues once someone
answers.
This chapter covers the full three-channel annotation, the
loop that carries such a conversation, and
yield from, which composes generators without any
of them learning who drives. The next chapter builds an Effect
system on all three, and this chapter stands on its own.
Earlier examples annotate every generator with the short
Iterator form. That fits a generator that only
produces values.
A generator that also receives values needs the full annotation:
Generator[YieldType, SendType, ReturnType]
This names the three things a generator exchanges with its caller:
YieldType is the type yield hands
out, thus the type next(generator) returns.SendType is the type send()
accepts, thus the type the yield expression
produces inside the generator.ReturnType is the type of the generator’s
return value, which arrives as
StopIteration.value.The last two type parameters default to None. A
generator that only produces values can use either form:
# generator_defaults.py
from collections.abc import Generator, Iterator
def countdown(n: int) -> Generator[int]:
while n > 0:
yield n
n -= 1
def squares(n: int) -> Iterator[int]:
for i in range(n):
yield i * i
print(list(countdown(6)), list(squares(6)))
#: [6, 5, 4, 3, 2, 1] [0, 1, 4, 9, 16, 25]Generator[int] means
Generator[int, None, None].
Iterator[int] describes the same one-way generator
and reads better, at the cost of saying nothing about the other
two channels: a type checker rejects send() on
anything annotated Iterator. The long form is
necessary when the other two channels carry something, as they
do in this chapter.
This interview() generator yields a question,
receives an answer, and returns a result:
# interview_generator.py
from collections.abc import Generator
from typing import NewType
Question = NewType("Question", str)
Answer = NewType("Answer", str)
Result = NewType("Result", str)
def interview() -> Generator[Question, Answer, Result]:
# Ask the world for the name
name = yield Question("name")
# Ask the world for the town
town = yield Question("town")
friend = yield Question("friend") # Ask for a friend
return Result(f"{name} of {town}, friend {friend}")
if __name__ == "__main__":
i = interview()
question1: Question = next(i)
print(f"{question1 = }")
question2: Question = i.send(Answer("Alice"))
print(f"{question2 = }")
question3: Question = i.send(Answer("Wonderland"))
print(f"{question3 = }")
try:
i.send(Answer("Rabbit"))
except StopIteration as stop:
result: Result = stop.value
print(f"{result = }")
#: question1 = 'name'
#: question2 = 'town'
#: question3 = 'friend'
#: result = 'Alice of Wonderland, friend Rabbit'Although Generator[str, str, str] describes
interview() accurately, it does not say which
str is which. With NewType you can
give each channel a distinct type, so the annotation states the
arrangement and a type checker enforces it.
Question fills the YieldType position,
Answer the SendType, and
Result the ReturnType. The distinction
exists only for the type checker. Question("name")
produces the plain str.
Driving the generator by hand sends one Answer
at a time. next(i) starts the generator and
produces a Question.
i.send(Answer("Alice")) provides an answer and
produces the next question. That single expression carries both
directions of the channel. The last send() finds no
further yield, so the generator returns its
Result. A returning generator also raises
StopIteration, and the Result arrives
as that exception’s value. A for loop
never sees that value, because for catches the
StopIteration and discards it along with its
value. To read the ReturnType, catch
the exception yourself, as this listing does.
A newly created generator pauses at the top of the function
body, before any code runs, so no yield expression
is waiting to receive a value. The first call must therefore be
next(): i.send(Answer("Alice")) at
that point raises
TypeError: can't send non-None value to a just-started generator.
A suspended generator holds its frame: the position in the
body and every local variable. interview()
remembers name and town across two
send() calls with no storage of its own, because
resuming continues an existing computation rather than starting
a new one. The frame is the generator’s state.
next(i) is equivalent to
i.send(None):
# send_none_is_next.py
from interview_generator import interview
print(f"{interview().send(None) = }") # type: ignore
#: interview().send(None) = 'name'
print(f"{next(interview()) = }")
#: next(interview()) = 'name'Each interview() call creates a new generator,
so both lines start from the beginning and produce the first
question. The # type: ignore marks a real mismatch:
interview() declares Answer as its
SendType, and None is not an
Answer. The type checker rejects the priming
send() even though the interpreter accepts it. The
equivalence holds only at runtime, and the annotation has no way
to state it, so a driver primes with next().
The NewType definitions prevent accidental
transposition. If you mistakenly annotate the generator as
Generator[Answer, Question, Result],
ty reports nine errors in three groups of three.
All three yield Question(...) expressions offer a
Question where the annotation declares an
Answer. All three send(Answer(...))
calls pass an Answer where send()
expects a Question. All three question
variables receive an Answer where their
declarations say Question.
Generator[str, str, str] accepts the reversal
without complaint.
Effect
Management showed that calling an async def
function runs nothing. The call returns a coroutine: a
description of work. A coroutine’s annotation is
Coroutine[YieldType, SendType, ReturnType], the
same three-part shape as a Generator, and the match
is deliberate. async def and generator functions
both build descriptions that something else drives. Calling
interview() returns a generator object but runs
nothing in the function body. next() and
send() do that work, one yield at a
time.
A generator is the more useful of the two here because you
write the driver. A coroutine’s requests go to the event loop. A
generator’s go to whatever code calls send(). The
generator yields a value out, and the caller sends a value back
in. That conversation makes an Effect Management System
possible, the EMS of Effect
Management. The generator yields a request, and
whatever drives it supplies the answer. Typically, a
driver function steps the generator:
# two_way_generator.py
from collections.abc import Generator
from typing import Final
from interview_generator import (Answer, Question,
Result, interview)
ANSWERS: Final[dict[Question, Answer]] = {
Question("name"): Answer("Alice"),
Question("town"): Answer("Wonderland"),
Question("friend"): Answer("Rabbit"),
}
def drive(conversation: Generator[Question, Answer, Result],
answers: dict[Question, Answer]) -> Result:
request = next(conversation)
while True:
answer = answers[request]
print(f"{request = }, {answer = }")
try:
request = conversation.send(answer)
except StopIteration as stop:
return stop.value
if __name__ == "__main__":
conversation = interview()
print(f"{type(conversation)}: {conversation.__name__}") # type: ignore
result = drive(conversation, ANSWERS)
print(f"{result = }")
#: <class 'generator'>: interview
#: request = 'name', answer = 'Alice'
#: request = 'town', answer = 'Wonderland'
#: request = 'friend', answer = 'Rabbit'
#: result = 'Alice of Wonderland, friend Rabbit'The generator arrives by import, unchanged. Only the driver
is new. The first line of output describes what
interview() produced: an ordinary
generator object that still carries the function’s
name. That __name__ exists on the object at runtime
but not in the Generator type, so the
# type: ignore on that line suppresses the type
checker’s complaint.
drive() touches all three type parameters:
next() produces the first Question,
send()’s argument supplies the Answer,
and stop.value in the except clause
becomes the Result that drive()
returns. The answers map keys on
Question and holds Answers. Inside the
try, StopIteration means the
conversation finished, so only the send() call sits
there. Any other code that could raise it, such as an exhausted
answer source, belongs outside.
The type checker verifies only two of those three parameters.
StopIteration.value’s type is Any, so
a type checker accepts return stop.value no matter
what return type drive() declares. The
Result in drive()’s signature states
the intent. Nothing verifies it.
interview() does not know where the answers
originate. It has no dictionary, no input() call,
and no network connection. It states what it needs and waits.
drive() decides how to meet those needs, and it
takes the answers as a parameter. Swapping the dictionary for a
database changes a single argument.
That is an EMS in miniature. The generator declares Effects, the driver interprets them.
One generator, one driver. No annotation states that pairing,
but the runtime enforces it: a generator resumed from two
threads at once raises
ValueError: generator already executing rather than
interleaving. Concurrency
shows the failure and
threading.synchronized_iterator(), which serializes
the conversation.
yield from
Composes DescriptionsGenerators can carry an EMS because they nest.
yield from runs an inner generator to exhaustion,
passing every yielded request out to the outer driver and every
sent answer back down. Each of the three channels crosses that
boundary differently.
The simplest yield from targets generators that
only yield:
# yield_to_exhaustion.py
from collections.abc import Iterator
def one() -> Iterator[str]:
yield "only"
def three() -> Iterator[str]:
yield "A"
yield "B"
yield "C"
def outer() -> Iterator[str]:
yield "start"
yield from one()
yield from three()
yield "end"
def top() -> Iterator[str]:
yield "TOP"
yield from outer()
yield "END"
print(list(outer()))
#: ['start', 'only', 'A', 'B', 'C', 'end']
print(list(top()))
#: ['TOP', 'start', 'only', 'A', 'B', 'C', 'end', 'END']Each yield from runs its target until that
generator runs out, so the line delegating to one()
contributes one value and the line delegating to
three() contributes three. The target decides how
many values each delegation contributes. The from
is what delegates. Without it, yield one() would
hand the generator object itself to the driver as a single
value. “Exhausted” describes where the delegation ends, not when
the driver receives each value. Each value still leaves the
inner generator only when the driver asks for the next one.
Exhaustion is transitive. top() delegates to
outer(), which delegates to one() and
three(), and the driver still receives one flat
sequence. top()’s single yield from
finishes only after every generator beneath it has finished.
A yield from expression evaluates to the inner
generator’s return value, not its yielded values. The yielded
values pass through to whoever is driving. Here,
report() captures the return value from
yield from emit(items) into size.
report() returns nothing and only yields:
# yield_from_return.py
from collections.abc import Generator, Iterator
def emit(items: list[str]) -> Generator[str, None, int]:
total = 0
for item in items:
yield item
total += len(item)
return total
def report(items: list[str]) -> Iterator[str]:
size: int = yield from emit(items)
yield f"({size} characters)"
print(list(report(["red", "green", "blue"])))
#: ['red', 'green', 'blue', '(12 characters)']emit() is a
Generator[str, None, int]: it yields strings,
receives nothing, and returns the int total it
accumulates while iterating.
The return channel is how a generator reports to whichever
generator delegated to it, so report() learns
something emit() computed while neither of them
knows who is driving.
Any iterable can follow yield from, but only a
generator can answer with a value. A list has no return channel,
so v = yield from [1, 2, 3] yields the three items
and sets v to None.
The SendType is the type of the value a caller
sends back into the generator. A generator that receives values
but produces no final result needs no
ReturnType:
# yield_from_send.py
from collections.abc import Generator
def collect(name: str) -> Generator[str, int]:
first = yield f"{name} needs a value"
second = yield f"{name} needs another"
print(f"{name} got {first} and {second}")
def both() -> Generator[str, int]:
yield from collect("alpha")
yield from collect("beta")
g = both()
print(next(g))
#: alpha needs a value
for value in [1, 2, 3]:
print(g.send(value))
#: alpha needs another
#: alpha got 1 and 2
#: beta needs a value
#: beta needs another
try:
g.send(4)
except StopIteration:
print("both() is exhausted")
#: beta got 3 and 4
#: both() is exhaustedcollect() yields prompts, receives numbers, and
returns nothing, so its type is
Generator[str, int, None]. An omitted
ReturnType defaults to None, so the
annotation shortens to Generator[str, int].
both() declares that same type, because
yield from passes the inner generator’s yield and
send channels through to the driver.
The numbers travel down to the yield that asked
for them. g.send(1) arrives inside
collect("alpha"), two frames below the driver.
both() needs no forwarding code of its own, because
yield from does the forwarding.
g.send(2) supplies alpha’s second value, which
lets collect("alpha") finish. That finish completes
the first yield from, so both() starts
the second. A single send() therefore ends one
inner generator and produces the first prompt of the next. The
driver sees StopIteration only when
both() runs out of delegations.
Writing the loop by hand is the natural first attempt, and it fails quietly:
# manual_forwarding.py
from collections.abc import Generator
def collect(name: str) -> Generator[str, int]:
first = yield f"{name} needs a value"
second = yield f"{name} needs another"
print(f"{name} got {first} and {second}")
def manual() -> Generator[str, int]:
for prompt in collect("alpha"): # noqa: UP028
yield prompt
g = manual()
print(next(g))
#: alpha needs a value
try:
for value in [1, 2, 3]:
print(g.send(value))
except StopIteration:
print("manual() is exhausted")
#: alpha needs another
#: alpha got None and None
#: manual() is exhaustedEach send() delivers its value to
manual()’s own yield, which throws it
away. The for loop then resumes
collect() with next(), so both of
collect()’s yield expressions produce
None. The type checker says nothing, because
manual() is a valid
Generator[str, int]: the send channel appears in
the declaration and goes unused. yield from is not
shorthand for this loop.
yield from restructures the
interview() example:
# yield_from_delegates.py
from collections.abc import Generator
from interview_generator import Answer, Question, Result
from two_way_generator import ANSWERS, drive
def ask(
question: Question
) -> Generator[Question, Answer, Answer]:
answer = yield question
print(f"ask({question = }) -> {answer = }")
return answer
def interview() -> Generator[Question, Answer, Result]:
name: Answer = yield from ask(Question("name"))
town: Answer = yield from ask(Question("town"))
friend: Answer = yield from ask(Question("friend"))
return Result(f"{name} of {town}, friend {friend}")
if __name__ == "__main__":
print(drive(interview(), ANSWERS))
#: request = 'name', answer = 'Alice'
#: ask(question = 'name') -> answer = 'Alice'
#: request = 'town', answer = 'Wonderland'
#: ask(question = 'town') -> answer = 'Wonderland'
#: request = 'friend', answer = 'Rabbit'
#: ask(question = 'friend') -> answer = 'Rabbit'
#: Alice of Wonderland, friend Rabbitdrive() never learns that ask()
exists. Only the generator portion changed.
ask() uses Answer in two of the
three positions, for two different reasons. As the
SendType it is the value the driver sends in, which
arrives as the value of the yield expression and
binds to answer. As the ReturnType it
is the value ask() hands back when it finishes,
which yield from produces as the value of the whole
yield from expression. The inner generator asks one
question and hands back one answer, so both channels carry an
Answer. interview() keeps
Result as its ReturnType, because the
sentence it builds from three answers is not an answer to any
one question.
The trace shows both directions of travel. A request yielded
two frames down inside ask() surfaces at
drive(), which knows nothing about where it
originated. The answer drive() sends back arrives
inside ask(), which also knows nothing about where
it originated. A single loop at the edge of the program
interprets Effects yielded anywhere inside it.
yield from also returns the inner generator’s
value, and that is why name and town
read like ordinary assignments.
drive() and yield from both step a
generator and both finish at StopIteration, so they
are easy to confuse. Delegation can take over the job the
previous listing gave to drive():
# yield_from_nested.py
from collections.abc import Generator
from interview_generator import Answer, Question, Result
from two_way_generator import ANSWERS, drive
from yield_from_delegates import ask, interview
def survey() -> Generator[Question, Answer, Result]:
profile: Result = yield from interview()
color: Answer = yield from ask(Question("color"))
return Result(f"{profile}, color {color}")
print(drive(survey(),
ANSWERS | {Question("color"): Answer("blue")}))
#: request = 'name', answer = 'Alice'
#: ask(question = 'name') -> answer = 'Alice'
#: request = 'town', answer = 'Wonderland'
#: ask(question = 'town') -> answer = 'Wonderland'
#: request = 'friend', answer = 'Rabbit'
#: ask(question = 'friend') -> answer = 'Rabbit'
#: request = 'color', answer = 'blue'
#: ask(question = 'color') -> answer = 'blue'
#: Alice of Wonderland, friend Rabbit, color blueinterview() arrives unchanged from the previous
example. It was the generator drive() drove. Now
survey() delegates to it. Its Result
arrives as the value of an expression instead of as
stop.value in the driver, and its questions surface
three frames up rather than two. The driver sees one more
question and the same shape of trace. survey() asks
about a color, so the call merges one more pair into
ANSWERS with the dictionary union operator.
yield from replaces drive() as the
consumer of interview(), but not as its runner.
Something must still call next() and
send() at the top, and that is why the example ends
with a drive() call. However deep you stack
delegations, the number of drivers stays at one.
drive() and yield from differ in
how they respond to a request. drive() answers it.
A Question comes out, the driver looks it up, and
the request stops there. yield from answers
nothing. It relays the request upward and passes the reply back
down untouched, so survey() has no idea what a
Question means. A driver can also
throw() an exception into a generator or
close() it, and yield from relays
both: a thrown exception surfaces inside the innermost generator
rather than at the delegating one, and a close()
unwinds every frame in the chain. A
Basic Context Manager showed this already, without naming
it: “Python resumes the generator by raising the block’s
exception at the yield.” throw() is
that same resumption, called directly instead of by a
with block:
# throw_and_close.py
from collections.abc import Generator
def worker() -> Generator[str]:
try:
yield "ready"
yield "still going"
except ValueError as e:
print(f"caught: {e}")
yield "recovered"
finally:
print("cleanup")
g = worker()
print(next(g))
#: ready
print(g.throw(ValueError("bad input")))
#: caught: bad input
#: recovered
g.close()
#: cleanupg.throw(ValueError("bad input")) raises that
exception at the suspended yield, inside
worker()’s frame, the same way the
with block’s exception did. worker()
catches it, prints, and yields again, so the generator survives
a throw() its except clause handles.
g.close() raises GeneratorExit at the
yield the generator now waits on,
yield "recovered". worker() has no
matching except, so GeneratorExit
passes through, the finally block runs, and the
generator ends. Nothing prints the GeneratorExit
itself, because close() swallows it once the
generator finishes.
A generator can catch GeneratorExit and yield
again instead of letting it end the frame. Doing so breaks
close():
# throw_and_close_gotcha.py
from collections.abc import Generator
from exceptions import expect
def stubborn() -> Generator[str]:
try:
yield "go"
except GeneratorExit:
yield "not done"
s = stubborn()
print(next(s))
#: go
expect(RuntimeError, s.close)
#: [RuntimeError] generator ignored GeneratorExitclose() expects the generator to stop.
stubborn() instead answers
GeneratorExit with another yield, so
close() raises
RuntimeError: generator ignored GeneratorExit
rather than returning quietly. A driver that abandons a live
generator shuts it down with close(), so a
generator meant to be driven by others must let
GeneratorExit end it.
StopIteration divides drive() and
yield from along that same line. Both catch it and
both take stop.value, but they hand that value to
different places. drive() returns the
Result to its own caller, ending the conversation.
yield from feeds it to the enclosing generator as
the value of the expression, after which that generator keeps
running.
yield from composes descriptions and a driver
interprets them. A program can hold any number of descriptions
and needs one driver, at its outermost edge.
Three ideas from this chapter carry into the next one. A
generator function builds a description instead of doing work.
yield makes that description two-way, so the
description can ask for something. yield from
composes those conversations without any participant learning
who drives.
Those ideas are enough to build a task runner: register each generator with a decorator, keep the live ones in a queue, and take turns:
# task_runner.py
from collections import deque
from collections.abc import Callable, Iterator
type Job = Callable[[], Iterator[str]]
ready: deque[Iterator[str]] = deque()
def task(fn: Job) -> Job:
ready.append(fn())
return fn
@task
def download() -> Iterator[str]:
for part in ("headers", "body", "checksum"):
yield f"download: {part}"
@task
def index() -> Iterator[str]:
yield "index: build"
yield "index: merge"
def task_runner() -> None:
while ready:
job = ready.popleft()
try:
print(next(job))
except StopIteration:
continue # Finished: never requeued
ready.append(job)
task_runner()
#: download: headers
#: index: build
#: download: body
#: index: merge
#: download: checksum@task calls each generator function once at
definition time, queues the generator it builds, and hands the
function back unchanged, the registering-decorator shape from Decorators.
task_runner() gives the front task one
next() per turn. A task that yields moves to the
back of the queue. One that finishes raises
StopIteration and never rejoins the queue. The
output interleaves the two tasks, though neither mentions the
other and no threads exist. Each yield is a task
agreeing to pause so the others can run.
task_runner() only ever calls
next(), so it takes turns without answering
anything. Giving each job a question closes the loop:
turn-taking and question-answering, together:
# task_runner_send.py
from collections import deque
from collections.abc import Callable, Generator
type Job = Callable[[], Generator[str, str]]
ready: deque[Generator[str, str]] = deque()
to_send: dict[Generator[str, str], str | None] = {}
def task(fn: Job) -> Job:
job = fn()
ready.append(job)
to_send[job] = None
return fn
def answer(request: str) -> str:
return f"answer to {request}"
@task
def download() -> Generator[str, str]:
reply = yield "download: headers?"
print(f"download: {reply}")
yield "download: checksum"
@task
def index() -> Generator[str, str]:
yield "index: build"
yield "index: merge"
def task_runner() -> None:
while ready:
job = ready.popleft()
try:
request = job.send(to_send.pop(job)) # type: ignore
except StopIteration:
continue
print(request)
to_send[job] = answer(request)
ready.append(job)
task_runner()
#: download: headers?
#: index: build
#: download: answer to download: headers?
#: download: checksum
#: index: mergeto_send holds what each job’s next turn will
receive: None until the runner has answered that
job’s most recent request.
job.send(to_send.pop(job)) primes a fresh job the
same way next(job) did, since
send(None) and next() are equivalent,
and delivers the runner’s answer on every later turn.
Job’s SendType is str,
not str | None, so the priming call needs the
# type: ignore from send_none_is_next.py
again: the type checker cannot see that
to_send.pop(job) is None only on a
generator’s first turn. download() reads what it
receives, into reply. index()’s
yield statements ignore what they receive; a task
that only takes turns is free to ignore the send channel. The
queue still rotates task to task, and now the runner also plays
drive()’s part, answering each request before the
next turn.
You have run a driver like drive() many times.
Concurrency
presented await and the event loop as a way to
overlap waiting, and left the mechanism alone. The mechanism is
the two halves task_runner_send.py just
combined: task_runner()’s turn-taking and
drive()’s question-answering, in one loop. A
coroutine object offers send(),
throw(), and close(), as a generator
does. await suspends the coroutine and hands a
request out to the loop, which supplies the answer once it has
one and resumes the coroutine by sending it back.
asyncio.run() is the single interpreter at the edge
of the program. That is why an await in a function
makes every caller async in turn: the requests must
reach the loop.
Once you see a program that way, the question stops being what a function does and becomes what it requests. That is the question the next chapter puts into the type system.
tally(), a generator that yields a prompt
string, receives an int for each prompt, and
returns the total once it has three. Give it the full
three-parameter annotation, then drive it by hand with
next() and send() and read the total
off StopIteration.drive() answers from a dict. Write
a second driver that answers from an
Iterator[Answer], in order, and run
interview() under both. Explain what, if anything,
needed to change in interview(), and why. Give your
driver fewer answers than questions and say what it returns.
StopIteration now means two different things in the
same loop. Keep them apart.yield_from_send.py after
adding a third yield from collect("gamma") to
both() and extending the loop to
[1, 2, 3, 4, 5]. Write down the sequence of printed
lines before running it.yield from in yield_from_nested.py,
leaving profile: Result = interview(). Run
ty check and the script, and explain both results.
Which one told you more, and what would the type checker have
said if profile carried no annotation?report() in yield_from_return.py
yields but does not return. Rewrite it to also return the
character count, and give it the full annotation. Then write a
caller that delegates to it with yield from and
yields that count in a line of its own, and say which type
parameter each of the two values traveled through.next()
rather than send(None), given that the two are
equivalent at runtime. send_none_is_next.py has
the answer. State it in terms of the SendType.send(), so the
position in the generator’s body carries the state. This
generator’s yield reports the state the machine
reached rather than requesting something the machine needs, the
opposite direction from interview(). Say which of
the two versions you would rather extend with another state, and
why.