Create a minimal Observer design of your own, without looking at
broadcaster.py: the smallestBroadcasterthat lets you connect callables, then notifies them. Demonstrate it by connecting several responders and causing one change that updates them all.
The design is the one The
Pythonic Observer describes: a list of callables, one method
that appends to it, and one that calls each entry with the same
arguments. Any callable is a responder, a lambda
included, so the demonstration needs no observer class.
# The shape of exercise_1.py
from collections.abc import Callable
from typing import Any
class Broadcaster:
def __init__(self) -> None:
...
def connect(self, responder: Callable) -> None:
...
def announce(self, *args: Any) -> None:
...# exercise_1.py
from collections.abc import Callable
from typing import Any
class Broadcaster:
def __init__(self) -> None:
self._responders: list[Callable] = []
def connect(self, responder: Callable) -> None:
self._responders.append(responder)
def announce(self, *args: Any) -> None:
for responder in self._responders:
responder(*args)
calls: list[tuple[str, int]] = []
broadcaster = Broadcaster()
broadcaster.connect(lambda v: calls.append(("A", v)))
broadcaster.connect(lambda v: calls.append(("B", v)))
broadcaster.announce(42)
print(calls)
#: [('A', 42), ('B', 42)]Collect the responders. Like broadcaster.py, this
solution has no separate Observer class at all. Any
callable, here two lambdas, is a responder.
connect() collects them in a list.
Deliver one update to every responder.
announce() then hands its own arguments to each one
in turn, so every connected responder sees the same update, in
connection order.
Rewrite the classic listings to use the pull model:
Display.update()readssubject.celsiusinstead ofarg. ADisplaythat narrows itssubjectparameter toThermometerno longer satisfiesObserver[float], so make it type-check two ways: once with a runtimeisinstance()check insideupdate(), and once with anObserver[S, T]protocol whose first parameter is the subject type, whichSubjectsupplies asSelf. Say what each version adds.
Push or
Pull describes the pull model. The type problem is that a
parameter is contravariant: an update() that
narrows subject to Thermometer accepts
less than Observer[float] requires. The first
version keeps the protocol and narrows at runtime with
isinstance(). The second gives the protocol a
second type parameter for the subject, and
Subject.attach() asks for an
Observer[Self, T].
# The shape of exercise_2_narrowing.py
from typing import Protocol
class Observer[T](Protocol):
def update(
self, subject: Subject[T], arg: T
) -> None: ...
class Subject[T]:
def __init__(self) -> None:
...
def attach(self, observer: Observer[T]) -> None:
...
def notify(self, arg: T) -> None:
...
class Thermometer(Subject[float]):
def __init__(self, celsius: float) -> None:
...
@property
def celsius(self) -> float:
...
@celsius.setter
def celsius(self, value: float) -> None:
...
class Display:
def update(
self, subject: Subject[float], arg: float
) -> None:
...# The shape of exercise_2_generic.py
from typing import Protocol, Self
class Observer[S, T](Protocol):
def update(self, subject: S, arg: T) -> None: ...
class Subject[T]:
def __init__(self) -> None:
...
def attach(
self, observer: Observer[Self, T]
) -> None:
...
def notify(self, arg: T) -> None:
...
class Thermometer(Subject[float]):
def __init__(self, celsius: float) -> None:
...
@property
def celsius(self) -> float:
...
@celsius.setter
def celsius(self, value: float) -> None:
...
class Display:
def update(
self, subject: Thermometer, arg: float
) -> None:
...If you declare subject: Thermometer in
Display.update() and keep the classic
Observer[T], the program still prints
display: 25C, but the type checker rejects
t.attach(Display()). A parameter is contravariant,
so an update() that accepts only a
Thermometer cannot stand in for one that accepts
any Subject[float]. Both versions below keep the
read of subject.celsius and repair the type, the
first by widening the parameter again and the second by changing
the protocol.
Declare the widest subject type. The
protocol here is classic_observer.py’s,
unchanged. update() declares the widest type
attach() can hand it, Subject[float],
and narrows that to a Thermometer before reading
celsius:
# exercise_2_narrowing.py
from typing import Protocol
class Observer[T](Protocol):
def update(
self, subject: Subject[T], arg: T
) -> None: ...
class Subject[T]:
def __init__(self) -> None:
self._observers: list[Observer[T]] = []
def attach(self, observer: Observer[T]) -> None:
self._observers.append(observer)
def notify(self, arg: T) -> None:
for observer in list(self._observers):
observer.update(self, arg)
class Thermometer(Subject[float]):
def __init__(self, celsius: float) -> None:
super().__init__()
self._celsius = celsius
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
self._celsius = value
self.notify(value)
class Display:
def update(
self, subject: Subject[float], arg: float
) -> None:
assert isinstance(subject, Thermometer)
print(f"display: {subject.celsius}C")
t = Thermometer(20.0)
t.attach(Display())
t.celsius = 25
#: display: 25CNarrow the subject at runtime. The
assert is the cost. It runs on every notification,
and it states a requirement the protocol cannot: this
Display works for a Thermometer and
fails on any other Subject[float], at the moment of
the first notification rather than at the attach()
call the type checker reads.
Let the subject supply its own type. The
second version moves the subject’s type into the protocol.
Observer[S, T] takes the subject’s type as a
parameter, and Subject supplies its own type with
Self, so Thermometer.attach() asks for
an Observer[Thermometer, float]:
# exercise_2_generic.py
from typing import Protocol, Self
class Observer[S, T](Protocol):
def update(self, subject: S, arg: T) -> None: ...
class Subject[T]:
def __init__(self) -> None:
self._observers: list[Observer[Self, T]] = []
def attach(
self, observer: Observer[Self, T]
) -> None:
self._observers.append(observer)
def notify(self, arg: T) -> None:
for observer in list(self._observers):
observer.update(self, arg)
class Thermometer(Subject[float]):
def __init__(self, celsius: float) -> None:
super().__init__()
self._celsius = celsius
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
self._celsius = value
self.notify(value)
class Display:
def update(
self, subject: Thermometer, arg: float
) -> None:
print(f"display: {subject.celsius}C")
t = Thermometer(20.0)
t.attach(Display())
t.celsius = 25
#: display: 25CName the subject the observer reads.
Display.update() now declares
subject: Thermometer and
t.attach(Display()) type-checks. The cost is at the
other end: each observer’s type names the subject it watches, so
a display written for a Thermometer cannot attach
to a different Subject[float]. A parameter is
contravariant, so an observer that declares the wider
Subject[float] still attaches to any
Subject[float], and an observer that reads
celsius is the one that gives up that freedom.
Both versions print the same line, and neither needs
arg. That is pull’s bargain: the subject decides
nothing about what its observers read, and each observer pays by
knowing what it is watching.
announce() that survives a failing responderMake
Broadcaster.announce()survive a responder that raises an exception: every other responder is still notified, andannounce()re-raises the failures afterward, together, as anExceptionGroup(which you build yourself here:raise ExceptionGroup("message", failures)). Write a test in which the first responder raises an exception and the second still records its notification.
Raising
an Exception shows one failing responder stopping the loop
in announce(). Put a try inside the
loop, append each exception to a list, and after the loop raise
an ExceptionGroup when the list is not empty. The
test catches it with
pytest.raises(ExceptionGroup).
# The shape of exercise_3.py
from collections.abc import Callable
type Responder[T] = Callable[[T], None]
class Broadcaster[T]:
def __init__(self) -> None:
...
def connect(self, responder: Responder[T]) -> None:
...
def announce(self, data: T) -> None:
...
def broken(data: int) -> None:
...If you catch each exception and move on without keeping it,
every responder runs, but announce() returns
normally. The demo’s except* block does not run, so
the script prints nothing, and the test’s
pytest.raises(ExceptionGroup) fails with “DID NOT
RAISE”. The solution keeps each exception in a list and raises
the list as one ExceptionGroup once the loop
ends.
# exercise_3.py
from collections.abc import Callable
type Responder[T] = Callable[[T], None]
class Broadcaster[T]:
def __init__(self) -> None:
self._responders: list[Responder[T]] = []
def connect(self, responder: Responder[T]) -> None:
self._responders.append(responder)
def announce(self, data: T) -> None:
failures: list[Exception] = []
for responder in list(self._responders):
try:
responder(data)
except Exception as e:
failures.append(e)
if failures:
raise ExceptionGroup(
"responder failures", failures)
received: list[int] = []
def broken(data: int) -> None:
raise RuntimeError(f"cannot handle {data}")
broadcaster = Broadcaster[int]()
broadcaster.connect(broken)
broadcaster.connect(received.append)
try:
broadcaster.announce(7)
except* RuntimeError as group:
print(len(group.exceptions), received)
#: 1 [7]# test_resilient_announce.py
import pytest
from exercise_3 import Broadcaster
def test_later_responder_still_runs_after_a_failure(
) -> None:
received: list[int] = []
def broken(data: int) -> None:
raise RuntimeError("boom")
broadcaster = Broadcaster[int]()
broadcaster.connect(broken)
broadcaster.connect(received.append)
with pytest.raises(ExceptionGroup):
broadcaster.announce(1)
assert received == [1]Keep the loop going past a failure. The loop catches each failure and keeps going, so connection order stops deciding who hears the change.
Catch any ordinary failure. Catching bare
Exception here is deliberate:
announce() has no idea what its responders do, so
it cannot name their failure modes. Catching
Exception still lets BaseException
through, so a KeyboardInterrupt or an
asyncio.CancelledError passing through a responder
stops the notification instead of joining
failures.
Record each failure. Collecting the exceptions rather than discarding them matters as much as catching them: a responder that fails silently is worse than one that stops the loop, because nothing reports the failure.
Report every failure together.
ExceptionGroup is the right container because more
than one responder can fail on a single notification, and the
caller needs every failure, not the first. except*
then lets a caller handle one kind of failure and re-raise the
rest, something a plain except on a single
re-raised exception cannot do.
Redo exercise 3 for
async_broadcaster.py. Makeannounce()usegather(*coros, return_exceptions=True), separate the returned exceptions from the successes, and raise them together as an exception group. Write a test in which the first responder raises an exception and the second still records its notification.
A
Failing Responder Orphans the Rest shows
gather() abandoning the other coroutines on the
first failure. With return_exceptions=True,
gather() returns every result, exceptions among
them. Keep the results that are exceptions and raise them as one
group. A responder that was cancelled comes back as a
CancelledError, a BaseException, so
the filter and the group must admit one.
# The shape of exercise_4.py
import asyncio
from collections.abc import Awaitable, Callable
type AsyncResponder[T] = Callable[[T], Awaitable[None]]
class Broadcaster[T]:
def __init__(self) -> None:
...
def connect(
self, responder: AsyncResponder[T]
) -> None:
...
async def announce(self, data: T) -> None:
...
async def broken(data: int) -> None:
...
async def record(data: int) -> None:
...
async def main() -> None:
...If you leave out return_exceptions=True,
gather() re-raises the first failure as a bare
RuntimeError, and the test fails because
pytest.raises(ExceptionGroup) does not match it.
The demo still prints 1 [7]: except*
wraps a bare exception in a group, and record()
finishes before main() resumes. A slower responder
keeps running with nothing awaiting it, as A
Failing Responder Orphans the Rest shows, so the solution
passes the keyword and gather() waits for every
responder.
# exercise_4.py
import asyncio
from collections.abc import Awaitable, Callable
type AsyncResponder[T] = Callable[[T], Awaitable[None]]
class Broadcaster[T]:
def __init__(self) -> None:
self._responders: list[AsyncResponder[T]] = []
def connect(
self, responder: AsyncResponder[T]
) -> None:
self._responders.append(responder)
async def announce(self, data: T) -> None:
results = await asyncio.gather(
*(responder(data)
for responder in self._responders),
return_exceptions=True)
failures = [
r for r in results
if isinstance(r, BaseException)]
if failures:
raise BaseExceptionGroup(
"responder failures", failures)
received: list[int] = []
async def broken(data: int) -> None:
raise RuntimeError(f"cannot handle {data}")
async def record(data: int) -> None:
await asyncio.sleep(0)
received.append(data)
async def main() -> None:
broadcaster = Broadcaster[int]()
broadcaster.connect(broken)
broadcaster.connect(record)
try:
await broadcaster.announce(7)
except* RuntimeError as group:
print(len(group.exceptions), received)
asyncio.run(main())
#: 1 [7]# test_async_resilient_announce.py
import asyncio
import pytest
from exercise_4 import Broadcaster
def test_later_responder_still_runs_after_a_failure(
) -> None:
received: list[int] = []
async def broken(data: int) -> None:
raise RuntimeError("boom")
async def record(data: int) -> None:
await asyncio.sleep(0)
received.append(data)
async def run() -> None:
broadcaster = Broadcaster[int]()
broadcaster.connect(broken)
broadcaster.connect(record)
with pytest.raises(ExceptionGroup):
await broadcaster.announce(1)
asyncio.run(run())
assert received == [1]
def test_a_cancelled_responder_is_reported() -> None:
received: list[int] = []
async def cancelled(data: int) -> None:
raise asyncio.CancelledError()
async def record(data: int) -> None:
received.append(data)
async def run() -> None:
broadcaster = Broadcaster[int]()
broadcaster.connect(cancelled)
broadcaster.connect(record)
with pytest.raises(BaseExceptionGroup) as info:
await broadcaster.announce(1)
assert info.group_contains(
asyncio.CancelledError)
asyncio.run(run())
assert received == [1]Run every responder to completion.
return_exceptions=True changes
gather() from “re-raise the first failure
immediately” to “run everything and hand back a list.” That one
keyword does what the synchronous version needed a
try inside a loop to do, because
gather() is already the loop.
Pick out the failures. The results come back
in argument order, so the list is a record of which responder
produced what. This version needs the failures alone, so its
comprehension keeps each result for which
isinstance(r, BaseException) is true. A successful
responder returned None, which fails that test and
stays out of failures.
Report a cancelled responder too. If another
task cancels the awaiting task while gather()
waits, gather() cancels every responder, and
asyncio.CancelledError reaches the awaiting task
before announce() has any results to filter, so
that cancellation needs nothing from the filter. The
cancellation that return_exceptions=True does hand
back is a responder’s own: a CancelledError raised
inside the responder, which is what a cancelled
await propagates. An
isinstance(r, Exception) filter drops that result
from failures with no report, because
CancelledError derives from
BaseException, so this version filters on
BaseException. ExceptionGroup cannot
hold a BaseException: with the wider filter,
ty reports an invalid-argument-type at
an ExceptionGroup(...) call. The listing raises a
BaseExceptionGroup instead, and that constructor
returns an ExceptionGroup when every member is an
Exception, so the demo’s
except* RuntimeError and the first test still see
the group the exercise asks for. When a cancellation is among
the failures, the group is a BaseExceptionGroup,
and the second test confirms that the cancelled responder is
reported and the later responder still runs. Exercise 3’s
synchronous version keeps except Exception, and the
difference is where a BaseException goes: there it
passes through the loop and stops the notification, which is
right for a KeyboardInterrupt; here
gather() has already turned it into a value, and
dropping a value reports nothing.
The synchronous and asynchronous versions now answer the same
question, and both end in an exception group. The difference is
only where the loop lives: written by hand in the synchronous
version, supplied by gather() in the async one.
Redo exercise 3 with each failure returned as a value instead of raised as an exception. Each responder returns a
Resultfromutils/result.py, andannounce()returns theErrvalues it collects. Write an adapter that lets a responder returningNone, such asreceived.append, be connected. Write a test in which the first responder fails and the second still records its notification.
Change the responder type to return a Result,
and make announce() a comprehension that keeps each
result that is an Err. The adapter takes a
None-returning callable and returns a responder
that calls it and answers Ok(None).
# The shape of exercise_5.py
from collections.abc import Callable
from result import Err, Ok, Result
type Responder[T] = Callable[[T], Result[None, str]]
class Broadcaster[T]:
def __init__(self) -> None:
...
def connect(self, responder: Responder[T]) -> None:
...
def announce(self, data: T) -> list[Err[str]]:
...
def succeeds[T](
action: Callable[[T], None],
) -> Responder[T]:
...
def checked(data: int) -> Result[None, str]:
...# exercise_5.py
from collections.abc import Callable
from result import Err, Ok, Result
type Responder[T] = Callable[[T], Result[None, str]]
class Broadcaster[T]:
def __init__(self) -> None:
self._responders: list[Responder[T]] = []
def connect(self, responder: Responder[T]) -> None:
self._responders.append(responder)
def announce(self, data: T) -> list[Err[str]]:
return [
result
for responder in list(self._responders)
if isinstance(result := responder(data), Err)
]
def succeeds[T](
action: Callable[[T], None],
) -> Responder[T]:
def responder(data: T) -> Result[None, str]:
action(data)
return Ok(None)
return responder
def checked(data: int) -> Result[None, str]:
if data < 0:
return Err(f"cannot handle {data}")
return Ok(None)
received: list[int] = []
broadcaster = Broadcaster[int]()
broadcaster.connect(checked)
broadcaster.connect(succeeds(received.append))
print(broadcaster.announce(7), received)
#: [] [7]
print(broadcaster.announce(-1), received)
#: [Err(error='cannot handle -1')] [7, -1]# test_result_announce.py
from exercise_5 import Broadcaster, succeeds
from result import Err, Result
def test_later_responder_runs_after_an_err() -> None:
received: list[int] = []
def broken(data: int) -> Result[None, str]:
return Err("boom")
broadcaster = Broadcaster[int]()
broadcaster.connect(broken)
broadcaster.connect(succeeds(received.append))
assert broadcaster.announce(1) == [Err("boom")]
assert received == [1]Collect the failures as values. No responder
raises an exception, so announce() needs no
try. It calls every responder and keeps each result
that is an Err. The caller receives the failures as
an ordinary list and decides what to do with them, where
exercise 3’s caller had to catch an ExceptionGroup.
An empty list means every responder succeeded.
Adapt a None-returning
callable. The type change reaches every responder.
received.append returns None, so the
type checker rejects
broadcaster.connect(received.append): a
Responder[int] must return a Result.
succeeds() adapts any None-returning
callable by calling it and returning Ok(None). The
adapter assumes the wrapped callable cannot fail; if the
callable raises an exception anyway, that exception leaves
announce() as it did in the chapter’s version.
Returning errors as values works when you write the responders. For a broadcaster that accepts arbitrary callables, exercise 3’s catch-and-collect protects the loop from code you did not write.
box_observer.py into a
flood-fill gameTurn
box_observer.pyinto a simple game: you own the contiguous patch of same-colored squares containing the top-left corner, and selecting any square recolors your patch to that square’s color, absorbing neighbors that now match. Write the neighbor test yourself, and count diagonal squares as neighbors. Track the moves it takes to make the whole field one color. For competition, alternate turns between players.
Keep Color and new_grid() from The Model. Your
patch is the set of cells a depth-first search reaches from the
origin through same-colored cells, with a neighbor test that
allows a difference of at most one in each coordinate. A move
repaints that set and then searches again.
# The shape of exercise_6.py
from enum import StrEnum
class Color(StrEnum):
SKYBLUE = "skyblue"
PALEGREEN = "palegreen"
KHAKI = "khaki"
@classmethod
def at(cls, n: int) -> Color:
...
type Coord = tuple[int, int]
type Grid = dict[Coord, Color]
def new_grid(size: int) -> Grid:
...
def adjacent(a: Coord, b: Coord) -> bool:
...
class FloodGame:
("Flood-fill game: grow a patch "
"from the origin to fill the board.")
def __init__(self, size: int,
origin: Coord = (0, 0)) -> None:
...
def _flood(self, color: Color) -> set[Coord]:
("Every cell reachable from origin "
"through same-colored cells.")
...
def select(self, cell: Coord) -> bool:
("Recolor the owned patch "
"to the selected cell's color.")
...
def is_complete(self) -> bool:
...If you repaint the patch and stop there, owned
holds only (0, 0), which the demo’s first move
recolors to match the first unowned cell. Every later
select() picks that same cell, finds that its color
matches the patch, and returns False, so the
while loop runs forever. The patch grows because
select() searches again from the origin after
repainting.
# exercise_6.py
from enum import StrEnum
class Color(StrEnum):
SKYBLUE = "skyblue"
PALEGREEN = "palegreen"
KHAKI = "khaki"
@classmethod
def at(cls, n: int) -> Color:
members = list(cls)
return members[n % len(members)]
type Coord = tuple[int, int]
type Grid = dict[Coord, Color]
def new_grid(size: int) -> Grid:
return {(x, y): Color.at(x + y)
for x in range(size) for y in range(size)}
def adjacent(a: Coord, b: Coord) -> bool:
return (a != b and abs(a[0] - b[0]) <= 1
and abs(a[1] - b[1]) <= 1)
class FloodGame:
("Flood-fill game: grow a patch "
"from the origin to fill the board.")
def __init__(self, size: int,
origin: Coord = (0, 0)) -> None:
self.size = size
self.grid = new_grid(size)
self.origin = origin
self.moves = 0
self.owned = self._flood(self.grid[origin])
def _flood(self, color: Color) -> set[Coord]:
("Every cell reachable from origin "
"through same-colored cells.")
seen: set[Coord] = set()
stack = [self.origin]
while stack:
cell = stack.pop()
if cell in seen or self.grid.get(cell) != color:
continue
seen.add(cell)
for other in self.grid:
if (adjacent(cell, other)
and other not in seen):
stack.append(other)
return seen
def select(self, cell: Coord) -> bool:
("Recolor the owned patch "
"to the selected cell's color.")
new_color = self.grid[cell]
if new_color == self.grid[self.origin]:
return False # No-op: already this color
for c in self.owned:
self.grid[c] = new_color
# Absorb new neighbors
self.owned = self._flood(new_color)
self.moves += 1
return True
def is_complete(self) -> bool:
return len(self.owned) == self.size * self.size
game = FloodGame(4)
while not game.is_complete():
remaining = [
c for c in game.grid if c not in game.owned]
game.select(remaining[0])
print("solved in", game.moves, "moves")
#: solved in 6 movesReuse the model’s grid.
FloodGame reuses new_grid() from box_observer.py unchanged
and adds the adjacent() the exercise asks for.
Find the owned patch. _flood()
is a plain graph search (depth-first, using a stack) starting
from origin and walking from each cell to every
neighbor adjacent() reports, as long as that
neighbor is still the same color.
Grow the patch by recoloring.
select() is the game move: it repaints every cell
in the currently owned patch to the selected cell’s
color, then re-runs _flood() to pick up the
neighbors that now match that new color and have joined the
patch.
Score the game. game.moves
gives the single-player scoring the exercise asks for: the moves
it takes to make the whole field one color. Two players can
share the same select() method, alternating whose
turn supplies the next color, and after a fixed number of rounds
whoever owns the larger patch wins.
FloodGame can also inherit from
Broadcaster[Grid], as BoxModel does,
and call self.announce(self.grid) at the end of a
successful select(). box_view.py’s existing
view then repaints after every move. The drawing code needs no
change, but show()’s parameter annotation does: it
names BoxModel, and a FloodGame is not
one. Widening it to a Protocol (or to
Broadcaster[Grid] plus size,
grid, and select()) lets the same view
draw either model.
Change the rule for a selection in
box_observer.py: makerecolored()advance every box in the selected box’s row and column. Runbox_view.pywithout editing it, and explain why the view needed no change.
In The
Model, recolored() alone decides which cells
change; BoxModel.select() calls it and announces
the result. Build the new Grid from every cell that
shares the selection’s x or its y. For
why the view needs no change, see what draw()
receives in The
View.
# The shape of exercise_7.py
from enum import StrEnum
class Color(StrEnum):
SKYBLUE = "skyblue"
PALEGREEN = "palegreen"
KHAKI = "khaki"
@classmethod
def at(cls, n: int) -> Color:
...
def next(self) -> Color:
...
type Coord = tuple[int, int]
type Grid = dict[Coord, Color]
def new_grid(size: int) -> Grid:
...
def recolored(grid: Grid, selected: Coord) -> Grid:
...
def initials(grid: Grid, size: int) -> str:
...# exercise_7.py
from enum import StrEnum
class Color(StrEnum):
SKYBLUE = "skyblue"
PALEGREEN = "palegreen"
KHAKI = "khaki"
@classmethod
def at(cls, n: int) -> Color:
members = list(cls)
return members[n % len(members)]
def next(self) -> Color:
return Color.at(list(Color).index(self) + 1)
type Coord = tuple[int, int]
type Grid = dict[Coord, Color]
def new_grid(size: int) -> Grid:
return {(x, y): Color.at(x + y)
for x in range(size) for y in range(size)}
def recolored(grid: Grid, selected: Coord) -> Grid:
x, y = selected
return grid | {cell: color.next()
for cell, color in grid.items()
if cell[0] == x or cell[1] == y}
def initials(grid: Grid, size: int) -> str:
return "\n".join(
" ".join(grid[(x, y)][0] for x in range(size))
for y in range(size))
grid = new_grid(4)
print(initials(grid, 4))
#: s p k s
#: p k s p
#: k s p k
#: s p k s
print(initials(recolored(grid, (1, 2)), 4))
#: s k k s
#: p s s p
#: s p k s
#: s k k sAdvance the selection’s row and column.
Color and new_grid() come from box_observer.py unchanged,
and recolored() is the one function that differs.
It keeps every cell whose column matches the selection’s
x or whose row matches its y, and
advances each one. The cells come from grid, so
none lies outside it and the in grid test goes
away.
Show the grid as text.
initials() prints each cell’s first letter, one row
per line. After selecting column 1, row 2, that column and that
row have moved one color along, and the other nine cells are as
they were.
Pasting this recolored() over the one in box_observer.py changes
what the window does, and box_view.py runs as it
stands. The view touches the model in two places. Its mouse
handler calls model.select() with a coordinate, and
its draw() receives a whole Grid and
paints every cell. Neither one says which cells a selection
changes, so the view holds nothing that a new rule could make
wrong. The rule sits in recolored(),
BoxModel.select() calls it, and
announce() delivers the result.
initials() makes the same point from the other
side: it is a second view of a Grid, written
without knowing the rule.
Add a second view to
box_observer.py’sBoxModel. Write one view that prints a letter per cell and another that prints how many cells each color holds, connect both to the same model, and show that oneselect()updates the pair. Keep both views textual so the example runs without a window, and leave the model asbox_observer.pyhas it.
In The
View, draw() is a responder that takes a
Grid, and any function with that signature is
another view. Write one that prints a letter per cell and one
that counts with collections.Counter, then
connect() both before calling
select().
# The shape of exercise_8.py
from collections import Counter
from collections.abc import Callable
from enum import StrEnum
class Color(StrEnum):
SKYBLUE = "skyblue"
PALEGREEN = "palegreen"
KHAKI = "khaki"
@classmethod
def at(cls, n: int) -> Color:
...
def next(self) -> Color:
...
type Coord = tuple[int, int]
type Grid = dict[Coord, Color]
type Responder[T] = Callable[[T], None]
def new_grid(size: int) -> Grid:
...
def recolored(grid: Grid, selected: Coord) -> Grid:
...
class Broadcaster[T]:
def __init__(self) -> None:
...
def connect(self, responder: Responder[T]) -> None:
...
def announce(self, data: T) -> None:
...
class BoxModel(Broadcaster[Grid]):
def __init__(self, size: int) -> None:
...
def select(self, cell: Coord) -> None:
...
def letters(grid: Grid) -> None:
...
def tally(grid: Grid) -> None:
...# exercise_8.py
from collections import Counter
from collections.abc import Callable
from enum import StrEnum
class Color(StrEnum):
SKYBLUE = "skyblue"
PALEGREEN = "palegreen"
KHAKI = "khaki"
@classmethod
def at(cls, n: int) -> Color:
members = list(cls)
return members[n % len(members)]
def next(self) -> Color:
return Color.at(list(Color).index(self) + 1)
type Coord = tuple[int, int]
type Grid = dict[Coord, Color]
type Responder[T] = Callable[[T], None]
def new_grid(size: int) -> Grid:
return {(x, y): Color.at(x + y)
for x in range(size) for y in range(size)}
def recolored(grid: Grid, selected: Coord) -> Grid:
x, y = selected
cross = [(x, y), (x - 1, y), (x + 1, y),
(x, y - 1), (x, y + 1)]
return grid | {cell: grid[cell].next()
for cell in cross if cell in grid}
class Broadcaster[T]:
def __init__(self) -> None:
self._responders: list[Responder[T]] = []
def connect(self, responder: Responder[T]) -> None:
self._responders.append(responder)
def announce(self, data: T) -> None:
for responder in list(self._responders):
responder(data)
class BoxModel(Broadcaster[Grid]):
def __init__(self, size: int) -> None:
super().__init__()
self.size = size
self.grid = new_grid(size)
def select(self, cell: Coord) -> None:
self.grid = recolored(self.grid, cell)
self.announce(self.grid)
model = BoxModel(3)
def letters(grid: Grid) -> None:
for y in range(model.size):
print(" ".join(grid[(x, y)][0]
for x in range(model.size)))
def tally(grid: Grid) -> None:
counts = Counter(grid.values())
print(" ".join(f"{c[0]}:{counts[c]}" for c in Color))
model.connect(letters)
model.connect(tally)
model.select((1, 1))
#: s k k
#: k s p
#: k p p
#: s:2 p:3 k:4
model.select((0, 0))
#: p s k
#: s s p
#: k p p
#: s:3 p:4 k:2Reuse the chapter’s model. The model is box_observer.py’s, copied
here so the solution runs on its own: Color,
new_grid(), and recolored() unchanged,
and a Broadcaster trimmed to the two methods this
example calls. BoxModel is the chapter’s, and the
exercise adds nothing to it.
Write each view as a responder.
letters() and tally() are the two
views. Each takes a Grid and returns
None, the shape connect() requires, so
each is a responder the same way draw() is.
letters() prints the first character of each color,
one row per line, and tally() counts the colors
with a Counter. Neither one names the other, and
neither names the model’s rule.
Update both views from one change.
model.select((1, 1)) calls recolored()
once and announce() once, and
announce() calls both views in connection order.
They read the same Grid object, so the letters and
the counts describe one state of the model: the first selection
advances the five cells of the cross, which moves two cells out
of skyblue and two into khaki. The
corner selection that follows has three cells inside the grid
rather than five.
Adding a third view means one more connect()
call. box_view.py’s
draw() is such a view, and show(model)
connects it to a model that already has letters()
and tally(), so the window and the terminal report
the same grid. Running that combination means
show() takes over with
root.mainloop(), so call show()
last.
Work out which colors the whole grid can reach from
new_grid(size)underbox_observer.py’s rule. Selecting a cell advances up to five cells by one, modulo three, and selections commute, so this is a linear system over the integers mod 3: the unknowns are how many times you select each cell. Write Gaussian elimination mod 3 to decide whether the system has a solution, and print the reachable colors for every size from 3 through 8. The 8x8 grid reachespalegreenalone, and one smaller size reaches nothing.
The rule in The Model adds
one, modulo three, to the selected cell and its four neighbors,
so each selection is a column of a 0/1 matrix and the target
color is a right-hand side. Gaussian elimination mod 3 differs
from the usual in two places: divide by a pivot with
pow(x, -1, 3), and reduce every subtraction with
% 3. A zero row with a nonzero right-hand side
means no solution.
# The shape of exercise_9.py
from enum import StrEnum
from typing import Final
class Color(StrEnum):
SKYBLUE = "skyblue"
PALEGREEN = "palegreen"
KHAKI = "khaki"
type Coord = tuple[int, int]
type Row = list[int]
MOD: Final[int] = len(Color)
def cross(cell: Coord, size: int) -> list[Coord]:
...
def system(size: int, target: int) -> list[Row]:
"One row per cell, with the target in the last column."
...
def solvable(rows: list[Row]) -> bool:
...
def reachable(size: int) -> list[Color]:
...If you leave the % MOD off the subtraction, as
ordinary elimination would, an entry can become a nonzero
multiple of three. next() accepts such an entry as
a pivot, since it is not zero, and pow(x, -1, MOD)
then raises a ValueError (“base is not invertible
for the given modulus”), so the script stops at 3x3 before
printing a line. Reducing every result mod 3 keeps each entry in
0, 1, or 2, where every
nonzero value has an inverse.
# exercise_9.py
from enum import StrEnum
from typing import Final
class Color(StrEnum):
SKYBLUE = "skyblue"
PALEGREEN = "palegreen"
KHAKI = "khaki"
type Coord = tuple[int, int]
type Row = list[int]
MOD: Final[int] = len(Color)
def cross(cell: Coord, size: int) -> list[Coord]:
x, y = cell
around = [(x, y), (x - 1, y), (x + 1, y),
(x, y - 1), (x, y + 1)]
return [(a, b) for a, b in around
if 0 <= a < size and 0 <= b < size]
def system(size: int, target: int) -> list[Row]:
"One row per cell, with the target in the last column."
cells = [(x, y) for x in range(size)
for y in range(size)]
at = {cell: i for i, cell in enumerate(cells)}
rows = [[0] * (len(cells) + 1) for _ in cells]
for cell in cells:
for other in cross(cell, size):
rows[at[other]][at[cell]] = 1
for i, (x, y) in enumerate(cells):
rows[i][-1] = (target - (x + y)) % MOD
return rows
def solvable(rows: list[Row]) -> bool:
width = len(rows[0]) - 1
pivot = 0
for col in range(width):
found = next((r for r in range(pivot, len(rows))
if rows[r][col]), None)
if found is None:
continue
rows[pivot], rows[found] = rows[found], rows[pivot]
scale = pow(rows[pivot][col], -1, MOD)
rows[pivot] = [v * scale % MOD for v in rows[pivot]]
for r, row in enumerate(rows):
if r != pivot and row[col]:
factor = row[col]
rows[r] = [(a - factor * b) % MOD for a, b
in zip(row, rows[pivot])]
pivot += 1
# A row of zeros with a nonzero target is 0 == 1
return all(any(row[:-1]) or row[-1] == 0
for row in rows)
def reachable(size: int) -> list[Color]:
return [color for target, color in enumerate(Color)
if solvable(system(size, target))]
for size in range(3, 9):
names = ", ".join(reachable(size))
print(f"{size}x{size}: {names or 'nothing'}")
#: 3x3: skyblue, palegreen, khaki
#: 4x4: skyblue, palegreen, khaki
#: 5x5: nothing
#: 6x6: skyblue, palegreen, khaki
#: 7x7: skyblue, palegreen, khaki
#: 8x8: palegreenState the puzzle as a linear system.
Selecting a cell adds one, modulo three, to that cell and to
each neighbor cross() finds, and selecting it twice
adds two. The order of the selections makes no difference, so a
whole sequence of them is a count per cell, and the puzzle
becomes one linear system: M v = b, over the
integers mod 3. M records which cells each
selection advances, v counts the selections, and
b is how far each cell must advance to reach the
target color. system() builds M and
b together, one row per cell, with b
in the last column.
Decide solvability by elimination.
solvable() answers whether that system has a
solution, and never computes one: the question is which colors
are reachable, not how. It is Gaussian elimination, with two
changes for arithmetic mod 3. Dividing by a pivot is multiplying
by its inverse, which pow(x, -1, MOD) supplies, and
every subtraction ends in % MOD. Elimination either
finds a pivot in a column or leaves that column free; what
decides the answer is the rows that survive with every
coefficient zero. Such a row states 0 == row[-1],
so a nonzero last column means no count of selections reaches
that color.
Report the reachable colors. The six sizes
split three ways. At 3x3, 6x6, and 7x7 the matrix has full rank,
so every color is reachable from any starting grid. At 4x4 the
rank is 14 of 16, and at 8x8 it is 60 of 64: the missing
dimensions are combinations of cells that no selection can
change, so the starting grid must already agree with the target
on each of them. The 4x4 banded grid agrees for all three
colors, and the 8x8 grid for palegreen alone, which
is the puzzle the window poses. At 5x5 three dimensions are
missing and no color satisfies them, so no sequence of
selections turns that board one color.
Color is a StrEnum, so its members
go straight into ", ".join(reachable(size)) with no
conversion, the same property that lets box_view.py hand a
Color to tkinter.
Write a
Notifyingdescriptor that replaces the@propertyandannounce()pair, so one class declares several independently watched attributes:celsius = Notifying[float]()besidehumidity = Notifying[float](). Each attribute keeps its own responders. Connecting needs the descriptor, not the value it stores, so__get__()returns the descriptor for an access through the class, andThermometer.celsius.connect(t, readings.append)reaches it. Show that an assignment to one attribute calls no responder of the other.
A descriptor’s __set_name__() (A
Descriptor That Validates) receives the attribute’s name,
from which it can derive one storage name and one responder-list
name per attribute. __set__() stores the value and
then calls that attribute’s responders. __get__()
returns self when obj is
None, so Thermometer.celsius.connect()
reaches it, and two @overloads tell the type
checker which result each access gets.
# The shape of exercise_10.py
from collections.abc import Callable
from typing import overload
type Responder[T] = Callable[[T], None]
class Notifying[T]:
def __set_name__(
self, owner: type, name: str
) -> None:
...
@overload
def __get__(self, obj: None,
owner: type) -> Notifying[T]: ...
@overload
def __get__(self, obj: object,
owner: type) -> T: ...
def __get__(self, obj: object | None,
owner: type) -> T | Notifying[T]:
...
def __set__(self, obj: object, value: T) -> None:
...
def connect(self, obj: object,
responder: Responder[T]) -> None:
...
class Thermometer:
celsius = Notifying[float]()
humidity = Notifying[float]()
def __init__(self, celsius: float,
humidity: float) -> None:
...If __get__() always returns the stored value, as
a validating descriptor’s does, Thermometer.celsius
runs getattr(None, "_celsius") and raises an
AttributeError before the call to
connect(). The type checker passes that version,
since the overloads still declare that class access returns the
descriptor, so the failure appears only when the program runs.
The solution tests for obj is None and returns the
descriptor.
# exercise_10.py
from collections.abc import Callable
from typing import overload
type Responder[T] = Callable[[T], None]
class Notifying[T]:
def __set_name__(
self, owner: type, name: str
) -> None:
self.storage = f"_{name}"
self.responders = f"_responders_{name}"
@overload
def __get__(self, obj: None,
owner: type) -> Notifying[T]: ...
@overload
def __get__(self, obj: object,
owner: type) -> T: ...
def __get__(self, obj: object | None,
owner: type) -> T | Notifying[T]:
if obj is None:
return self # Thermometer.celsius
return getattr(obj, self.storage)
def __set__(self, obj: object, value: T) -> None:
setattr(obj, self.storage, value)
for responder in getattr(obj, self.responders, ()):
responder(value)
def connect(self, obj: object,
responder: Responder[T]) -> None:
obj.__dict__.setdefault(
self.responders, []).append(responder)
class Thermometer:
celsius = Notifying[float]()
humidity = Notifying[float]()
def __init__(self, celsius: float,
humidity: float) -> None:
self.celsius = celsius
self.humidity = humidity
t = Thermometer(20.0, 0.4)
readings: list[float] = []
humidities: list[float] = []
Thermometer.celsius.connect(t, readings.append)
Thermometer.humidity.connect(t, humidities.append)
t.celsius = 25.0
t.humidity = 0.5
t.celsius = 150.0
print(readings, humidities)
#: [25.0, 150.0] [0.5]
print(t.celsius, t.humidity)
#: 150.0 0.5Give each attribute its own storage.
__set_name__() receives the name the class body
binds each descriptor to, so celsius and
humidity derive different attribute names:
_celsius and _responders_celsius for
one, _humidity and
_responders_humidity for the other. Two
Notifying instances in one class therefore share no
storage and no responder list, so the two attributes are
independent. Broadcaster keeps one list for the
whole object; a descriptor keeps one per attribute.
Return the descriptor on class access. Class
access is the part a validating descriptor never needs.
Thermometer.celsius calls __get__()
with obj set to None, and returning
the descriptor there puts connect() within reach.
The two @overload declarations tell the type
checker which result each access gets: Notifying[T]
from the class, T from an instance. Without them
the declared return type is the union, and
t.celsius * 2 fails to check. The overloads also
check the responder against the attribute:
Thermometer.celsius.connect(t, readings.append)
passes only because readings is a
list[float].
Pyright rejects Thermometer.celsius.connect. It
reads the constructor’s self.celsius = celsius as
declaring an instance attribute of type float
beside the descriptor, so it types the class access as
Notifying[float] | float and reports that
float has no connect. ty
types the class access from the __get__() overload
alone. A codebase on Pyright looks the descriptor up in
type(obj).__dict__ instead, which draws no
complaint from Pyright.
Announce on assignment.
__set__() stores the value and then calls each
responder connected to that attribute, the work
Thermometer’s property setter did with
self.announce(value).
Keep each instance’s responders apart.
connect() writes the responder list into the
instance’s __dict__ rather than declaring it on the
class, where every instance shares one list.
Write a load-time version of
Broadcaster: a module-level list of responders and a@respondsdecorator that appends a function to it and returns the function unchanged. Each responder then registers when Python runs itsdefstatement, and for a module-level function Python runs that statement while it imports the module. Give aThermometeracelsiussetter that announces to that list, and create two thermometers. Say which of the problems in this chapter’s runtime sections the load-time form keeps, which it removes, and what it costs thatBroadcasterdoes not.
A module-level list and a decorator that appends its function
and returns it unchanged make every decorated def a
registration. For the comparison, reread the problems the
runtime sections raise after The
Pythonic Observer: disconnecting, a raised exception, lapsed
listeners, and re-entrant notification.
# The shape of exercise_11.py
from collections.abc import Callable
from typing import Final
type Responder = Callable[[float], None]
RESPONDERS: Final[list[Responder]] = []
def responds(fn: Responder) -> Responder:
...
class Thermometer:
def __init__(self, celsius: float) -> None:
...
@property
def celsius(self) -> float:
...
@celsius.setter
def celsius(self, value: float) -> None:
...
@responds
def display(celsius: float) -> None:
...
@responds
def alarm(celsius: float) -> None:
...If responds() appends the function and returns
nothing, the demo still prints its three lines, because
RESPONDERS holds each function. The decorator’s
result replaces the name, though, so the name
display refers to None, and calling
display(5.0) raises a TypeError. The
type checker catches the mistake before any run: with
-> Responder declared, responds()
draws an invalid-return-type. Returning
fn keeps each decorated name bound to its
function.
# exercise_11.py
from collections.abc import Callable
from typing import Final
type Responder = Callable[[float], None]
RESPONDERS: Final[list[Responder]] = []
def responds(fn: Responder) -> Responder:
RESPONDERS.append(fn)
return fn
class Thermometer:
def __init__(self, celsius: float) -> None:
self._celsius = celsius
@property
def celsius(self) -> float:
return self._celsius
@celsius.setter
def celsius(self, value: float) -> None:
self._celsius = value
for responder in RESPONDERS:
responder(value)
@responds
def display(celsius: float) -> None:
print(f"display: {celsius}C")
@responds
def alarm(celsius: float) -> None:
if celsius > 100:
print("alarm!")
room = Thermometer(20.0)
oven = Thermometer(180.0)
room.celsius = 21.0
#: display: 21.0C
oven.celsius = 200.0
#: display: 200.0C
#: alarm!Register at definition time. Python calls
responds() once for each decorated
def, when it runs that statement. For a
module-level function, that is while Python imports the module,
so the registry is complete before any thermometer exists.
Keep the function callable.
responds() returns fn unchanged, so
display is still a function you can call
directly.
The load-time form removes three of the runtime problems:
Broadcaster.announce() copies its list because
a responder can disconnect itself mid-notification. The registry
has no disconnect(), so the setter iterates through
RESPONDERS directly.disconnect(). The @ form
also needs a def, so every decorated responder has
a name.Two problems remain. A responder that raises an exception
stops the loop, and the exception reaches the assignment to
celsius. A responder that assigns to
celsius re-enters the setter.
The load-time form also costs three things that
Broadcaster does not:
room and
oven announce to the same two responders, and
alarm cannot tell which thermometer changed.
Broadcaster keeps one list per instance. Sending
the thermometer along with the reading tells the responders the
source, but every responder still receives every thermometer’s
changes.AppConfig.ready() method, which Django calls at
startup.RESPONDERS.