Optional Modules
pysm keeps the default import small:
from pysm import StateMachine, State, Event
The classic core stays dependency-free. Runtime helpers that need queues,
threading, asyncio, serialization helpers, or builder ergonomics live
in separate modules and must be imported explicitly.
Core vs Optional Modules
Module |
Import |
Purpose |
MicroPython / |
|---|---|---|---|
Core |
|
Classic tiny runtime |
Yes |
Queued runtime |
|
Run-to-completion event scheduling |
CPython-oriented optional module |
Thread-safe queued runtime |
|
Queued dispatch protected by |
CPython-only |
Async queued runtime |
|
Run-to-completion dispatch for |
CPython 3.7+ only |
Serialization |
|
Snapshot active state and history using stable state paths |
Optional utility |
Builder |
|
Reduce setup boilerplate without metaclasses or magic |
Optional utility |
Type stubs |
|
IDE and type-checker help without runtime annotations |
Development-time only |
Core Behavior Updates
The core API is still the same explicit building-block API, but recent runtime hardening added a few details worth making visible:
dispatch()now raisesStateMachineExceptionif the machine has not been initialized. Callinitialize()on the root machine after adding all states and transitions.The built-in
state_stack,leaf_state_stack, andstackonStateMachineinstances are bounded byStateMachine.STACK_SIZE. The default is32. If you need an unbounded standalone stack, createStack(maxlen=None)yourself.The core import does not load
asyncio,json,threading, or any of the optionalpysm.*helper modules.
Initial Entry Events
By default, initialize() preserves the historical behavior and only selects
the active initial path. If you want initial states to receive enter events,
opt in explicitly:
from pysm import State, StateMachine
calls = []
ready = State('ready')
ready.handlers = {'enter': lambda state, event: calls.append(state.name)}
machine = StateMachine('m')
machine.add_state(ready, initial=True)
machine.initialize(fire_events_on_init=True)
assert calls == ['ready']
For hierarchical machines, enter handlers fire from the root’s initial
child down to the active leaf state. The root machine itself is not entered.
Queued and async machines keep the same ordering, but they drain any events
dispatched by initial enter handlers only after the initial path is ready.
Queued Run-To-Completion Dispatch
The classic StateMachine preserves historical behavior. If handlers dispatch
events and you need deterministic scheduling, use the opt-in queued runtime:
from pysm import Event, State
from pysm.queued import QueuedStateMachine
calls = []
machine = QueuedStateMachine('m')
start = State('start')
ready = State('ready')
def on_enter_ready(state, event):
machine.dispatch(Event('validate'))
machine.dispatch(Event('metrics'))
ready.handlers = {'enter': on_enter_ready}
machine.add_state(start, initial=True)
machine.add_state(ready)
machine.add_transition(start, ready, events=['go'])
machine.add_transition(
ready, None, events=['validate'],
action=lambda state, event: calls.append('validate'))
machine.add_transition(
ready, None, events=['metrics'],
action=lambda state, event: calls.append('metrics'))
machine.initialize()
machine.dispatch(Event('go'))
assert calls == ['validate', 'metrics']
QueuedStateMachine uses two FIFO queues. External events go to the external
queue. Events raised while the machine is already processing go to the internal
queue. Internal events are drained before the next external event is processed.
This gives deterministic run-to-completion behavior without changing the
classic core class.
Pass max_internal_steps while debugging if you want to fail loudly on an
accidental internal event cycle:
machine = QueuedStateMachine('m', max_internal_steps=100)
If a handler, condition, or transition callback raises, the queued runtime clears pending internal and external work before re-raising the original exception. A later dispatch starts from a clean queue.
Thread-Safe Queued Dispatch
For multi-threaded CPython programs, use the locked queued runtime:
from pysm.queued import ThreadSafeQueuedStateMachine
machine = ThreadSafeQueuedStateMachine('m')
It uses threading.RLock and holds the lock for the whole
run-to-completion cycle. Long blocking handlers will therefore block other
threads from dispatching into the same machine. Async support is intentionally
not part of this class.
Async Queued Dispatch
For asyncio applications, use the async queued runtime:
from pysm import Event, State
from pysm.aio import AsyncQueuedStateMachine
machine = AsyncQueuedStateMachine('m')
idle = State('idle')
ready = State('ready')
async def on_enter_ready(state, event):
await machine.dispatch(Event('validate'))
ready.handlers = {'enter': on_enter_ready}
machine.add_state(idle, initial=True)
machine.add_state(ready)
machine.add_transition(idle, ready, events=['go'])
machine.add_transition(ready, None, events=['validate'])
machine.initialize()
await machine.dispatch(Event('go'))
AsyncQueuedStateMachine supports both synchronous and asynchronous
handlers, conditions, and transition callbacks. Idle dispatches are serialized
with asyncio.Lock. Events raised from the currently running transition task
are queued as internal events. If another task dispatches into a machine that
is already processing, that event is queued as external work and the dispatch
returns after enqueueing; the active processor drains it after the current
internal queue is empty. Use one event loop per machine.
Use await machine.async_initialize(fire_events_on_init=True) if initial
enter handlers need to be awaited.
Typed Packages
The distribution ships py.typed and .pyi files for the core and
optional modules. This gives static type checkers a public API surface without
adding runtime annotations to the MicroPython-friendly core.
Snapshot And Restore
Serialization returns plain Python primitives. It does not import json and
does not serialize callback code or domain objects.
Snapshots include machine-owned runtime state: the current local state for
each state machine, the root leaf_state, each machine’s state_stack,
and the root leaf_state_stack. They intentionally do not include
machine.stack. That stack is user PDA/domain storage and may contain
arbitrary objects, so persist it separately if your application needs it.
from pysm.serialization import restore, snapshot
data = snapshot(machine)
# Store data with json.dumps(data), a database JSON column, Redis, etc.
restored = build_the_same_machine_graph()
restored.initialize()
restore(restored, data)
Snapshots use full state paths, for example ['oven', 'door_closed',
'heating', 'baking']. Bare state names are not enough in a hierarchical
machine because different branches may contain states with the same name.
Restore is strict and raises if the saved topology does not match the machine
being restored.
Pass metadata when your persistence layer needs application data next to
the runtime state:
data = snapshot(machine, metadata={'schema': 3})
The metadata is copied into the returned dictionary, but restore only uses the runtime fields.
Builder
The builder is an optional setup helper. It does not change the core runtime and does not add methods to your domain objects.
from pysm.builder import StateMachineBuilder
machine = (StateMachineBuilder('toggle')
.state('off', initial=True)
.state('on')
.transition('off', 'on', events=['turn_on'])
.transition('on', 'off', events=['turn_off'])
.build())
For nested machines, use paths when short names would be ambiguous:
machine = (StateMachineBuilder('oven')
.machine('door_closed', initial=True)
.state('off', initial=True, parent_path='door_closed')
.machine('heating', parent_path='door_closed')
.state('baking', initial=True, parent_path='heating')
.transition('off', 'baking', events='bake')
.build())
String events and input values are treated as one event or input value,
not as iterables of characters. Use a list, tuple, or other iterable when a
single transition should match many values.
MicroPython / upysm
The MicroPython-oriented target should stay core-only:
from pysm import StateMachine, State, Event
Do not copy optional modules such as pysm.queued, pysm.aio,
pysm.serialization, or pysm.builder into a constrained device build
unless you explicitly need them and have measured the memory cost.