Many systems are in one of a known set of states at any moment, and each input, together with the current state, decides the next state. A State Machine names the states and the transitions between them.
Recall State: a surrogate object that forwards calls to a swappable implementation. State lets the client programmer swap the implementation. State Machine 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’s
next(), which returns the 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.
A State runs its operation and, given an event,
names the next State:
# state.py
from typing import Protocol
class State(Protocol):
def run(self) -> None: ...
def next(self, event: object) -> State: ...Python does not require this Protocol. The Protocol’s few
lines do two things: 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.
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 defines 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.
In this style of State Machine, 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.
Because MouseAction 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.
MouseAction("mouse appears") returns the
matching member. mouse_trap_states.py
parses each line of the test input with that call.
A text file supplies the sequence of mouse inputs, nine moves that between them exercise every transition in the trap:
# mouse_moves.txt
mouse appears
mouse runs away
mouse appears
mouse enters trap
mouse escapes
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
# ESCAPES has no case in Waiting, so case _
# fires and the machine stays at Waiting:
trap = MouseTrap()
#: Waiting: Broadcasting cheese smell
trap.run_all([MouseAction.ESCAPES])
#: mouse escapes
#: Waiting: Broadcasting cheese smellMouseTrap holds all the possible states as class
attributes and sets up the initial state. A state class stores
nothing, so each state can be one shared object. A single
Waiting serves every MouseTrap and
every visit to that 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.
Each next() is a match on the
event: a case for every input the state recognizes,
and a case _ that returns the machine’s current
state. Each case names its member through the
class, as in MouseAction.APPEARS. A dotted name
compares the event with that member, while a bare
APPEARS would be a capture
pattern that matches every event.
Waiting.next() returns
MouseTrap.luring although the
class MouseTrap statement comes further down the
file. Python looks up a name inside a function when the function
runs, not when its def executes. By the time
anything calls next(), the whole module has run and
MouseTrap exists.
StateMachine’s constructor runs the initial
state, the construction-starts-the-engine choice that Template
Method discourages. Two facts make that choice safe
here: MouseTrap.__init__() assigns nothing after
its super().__init__() call, and no state’s
run() reads anything off the machine. A later edit
can undo either fact. If you give a State a
run() that reads a machine attribute, that warning
applies again.
None of the four state classes inherits from
State. Each satisfies the Protocol by defining
run() and next(). A base class could
also give the annotations a type to name:
class State: pass
With that base, the type checker rejects the engine, since
State declares neither the run() nor
the next() the engine calls. At runtime, Python
builds a derived class that defines run() alone
without complaint, and the machine runs an instance of it. The
error waits until run_all() calls the missing
next() and Python raises an
AttributeError. A base whose methods
raise NotImplementedError satisfies the checker.
The call to the missing method then raises that
NotImplementedError from the base, with whatever
message you write there. Surrogate
shows one more option. If you make State an
ABC with @abstractmethod on both
methods, the error moves to the constructor, which raises a
TypeError for a subclass that defines
run() alone. The type checker reports that
construction too, so the ABC and the Protocol both
report the missing method before the program runs. The empty
base and the NotImplementedError base report it at
the call.
The match statements inside next()
work, but a machine with many states means many
match statements, spread across many classes. The
second version 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 module-level code fills in the tables 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. It is an ABC, the alternative the first
version sets aside, because here the base has code to share.
Every subclass inherits next().
TableState’s run() and
next() have the signatures the State
Protocol names, so the StateMachine class from state_machine.py drives
every TableState, with no change to that class:
# table_state.py
from abc import ABC, abstractmethod
from state import State
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 NoneTableState.__init__() starts every state with an
empty dict. If you forget to fill a state’s table, the machine
reports Waiting has no transition for ... rather
than the AttributeError a missing
transitions attribute would produce.
next() raises its RuntimeError
from None, because the chained
KeyError would repeat the message. Its key is the
event the message names.
The subclasses shrink to their run() behavior.
The transitions live in the tables filled in at the bottom of
the file:
# mouse_trap_tables.py
from pathlib import Path
from typing import ClassVar, override
from exceptions import expect
from mouse_action import MouseAction
from state_machine import StateMachine
from table_state import TableState
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])
#: 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()
#: Waiting: Broadcasting cheese smell
expect(RuntimeError, trap2.run_all, [MouseAction.ESCAPES])
#: mouse escapes
#: [RuntimeError] Waiting has no transition for mouse
#: escapesWith many State classes to maintain, the tables
read more easily than the match statements.
The two versions also differ on an unexpected input, one the
current state does not name. None of the nine moves in the file
produces one, so both listings end with one more call: feeding
MouseAction.ESCAPES to a fresh trap in
Waiting, where neither the match nor
the table names that input. Version 1 prints
Waiting: Broadcasting cheese smell a second time.
Version 2 raises
RuntimeError: Waiting has no transition for mouse escapes.
The figure traces the run both versions share, then this one
input:
The trace at the top covers the nine shared moves. In each
row the current state’s next() picks the successor,
so the machine moves from object to object and leaves every
decision to the states. Below the trace, the one input that
splits the versions appears twice, once under each policy.
Version 1’s case _ arms return the current
state, so an unrecognized input keeps the machine where it is.
Staying in the same state still does something.
run_all() calls run() on whatever
state next() returns, so returning the current
state runs that state’s action a second time. Version 2’s table
holds the explicit transitions alone, and its
next() raises an exception on every other
input.
Either policy can be right, so choose one on purpose. Staying
in the same state suits a machine fed from a 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 the exception reports. The table-driven engine in tabledriven/table_machine.py
raises an exception for the same reason.
The each-state-decides design keeps each state’s transitions inside the state class. A fully table-driven design represents the entire machine as a single transition table. All the behavior is then in one place, so you can build and maintain the table from a state-transition diagram. The example is a vending machine, built in two steps: an engine with no vending-specific code in it, then the machine’s table.
For a given current state and input, a transition row records three things: 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 the Python version needs neither hierarchy. 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, bare names. The 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 is the wrong shape here for two reasons. You set its
members when you write it, so its values are the ones you knew
about then. Every member of one enum also shares that enum’s
class, so type(event) is the same key for all of
them.
This design reuses two names with new meanings. tabledriven/table_machine.py
holds a different StateMachine from the one in state_machine.py, and
State is now an Enum of names rather
than a Protocol the state classes satisfy. The
states in this design are bare names. The table holds all the
behavior.
The name table_machine.py differs
from the first engine’s state_machine.py on
purpose. Python caches each
module under its import name, so a program that imports two
files named state_machine.py gets the
first one both times, with no error.
For the current state and the type of the incoming event, the engine tries 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
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__() names each parameter after its field.
This constructor renames what it stores. The caller passes
initial, but the attribute is state,
which handle() updates.
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.
When every condition in a group returns False,
handle() raises the same NoTransition
a missing key raises. NoTransition derives from
RuntimeError, so a caller can catch the specific
failure instead of every RuntimeError an action
method might raise.
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 in the table.
Money moves the machine to
COLLECTING and keeps it there, a first digit moves
it to SELECTING, and a second digit moves it to one
of three states, decided by price and stock. Quit
refunds from any of the other states back to
QUIESCENT.
The states are an Enum, so the type checker
reports a misspelled state name before the program runs. A
misspelled string would pass every check and surface as a
NoTransition at runtime. MouseAction
is a StrEnum because its values must match lines of
the input file. These states stay inside the program, 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 = 0The engine’s lookup keys on type(event) exactly,
one dictionary lookup rather than an isinstance()
test against each row. The table keys separate rows on
FirstDigit and SecondDigit, two
subclasses of Digit that differ only in their
class. The same exactness excludes a further subclass. An event
whose class derives from Money matches none of
Money’s rows, because the key is the event’s exact
class.
The table goes in __init__() rather than in the
class body, because each entry is a bound method.
self.add_money holds a reference to this machine,
so each VendingMachine’s table calls methods that
read and write its own amount and
items.
The engine passes the event to both the condition and the
action, so 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 those types differ from row
to row. With that ..., the type checker accepts any
callable in any row, whatever event class the 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.
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 mechanisms exist to provide.
vending_demo.py feeds a
VendingMachine a list of events and prints each
event with the resulting message and state:
# tabledriven/vending_demo.py
from vending_machine import (FirstDigit, Money, Quit,
SecondDigit, VendingMachine)
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 the condition 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() comes first in the
(State.SELECTING, SecondDigit) list, so the engine
takes the too_expensive() row. The machine reports
COLLECTING, as though more money would sell the
item, although the slot is empty and no amount of money would.
If you swap the row order, the same input reports
UNAVAILABLE instead. Both results follow from the
engine’s ordering rule. The first row
whose condition passes wins, whether or not a lower row matters
more.
Because the machine is deterministic, a test can drive it through a sequence of events and check which state it reaches. The cases worth testing are:
# 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_spends() -> None:
vm = VendingMachine()
assert vm.items[0][1].price == 50
feed(vm, Money("quarter", 25), Money("quarter", 25),
FirstDigit("A", 0), SecondDigit("col 1", 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()
assert vm.items[0][1].price == 50
# 25c in
feed(vm, Money("quarter", 25),
FirstDigit("A", 0), SecondDigit("col 1", 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_clears_to_unavailable() -> None:
vm = VendingMachine()
# [3][0] is sold out
feed(vm, Money("quarter", 25),
FirstDigit("D", 3), SecondDigit("col 0", 0))
assert vm.state is State.UNAVAILABLE
assert vm.amount == 25 # Money kept
assert vm.items[3][0].quantity == 0 # Nothing dispensed
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:
vm = VendingMachine()
assert vm.state is State.QUIESCENT
with pytest.raises(NoTransition):
vm.handle(Quit())Because the actions set vm.message,
VendingMachine leaves all output to its caller. The
same machine can therefore drive more than one view. The text
demo in vending_demo.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
puts the print() call in the engine, where every
user of run_all() gets it.
A tkinter GUI is a second view of the same
vending machine. The GUI reads amount, the stock,
and message and shows them on screen. Its coin,
refund, and item buttons turn presses into events for
handle(). 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()send() hands each event to handle()
and catches the NoTransition that a rejected click
raises (a selection before any money, say), so the GUI shows a
message instead of the traceback tkinter would
otherwise print.
The button loop builds sixteen commands with
partial(select, r, c) rather than a lambda. Sixteen
lambdas closing over r and c would all
read the loop’s final values, the late-binding
trap. The three coin and refund buttons use lambdas safely,
since the one name they capture, send, keeps one
value for the life of the window.
The two designs answer the same question, which state comes next, 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 holds both the
action and the transitions, so reading mouse_trap_states.py’s
Luring tells you what luring does and which states
can follow it. Adding a state is one class. The design reads
best when the transitions are obvious from the state’s own name.
An action that must run on every entry into one state belongs in
that state’s run(), written once. Sounding a chime
whenever the trap reaches Holding is such an
action.
Inside that design, match statements and
per-state tables differ in which code handles an unrecognized
input. With match, each state’s case _
sets its own policy, in the method you read. With tables,
TableState.next() sets one policy for every state,
and each class shrinks to its run(). The tables
read better as the states multiply, because every state’s
transitions have the same shape and sit together at the bottom
of the file.
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.
Adding a state or an input is an entry in the table and a method
or two. The states shrink to Enum members, bare
names, so an action that runs on every entry into one state must
live in the table. The action repeats on every row that leads to
that state, or routes through a helper you write yourself.
The deciding question 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 with a few states works in either design, and a machine you drew as a diagram first belongs in the table.
With either design you write for yourself what a library
supplies. Mature libraries such as transitions and
python-statemachine supply guards, callbacks, and
hierarchical states. Using one adds a dependency. 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 needs more than a page of code.
This chapter’s solutions give a hint, usually the shape of the code, and a full answer for each exercise.
UnpredictablePerson that
changes the kind of response to its hello() method
depending on its current Mood. Add another kind of
Mood called Prozac.UnpredictablePerson into a
state machine using state_machine.py, the
first design, where each state decides the next one.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.mouse_move_generator(), a generator 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.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. Then
press Start in the middle of a cycle, an input for
which that state has no row, and decide what the caller does
with the NoTransition: ignore the press or stop the
machine. Say which policy suits a washing machine, and why.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.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.