State Machines¶
Specwright's StateMachine base class enforces valid state transitions at runtime. You declare the states, define transitions as decorated methods, and the framework prevents impossible state changes.
Basic Usage¶
from specwright import StateMachine, transition
class TrafficLight(StateMachine):
states = ["red", "green", "yellow"]
initial_state = "red"
@transition(from_state="red", to_state="green")
def go(self):
return "Light turned green"
@transition(from_state="green", to_state="yellow")
def caution(self):
return "Light turned yellow"
@transition(from_state="yellow", to_state="red")
def stop(self):
return "Light turned red"
>>> light = TrafficLight()
>>> light.state
'red'
>>> light.go()
'Light turned green'
>>> light.state
'green'
>>> light.stop() # Can't go from green to red!
# InvalidTransitionError: Cannot transition from 'green' to 'red'
# via 'stop'. Valid source state(s): yellow
State Diagram¶
Here's the traffic light as a state diagram:
stateDiagram-v2
[*] --> red
red --> green: go()
green --> yellow: caution()
yellow --> red: stop()
Defining States and Transitions¶
Required Class Attributes¶
Every StateMachine subclass must define:
states— a list of valid state names (strings)initial_state— the starting state (must be instates)
The @transition Decorator¶
Mark methods as state transitions:
@transition(from_state="idle", to_state="running")
def start(self):
"""Begin processing."""
return "Started"
from_state— the state(s) the machine must be in for this method to executeto_state— the state the machine moves to after the method succeeds
Multiple Source States¶
A transition can be valid from multiple states:
@transition(from_state=["pending", "paid"], to_state="cancelled")
def cancel(self, reason: str) -> str:
"""Cancel the order (only before shipping)."""
return f"Cancelled: {reason}"
stateDiagram-v2
[*] --> pending
pending --> paid: pay()
paid --> shipped: ship()
pending --> cancelled: cancel()
paid --> cancelled: cancel()
Class Validation¶
Specwright validates state machine classes at definition time (when the class body is executed):
# Invalid initial state
class Bad(StateMachine):
states = ["a", "b"]
initial_state = "c" # InvalidStateError — "c" not in states
# Transition references invalid state
class Bad(StateMachine):
states = ["a", "b"]
initial_state = "a"
@transition(from_state="a", to_state="z") # InvalidStateError — "z" not in states
def go(self):
pass
# Empty states
class Bad(StateMachine):
states = [] # InvalidStateError — must define at least one state
initial_state = "a"
State History¶
Opt into tracking every state the machine visits:
class Tracked(StateMachine):
states = ["a", "b", "c"]
initial_state = "a"
track_history = True # Enable history tracking
@transition(from_state="a", to_state="b")
def go_b(self): pass
@transition(from_state="b", to_state="c")
def go_c(self): pass
sm = Tracked()
sm.go_b()
sm.go_c()
sm.state_history # ["a", "b", "c"]
History is disabled by default. When disabled, state_history returns an empty list.
Note
state_history always returns a copy of the internal list, so modifying it won't affect the machine.
Lifecycle Hooks¶
Define on_enter_<state> and on_exit_<state> methods to run code automatically during transitions:
class WithHooks(StateMachine):
states = ["active", "suspended", "terminated"]
initial_state = "active"
@transition(from_state="active", to_state="suspended")
def suspend(self):
return "Suspended"
@transition(from_state="suspended", to_state="terminated")
def terminate(self):
return "Terminated"
def on_exit_active(self):
print("Leaving active state")
def on_enter_suspended(self):
print("Entering suspended state")
def on_exit_suspended(self):
print("Leaving suspended state")
Hook execution order:
- Transition method executes
on_exit_<old_state>runs (if defined)- State updates to the new value
on_enter_<new_state>runs (if defined)
Hooks don't run on failed transitions
If the transition method raises an exception, hooks are not called and the state doesn't change.
Exception Safety¶
If a transition method raises an exception, the state remains unchanged:
class Safe(StateMachine):
states = ["idle", "running"]
initial_state = "idle"
@transition(from_state="idle", to_state="running")
def start(self):
raise RuntimeError("Engine failed")
sm = Safe()
try:
sm.start()
except RuntimeError:
pass
sm.state # Still "idle" — the transition was rolled back
Custom init¶
Subclasses can have custom constructors. Just call super().__init__():
class OrderProcessor(StateMachine):
states = ["pending", "paid", "shipped"]
initial_state = "pending"
def __init__(self, order_id: str) -> None:
super().__init__()
self.order_id = order_id
@transition(from_state="pending", to_state="paid")
def pay(self, amount: float) -> str:
return f"Order {self.order_id}: paid ${amount:.2f}"
Combining with @spec¶
Use @spec on transition methods for full type validation:
class Machine(StateMachine):
states = ["idle", "done"]
initial_state = "idle"
@transition(from_state="idle", to_state="done")
@spec
def finish(self, result: str) -> str:
"""Complete the task."""
return f"done: {result}"
Decorator order
@transition must be the outer decorator and @spec the inner one. This ensures the transition logic runs first (checking state), then @spec validates types.
Transition Metadata¶
Every @transition-decorated method carries a __transition__ attribute with a TransitionMeta dataclass:
This metadata is frozen (immutable) and used by specwright validate and specwright docs for introspection.
Inheritance¶
State machines can be extended through subclassing:
class Base(StateMachine):
states = ["a", "b"]
initial_state = "a"
@transition(from_state="a", to_state="b")
def go(self): pass
class Extended(Base):
states = ["a", "b", "c"]
@transition(from_state="b", to_state="c")
def go_further(self): pass
Why this matters for LLM-assisted development
State machines make valid transitions explicit and machine-readable. When an LLM sees a state machine class, it knows exactly which states exist, which transitions are allowed, and what the current state is. This eliminates an entire class of bugs where generated code attempts impossible operations — like shipping an order that hasn't been paid for.
Common Pitfalls¶
Don't forget super().init()
If you override __init__, you must call super().__init__() or the state won't be initialized: