Coming from python-statemachine
Port a python-statemachine 3.x chart to xstate-statemachine. A concept map, a worked traffic light with a guard, a nested state and a timeout, what maps one to one, what differs, and what you gain.
If you already write class Light(StateChart) with cycle = green.to(yellow), you already know most of this library’s class syntax: the Pythonic API deliberately uses the same spelling. This page is the short route from one to the other. For a feature-by-feature comparison and when to stay with python-statemachine, read vs python-statemachine first.
This page was checked against python-statemachine 3.2.x.
Concept map
| Concept | python-statemachine 3.x | xstate-statemachine |
|---|---|---|
| Machine class | class M(StateChart) |
class M(StateMachine) |
| States | State(initial=True), State(final=True) |
identical |
| Nested and parallel states | class paid(State.Compound), State.Parallel |
identical spelling |
| Event from the attribute name | cycle = green.to(yellow) is event cycle |
same attribute, event CYCLE |
| Several arms for one event | a.to(b) \| b.to(c) |
identical |
| Guard | cond="ok", unless="blocked" |
guard="ok" (a plain method named ok, or @guard), guard="!blocked" |
| Convention hooks | on_enter_<state>, on_exit_<state>, before_<event>, on_<event>, after_<event>, on_enter_state, on_transition |
identical names |
| Arguments | injected by name from send(**kwargs) plus event, source, target, state |
injected by name: payload fields plus context, event, event_type, interpreter, error, data, input, source, target, state (full table in Pythonic API) |
| Start and stop | sm = M() |
with M() as sm: (async with on the asyncio engine) |
| Send | sm.send("cycle", amount=3) or sm.cycle(amount=3) |
sm.send("CYCLE", amount=3) or sm.cycle(amount=3) |
| Return value | a list of callback results | the callback result, None, or a list when several ran |
| Introspection | sm.configuration, sm.green.is_active, sm.allowed_events |
sm.configuration, sm.green.is_active, sm.allowed_events, plus sm.state, sm.can("CYCLE"), sm.matches("red.walk") |
| Timeout | State(invoke=timeout(3, on="expire")) |
State(after={3000: "red"}), milliseconds |
| Data | attributes on the instance or a model | context dict (initial_context); this is what snapshots store |
| Event with no enabled transition | TransitionNotAllowed or ignored, per allow_event_without_transition |
ignored; strict = True raises TransitionNotAllowedError |
| Missing callback | InvalidDefinition when instantiated |
ImplementationMissingError when instantiated |
| Listeners | listeners=[...] |
plugins=[...] (Plugins) |
| Diagram | f"{sm:mermaid}" |
M.create_machine().to_mermaid(), xsm diagram (Diagrams) |
| Persist on a model | MachineMixin, state_field_name |
Persistence, StatechartMixin for SQLAlchemy and Django |
| Load from a file | statemachine.io.load("x.yaml") |
create_machine(config) from XState JSON (JSON Configuration) |
A worked port: traffic light
The chart has three states. green moves to yellow on cycle only when a guard allows it. yellow is a timeout: it moves to red on its own. red is a compound state with two children, stopped and walk, and a pedestrian event that switches between them.
Theirs: python-statemachine
This is not executed here.
from statemachine import State, StateChart
from statemachine.contrib.timeout import timeout
class TrafficLight(StateChart):
green = State(initial=True)
yellow = State(invoke=timeout(3, on="expire"))
class red(State.Compound):
stopped = State(initial=True)
walk = State()
pedestrian = stopped.to(walk)
cycle = green.to(yellow, cond="can_cycle") | yellow.to(red) | red.to(green)
expire = yellow.to(red)
count = 0
def can_cycle(self, amount=1):
return amount < 10
def on_cycle(self, amount=1):
self.count += amount
return f"counted {amount}"
def on_enter_red(self):
print("Don't move.")
sm = TrafficLight()
sm.send("cycle", amount=99) # None: the guard said no, the machine stays in green
sm.send("cycle", amount=2) # [None, 'counted 2'] in 3.x; now in yellow
# ...three seconds later the timeout fires: now in red.stopped
sm.pedestrian() # red.walk
sm.cycle() # back to green
Ours
The same machine. Only four things changed: the base class, cond= became guard=, the timeout became after= in milliseconds, and the data lives in context instead of an instance attribute.
import time
from xstate_statemachine import State, StateMachine, guard
YELLOW_MS = 30 # 3000 in production; short here so the example finishes
class TrafficLight(StateMachine):
initial_context = {
"count": 0,
}
green = State(initial=True)
yellow = State(
after={
YELLOW_MS: "red",
}
) # the timeout
class red(State.Compound): # a nested compound state
stopped = State(initial=True)
walk = State()
pedestrian = stopped.to(walk)
cycle = (
green.to(yellow, guard="can_cycle") | yellow.to(red) | red.to(green)
)
@guard
def can_cycle(self, amount=1):
return amount < 10
def on_cycle(self, context, amount=1):
context["count"] += amount
return f"counted {amount}"
def on_enter_red(self):
print("Don't move.")
with TrafficLight() as light:
print(light.state)
print(light.cycle(amount=99), light.state)
print(light.cycle(amount=2), light.state)
time.sleep(0.1) # let the timeout come due ...
light.interpreter.tick() # ... and fire it (see "What differs")
print(light.state)
light.pedestrian()
print(light.state, light.red.is_active)
print(light.cycle(), light.state)
print(light.context)
Output:
green
None green
counted 2 yellow
Don't move.
red.stopped
red.walk True
counted 1 green
{'count': 3}
What maps one to one
- The class body.
State(initial=True),State(final=True),a.to(b),a.to(b) | b.to(c), and nestedState.Compound/State.Parallelclasses work as you wrote them. A nested class is a valid transition source and target. - The attribute is the event.
pedestrian = stopped.to(walk)declares the event and the methodlight.pedestrian()in one line. Give an explicit name withevent="WALK_NOW"when the attribute should not be the event. - Convention hooks.
on_enter_red,on_exit_red,before_cycle,on_cycle,after_cycleand the genericon_enter_state/on_transitionare found by name. Nothing to register. - Dependency injection by name. A callback declares only the arguments it wants.
def can_cycle(self, amount=1)receives theamountsent with the event, and a callback with no parameters receives nothing. - Return values.
light.cycle(amount=2)gives back whaton_cyclereturned. - Introspection.
light.configuration,light.red.is_active,light.allowed_events. - Async detection. One
async defcallback switches the instance to the asyncio engine;async withandawait sm.cycle()then work as you expect.
What differs
- Events are
SCREAMING_SNAKE_CASE, inferred from the attribute name.cycleisCYCLE,send_mailisSEND_MAIL.light.cycle()andlight.send("CYCLE")are the same call, butlight.send("cycle"), your old spelling, is a different, undeclared event and is silently ignored. Setstrict = Trueto make it raise (example below). strictinstead of an exception by default. python-statemachine raisesTransitionNotAllowedwhen no transition is enabled (depending onallow_event_without_transition). Here an event that declares no enabled transition is ignored, as in XState.strict = Trueon the class, orM(strict=True), raisesUnknownEventErrorfor an undeclared event andTransitionNotAllowedErrorfor a declared one that no active state can take right now.- Callbacks get only the arguments they name. Same idea, different vocabulary. The injectable names here are the payload fields plus
context,event,event_type,interpreter,machine,action_def,params,error,data,input,source,targetandstate(with**kwargsreceiving the remaining payload; the full table says which apply to guards, actions and services). There is nomodel:selfis the instance. - Data lives in
context. An attribute such asself.countworks while the process lives, but onlycontextis stored in a snapshot and handed to stores, receipts and plugins. Move anything that must survive a restart intocontext. - Guards and actions are found by name.
guard="can_cycle"binds a plaindef can_cycle(self, amount=1), andactions=["note"]a plaindef note;@guard/@actionremain the explicit form and the way to register under another name. A name the chart references that no method provides is reported when you instantiate the class, not at the firstsend(). send()returns the action result, not a list. One result comes back as itself, no result asNone, several as a list.- Time is milliseconds, and it is data.
after={3000: "red"}replacesinvoke=timeout(3, on="expire"), and the deadline is part of the snapshot. On the sync engine a due timer fires on the nextsend()ortick(), on your thread; on the asyncio engine the loop fires it. See Delayed Transitions. - The lifecycle is a context manager.
with TrafficLight() as light:starts the machine and stops it on exit, exceptions included. For a machine that outlives a block,light.interpreter.stop()is the escape hatch. A transition namedstartorstopis allowed, but it gets nosm.stop()shortcut: send it withsm.send("STOP").
The lowercase send() trap
from xstate_statemachine import State, StateMachine, UnknownEventError
class Switch(StateMachine):
off = State(initial=True)
on = State()
toggle = off.to(on) | on.to(off)
with Switch() as switch:
switch.send("toggle") # your old spelling: ignored
print(switch.state)
with Switch(strict=True) as switch:
try:
switch.send("toggle")
except UnknownEventError as exc:
print(exc)
Output:
off
Event 'toggle' is not declared by machine 'Switch'. Known events: TOGGLE.
What you gain
The class you just wrote is a front end to a portable statechart, so everything built on the chart comes with it.
- XState and Stately round-trip.
TrafficLight.create_machine().to_json()is XState JSON. Open it in Stately, edit it, load it back withcreate_machine(config). - Snapshots and durable timers. A snapshot holds the state, the context and the pending
afterdeadline, so a restarted process resumes the remaining time. See Snapshots and Persistence. - Stores, locks and idempotency.
persisted(), optimistic and pessimistic locking and a replayed webhook getting its original receipt: Persistence and Guarantees. - Actors. Spawned child machines that message each other: Actors.
- Web and database integrations. FastAPI, Django, SQLAlchemy, Flask, brokers and Celery, listed on the integrations journey.
- Testing. A
SimulatedClockmakes timers instant and deterministic; the pytest plugin reports state and transition coverage: Testing.
A snapshot of the machine above, taken while it is running:
import json
from xstate_statemachine import State, StateMachine
class Switch(StateMachine):
initial_context = {
"flips": 0,
}
off = State(initial=True)
on = State()
toggle = off.to(on) | on.to(off)
def on_toggle(self, context):
context["flips"] += 1
with Switch() as switch:
switch.toggle()
snapshot = json.loads(switch.interpreter.get_snapshot())
print(snapshot["context"], snapshot["state_ids"])
print(sorted(json.loads(Switch.create_machine().to_json())))
Output:
{'flips': 1} ['Switch.on']
['context', 'id', 'initial', 'states']
Next steps
- Pythonic API: nested states, strict mode,
aftertimers and the full hook list. - Quick Start: the same machine in two minutes, with XState JSON as the alternative route.
- vs python-statemachine: the feature table and when python-statemachine is the better fit.