Recall State: a surrogate object that forwards calls to a swappable implementation. State lets the client programmer swap the implementation. StateMachine adds a structure that swaps it automatically, from one object to the next. Each implementation represents one state the system can occupy, so the system behaves differently as it moves from state to state.
The code that moves the system from one state to the next is
often a Template
Method, as the following framework for a basic state
machine shows. You call run() on a state to perform
its behavior, and you pass an “input” object to the state so it
can tell you which state to enter next. The chapter shows two
designs that differ in one way: in the first, each
State object decides its own next state; in the
second, a single table holds every transition.
# state.py
# A State has an operation, and can be moved
# into the next State given an Input:
from typing import Protocol
class State(Protocol):
def run(self) -> None: ...
def next(self, event: object) -> State: ...Python does not require this Protocol. It earns its few lines
twice over: annotations can name State as a type,
and a state class that leaves a method out fails the type check
wherever the program uses it as a State, before
anything runs. A base class could do the first job as well:
class State: pass
Calling run() or next() on a
derived type that lacks them then raises an
AttributeError, and a base whose methods
raise NotImplementedError moves the failure into
the base, with whatever message you write there. Surrogate shows the
other option: make State an ABC with
@abstractmethod on both methods, and constructing
an incomplete subclass fails outright. Both fail later than the
check, at the call or at construction.
The StateMachine keeps track of the current
state, which the constructor initializes. The
run_all() method takes a sequence of input objects.
For each one it asks the current state for the next state, moves
there, and calls that state’s run(). That loop is
the State pattern plus the transition: what
run() does depends on which state the machine
occupies:
# state_machine.py
from collections.abc import Iterable
from state import State
class StateMachine:
def __init__(self, initial_state: State) -> None:
self.current_state = initial_state
self.current_state.run()
# Template method:
def run_all(self, inputs: Iterable[object]) -> None:
for event in inputs:
print(event)
self.current_state = (
self.current_state.next(event))
self.current_state.run()run_all() is the template method: it fixes the
flow (report the input, transition, run the new state), while
the varying behavior lives in each State’s
run() and next(). Template Method
puts the varying steps in a subclass. Here they come from the
State objects the machine holds. The constructor
also runs the initial state, the construction-starts-the-engine
choice that drew
a warning in that chapter. Two facts make it safe here, and
either one is easy to lose in a later edit:
MouseTrap.__init__() assigns nothing after its
super().__init__() call, and no state’s
run() reads anything off the machine. If you give a
State a run() that reads a machine
attribute, the trap is back.
In this style of StateMachine, each state decides the next state. As an example, here’s a fancy mousetrap that can move through several states while trapping a mouse. The possible moves a mouse can make are the inputs to the state machine:
# mouse_action.py
from enum import StrEnum
class MouseAction(StrEnum):
APPEARS = "mouse appears"
RUNS_AWAY = "mouse runs away"
ENTERS = "mouse enters trap"
ESCAPES = "mouse escapes"
TRAPPED = "mouse trapped"
REMOVED = "mouse removed"Each possible move by a mouse is a member of the
MouseAction enumeration (Data
Classes as Types introduces Enum). Because it
is a StrEnum, each member is a
str, and compares equal to and prints as its value.
That is why print(event) in run_all()
shows mouse appears rather than
MouseAction.APPEARS. The members still hash and
look up correctly, so they work as dictionary keys, and
MouseAction("mouse appears") returns the matching
member, which is how the code below parses the test input.
A text file supplies the sequence of mouse inputs:
# mouse_moves.txt
mouse appears
mouse runs away
mouse appears
mouse enters trap
mouse escapes
mouse appears
mouse enters trap
mouse trapped
mouse removed
mouse appears
mouse runs away
mouse appears
mouse enters trap
mouse trapped
mouse removed
Here’s the first version of the mousetrap program. Each state
class defines its run() behavior, and establishes
its next state with a match statement:
# mouse_trap_states.py
from pathlib import Path
from typing import ClassVar
from mouse_action import MouseAction
from state import State
from state_machine import StateMachine
class Waiting:
def run(self) -> None:
print("Waiting: Broadcasting cheese smell")
def next(self, event: object) -> State:
match event:
case MouseAction.APPEARS:
return MouseTrap.luring
case _:
return MouseTrap.waiting
class Luring:
def run(self) -> None:
print("Luring: Presenting Cheese, door open")
def next(self, event: object) -> State:
match event:
case MouseAction.RUNS_AWAY:
return MouseTrap.waiting
case MouseAction.ENTERS:
return MouseTrap.trapping
case _:
return MouseTrap.luring
class Trapping:
def run(self) -> None:
print("Trapping: Closing door")
def next(self, event: object) -> State:
match event:
case MouseAction.ESCAPES:
return MouseTrap.waiting
case MouseAction.TRAPPED:
return MouseTrap.holding
case _:
return MouseTrap.trapping
class Holding:
def run(self) -> None:
print("Holding: Mouse caught")
def next(self, event: object) -> State:
match event:
case MouseAction.REMOVED:
return MouseTrap.waiting
case _:
return MouseTrap.holding
class MouseTrap(StateMachine):
waiting: ClassVar[State] = Waiting()
luring: ClassVar[State] = Luring()
trapping: ClassVar[State] = Trapping()
holding: ClassVar[State] = Holding()
def __init__(self) -> None:
super().__init__(MouseTrap.waiting)
text = Path("mouse_moves.txt").read_text()
moves = [line.strip() for line in text.splitlines()
if line.strip() and not line.startswith("#")]
MouseTrap().run_all([MouseAction(m) for m in moves])
#: Waiting: Broadcasting cheese smell
#: mouse appears
#: Luring: Presenting Cheese, door open
#: mouse runs away
#: Waiting: Broadcasting cheese smell
#: mouse appears
#: Luring: Presenting Cheese, door open
#: mouse enters trap
#: Trapping: Closing door
#: mouse escapes
#: Waiting: Broadcasting cheese smell
#: mouse appears
#: Luring: Presenting Cheese, door open
#: mouse enters trap
#: Trapping: Closing door
#: mouse trapped
#: Holding: Mouse caught
#: mouse removed
#: Waiting: Broadcasting cheese smell
#: mouse appears
#: Luring: Presenting Cheese, door open
#: mouse runs away
#: Waiting: Broadcasting cheese smell
#: mouse appears
#: Luring: Presenting Cheese, door open
#: mouse enters trap
#: Trapping: Closing door
#: mouse trapped
#: Holding: Mouse caught
#: mouse removed
#: Waiting: Broadcasting cheese smell
# ESCAPES has no case in Waiting, so case _
# fires and the machine stays at Waiting:
trap = MouseTrap()
trap.run_all([MouseAction.ESCAPES])
#: Waiting: Broadcasting cheese smell
#: mouse escapes
#: Waiting: Broadcasting cheese smellMouseTrap holds all the possible states as class
attributes and sets up the initial state. The code at the bottom
of the file builds a MouseTrap and runs it through
the whole sequence of moves read from the text file.
The match statements inside next()
work, but a machine with many states means many of them, spread
across many classes. Another approach puts a table inside each
State object, listing the next state for each
input. A state’s table cannot sit in that state’s class body,
because the entries name the other states, and those states
exist only after every class definition has run. So the classes
come first, and the tables fill in at module level once every
state object exists.
TableState supplies next() from a
transitions dict that maps each input to its next
state, and leaves run() abstract for its
subclasses. Its next() looks the input up in that
dict, so the StateMachine class from the previous
example still serves. TableState.__init__() starts
every state with an empty dict. If you forget to fill one, the
machine reports Waiting has no transition for ...
rather than an AttributeError. The subclasses now
define only their run() behavior. The transitions
live in the tables filled in at the bottom of the file:
# mouse_trap_tables.py
# A better mousetrap using tables
from abc import ABC, abstractmethod
from pathlib import Path
from typing import ClassVar, override
from exceptions import expect
from mouse_action import MouseAction
from state import State
from state_machine import StateMachine
class TableState(ABC):
def __init__(self) -> None:
self.transitions: dict[object, State] = {}
@abstractmethod
def run(self) -> None: ...
def next(self, event: object) -> State:
try:
return self.transitions[event]
except KeyError:
raise RuntimeError(
f"{type(self).__name__} has no transition "
f"for {event}") from None
class Waiting(TableState):
@override
def run(self) -> None:
print("Waiting: Broadcasting cheese smell")
class Luring(TableState):
@override
def run(self) -> None:
print("Luring: Presenting Cheese, door open")
class Trapping(TableState):
@override
def run(self) -> None:
print("Trapping: Closing door")
class Holding(TableState):
@override
def run(self) -> None:
print("Holding: Mouse caught")
class MouseTrap(StateMachine):
waiting: ClassVar[TableState] = Waiting()
luring: ClassVar[TableState] = Luring()
trapping: ClassVar[TableState] = Trapping()
holding: ClassVar[TableState] = Holding()
def __init__(self) -> None:
super().__init__(MouseTrap.waiting)
# Every state object now exists, so each table can name
# its next states directly:
MouseTrap.waiting.transitions = {
MouseAction.APPEARS: MouseTrap.luring,
}
MouseTrap.luring.transitions = {
MouseAction.RUNS_AWAY: MouseTrap.waiting,
MouseAction.ENTERS: MouseTrap.trapping,
}
MouseTrap.trapping.transitions = {
MouseAction.ESCAPES: MouseTrap.waiting,
MouseAction.TRAPPED: MouseTrap.holding,
}
MouseTrap.holding.transitions = {
MouseAction.REMOVED: MouseTrap.waiting,
}
text = Path("mouse_moves.txt").read_text()
moves = [line.strip() for line in text.splitlines()
if line.strip() and not line.startswith("#")]
MouseTrap().run_all([MouseAction(m) for m in moves[:9]])
#: Waiting: Broadcasting cheese smell
#: mouse appears
#: Luring: Presenting Cheese, door open
#: mouse runs away
#: Waiting: Broadcasting cheese smell
#: mouse appears
#: Luring: Presenting Cheese, door open
#: mouse enters trap
#: Trapping: Closing door
#: mouse escapes
#: Waiting: Broadcasting cheese smell
#: mouse appears
#: Luring: Presenting Cheese, door open
#: mouse enters trap
#: Trapping: Closing door
#: mouse trapped
#: Holding: Mouse caught
#: mouse removed
#: Waiting: Broadcasting cheese smell
# ESCAPES is not a key in Waiting.transitions:
trap2 = MouseTrap()
expect(RuntimeError, trap2.run_all, [MouseAction.ESCAPES])
#: Waiting: Broadcasting cheese smell
#: mouse escapes
#: [RuntimeError] Waiting has no transition for mouse
#: escapesThe demonstration stops after the first nine moves, which between them exercise every transition in the trap. The rest of the input file only repeats them, so the output continues as in the first version.
With many State classes to maintain, the tables
read more easily than the match statements.
next() raises its RuntimeError
from None. Chaining would keep the
KeyError, which only repeats the event the message
already names.
The two versions also answer a question this input file does
not ask: what happens on an unexpected input? They answer it
differently. Version 1’s case _ arms return the
current state, so an input a state does not recognize raises no
exception and the machine stays put. Staying put is not the same
as doing nothing: run_all() calls
run() on whatever state next()
returns, so a transition back to the current state runs that
state’s action a second time. Version 2’s table holds only the
explicit transitions, and its next() raises an
exception on anything else. Either answer can be right, so
choose it on purpose. Staying put suits a machine fed from a
noisy source that includes events meant for something else.
Raising an exception suits a table you are still building, where
a missing entry is a bug to flag, and the table-driven engine
below raises an exception for the same reason.
Both listings end with one more call that puts this to the
test: feeding MouseAction.ESCAPES to a fresh trap
sitting in Waiting, where no case
names it. Version 1 prints
Waiting: Broadcasting cheese smell a second time:
the state stayed put and ran again. Version 2 raises
RuntimeError: Waiting has no transition for mouse escapes.
The previous design keeps each state’s transitions inside the state class. A fully table-driven design can go further and represent the entire machine as a single transition table. All the behavior then lives in one place, so you can build and maintain it directly from a state-transition diagram.
For a given current state and input, a transition row answers three questions: whether a condition must pass, what action runs during the transition, and what state comes next. As a table:
{(current_state, InputType): [(condition, action, next_state), ...]}
The original Java version of this example needed two extra
class hierarchies, Condition and
Transition, because the Java of the time had no way
to store a method as a value. Python functions are first-class,
so those hierarchies vanish. A condition is any callable
returning a bool, an action is any callable, and
the table is an ordinary dict.
The inputs change shape too. The mousetrap’s inputs are
MouseAction members, names with nothing attached. A
vending machine’s inputs carry values: what a coin is worth,
which digit the user pressed. So each input becomes an object of
its own class, and the table keys on that class rather than on a
value. An enum would fail here twice: you set its members when
you write it, so it can carry only the values you knew about
then, and every member of one enum shares that enum’s class, so
they would all arrive under the same dispatch key.
The names restart here. tabledriven/table_machine.py
holds a different StateMachine from the one above,
and State is now an Enum of names
rather than a Protocol the state classes satisfy.
The file’s name differs from the first engine’s state_machine.py on
purpose. Python caches a module in sys.modules
under its import name (Modules and
Packages shows the cache), and a later import
takes the cached module without looking at any file. Two files
named state_machine.py in one
program therefore collapse into one: whichever imported first
wins, and the second import silently gets the wrong module. The
states in this design do nothing. The table holds all the
behavior.
For the current state and the type of the incoming event, the engine walks the candidate transitions in order, takes the first whose condition passes (or has no condition), runs that transition’s action, and moves to the next state:
# tabledriven/table_machine.py
# A generic table-driven state machine.
from collections.abc import Callable
from enum import Enum
# (condition, action, next_state); condition and action
# may be None. A state is an Enum member, so a misspelled
# state is a type error rather than a silent dead end.
type Transition = tuple[
Callable[..., bool] | None, Callable[..., None] | None,
Enum
]
type Table = dict[tuple[Enum, type], list[Transition]]
class NoTransition(RuntimeError):
"No table row matched this state and event."
class StateMachine:
def __init__(self, initial: Enum, table: Table) -> None:
self.state = initial
self.table = table
def handle(self, event: object) -> None:
for condition, action, next_state in self.table.get(
(self.state, type(event)), []):
if condition is None or condition(event):
if action is not None:
action(event)
self.state = next_state
return
raise NoTransition(
f"no transition from {self.state!r} "
f"on {type(event).__name__}")The listing writes StateMachine by hand rather
than as a @dataclass because a generated
__init__() cannot rename its parameter, and this
constructor renames what it stores: the caller passes
initial, but the attribute is state,
which handle() updates. NoTransition
derives from RuntimeError, so a caller can catch
the specific failure instead of every RuntimeError
an action method might raise.
Several candidate transitions can share one
(state, input) key. Their conditions tell them
apart. The engine tries them top to bottom, which is how a
single input can lead to different states depending on a test. A
row whose condition is None matches every time, so
it belongs last in its group, as the else for the
rows above it. Without such a row, a group can match nothing.
When every condition returns False,
handle() raises the same NoTransition
a missing key raises. The lookup keys on
type(event) exactly, a dictionary probe rather than
an isinstance() walk. That lets the vending machine
below treat FirstDigit and SecondDigit
as distinct inputs even though both derive from
Digit. It cuts the other way too. A further
subclass of an event type matches none of its parent’s rows,
because the table must name an event’s exact class.
The engine passes the event to both callables, whether they
need it or not, which is why refund() takes an
argument it ignores. The Callable[..., bool] and
Callable[..., None] annotations leave the
parameters as ... because each method declares the
specific event type it handles, and no one signature covers them
all. That ... costs you a check: nothing verifies
that a row’s condition and action accept the event class its key
names. If you pair a SecondDigit key with a method
written for a FirstDigit, the table type-checks
clean and does the wrong thing at runtime.
One table now defines the whole machine. The machine collects money, takes a two-digit selection, then either dispenses the item, reports it sold out, or clears a selection that costs more than the money inserted. The conditions and actions are ordinary methods, stored directly in the table.
The states are an Enum, so the type checker
catches a misspelled state name before it can fail silently at
runtime. MouseAction was a StrEnum
because its values had to match lines of the input file. Nothing
parses these states from text, so a plain Enum with
auto() serves:
# tabledriven/vending_machine.py
from dataclasses import dataclass
from enum import Enum, auto
from table_machine import StateMachine, Table
class State(Enum):
QUIESCENT = auto()
COLLECTING = auto()
SELECTING = auto()
UNAVAILABLE = auto()
WANT_MORE = auto()
@dataclass
class Money:
name: str
value: int
def __str__(self) -> str:
return self.name
class Quit:
def __str__(self) -> str:
return "Quit"
@dataclass
class Digit:
name: str
value: int
def __str__(self) -> str:
return self.name
class FirstDigit(Digit):
pass
class SecondDigit(Digit):
pass
@dataclass
class ItemSlot:
price: int
quantity: int
class VendingMachine(StateMachine):
def __init__(self) -> None:
self.amount = 0 # Money inserted, in cents
self.row = 0 # The first selection digit
# Last action, for a view to display
self.message = ""
# A 4x4 grid; column c costs (c + 1) * 25 cents:
self.items = [[ItemSlot((c + 1) * 25, 5)
for c in range(4)]
for _ in range(4)]
# One sold-out slot
self.items[3][0] = ItemSlot(25, 0)
table: Table = {
(State.QUIESCENT, Money):
[(None, self.add_money, State.COLLECTING)],
(State.COLLECTING, Money):
[(None, self.add_money, State.COLLECTING)],
(State.COLLECTING, Quit):
[(None, self.refund, State.QUIESCENT)],
(State.COLLECTING, FirstDigit):
[(None, self.choose_row, State.SELECTING)],
(State.SELECTING, Quit):
[(None, self.refund, State.QUIESCENT)],
(State.SELECTING, SecondDigit): [
(self.too_expensive, self.clear,
State.COLLECTING),
(self.sold_out, self.clear,
State.UNAVAILABLE),
(None, self.dispense, State.WANT_MORE),
],
(State.UNAVAILABLE, Quit):
[(None, self.refund, State.QUIESCENT)],
(State.UNAVAILABLE, FirstDigit):
[(None, self.choose_row, State.SELECTING)],
(State.WANT_MORE, Quit):
[(None, self.refund, State.QUIESCENT)],
(State.WANT_MORE, FirstDigit):
[(None, self.choose_row, State.SELECTING)],
}
super().__init__(State.QUIESCENT, table)
def _slot(self, col: SecondDigit) -> ItemSlot:
return self.items[self.row][col.value]
# Conditions:
def too_expensive(self, col: SecondDigit) -> bool:
return self._slot(col).price > self.amount
def sold_out(self, col: SecondDigit) -> bool:
return self._slot(col).quantity == 0
def add_money(self, money: Money) -> None:
self.amount += money.value
self.message = f"Total = {self.amount}"
def choose_row(self, digit: FirstDigit) -> None:
self.row = digit.value
self.message = f"Row {digit}"
def clear(self, col: SecondDigit) -> None:
slot = self._slot(col)
self.message = (f"Cleared: costs {slot.price}, "
f"quantity {slot.quantity}")
def dispense(self, col: SecondDigit) -> None:
slot = self._slot(col)
slot.quantity -= 1
self.amount -= slot.price
self.message = (
f"Dispensing; remaining {self.amount}")
def refund(self, event: object) -> None:
self.message = f"Returning {self.amount}"
self.amount = 0
if __name__ == "__main__":
events = [
Money("quarter", 25), Money("quarter", 25),
Money("dollar", 100),
# Buy [0][1]
FirstDigit("A", 0), SecondDigit("col 1", 1),
# Buy it again
FirstDigit("A", 0), SecondDigit("col 1", 1),
# Too expensive
FirstDigit("C", 2), SecondDigit("col 2", 2),
# Sold out
FirstDigit("D", 3), SecondDigit("col 0", 0),
Quit(), # Refund and reset
# Row D, col 0 is both too expensive (a dime
# isn't 25 cents) and sold out (quantity 0);
# too_expensive is listed first, so it wins:
Money("dime", 10),
FirstDigit("D", 3), SecondDigit("col 0", 0),
]
machine = VendingMachine()
for event in events:
machine.handle(event)
print(f"{event}: {machine.message} "
f"[{machine.state.name}]")
#: quarter: Total = 25 [COLLECTING]
#: quarter: Total = 50 [COLLECTING]
#: dollar: Total = 150 [COLLECTING]
#: A: Row A [SELECTING]
#: col 1: Dispensing; remaining 100 [WANT_MORE]
#: A: Row A [SELECTING]
#: col 1: Dispensing; remaining 50 [WANT_MORE]
#: C: Row C [SELECTING]
#: col 2: Cleared: costs 75, quantity 5 [COLLECTING]
#: D: Row D [SELECTING]
#: col 0: Cleared: costs 25, quantity 0 [UNAVAILABLE]
#: Quit: Returning 50 [QUIESCENT]
#: dime: Total = 10 [COLLECTING]
#: D: Row D [SELECTING]
#: col 0: Cleared: costs 25, quantity 0 [COLLECTING]The sold-out and too-expensive clears both print
Cleared and end in different states. Too expensive
returns to COLLECTING with the money still
inserted, while sold out goes to UNAVAILABLE. The
state names the condition; the message alone leaves you
inferring it from the quantity. The last three events insert a
dime and pick the same sold-out slot again, this time with too
little money for it as well. Both conditions are now true, and
too_expensive sits first in that row’s list, so it
wins. The machine reports COLLECTING, as though a
dollar more would sell it, when the slot is empty and no amount
of money would. If you swapped the row order, the same input
would report UNAVAILABLE instead. That is the cost
of the ordering rule stated above: a row lower in the list can
never override one above it, even when the lower row is the one
that matters.
The table goes in __init__() rather than in the
class body, because each entry is a bound method:
self.add_money carries this machine with it, so
each VendingMachine gets a table wired to its own
money and stock.
Adding a state or an input is now a local change: an entry in
the table and a method or two. Nothing here needs a
switch, reflection, or a
Condition/Transition class hierarchy.
The language’s first-class functions and its dict
supply what those patterns existed to provide.
Because the machine is deterministic, a test can drive it through a sequence of events and check which state it reaches. The cases worth pinning down are a successful purchase, the two conditional branches (too expensive and sold out), a refund, and the error when no transition matches:
# tabledriven/test_vending.py
import pytest
from table_machine import NoTransition
from vending_machine import (
FirstDigit,
Money,
Quit,
SecondDigit,
State,
VendingMachine,
)
def feed(vm: VendingMachine, *events: object) -> None:
for event in events:
vm.handle(event)
def test_buy_dispenses_and_charges() -> None:
vm = VendingMachine()
assert vm.state is State.QUIESCENT
# Item [0][1], 50c
feed(vm, Money("quarter", 25), Money("quarter", 25),
FirstDigit("A", 0), SecondDigit("two", 1))
assert vm.state is State.WANT_MORE
assert vm.amount == 0 # 50 in, 50 spent
# One dispensed from five
assert vm.items[0][1].quantity == 4
assert vm.message == "Dispensing; remaining 0"
def test_too_expensive_clears_back_to_collecting() -> None:
vm = VendingMachine()
# 50c item, 25c in
feed(vm, Money("quarter", 25),
FirstDigit("A", 0), SecondDigit("two", 1))
assert vm.state is State.COLLECTING
assert vm.amount == 25 # Money kept
assert vm.items[0][1].quantity == 5 # Nothing dispensed
def test_sold_out_goes_to_unavailable() -> None:
vm = VendingMachine()
# [3][0] is sold out
feed(vm, Money("quarter", 25),
FirstDigit("D", 3), SecondDigit("one", 0))
assert vm.state is State.UNAVAILABLE
assert vm.items[3][0].quantity == 0
def test_quit_refunds_and_resets() -> None:
vm = VendingMachine()
feed(vm, Money("dollar", 100), Quit())
assert vm.state is State.QUIESCENT
assert vm.amount == 0
def test_no_transition_raises() -> None:
# QUIESCENT has no transition for Quit
vm = VendingMachine()
with pytest.raises(NoTransition):
vm.handle(Quit())Because the actions set vm.message instead of
printing, VendingMachine produces no output of its
own, and the same machine can drive more than one view. The text
demo in vending_machine.py reads
message and prints it. Contrast
run_all() in the first design, which prints its
input from inside the framework. Printing there is convenient
for a book listing and wrong for a reusable machine, because it
fixes one output device into the engine. Recording a message
instead leaves the choice to whoever is watching.
Using tkinter, you can build a GUI for the
vending machine. The panel reads amount, the stock,
and message and shows them on screen. The coin and
item buttons turn presses into events for handle(),
and the GUI catches a click that the state machine rejects (a
selection before any money, say) and shows a message rather than
crashing. The button loop builds sixteen commands with
partial(select, r, c) rather than a lambda. Sixteen
lambdas closing over r and c would all
see the loop’s final values, the late-binding trap from Function
Objects. The three fixed buttons above use lambdas safely,
since they close over nothing that varies. Because this listing
requires user interaction, the harness skips it
(tools/data/norun.txt):
# tabledriven/vending_view.py
import tkinter as tk
from functools import partial
from table_machine import NoTransition
from vending_machine import (
FirstDigit,
Money,
Quit,
SecondDigit,
VendingMachine,
)
def show() -> None:
vm = VendingMachine()
root = tk.Tk()
root.title("Vending Machine")
display = tk.Label(root, width=34, anchor="w")
display.grid(row=0, column=0, columnspan=4, sticky="we")
buttons: list[list[tk.Button]] = []
def render() -> None:
display.config(
text=f"Inserted {vm.amount}c {vm.message}")
for r, row in enumerate(vm.items):
for c, slot in enumerate(row):
out = slot.quantity == 0
qty = "OUT" if out else f"x{slot.quantity}"
buttons[r][c].config(
text=f"{r}{c}\n{slot.price}c\n{qty}",
state="disabled" if out else "normal")
def send(event: object) -> None:
try:
vm.handle(event)
except NoTransition:
vm.message = "not allowed yet"
render()
def select(r: int, c: int) -> None:
send(FirstDigit(f"row {r}", r))
send(SecondDigit(f"col {c}", c))
tk.Button(root, text="+25c",
command=lambda: send(Money("quarter", 25))
).grid(row=1, column=0, sticky="we")
tk.Button(root, text="+$1",
command=lambda: send(Money("dollar", 100))
).grid(row=1, column=1, sticky="we")
tk.Button(root, text="Refund",
command=lambda: send(Quit())
).grid(row=1, column=2, columnspan=2,
sticky="we")
for r in range(4):
button_row: list[tk.Button] = []
for c in range(4):
b = tk.Button(root, width=6, height=3,
command=partial(select, r, c))
b.grid(row=2 + r, column=c)
button_row.append(b)
buttons.append(button_row)
render()
root.mainloop()
if __name__ == "__main__":
show()The two designs answer the same question and put the answer in different places.
Each-state-decides suits a machine whose states do something
and have few transitions apiece. The state class owns both
halves, so reading mouse_trap_states.py’s
Luring tells you what luring does and where it can
go next, and adding a state is one class. It reads best when the
transitions are obvious from the state’s own name. An action
that must run on every entry into one state, such as chiming
whenever the machine reaches COLLECTING, belongs in
that state’s run(), written once.
One-table suits a machine you build from a diagram, whose
inputs carry data, or whose transitions need conditions.
Everything is in one place, in the same order as the diagram,
and adding a state or an input is an entry in the table and a
method or two. The states shrink to Enum members
with no behavior, so that per-state action has no home: an
action shared by several edges into the same state must repeat
on every row that leads there, or route through a helper the
table does not provide on its own.
The tell is which you would rather read: one state’s transitions, gathered in that state, or the whole machine’s, gathered in one table. A machine small enough to hold in your head goes either way, and a machine that arrived as a diagram belongs in the table.
Both designs also cost you what a library would supply.
Mature libraries such as transitions and
python-statemachine add guards, callbacks, and
hierarchical states for the price of an import. Choose one of
the two designs here when you cannot take that dependency, or
want the mechanism visible in your own code. Choose a library
once the machine outgrows what a page of code should carry.
UnpredictablePerson that
changes the kind of response to its hello() method
depending on its current Mood. Add another kind of
Mood called Prozac.StateMachine from tabledriven/table_machine.py
to a washing-machine problem. Give one
(state, input) pair two rows told apart by a
condition, such as a load too heavy for the fast spin.dict to map a str naming a state to
its state object. Give each state subclass its own transition
table, which its next_state() method consults. Feed
the machine a sequence of single words, such as a text file with
one word per line.state_machine.py, the
first design, where each state decides the next one.tabledriven/table_machine.py.
Give the “doors closing” state two rows for the same input, one
guarded by a door-obstruction condition.tabledriven/table_machine.py.
A single TemperatureReading input must be able to
lead to heating, cooling, or idle, decided entirely by
conditions on one (state, input) key.mouse_move_generator() (Iterators)
that yields valid MouseAction moves in sequence,
where each possible move depends on the previous one (it is
another state machine). Have it accept an int for
the number of moves to produce, then stop.Money,
modeled on vending_machine.py’s. Add
a Nickel class deriving from Money and
feed one in without touching the table. Explain the exception,
then make it work two ways: by adding a row, and by making
Nickel an instance of Money rather
than a subclass. Say which you would keep.