tally(), driven by
handWrite
tally(), a generator that yields a prompt string, receives anintfor each prompt, and returns the total once it has three. Give it the full three-parameter annotation, then drive it by hand withnext()andsend()and read the total offStopIteration.
Annotating
a Generator names the three parameters of
Generator: what it yields, what it receives, and
what it returns. Give each channel its own type so that a swap
is a type error. After the third send(), the
generator finishes, and the total is the value of
the StopIteration that send()
raises.
# The shape of exercise_1.py
from collections.abc import Generator
from typing import NewType
def tally() -> Generator[Prompt, Amount, Total]:
...If you read the total as the result of the last
send(), writing
total: Total = t.send(Amount(12)) with no
try, the script prints the three prompts and then
stops with StopIteration: 42. ty
rejects that line before it runs: send() returns
the YieldType, so it reports an
invalid-assignment of a Prompt to a
Total. A generator’s return value arrives only on
its StopIteration, so the solution catches that
exception and reads its value.
# exercise_1.py
from collections.abc import Generator
from typing import NewType
Prompt = NewType("Prompt", str)
Amount = NewType("Amount", int)
Total = NewType("Total", int)
def tally() -> Generator[Prompt, Amount, Total]:
total = 0
for n in (1, 2, 3):
amount = yield Prompt(f"amount {n} of 3")
total += amount
return Total(total)
t = tally()
print(next(t))
#: amount 1 of 3
print(t.send(Amount(10)))
#: amount 2 of 3
print(t.send(Amount(20)))
#: amount 3 of 3
try:
t.send(Amount(12))
except StopIteration as stop:
total: Total = stop.value
print(total)
#: 42Keep the three channels apart. The
three-parameter annotation names all three channels:
Generator[Prompt, Amount, Total] says this
generator yields a Prompt, receives an
Amount, and finally returns a Total.
Three NewType definitions over str,
int, and int keep the two integer
channels apart, so transposing the SendType and the
ReturnType is a type checker error rather than a
bug that shows up in arithmetic.
Drive the conversation by hand. Driving
tally() by hand takes four calls: one
next() and three sends. next(t) runs
the body up to the first yield and produces the
first prompt. Each send() resumes at that suspended
yield, whose value becomes amount,
then runs to the next one. The third send() finds
no fourth yield, so the loop ends and
tally() returns. The return value arrives as
StopIteration’s value rather than as
the result of send().
total is the generator’s own local, and it
survives across three suspensions with no storage anywhere else.
The frame is the state.
drive()answers from adict. Write a second driver that answers from anIterator[Answer], in order, and runinterview()under both. Explain what, if anything, needed to change ininterview(), and why. Give your driver fewer answers than questions and say what it returns.StopIterationnow means two different things in the same loop. Keep them apart.
A
Generator Is a Description explains why
interview() does not care who answers it. Write the
new driver as a loop that fetches the next answer, then calls
send(). Both the answer iterator and the generator
signal the end with StopIteration, so choose
carefully which call sits inside the try.
# The shape of exercise_2.py
from collections.abc import Generator, Iterator
from typing import NewType
def interview() -> Generator[Question, Answer, Result]:
...
def drive_from_dict(
conversation: Generator[Question, Answer, Result],
answers: dict[Question, Answer]) -> Result:
...
def drive_naive(
conversation: Generator[Question, Answer, Result],
answers: Iterator[Answer]) -> Result:
...
def drive_in_order(
conversation: Generator[Question, Answer, Result],
answers: Iterator[Answer]) -> Result:
...# exercise_2.py
from collections.abc import Generator, Iterator
from typing import NewType
Question = NewType("Question", str)
Answer = NewType("Answer", str)
Result = NewType("Result", str)
def interview() -> Generator[Question, Answer, Result]:
name = yield Question("name")
town = yield Question("town")
friend = yield Question("friend")
return Result(f"{name} of {town}, friend {friend}")
def drive_from_dict(
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
def drive_naive(
conversation: Generator[Question, Answer, Result],
answers: Iterator[Answer]) -> Result:
request = next(conversation)
while True:
try: # Both next() calls share one except clause
reply = next(answers)
print(f"{request = }, {reply = }")
request = conversation.send(reply)
except StopIteration as stop:
return stop.value
def drive_in_order(
conversation: Generator[Question, Answer, Result],
answers: Iterator[Answer]) -> Result:
request = next(conversation)
while True:
# Fetched outside the try on purpose
reply = next(answers)
print(f"{request = }, {reply = }")
try:
request = conversation.send(reply)
except StopIteration as stop:
return stop.value
by_name = {
Question("name"): Answer("Alice"),
Question("town"): Answer("Wonderland"),
Question("friend"): Answer("Rabbit"),
}
print(drive_from_dict(interview(), by_name))
#: request = 'name', answer = 'Alice'
#: request = 'town', answer = 'Wonderland'
#: request = 'friend', answer = 'Rabbit'
#: Alice of Wonderland, friend Rabbit
in_order = iter([Answer("Alice"), Answer("Wonderland"),
Answer("Rabbit")])
print(drive_in_order(interview(), in_order))
#: request = 'name', reply = 'Alice'
#: request = 'town', reply = 'Wonderland'
#: request = 'friend', reply = 'Rabbit'
#: Alice of Wonderland, friend Rabbit
# One answer, three questions:
try:
drive_in_order(interview(), iter([Answer("Alice")]))
except StopIteration:
print("answer source ran out")
#: request = 'name', reply = 'Alice'
#: answer source ran out
print(repr(drive_naive(interview(),
iter([Answer("Alice")]))))
#: request = 'name', reply = 'Alice'
#: NoneReuse the generator unchanged. Nothing in
interview() changes, and nothing could have. It
yields a Question and receives an
Answer without asking about the answer’s source.
That is the separation the chapter teaches: the generator
describes the conversation, and the driver interprets it.
Swapping one interpreter for another leaves the description
untouched.
Find the answer for each request. The two drivers differ in the property on which they rely. The dictionary driver looks each answer up by the request, so it answers correctly in whatever order the questions arrive, and it answers a repeated question the same way twice. The iterator driver goes by position, so it depends on the generator asking the questions for which the driver has replies, in that order. Both satisfy the same type. The type says what travels, not what the driver knows.
Keep the two endings apart. One detail in
drive_in_order() earns its comment.
next(answers) sits outside the try
because the except StopIteration meant for the
conversation otherwise catches a StopIteration
raised by an exhausted answer list. Two different iterators
raising one exception type is a real hazard when a driver holds
both.
Test a short answer source. The last two
runs show that hazard. Given one answer and three questions,
drive_in_order() lets the
StopIteration escape, so the caller learns the
answer source ran dry. drive_naive() differs only
in having next(answers) inside the
try. It catches that same exception, reads it as
“the conversation finished,” and returns
stop.value, which is None.
None is the wrong answer twice over. The
interview did not finish, so no Result exists, and
None is not a Result in any case.
Nothing catches the mistake: StopIteration.value
has type Any, so return stop.value
satisfies a declared Result and the checker reports
nothing. The failure is silent at the type checker and silent at
runtime. It surfaces later as a None where the
caller expects a string, far from the driver that produced
it.
Keeping the two meanings apart is a one-line discipline: put
inside the try only the call whose
StopIteration you mean to interpret.
yield_from_send.pyPredict the output of
yield_from_send.pyafter adding a thirdyield from collect("gamma")toboth()and extending the loop to[1, 2, 3, 4, 5]. Write down the sequence of printed lines before running it.
The
Send Channel follows each sent value to the delegated
generator suspended at that moment. Count how many values one
collect() consumes, then count how many the loop
supplies in total. Write the printed lines before you run the
script, and note which sends produce one line and which produce
two.
# The shape of exercise_3.py
from collections.abc import Generator
def collect(name: str) -> Generator[str, int]:
...
def both() -> Generator[str, int]:
...# exercise_3.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")
yield from collect("gamma")
g = both()
print(next(g))
#: alpha needs a value
for value in [1, 2, 3, 4, 5]:
print(g.send(value))
#: alpha needs another
#: alpha got 1 and 2
#: beta needs a value
#: beta needs another
#: beta got 3 and 4
#: gamma needs a value
#: gamma needs another
try:
g.send(6)
except StopIteration:
print("both() is exhausted")
#: gamma got 5 and 6
#: both() is exhaustedCount the values the collectors need. The
five sends do not divide evenly among three collectors. Each
collect() consumes two values, so the three
collectors need six, and the loop supplies five.
gamma stays suspended at its second
yield until the send(6) after the loop
completes it.
Read the output in pairs. The pairs of lines
are the tell. A send that supplies a collector’s first value
produces one line, the same collector’s second prompt. A send
that supplies a collector’s second value produces two: the
completed collector’s print(), then the first
prompt of the next collector. The two-line sends are
send(2), send(4), and
send(6), so the output alternates between one-line
and two-line responses all the way down. The second line of
send(6)’s pair is the exception: no collector
remains to prompt, so gamma finishes,
both() raises StopIteration, and the
except prints both() is exhausted.
both() takes no part in that alternation. It
contains three yield from statements and no code
that forwards a value. yield from relays in both
directions on its own: prompts up to the driver, numbers down to
whichever yield is currently suspended, two frames
below.
yield fromRemove
yield frominyield_from_nested.py, leavingprofile: Result = interview(). Runty checkand the script, and explain both results. Which one told you more, and what does the type checker say ifprofilecarries no annotation?
Composing
Is Not Interpreting shows survey() delegating
to interview() with yield from.
Without it, the call builds a generator object, and nothing
drives it. Compare the declared type of profile
with what the call produces, then compare the type checker’s
report with the script’s output.
def survey() -> Generator[Question, Answer, Result]:
profile: Result = interview()
color: Answer = yield from ask(Question("color"))
return Result(f"{profile}, color {color}")ty rejects it:
error[invalid-assignment]: Object of type
`Generator[Question, Answer, Result]` is not assignable to `Result`
--> yield_from_nested.py:8:23
|
8 | profile: Result = interview()
| ------ ^^^^^^^^^^^ Incompatible value of type
| | `Generator[Question, Answer, Result]`
| Declared type
The script still runs, and produces:
request = 'color', answer = 'blue'
ask(question = 'color') -> answer = 'blue'
<generator object interview at 0x000001C5FB3FDE40>, color blue
Both results describe the same mistake: calling a generator
function produces a description rather than a conversation.
interview() builds a generator object and stops.
Nothing calls next() or send() on that
object, so the generator asks none of its three questions. The
final line interpolates the object’s repr into the sentence
where an answer belonged.
The type checker told you more. Its message names the two
types and points at the assignment that mismatches them, and
that assignment is the defect. The runtime output shows a
consequence three steps downstream, at the one place the
f-string formats the object. A reader must work backward from a
<generator object ...> in a report to the
missing yield from. Worse, the failure is quiet: no
exception, an exit code of zero, and output that a log scraper
would happily accept.
Without the annotation, the type checker says nothing. For
profile = interview() it infers the expression’s
own type, Generator[Question, Answer, Result], and
no declared type contradicts that inference. The f-string then
accepts any object, since formatting one calls
str() on it, and a generator object’s
str() is its repr. The annotation does the whole of
the work here. That is the argument for annotating a local whose
value comes from a call whose return type you want to pin
down.
report()
with a return value
report()inyield_from_return.pyyields but returns nothing. Rewrite it to return the character count as well, and give it the full annotation. Then write a caller that delegates to it withyield fromand yields that count in a line of its own, and say which type parameter carries each of the two values.
The
Return Channel shows how yield from delivers a
delegate’s return value to the delegating generator.
Iterator[str] cannot declare a return value, so
report() needs the full Generator
annotation. The caller binds the count with
yield from, then yields it as a string of its
own.
# The shape of exercise_5.py
from collections.abc import Generator
def emit(items: list[str]) -> Generator[str, None, int]:
...
def report(items: list[str]) -> Generator[str, None, int]:
...
def summarize(items: list[str]) -> Generator[str]:
...If you change report()’s annotation but leave
out return size, the script still runs, and the
printed list ends with 'total: None':
report() returns None, and the second
yield from delivers that None into
counted. ty reports an
invalid-return-type on the annotation, because with
no return statement report() always
implicitly returns None, and its annotation
declares an int return value. The solution returns
size from report(), so the count
reaches summarize() through a second return.
# exercise_5.py
from collections.abc import Generator
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]) -> Generator[str, None, int]:
size: int = yield from emit(items)
yield f"({size} characters)"
return size
def summarize(items: list[str]) -> Generator[str]:
counted: int = yield from report(items)
yield f"total: {counted}"
print(list(summarize(["red", "green", "blue"])))
#: ['red', 'green', 'blue', '(12 characters)', 'total: 12']Declare the return channel.
report()’s annotation changes from
Iterator[str] to
Generator[str, None, int], because a generator that
returns something needs the long form. Iterator
sets the ReturnType to None, so
ty rejects report()’s own
return size with expected None, found
int.
Keep the strings and the count apart. The
strings and the count travel by different channels, and the
listing shows both at once. Every string that
emit() or report() yields travels
through the YieldType and comes out in the list.
The count travels through the ReturnType:
emit() returns it, yield from delivers
it into report()’s size,
report() returns it again, and the second
yield from delivers it into
summarize()’s counted. The
SendType is None throughout, since
nobody sends anything in.
12 therefore reaches summarize()
through two returns and is not yielded on its own.
'(12 characters)' takes the other route:
report() yields it, the yield from in
summarize() relays it, and the driver receives it.
No generator binds it to a name. The same number can travel
either way, and the choice decides who can see it: a yielded
value goes to the driver, a returned value goes to the
delegating generator.
next()Explain why a driver must prime with
next()rather thansend(None), given that the two are equivalent at runtime.send_none_is_next.pyhas the answer. State it in terms of theSendType.
Annotating
a Generator defines the SendType, and send_none_is_next.py calls
send(None) on a generator whose
SendType is not None. Read the
signature of send() and ask what it accepts for
interview(). Then ask what next()
accepts, and whether a generator that has not yet run has
anywhere to put a sent value.
next(g) and g.send(None) do the
same thing at runtime, and the SendType is where
they stop being interchangeable.
send()’s signature is
send(self, value: _SendT_contra, /) -> _YieldT_co,
so its parameter type is whatever the generator’s
SendType is. For interview() that is
Answer, and None is not an
Answer, so the priming call is a type error. send_none_is_next.py
carries a # type: ignore to suppress it. Removing
that comment draws:
error[invalid-argument-type]: Argument to bound method
`Generator.send` is incorrect
--> send_none_is_next.py:4:27
|
4 | print(f"{interview().send(None) = }")
| ^^^^ Expected `Answer`, found `None`
next() takes no such argument. It asks for the
generator’s next yielded value and has nothing to say about the
SendType, so priming with it type-checks for any
generator whatsoever.
The mismatch is real rather than a type checker limitation. A
generator’s SendType describes what a suspended
yield expression can receive, and a just-started
generator has no suspended yield, so nothing
receives the value handed to the first send(). The
runtime enforces the same rule from the other side:
send() with a non-None value on a
fresh generator raises
TypeError: can't send non-None value to a just-started generator.
So the first call is special in both directions, and
None is the only value it accepts.
An annotation cannot express “None for the first
call, Answer afterward,” because a single
SendType covers every call. Widening the
SendType to Answer | None states the
exception in the type, and every yield expression
whose value the generator uses as an Answer must
then handle a None that arrives only once.
interview() puts what it receives into an f-string,
which formats a None as readily as an
Answer, so interview() passes the
check either way. Priming with next() sidesteps the
whole question: the one call that cannot carry a value comes
from the one function that cannot pass one.
A Vending Machine keeps its current state in an attribute and looks up each transition in a table. Write a simplified version as a single generator instead. It collects money, takes two digits, then dispenses or refuses. It yields its current state and receives each event with
send(), so the position in the generator’s body carries the state. This generator’syieldreports the state the machine reached rather than requesting something the machine needs, the opposite direction frominterview(). Say which of the two versions you would rather extend with another state, and why.
A
Vending Machine holds its state in an attribute and a
transition table. Here the position inside the generator’s body
is the state, so use loops and isinstance() checks
on the event that yield returns. Because
yield reports the state reached, the driver’s
send() delivers events rather than answers.
# The shape of exercise_7.py
from collections.abc import Generator
from typing import Final, Literal
from record import record
@record
class Coin:
cents: int
@record
class Digit:
value: str
type Event = Coin | Digit
type Report = Literal[
"QUIESCENT", "COLLECTING", "SELECTING",
"UNAVAILABLE", "WANT_MORE", "DISPENSED"]
PRICES: Final[dict[str, int]] = {"11": 25, "12": 75}
STOCK: Final[dict[str, int]] = {"11": 0, "12": 3}
def machine() -> Generator[Report, Event]:
...If you write stock = STOCK instead of copying
with dict(STOCK), each sale changes the
module-level table from which every machine starts. After three
"12" sales, by any mix of machines, a fresh
machine() answers the next "12" order
with UNAVAILABLE. The Final annotation
forbids rebinding the name, not changing the dictionary, so the
type checker passes the shared version. The solution copies
STOCK into a local, so each generator’s frame holds
its own stock.
# exercise_7.py
from collections.abc import Generator
from typing import Final, Literal
from record import record
@record
class Coin:
cents: int
@record
class Digit:
value: str
type Event = Coin | Digit
type Report = Literal[
"QUIESCENT", "COLLECTING", "SELECTING",
"UNAVAILABLE", "WANT_MORE", "DISPENSED"]
PRICES: Final[dict[str, int]] = {"11": 25, "12": 75}
STOCK: Final[dict[str, int]] = {"11": 0, "12": 3}
def machine() -> Generator[Report, Event]:
stock = dict(STOCK)
amount = 0
event: Event = yield "QUIESCENT"
while True:
while isinstance(event, Coin):
amount += event.cents
event = yield "COLLECTING"
row = event.value # Not a Coin, so a first Digit
second = yield "SELECTING"
# A coin instead of a digit
if isinstance(second, Coin):
amount += second.cents
event = yield "COLLECTING"
continue
code = row + second.value
if stock.get(code, 0) == 0:
event = yield "UNAVAILABLE"
elif amount < PRICES.get(code, 0):
event = yield "WANT_MORE"
else:
amount -= PRICES[code]
stock[code] -= 1
event = yield "DISPENSED"
m = machine()
print(next(m))
#: QUIESCENT
for event in [Coin(25), Digit("1"), Digit("1"), Digit("1"),
Digit("2"), Coin(50), Digit("1"), Digit("2")]:
print(f"{event} -> {m.send(event)}")
#: Coin(cents=25) -> COLLECTING
#: Digit(value='1') -> SELECTING
#: Digit(value='1') -> UNAVAILABLE
#: Digit(value='1') -> SELECTING
#: Digit(value='2') -> WANT_MORE
#: Coin(cents=50) -> COLLECTING
#: Digit(value='1') -> SELECTING
#: Digit(value='2') -> DISPENSEDLet position carry the state. The machine
holds no state attribute and consults no table.
Where the generator pauses is the state: paused in the coin loop
means COLLECTING, paused after yield "SELECTING"
means a first digit has arrived and the machine waits for a
second. amount, row, and
stock are locals that survive because the frame
does. This version has no counterpart for two parts of the
table-driven version: the state attribute and the transition
lookup.
Report the state reached. The
yield in machine() runs the opposite
direction from the one in interview(), and neither
signature says so. Both yield strings, but
interview() yields a request the driver must
satisfy, while machine() yields a report the driver
may ignore. The driver’s event and the machine’s report travel
independently: send(Coin(25)) answers no question,
it delivers an event. A generator’s type describes the traffic,
not who is in charge. Both arrangements fit the same
Generator annotation.
For another state, take the table. The generator’s
compactness comes from the states forming a line, so control
flow can express the sequence. The two states here that break
the line cost something: an if chain reaches
UNAVAILABLE and WANT_MORE, and each
one returns by looping back to the top, a goto
written as a while True. Now add a state reachable
from three others, the way the table handles Quit
from every state but QUIESCENT. No position in the
body corresponds to it. The new state becomes a flag, or a check
repeated at several yields, and either one breaks
the correspondence between position and state, the one thing
that makes this version readable.
The table pays a fixed cost instead. Adding a state means one
new Enum member and a few new rows. Those rows sit
next to the existing ones, where you can read the whole machine
at once. The generator is the better choice for a conversation
with a beginning and an end, like interview(). The
table is the better choice for a machine that runs forever and
can go anywhere from anywhere.