API Reference
Complete class, function, and decorator reference for XState-StateMachine.
API Reference
Complete reference for every public class, function, decorator, and exception
exported by xstate_statemachine. All items listed here are available as
top-level imports:
from xstate_statemachine import create_machine, Interpreter, State # etc.
Factory Functions
create_machine(config, *, context_type=None, logic=None, logic_modules=None, logic_providers=None, strict_targets=True, event_schemas=None)
Creates, validates, and assembles a state machine instance from an XState-compatible JSON configuration dictionary. This is the primary entry point when working with JSON/dict-based machine definitions.
The function intelligently resolves business logic (actions, guards, services)
from whichever source you provide. If an explicit MachineLogic instance is
passed via logic, it takes precedence. Otherwise, the factory delegates to
LogicLoader to auto-discover implementations from the specified modules or
provider objects.
create_machine() also validates the fully built tree: every transition
target must resolve, and no always self-target may be a permanent dead end.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
config |
Dict[str, Any] |
Yes | – | The machine’s structural definition. Must contain top-level "id" (non-empty string) and "states" keys. Typically loaded from JSON or YAML. |
context_type |
Type[TContext] |
No | None |
[wave 3] Type-only: the class (usually a TypedDict) that describes the context shape. The returned MachineNode[TContext] carries it to every interpreter, so interp.context is typed. No runtime effect. |
logic |
MachineLogic |
No | None |
A pre-constructed MachineLogic instance containing all required actions, guards, and services. When provided, auto-discovery is skipped. |
logic_modules |
List[Union[str, ModuleType]] |
No | None |
Python modules (or their dotted import-path strings, e.g. "my_app.logic.actions") to scan for logic functions. |
logic_providers |
List[object] |
No | None |
Class instances whose public methods are scanned to satisfy the machine’s logic requirements. Provider methods override module-level functions on name collision. |
strict_targets |
bool |
No | True |
When True, an unresolvable transition target raises InvalidConfigError at build time. When False, it downgrades to a DeprecationWarning (0.7.x behavior; removed in 1.0). |
event_schemas |
Optional[Dict[str, Any]] |
No | None |
Opt-in payload validation. Maps an event type to a validator – a callable, a dataclass, or anything with a model_validate/parse_obj-style constructor – that the event’s payload/data is passed through before a transition runs. A validation failure raises InvalidEventPayloadError (#51). |
Returns: MachineNode – a fully constructed, validated machine ready for
an interpreter.
Raises:
| Exception | Condition |
|---|---|
InvalidConfigError |
config is missing "id", "states", or "id" is not a non-empty string; a transition target does not resolve (when strict_targets=True); an always self-target can never make progress; or a built-in action is missing a required params key. |
ImplementationMissingError |
Auto-discovery is active and a required action, guard, or service cannot be found. |
InvalidEventPayloadError |
Raised later, at send-time (not by create_machine() itself), when event_schemas is set and an incoming event’s payload fails its declared schema. Listed here because it is a direct consequence of the event_schemas parameter. |
Machine-level policy keys
These keys go at the root of the JSON config (alongside "id" and "states"). Every one preserves 0.7.x behavior at its default; nothing changes on upgrade unless you opt in.
| Key | Allowed values | Default | Effect |
|---|---|---|---|
actionErrorPolicy |
"continue" | "rollback" | "fail" |
"continue" |
What happens when an action raises during a transition. "continue" emits a one-shot DeprecationWarning; flips to "rollback" in 1.0. |
guardErrorPolicy |
"false" | "true" | "raise" |
"false" |
What a raising guard is treated as. |
onUnhandled |
"ignore" | "defer" | "error" |
"ignore" |
What happens to an event that matches no transition. |
strictTargets |
true | false |
false |
Disables the sibling-reading fallback for leading-dot (.child) targets. |
spawnBlockingTimeout |
number (ms) | 30000 |
Upper bound a spawn_blocking_<key> action waits for the child to reach a terminal status. Never unbounded (a child with no final state would otherwise wedge its parent); on timeout the parent logs a warning and continues (does not raise). |
Example 1 – Minimal (no logic)
from xstate_statemachine import create_machine
config = {
"id": "toggle",
"initial": "inactive",
"states": {
"inactive": {"on": {"TOGGLE": "active"}},
"active": {"on": {"TOGGLE": "inactive"}},
},
}
machine = create_machine(config)
Example 2 – With explicit MachineLogic
from xstate_statemachine import create_machine, MachineLogic
def log_toggle(interpreter, context, event, action_def):
print(f"Toggled! Count: {context['count']}")
logic = MachineLogic(actions={"logToggle": log_toggle})
config = {
"id": "toggle",
"initial": "off",
"states": {
"off": {"on": {"FLIP": {"target": "on", "actions": "logToggle"}}},
"on": {"on": {"FLIP": {"target": "off", "actions": "logToggle"}}},
},
}
machine = create_machine(config, logic=logic)
Example 3 – With module-based auto-discovery
# file: my_logic.py
def log_toggle(interpreter, context, event, action_def):
context["count"] += 1
# file: main.py
import my_logic
from xstate_statemachine import create_machine
machine = create_machine(config, logic_modules=[my_logic])
# or by string path:
machine = create_machine(config, logic_modules=["my_logic"])
Example 4 – With provider objects
from xstate_statemachine import create_machine
class ToggleLogic:
def log_toggle(self, interpreter, context, event, action_def):
context["count"] += 1
machine = create_machine(config, logic_providers=[ToggleLogic()])
build_machine(*, id, states, transitions=None, actions=None, guards=None, services=None, context=None, root=None)
Builds a state machine entirely from Python objects using the functional
Pythonic API. This is the simplest style – define State objects,
Transition objects, and decorated callables at module level, then call this
function to produce a MachineNode.
Internally, build_machine compiles the provided objects into a JSON config
dict and a MachineLogic instance, then delegates to create_machine().
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
id |
str |
Yes | – | Machine identifier string. |
states |
List[State] |
Yes | – | Top-level State objects. Exactly one must have initial=True (unless a single parallel root). |
root |
State |
No | None |
A State carrying MACHINE-LEVEL properties: on, always, entry, exit, after, invoke, on_done, tags, meta, and parallel=True. Use it for a global escape transition such as on={"EMERGENCY": "halted"}, which is live from every state. Added in v0.7.0. |
transitions |
List[Union[Transition, TransitionGroup]] |
No | None |
Explicit transitions created with state.to(), transition(), or the \| operator. |
actions |
List[Callable] |
No | None |
Action callables (may be decorated with @action). |
guards |
List[Callable] |
No | None |
Guard callables (may be decorated with @guard). Must be synchronous. |
services |
List[Callable] |
No | None |
Service callables (may be decorated with @service). |
context |
Dict |
No | None |
Initial context dictionary. |
Returns: MachineNode
Raises: InvalidConfigError on duplicate state names, missing initial
state, or invalid transition sources.
from xstate_statemachine import State, build_machine, action
idle = State("idle", initial=True)
running = State("running")
done = State("done", final=True)
@action
def log_start(interpreter, context, event, action_def):
print("Machine started!")
machine = build_machine(
id="workflow",
states=[idle, running, done],
transitions=[
idle.to(running, event="START", actions=["logStart"]),
running.to(done, event="FINISH"),
],
actions=[log_start],
context={"step": 0},
)
Core Classes
State
State(
name: str = "",
*,
initial: bool = False,
final: bool = False,
parallel: bool = False,
history: Optional[str] = None,
on: Optional[Dict[str, Any]] = None,
entry: Optional[List[str]] = None,
exit: Optional[List[str]] = None,
after: Optional[Dict[Union[int, str], Any]] = None,
invoke: Optional[Union[Dict, List]] = None,
on_done: Optional[Union[str, Dict]] = None,
always: Optional[Union[str, Dict, List]] = None,
context: Optional[Dict] = None,
states: Optional[List[State]] = None,
tags: Optional[List[str]] = None,
meta: Optional[Dict[str, Any]] = None,
)
A state definition for use across all three Pythonic API styles (functional, builder, and class-based).
| Parameter | Type | Default | Description |
|---|---|---|---|
name |
str |
"" |
State name. Auto-inferred from the class attribute name in StateMachine subclasses. |
initial |
bool |
False |
Whether this is the initial state among its siblings. |
final |
bool |
False |
Whether this is a final (terminal) state. Cannot be combined with parallel. |
parallel |
bool |
False |
Whether this is a parallel state. Cannot be combined with final. |
history |
str |
None |
Marks this as a history pseudo-state. Pass "shallow" or "deep". Added in v0.7.0. |
on |
Dict[str, Any] |
None |
Event-to-target shorthand dict, e.g. {"CLICK": "active"}. |
entry |
List[str] |
None |
List of entry action names executed when the state is entered. |
exit |
List[str] |
None |
List of exit action names executed when the state is exited. |
after |
Dict[int, Any] |
None |
Delayed transition dict. Keys are milliseconds, values are target strings or transition dicts, e.g. {3000: "timeout"}. |
invoke |
Union[Dict, List] |
None |
Service invocation config. A single dict or list of dicts, each with "src", optional "onDone", "onError". |
on_done |
Union[str, Dict] |
None |
Completion transition. Fired when all child states in a compound/parallel state reach a final state. |
always |
Union[str, Dict, List] |
None |
Eventless (transient) transition config. Evaluated immediately after state entry. |
context |
Dict |
None |
Initial context dict. Only meaningful at the root level. |
states |
List[State] |
None |
Child State objects for building hierarchical/nested machines. |
tags |
List[str] |
None |
Tags for this state. Query with interpreter.has_tag(...) / .tags. Added in v0.7.0. |
meta |
Dict[str, Any] |
None |
Arbitrary metadata. Read merged values with interpreter.get_meta(). Added in v0.7.0. |
Raises: InvalidConfigError if both final=True and parallel=True.
Key Methods
state.to(target, *, event, guard=None, actions=None, reenter=False)
Creates an external transition from this state to a target state.
state.to(
target: State,
*,
event: Optional[str] = None, # REQUIRED -- event name
guard: Optional[str] = None, # guard function name
actions: Optional[List[str]] = None, # action names to run
reenter: bool = False, # force exit/re-entry on self-transitions
) -> Transition
| Parameter | Type | Required | Description |
|---|---|---|---|
target |
State |
Yes | The destination State object. |
event |
str |
Yes | The event name that triggers this transition. |
guard |
str |
No | Guard function name. Transition only taken if guard returns True. |
actions |
List[str] |
No | Action names executed during the transition. |
reenter |
bool |
No | If True and target equals source, forces exit and re-entry (runs exit/entry actions). |
Returns: Transition
Raises: InvalidConfigError if event is not provided.
idle = State("idle", initial=True)
active = State("active")
t = idle.to(active, event="ACTIVATE", actions=["logActivation"])
state.internal(event, *, guard=None, actions=None)
Creates an internal transition – actions execute but no state change occurs. Entry and exit actions of the current state are not run.
state.internal(
event: str,
*,
guard: Optional[str] = None,
actions: Optional[List[str]] = None,
) -> Transition
| Parameter | Type | Required | Description |
|---|---|---|---|
event |
str |
Yes | The event name. |
guard |
str |
No | Guard function name. |
actions |
List[str] |
No | Action names to execute. |
Returns: Transition with internal=True.
counter = State("counter", initial=True)
t = counter.internal("INCREMENT", actions=["addOne"])
@state.enter – Entry action decorator
Registers a function as an entry action for this state. Only valid inside a
StateMachine class definition. The function name is auto-converted from
snake_case to camelCase.
class MyMachine(StateMachine):
idle = State(initial=True)
@idle.enter
def on_idle_entered(self, interpreter, context, event, action_def):
print("Entered idle!")
Returns: The original function with _xsm_type="action" and
_xsm_name markers attached.
@state.exit – Exit action decorator
Registers a function as an exit action for this state. Same semantics as
@state.enter.
class MyMachine(StateMachine):
active = State()
@active.exit
def on_active_exited(self, interpreter, context, event, action_def):
print("Left active!")
Properties
| Property | Type | Description |
|---|---|---|
exit_actions |
List[str] |
Read-only list of exit action names for this state. |
__init_subclass__ keyword behavior
State supports class-inheritance keywords for concise nested-state
definitions inside StateMachine subclasses:
class MyMachine(StateMachine):
class loading(State, initial=True):
"""A compound initial state defined as a class."""
fetching = State(initial=True)
parsing = State()
class done(State, final=True):
"""A final state."""
pass
class regions(State, parallel=True):
"""A parallel state."""
pass
The keywords initial, final, and parallel are captured by
__init_subclass__ and stored as _xsm_initial, _xsm_final, and
_xsm_parallel class attributes, respectively.
StateMachine
Base class for defining state machines using Python class syntax. Uses a
custom metaclass (_StateMachineMeta) that collects State attributes,
Transition/TransitionGroup attributes, and @action/@guard/@service
decorated methods at class-definition time.
Class Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
machine_id |
Optional[str] |
None |
Machine identifier. Defaults to the class name if not set. |
initial_context |
Optional[Dict] |
None |
Initial context dictionary for the machine. |
machine_root |
Optional[State] |
None |
A State carrying MACHINE-LEVEL properties (on, entry, exit, tags, parallel=True). Give it an empty name. machine_root is reserved — a state genuinely named machine_root raises InvalidConfigError rather than being silently dropped. Added in v0.7.0. |
Class Method
StateMachine.create_machine(context=None) -> MachineNode
Compiles the class definition into a JSON config and MachineLogic, then
delegates to create_machine().
| Parameter | Type | Required | Description |
|---|---|---|---|
context |
Dict |
No | Optional context override. If provided, replaces initial_context. |
Returns: MachineNode
from xstate_statemachine import StateMachine, State, action
class TrafficLight(StateMachine):
machine_id = "trafficLight"
initial_context = {"cycle_count": 0}
red = State(initial=True)
yellow = State()
green = State()
cycle = (
red.to(green, event="NEXT")
| green.to(yellow, event="NEXT")
| yellow.to(red, event="NEXT", actions=["countCycle"])
)
@action
def count_cycle(self, interpreter, context, event, action_def):
context["cycle_count"] += 1
machine = TrafficLight.create_machine()
# With a context override:
machine = TrafficLight.create_machine(context={"cycle_count": 10})
MachineBuilder
MachineBuilder(machine_id: str)
Fluent builder for constructing state machines step-by-step using method
chaining. Every mutating method returns self, enabling a fluent pipeline
that terminates with .build().
| Parameter | Type | Description |
|---|---|---|
machine_id |
str |
The machine’s identifier string. |
Methods
| Method | Signature | Returns | Description |
|---|---|---|---|
.context(ctx) |
ctx: Dict |
MachineBuilder |
Sets the initial context dictionary. |
.state(name, **kwargs) |
See below | MachineBuilder |
Adds a state to the machine. |
.transition(source, event, target, **kwargs) |
See below | MachineBuilder |
Adds a transition between two named states. |
.child_states(parent, *, initial, states, parallel) |
See below | MachineBuilder |
Adds child states to an existing state. |
.action(name, fn) |
name: str, fn: Callable |
MachineBuilder |
Registers an action function by name. |
.guard(name, fn) |
name: str, fn: Callable |
MachineBuilder |
Registers a guard function by name. |
.service(name, fn) |
name: str, fn: Callable |
MachineBuilder |
Registers a service function by name. |
.root(**properties) |
JSON key spellings | MachineBuilder |
Sets MACHINE-LEVEL properties: on, entry, exit, after, invoke, onDone, tags, meta, type="parallel". Use for a global escape transition live from every state. Note the JSON spelling onDone, not on_done. Added in v0.7.0. |
.build(context=None) |
context: Optional[Dict] |
MachineNode |
Builds and returns the final MachineNode. Idempotent – safe to call multiple times. |
.state() full parameters
.state(
name: str,
*,
initial: bool = False,
final: bool = False,
parallel: bool = False,
on: Optional[Dict] = None,
entry: Optional[List] = None,
exit: Optional[List] = None,
after: Optional[Dict] = None,
invoke: Optional[Union[Dict, List]] = None,
on_done: Optional[Union[str, Dict]] = None,
always: Optional[Union[str, Dict, List]] = None,
history: Optional[str] = None,
tags: Optional[List[str]] = None,
meta: Optional[Dict[str, Any]] = None,
) -> MachineBuilder
Raises: InvalidConfigError if the state name already exists, or if both
final and parallel are True.
.transition() full parameters
.transition(
source: str,
event: str,
target: str,
*,
guard: Optional[str] = None,
actions: Optional[List[str]] = None,
reenter: bool = False,
internal: bool = False,
) -> MachineBuilder
.child_states() full parameters
.child_states(
parent: str,
*,
initial: Optional[str] = None,
states: Optional[Dict[str, Dict]] = None,
parallel: bool = False,
) -> MachineBuilder
Raises: InvalidConfigError if parent is not an already-defined state.
.build() full parameters
.build(context: Optional[Dict] = None) -> MachineNode
Optional context overrides the context set by .context(). Deep-copies
internal state, so repeated .build() calls are safe.
Raises: InvalidConfigError if no initial state is defined (for
non-parallel, multi-state machines) or if a transition source is invalid.
Chaining example
from xstate_statemachine import MachineBuilder
def log_it(interpreter, context, event, action_def):
context["count"] += 1
print(f"Count: {context['count']}")
machine = (
MachineBuilder("counter")
.context({"count": 0})
.state("idle", initial=True)
.state("counting")
.state("done", final=True)
.transition("idle", "START", "counting")
.transition("counting", "INCREMENT", "counting",
actions=["logIt"], reenter=True)
.transition("counting", "FINISH", "done")
.action("logIt", log_it)
.build()
)
Transition
Represents a transition between two states, triggered by an event. Created
by State.to(), State.internal(), or the standalone transition()
function. Not typically instantiated directly by users.
Attributes
| Attribute | Type | Description |
|---|---|---|
source |
State |
The source State. |
target |
Optional[State] |
The target State. None for internal transitions. |
event |
str |
The event name that triggers this transition. |
guard |
Optional[str] |
Guard function name. |
actions |
List[str] |
Action names to execute during the transition. |
reenter |
bool |
Whether to force exit/re-entry on self-transitions. |
internal |
bool |
Whether this is an internal (no-state-change) transition. |
| operator
Combine multiple transitions into a TransitionGroup using the pipe
operator. The first transition whose guard passes wins (evaluated in order).
idle = State("idle", initial=True)
premium = State("premium")
basic = State("basic")
# Multiple guarded transitions for the same event
signup = (
idle.to(premium, event="SIGNUP", guard="isPremium")
| idle.to(basic, event="SIGNUP")
)
# signup is a TransitionGroup with 2 transitions
TransitionGroup
A collection of Transition objects created implicitly by the | operator.
Users never instantiate this directly.
Attributes
| Attribute | Type | Description |
|---|---|---|
transitions |
List[Transition] |
The list of transitions in this group. |
| operator
TransitionGroup objects can be further combined with other Transition or
TransitionGroup objects:
group_a = idle.to(s1, event="GO", guard="isA") | idle.to(s2, event="GO", guard="isB")
group_b = idle.to(s3, event="GO")
# Combine groups
all_transitions = group_a | group_b
# all_transitions is a TransitionGroup with 3 transitions
transition() – Standalone function
transition(
source: State,
event: str,
target: State,
*,
guard: Optional[str] = None,
actions: Optional[List[str]] = None,
reenter: bool = False,
internal: bool = False,
) -> Transition
Functional alternative to State.to(). Creates a Transition between two
states. Useful when you prefer a function-call style over the method style.
| Parameter | Type | Required | Description |
|---|---|---|---|
source |
State |
Yes | The source state. |
event |
str |
Yes | The event name (required). |
target |
State |
Yes | The target state. |
guard |
str |
No | Guard function name. |
actions |
List[str] |
No | Action names to execute. |
reenter |
bool |
No | Force exit/re-entry on self-transitions. |
internal |
bool |
No | Create an internal transition (no state change). |
Returns: Transition
from xstate_statemachine import State, transition
idle = State("idle", initial=True)
active = State("active")
# These two are equivalent:
t1 = idle.to(active, event="START")
t2 = transition(idle, "START", active)
# With all options:
t3 = transition(
idle, "START", active,
guard="isReady",
actions=["logStart", "initProcess"],
reenter=False,
internal=False,
)
Interpreters
Interpreter(machine) – Async
Interpreter(
machine: MachineNode,
input: Optional[Any] = None,
clock: Optional[Clock] = None,
max_queue_size: Optional[int] = None,
overflow_policy: OverflowPolicy = OverflowPolicy.RAISE,
strict: Optional[bool] = None,
service_executor: Optional[concurrent.futures.Executor] = None,
service_pool_size: int = DEFAULT_SERVICE_POOL_SIZE,
)
The primary asynchronous state machine engine. Processes events from an
asyncio.Queue, manages background tasks for after timers and invoked
services, and supports spawning child actors. Recommended for I/O-bound
applications (web servers, IoT, automation scripts).
Uses a dedicated TaskManager to track all background asyncio.Task objects,
ensuring clean cancellation when states are exited.
Constructor parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
machine |
MachineNode |
– | The machine to run. |
input |
Optional[Any] |
None |
Creation input for a context factory. |
clock |
Optional[Clock] |
None |
Source of time. Defaults to RealClock. Pass a SimulatedClock for deterministic virtual-time tests [wave 3] (#49). |
max_queue_size |
Optional[int] |
None |
Bound on the inbox. None keeps the unbounded queue; when set, overflow_policy decides what a full inbox does to send() [wave 3] (#38). |
overflow_policy |
OverflowPolicy |
OverflowPolicy.RAISE |
RAISE / BLOCK / DROP_NEWEST; ignored when no max_queue_size is set. The priority lane (send(priority=True)) is never bounded [wave 3] (#38). |
strict |
Optional[bool] |
None |
When True, an event type not declared anywhere in the machine raises UnknownEventError at the send() call site instead of silently no-opping [wave 3] (#51). |
service_executor |
Optional[concurrent.futures.Executor] |
None |
[0.8.1] (#149) Where a plain (non-coroutine) invoke service runs. None lazily creates a small ThreadPoolExecutor owned by the interpreter and shut down with it; pass a shared / bounded pool or a ProcessPoolExecutor for CPU-bound work. The entering macrostep still awaits the result — so a plain service’s done.invoke lands ahead of any event already in the inbox exactly as on the sync engine (#116) — but the event loop is free for the duration. |
service_pool_size |
int |
DEFAULT_SERVICE_POOL_SIZE (= 4) |
[0.8.1] (#173) Worker count of the executor created when service_executor is None. The (N+1)-th concurrently-running plain service waits for a worker, and because the entering macrostep awaits its result that wait also blocks the macrostep — size it to the number of plain services one configuration can have in flight (a parallel machine with 9 invoking regions wants 9). Ignored when an executor is supplied. Must be ≥ 1. |
Methods
| Method | Signature | Returns | Description |
|---|---|---|---|
await .start() |
() -> Interpreter |
Interpreter |
Starts the interpreter and its event loop. Enters the initial state(s). Returns self for chaining. Idempotent. |
await .stop(drain=False, timeout=None) |
(bool, Optional[float]) -> None |
None |
Gracefully stops the event loop, cancels all tasks and child actors. drain=True processes the inbox to empty first, bounded by timeout seconds (None waits until empty). Idempotent; a no-op on an already-"done"/"stopped" interpreter. |
await .send(event, *, wait=False, priority=False, **payload) |
(Union[str, Dict, Event, DoneEvent, AfterEvent, ErrorEvent], bool, bool, **Any) -> Optional[Receipt] |
Optional[Receipt] |
Sends an event to the queue. Accepts a string, dict, or Event object. Non-blocking unless overflow_policy=OverflowPolicy.BLOCK. wait=True [wave 3] (#39) makes the returned awaitable resolve to a Receipt once the event’s macrostep has fully run; False (default) resolves immediately to None. priority=True [wave 3] (#39) delivers the event ahead of every already-queued external event and exempts it from max_queue_size. Raises WrongThreadError when called from a thread other than the one whose event loop owns this interpreter, and QueueOverflowError when the inbox is bounded, full, and the policy is RAISE [wave 3] (#38). |
await .send_priority(event, **payload) |
(Union[str, Dict, Event, DoneEvent, AfterEvent, ErrorEvent], **Any) -> Optional[Receipt] |
Optional[Receipt] |
Shorthand for send(event, wait=True, priority=True, **payload) (#39) – ask an urgent question and get a Receipt back once it settles, jumping ahead of any backlog. Pass wait=False for a fire-and-forget priority send. |
.send_threadsafe(event, *, internal=None, **payload) |
(Union[str, Dict, Event, DoneEvent, AfterEvent, ErrorEvent], Optional[bool], **Any) -> concurrent.futures.Future[None] |
concurrent.futures.Future[None] |
Sends an event from any thread by routing the enqueue through the interpreter’s owning event loop. Returns a Future you may .result() on to block until the event is queued (not processed). [0.8.1] Under a bounded inbox with OverflowPolicy.RAISE, a full inbox raises QueueOverflowError on the calling thread (#157) — backpressure at the call site, not on a future a fire-and-forget producer never reads. That call-site check is optimistic: a concurrent producer may still be refused on the loop, in which case the returned future carries the QueueOverflowError and — so a fire-and-forget producer’s shed rate is never hidden — the interpreter logs a WARNING and fires on_event_dropped(reason="queue_full") (#157 reopen). internal (#150): None classifies the send by context — issued from inside one of this interpreter’s own actions (or a thread/executor that inherited that contextvars context) it is a self-send charged to maxIterations; a plain threading.Thread does not inherit the context, so an action handing its own re-trigger to one must pass internal=True (or start the thread with contextvars.copy_context().run). |
await .send_events(events) |
(List[Union[str, Dict, Event]]) -> None |
None |
Sends a list of events to the queue. Non-blocking. |
.matches(state) |
(Union[str, Dict[str, Any]]) -> bool |
bool |
Reports whether state is part of the active configuration. Accepts a string id (fully-qualified, #-prefixed, or trailing partial path) or a partial .value dict. |
.can(event) |
(Union[str, Event, Dict[str, Any]]) -> bool |
bool |
Reports whether sending event right now would cause a transition. Guards are evaluated, so this predicts accurately rather than checking structure only; has no side effects. |
.has_tag(tag) |
(str) -> bool |
bool |
Reports whether any currently active state declares the given tag. |
.get_meta() |
() -> Dict[str, Any] |
Dict[str, Any] |
Collects the meta of every active state, keyed by state id. |
.subscribe(listener) |
(Callable[[BaseInterpreter], None]) -> Callable[[], None] |
Callable[[], None] |
Registers a listener invoked after every settled change; mirrors XState’s actor.subscribe(). The listener receives the interpreter itself. Returns an unsubscribe function. |
.on(event_type, listener) |
(str, Callable[[Event], None]) -> Callable[[], None] |
Callable[[], None] |
Registers a listener for events published via the emit action. event_type may be "*" to catch every emitted event. Returns an unsubscribe function. |
.use(plugin) |
(PluginBase) -> Interpreter |
Interpreter |
Registers a plugin. Returns self for chaining. |
.get_snapshot() |
() -> str |
str |
Returns a JSON string snapshot of current state, context, and status. |
.get_persisted_snapshot() |
() -> Dict[str, Any] |
Dict[str, Any] |
Returns a deep, JSON-serialisable snapshot as a dict, including the full actor hierarchy (child actors, history, output) and every accepted-but-unprocessed event, fired timers included (#107). Mirrors XState’s actor.getPersistedSnapshot(); get_snapshot() above is the JSON-string convenience wrapper around this. Raises SnapshotMidStepError [0.8.1] (#102) if called while a macrostep is in flight (e.g. from inside an action) — snapshot after send(wait=True) resolves, from an on_transition hook, or after stop(drain=True). Raises SnapshotSerializationError [0.8.1] (#131) if a pending event carries non-JSON-native data (Decimal, datetime, …) instead of silently stringifying it. |
await .drain_pending() |
() -> List[Union[Event, DoneEvent, AfterEvent, ErrorEvent]] |
list |
Removes and returns every accepted-but-unprocessed event, without processing it. |
await .wait_done() |
() -> asyncio.Future[str] |
Future[str] |
Resolves to "done"/"error" the instant the machine reaches a terminal status. Already-resolved if the machine is terminal now. |
.pending_invocations() |
() -> List[PendingInvocation] |
list |
Invokes in the active configuration that have NO live service – the truthful list of what a static from_snapshot() restore left dormant [wave 3] (#44). Empty on a live machine and after restart_services=True. |
Class Method
| Method | Signature | Returns | Description |
|---|---|---|---|
Interpreter.from_snapshot(json_str, machine, *, verify_machine_hash=True, restart_services=False, restart_timers=None, clock=None) |
(str, MachineNode, bool, bool, Optional[bool], Optional[Clock]) -> Interpreter |
Interpreter |
Restores an interpreter from a snapshot. Does not re-run entry actions, and by default restarts neither services nor timers. Raises SnapshotVersionError for a snapshot newer than this library supports, SnapshotDriftError on a machine id or structural-hash mismatch (skip the hash check with verify_machine_hash=False), and SnapshotCorruptError [0.8.1] (#110) for a malformed blob (missing key, non-object context, unknown status, running with an empty configuration). restart_services=True [wave 3] (#44) makes the restored interpreter’s start() re-invoke every invoke in the restored configuration from scratch (not resumed) – opt in only when the service is safe to run again. restart_timers [0.8.1] (#128) does the same for dormant after timers, re-armed from zero; None (default) follows restart_services. clock [0.8.1] (#117) is the Clock the restored interpreter runs on (default RealClock); pass the SimulatedClock you mean for deterministic replay. After a static restore .status reports the persisted value immediately and is not a liveness signal — consult .has_dormant_invocations / .has_dormant_timers (#135). |
Properties
| Property | Type | Description |
|---|---|---|
.current_state_ids |
Set[str] |
Set of fully qualified IDs of all currently active atomic/final states. |
.active_state_ids |
Set[str] |
Alias of .current_state_ids, kept for compatibility with docs/README examples that predate the canonical name. |
.context |
Dict[str, Any] |
The mutable machine context. Changes made to this dict persist across transitions. |
.status |
str |
One of "uninitialized", "running", "done", "error", or "stopped". |
.is_running |
bool |
True between a successful start() and a stop(); a convenience wrapper over .status == "running". |
.id |
str |
The interpreter’s identifier (inherited from machine ID). |
.parent |
Optional[BaseInterpreter] |
Reference to parent interpreter (for spawned child actors), otherwise None. |
.system |
ActorSystem |
The actor system this interpreter belongs to; exposes .get(id) and .get_all() for looking up sibling/child actors registered under a systemId. |
.plugins |
List[PluginBase] |
The list of plugin instances attached to this interpreter. Assigning a new list replaces the whole set (the form .use() builds on). |
.last_transition_ok |
bool |
Whether the most recently processed transition’s actions all ran to completion, given actionErrorPolicy. Also False after a settle-budget trip (#112) or a sendTo with no live target (#133). |
.last_error |
Optional[BaseException] |
The exception behind the most recent last_transition_ok=False — an action’s exception, a RunawayChainError, an ActorSpawningError for an unresolved sendTo — or None. |
.last_plugin_error |
Optional[Tuple[str, str, BaseException]] |
[0.8.1] (plugin_class_name, hook_name, error) for the most recent plugin hook that raised or was an un-awaitable async def (#127). Plugin failures never stop the machine; this is where they surface, alongside on_plugin_error. |
.has_dormant_invocations |
bool |
[wave 3] True when an active state declares an invoke that has no live service — the state of a static from_snapshot() restore before start(), or after it without restart_services=True (#44). |
.has_dormant_timers |
bool |
[0.8.1] True when an active state declares an after timer that is not armed — the timer counterpart of has_dormant_invocations; cleared by start() with restart_timers=True (#128). |
.deferred_count |
int |
Number of events currently buffered under onUnhandled: "defer". |
Interpreter.DEFER_MAX |
int |
Class attribute bounding the deferral buffer’s size; oldest entries are evicted once full. |
Interpreter.MAX_ACTION_DEPTH |
int |
Class attribute (default 50) bounding nested action expansion (pure / choose / enqueueActions returning further actions), guarding against a callback that re-enqueues itself. |
.value |
str \| Dict[str, Any] |
The active configuration in hierarchical form: a string for an atomic state, {parent: child} for compound, one key per region for parallel, {} before start(). |
.pending_events |
Sequence[Union[Event, DoneEvent, AfterEvent, ErrorEvent]] |
Events accepted by send() but not yet processed, FIFO order. |
.queue_depth |
int |
Number of events accepted but not yet processed; len(.pending_events). Read-only. |
.tags |
Set[str] |
The union of tags across every currently active state. |
Full async example
import asyncio
from xstate_statemachine import create_machine, Interpreter, MachineLogic
config = {
"id": "lightSwitch",
"initial": "off",
"context": {"flips": 0},
"states": {
"off": {"on": {"TOGGLE": {"target": "on", "actions": "countFlip"}}},
"on": {"on": {"TOGGLE": {"target": "off", "actions": "countFlip"}}},
},
}
def count_flip(interpreter, context, event, action_def):
context["flips"] += 1
logic = MachineLogic(actions={"countFlip": count_flip})
machine = create_machine(config, logic=logic)
async def main():
service = await Interpreter(machine).start()
await service.send("TOGGLE") # off -> on
await asyncio.sleep(0.05) # let event loop process
print(service.current_state_ids) # {"lightSwitch.on"}
print(service.context) # {"flips": 1}
await service.send("TOGGLE") # on -> off
await asyncio.sleep(0.05)
print(service.context) # {"flips": 2}
await service.stop()
asyncio.run(main())
SyncInterpreter(machine) – Synchronous
SyncInterpreter(
machine: MachineNode,
input: Optional[Any] = None,
clock: Optional[Clock] = None,
strict: Optional[bool] = None,
)
A fully synchronous interpreter that processes events immediately within
the send() call. Suitable for CLI tools, desktop GUI event loops, simple
workflows, and predictable testing scenarios.
Events are handled one at a time from an internal collections.deque,
ensuring sequential, blocking execution. after timers are scheduled on the
injected Clock (thread-free; #49/#50) rather than a background thread.
Constructor parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
machine |
MachineNode |
– | The state machine definition to run. |
input |
Optional[Any] |
None |
Creation input for a context factory. |
clock |
Optional[Clock] |
None |
Source of time. Defaults to RealClock, whose sync-side timers are a thread-free deadline list drained by .tick() / .send() [wave 3] (#49, #50). Pass a SimulatedClock for deterministic virtual-time tests. |
strict |
Optional[bool] |
None |
When True, an event type not declared anywhere in the machine raises UnknownEventError at the send() call site [wave 3] (#51). |
Methods
| Method | Signature | Returns | Description |
|---|---|---|---|
.start() |
() -> SyncInterpreter |
SyncInterpreter |
Starts the interpreter and enters the initial state(s). Returns self for chaining. Idempotent. |
.stop(drain=False, timeout=None) |
(bool, Optional[float]) -> None |
None |
Stops the interpreter, cancels timers, stops child actors. drain=True processes the inbox to empty first. Idempotent; a no-op on an already-"done"/"stopped" interpreter. |
.send(event, *, wait=False, priority=False, **payload) |
(Union[str, Dict, Event, DoneEvent, AfterEvent, ErrorEvent], bool, bool, **Any) -> Optional[Receipt] |
Optional[Receipt] |
Sends an event for immediate synchronous processing. Blocks until the event and all resulting transitions are fully processed. wait=True [wave 3] (#39) returns a Receipt for API symmetry with the async engine’s send(wait=True) (the sync engine already processes inline by the time send() returns). priority is accepted for signature symmetry but has no effect – there is no backlog to jump. |
.send_events(events) |
(List[Union[str, Dict, Event]]) -> None |
None |
Sends a list of events for immediate processing. |
.matches(state) |
(Union[str, Dict[str, Any]]) -> bool |
bool |
Reports whether state is part of the active configuration. Accepts a string id or a partial .value dict. |
.can(event) |
(Union[str, Event, Dict[str, Any]]) -> bool |
bool |
Reports whether sending event right now would cause a transition, per Interpreter.can() above. |
.has_tag(tag) |
(str) -> bool |
bool |
Reports whether any currently active state declares the given tag. |
.get_meta() |
() -> Dict[str, Any] |
Dict[str, Any] |
Collects the meta of every active state, keyed by state id. |
.subscribe(listener) |
(Callable[[BaseInterpreter], None]) -> Callable[[], None] |
Callable[[], None] |
Registers a listener invoked after every settled change. Returns an unsubscribe function. |
.on(event_type, listener) |
(str, Callable[[Event], None]) -> Callable[[], None] |
Callable[[], None] |
Registers a listener for events published via the emit action; "*" catches every emitted event. Returns an unsubscribe function. |
.use(plugin) |
(PluginBase) -> SyncInterpreter |
SyncInterpreter |
Registers a plugin. Returns self for chaining. |
.get_snapshot() |
() -> str |
str |
Returns a JSON string snapshot. |
.get_persisted_snapshot() |
() -> Dict[str, Any] |
Dict[str, Any] |
Returns a deep, JSON-serialisable snapshot as a dict, including the full actor hierarchy, per Interpreter.get_persisted_snapshot() above. |
.drain_pending() |
() -> List[Union[Event, DoneEvent, AfterEvent, ErrorEvent]] |
list |
Removes and returns every accepted-but-unprocessed event, without processing it. |
.pending_invocations() |
() -> List[PendingInvocation] |
list |
Invokes in the active configuration that have NO live service, per Interpreter.pending_invocations() above [wave 3] (#44). |
Class Method
| Method | Signature | Returns | Description |
|---|---|---|---|
SyncInterpreter.from_snapshot(json_str, machine, *, verify_machine_hash=True, restart_services=False, restart_timers=None, clock=None) |
(str, MachineNode, bool, bool) -> SyncInterpreter |
SyncInterpreter |
Restores an interpreter from a snapshot. Raises SnapshotVersionError/SnapshotDriftError as described for Interpreter.from_snapshot above. restart_services=True [wave 3] (#44) re-invokes every dormant invoke from scratch when the restored interpreter starts. |
Properties
Same as Interpreter:
| Property | Type | Description |
|---|---|---|
.current_state_ids |
Set[str] |
Active atomic/final state IDs. |
.active_state_ids |
Set[str] |
Alias of .current_state_ids, per Interpreter.active_state_ids above. |
.context |
Dict[str, Any] |
Mutable machine context. |
.status |
str |
"uninitialized", "running", "done", "error", or "stopped". |
.is_running |
bool |
True between a successful start() and a stop(). |
.id |
str |
Interpreter identifier. |
.parent |
Optional[BaseInterpreter] |
Parent interpreter reference. |
.system |
ActorSystem |
The actor system this interpreter belongs to, per Interpreter.system above. |
.plugins |
List[PluginBase] |
The list of plugin instances attached to this interpreter. |
.value |
str \| Dict[str, Any] |
The active configuration in hierarchical form. |
.pending_events |
Sequence[Union[Event, DoneEvent, AfterEvent, ErrorEvent]] |
Events accepted but not yet processed. |
.queue_depth |
int |
Number of events accepted but not yet processed. Read-only. |
.tags |
Set[str] |
The union of tags across every currently active state. |
Sync limitations
| Feature | Supported? | Notes |
|---|---|---|
after timers |
Yes | Scheduled on the injected Clock (no background thread; #49/#50). Fires event when delay elapses. |
invoke services |
Sync only | Async (async def) services raise NotSupportedError. |
| Async actions | No | async def actions raise NotSupportedError. |
| Actor spawning | Yes | Supports spawn_ (background thread) and spawn_blocking_ (blocking). |
Full sync example
from xstate_statemachine import create_machine, SyncInterpreter, MachineLogic
config = {
"id": "door",
"initial": "closed",
"states": {
"closed": {"on": {"OPEN": "opened"}},
"opened": {"on": {"CLOSE": "closed"}},
},
}
machine = create_machine(config)
interpreter = SyncInterpreter(machine).start()
print(interpreter.current_state_ids) # {"door.closed"}
interpreter.send("OPEN")
print(interpreter.current_state_ids) # {"door.opened"}
interpreter.send("CLOSE")
print(interpreter.current_state_ids) # {"door.closed"}
# Snapshot round-trip
snapshot = interpreter.get_snapshot()
restored = SyncInterpreter.from_snapshot(snapshot, machine)
print(restored.current_state_ids) # {"door.closed"}
interpreter.stop()
Using plugins with .use()
from xstate_statemachine import SyncInterpreter, LoggingInspector
interpreter = (
SyncInterpreter(machine)
.use(LoggingInspector())
.start()
)
# All events, transitions, and actions will be logged
Clock [wave 3]
Time as an injectable dependency (#48, #49, #50). A Clock schedules
callbacks; the interpreter decides what a fired callback means. The same
Clock object may serve both engines at once, so a parent and its invoked
children (which may be either engine) share one timeline.
Clock (Protocol)
@runtime_checkable
class Clock(Protocol):
def now(self) -> float: ...
def set_timeout(self, fn, delay_sec: float, *, owner: Any = None, sync: bool | None = None) -> Any: ...
def clear_timeout(self, handle: Any) -> None: ...
def pump(self) -> int: ...
| Method | Signature | Description |
|---|---|---|
.now() |
() -> float |
Current time in seconds (monotonic; origin is clock-specific). |
.set_timeout(fn, delay_sec, *, owner=None, sync=None) |
(Callable[[], Any], float, Any, bool \| None) -> Any |
Schedule fn after delay_sec; returns a cancellation handle. Engines pass sync=True (SyncInterpreter) or sync=False (Interpreter) so the clock picks the lane the caller can drain (0.8.1, #76); RealClock falls back to the ambient-loop heuristic when sync is None. A clock written against the 0.8.0 protocol (no sync parameter) is still accepted — the engine retries without it. |
.clear_timeout(handle) |
(Any) -> None |
Cancel a scheduled callback. Idempotent. |
.pump() |
() -> int |
Run every callback whose deadline has passed; returns how many fired. |
RealClock()
Wall-clock time and the default for both engines when no clock is passed to
Interpreter/SyncInterpreter. Inside a running asyncio loop, a timeout is
loop.call_later (the same primitive asyncio.sleep uses). Outside a loop
(the sync engine), a timeout is a record in a deadline heap and no thread is
started – due callbacks run when SyncInterpreter.send() / .tick() calls
.pump().
| Property | Type | Description |
|---|---|---|
.pending |
int |
Deadlines waiting in the heap (sync-engine timers only). |
SimulatedClock()
Virtual time for deterministic tests, mirroring XState’s SimulatedClock.
Time does not pass on its own – .increment() advances virtual time and
fires every timer that became due, in due order.
| Method | Signature | Returns | Description |
|---|---|---|---|
.increment(ms) |
(float) -> Union[None, Awaitable[None]] |
None or an awaitable |
Advances virtual time by ms milliseconds and fires what became due, one timer at a time (so an after chain scheduled inside the same window fires in order). Returns an awaitable when called inside a running event loop (await clock.increment(ms)) and None otherwise; a forgotten await inside a loop raises a RuntimeWarning at garbage-collection time instead of silently racing. |
.set(ms) |
(float) -> Union[None, Awaitable[None]] |
Same as .increment() |
Jumps to absolute virtual time ms; raises ValueError if that would move backwards. |
.pending |
int |
– | Property: number of live (uncancelled) timers. |
from xstate_statemachine import Interpreter, SyncInterpreter, SimulatedClock
# Sync engine
clock = SimulatedClock()
interp = SyncInterpreter(machine, clock=clock).start()
clock.increment(30_000) # fires a 30s `after` transition synchronously
# Async engine
async def main():
clock = SimulatedClock()
service = await Interpreter(machine, clock=clock).start()
await clock.increment(30_000) # fires the 30s `after`, settles the loop
Data Classes
Event(type, payload={})
@dataclass(frozen=True)
class Event:
type: str
payload: Dict[str, Any] = field(default_factory=dict)
The standard event sent to the state machine. This is a frozen dataclass
– instances are immutable after creation. Each instance gets its own
payload dict (no shared mutable default).
| Field | Type | Description |
|---|---|---|
type |
str |
The event type identifier, matched against "on" transitions in the config. |
payload |
Dict[str, Any] |
Optional event data accessible to actions and guards. Defaults to empty dict. |
Properties
| Property | Type | Description |
|---|---|---|
.data |
Dict[str, Any] |
Read-only alias for payload. Provided for compatibility. Raises TypeError if payload is not a dict. |
Type-checking behavior
Because Event is frozen, attempts to mutate fields after creation raise
FrozenInstanceError:
e = Event(type="CLICK", payload={"x": 10})
e.type = "OTHER" # Raises dataclasses.FrozenInstanceError
However, the payload dict itself is mutable (only the reference is frozen):
e.payload["y"] = 20 # This works -- dict mutation, not field reassignment
Creating events
from xstate_statemachine import Event
# Simple event
click = Event(type="CLICK")
# Event with payload
login = Event(type="LOGIN", payload={"username": "alice", "role": "admin"})
# Access payload via .data alias
print(login.data["username"]) # "alice"
The send() method on interpreters also accepts strings and dicts, which are
internally converted to Event objects:
interpreter.send("CLICK") # Event(type="CLICK")
interpreter.send("LOGIN", username="alice") # Event(type="LOGIN", payload={"username": "alice"})
interpreter.send({"type": "CLICK", "x": 10}) # Event(type="CLICK", payload={"x": 10})
ActionDefinition(config)
class ActionDefinition:
def __init__(self, config: Union[str, Dict[str, Any]]): ...
Represents a single action to be executed, standardizing both shorthand string definitions and detailed object definitions from the JSON config.
| Field | Type | Description |
|---|---|---|
.type |
str |
The action name/identifier. |
.params |
Optional[Dict[str, Any]] |
Static parameters from the JSON config, or None if none were specified. |
Construction from string:
ad = ActionDefinition("myAction")
# ad.type == "myAction", ad.params == None
Construction from dict:
ad = ActionDefinition({"type": "myAction", "params": {"delay": 100}})
# ad.type == "myAction", ad.params == {"delay": 100}
Raises: InvalidConfigError if config is not a string or dictionary.
DoneEvent(type, data, src)
class DoneEvent(NamedTuple):
type: str
data: Any
src: str
An internal success event generated by the interpreter when:
- An
invoked service completes successfully (done.invoke.<service_id>). - A compound/parallel state reaches its final state (
done.state.<state_id>).
Failures are delivered as an ErrorEvent since 0.8.1 (#80).
| Field | Type | Description |
|---|---|---|
type |
str |
"done.invoke.<id>" or "done.state.<id>". |
data |
Any |
The service’s return value, the child actor’s final context, or a final state’s output. |
src |
str |
The unique identifier of the service or state that generated this event. |
Accessing data in handlers
def save_result(interpreter, context, event, action_def):
# event is a DoneEvent when handling onDone
context["result"] = event.data
ErrorEvent(type, error, src) [0.8.1]
class ErrorEvent(NamedTuple):
type: str
error: BaseException
src: str
Delivered when an invoked service raises, a child actor’s bring-up fails, or a child machine ends in the error status (#80). A distinct type from DoneEvent, so onError handlers branch on isinstance(event, ErrorEvent) or read event.error instead of string-prefixing event.type — the shape XState v5 uses for xstate.error.actor.*.
| Field | Type | Description |
|---|---|---|
type |
str |
"error.platform.<invocation_id>" (v4 naming, kept for config compatibility). |
error |
BaseException |
The exception the service or child raised. |
src |
str |
The id of the invoke that failed. |
event.data still returns error with a DeprecationWarning so 0.8.0-era onError actions keep working; it is removed in 0.9.
from xstate_statemachine import ErrorEvent
def store_error(interpreter, context, event, action_def):
assert isinstance(event, ErrorEvent)
context["error"] = str(event.error)
AfterEvent(type, scheduled_for=0.0, fired_at=0.0)
class AfterEvent(NamedTuple):
type: str
scheduled_for: float = 0.0 # clock time the deadline was set for
fired_at: float = 0.0 # clock time the timer actually fired
@property
def lateness_ms(self) -> float: ...
An internal event for delayed (after) transitions. Created and sent
automatically by the interpreter when entering a state with "after"
configuration. Users never create this event manually.
| Field | Type | Description |
|---|---|---|
type |
str |
Internally generated name in the format "after.<delay>.<machineId>.<stateId>". |
scheduled_for |
float |
The Clock reading the deadline was armed for. |
fired_at |
float |
The Clock reading when it actually fired. |
lateness_ms |
float (property) |
max(0, fired_at - scheduled_for) * 1000 — how late the timer ran, the figure behind Production Characteristics § 2. Both timestamps survive a snapshot round-trip (#118). |
Format examples
| Config | Generated AfterEvent.type |
|---|---|
"after": {"3000": "timeout"} on state pending in machine myApp |
"after.3000.myApp.pending" |
"after": {"500": "retry"} on state loading in machine fetch |
"after.500.fetch.loading" |
Engine provenance: is_system_event, system_event, ENGINE_EVENT_SHAPES [0.8.1]
def is_system_event(event: Any) -> bool: ...
def system_event(event_type: str, **payload: Any) -> Event: ...
ENGINE_EVENT_SHAPES: Tuple[str, ...] # ("done.invoke.", "done.state.", "error.platform.", "after.", "xstate.", "___xstate")
SYSTEM_EVENT_PREFIXES: Tuple[str, ...] # coarser legacy prefixes, kept for compatibility
Three behaviours — wildcard matching ("*" / "prefix.*"), onUnhandled: "error" / "defer", and strict=True — treat events minted by the engine differently from yours. Since 0.8.1 (#79, #137) that decision is made by provenance, not by name: is_system_event is the single predicate all three consult, and system_event(...) is the only way to mint a plain Event that satisfies it. An event you name done.review is user traffic. The marker survives deepcopy, pickle, send(wait=True) and a snapshot round-trip (#111, #138). ENGINE_EVENT_SHAPES lists the name shapes the engine produces, for build-time checks and documentation only.
from xstate_statemachine import Event, is_system_event, system_event
is_system_event(Event("done.review")) # False
is_system_event(system_event("___xstate_statemachine_init___")) # True
Receipt(state_ids, changed, error=None, deferred=False, denied=False) [wave 3, 0.8.1]
class Receipt(NamedTuple):
state_ids: FrozenSet[str]
changed: bool
error: Optional[BaseException] = None
deferred: bool = False
denied: bool = False
What send(..., wait=True) resolves to once the event’s macrostep has run to
completion (#39).
| Field | Type | Description |
|---|---|---|
state_ids |
FrozenSet[str] |
The active leaf ids the instant processing finished. |
changed |
bool |
True if a transition was taken (configuration or context changed) for THIS event. |
error |
Optional[BaseException] |
The exception raised while processing this event – an action that raised, an unresolvable target, a sendTo with no live target – or None. The machine may still be "running" (per actionErrorPolicy); the receipt tells the caller its request did not run cleanly. |
deferred |
bool |
[0.8.1] True when this event selected no transition and was parked under onUnhandled: "defer" (#84). It will be replayed, as its own macrostep, after the next event that changes the configuration; the replay does not fold into that event’s receipt (#125, both engines). |
denied |
bool |
[0.8.1] True when the active state declared a handler for this event but every candidate’s guard returned False (#153). Distinguishes “a business rule refused it” from “this event does not apply here” (denied=False, changed=False), which are otherwise identical receipts. A guard that crashed under guardErrorPolicy: "raise" is a third case: denied=False and error carries the exception (#170). (denied, error is None) therefore discriminates all three. Note that under onUnhandled: "defer" a denied event enters the defer buffer (deferred=True too) and is re-evaluated after the next state change. |
⚠️ 0.8.1 arity change.
Receiptgrew from three fields to four. A positional destructure written for 0.8.0 —state_ids, changed, error = receipt— now raisesValueError; read fields by attribute (#119).
receipt = await interpreter.send("SUBMIT", wait=True)
if receipt.error is not None:
print(f"SUBMIT did not run cleanly: {receipt.error}")
OverflowPolicy [wave 3]
class OverflowPolicy(str, Enum):
RAISE = "raise"
BLOCK = "block"
DROP_NEWEST = "drop_newest"
What send() does when a bounded inbox (max_queue_size on Interpreter) is
full (#38).
| Member | Value | Behavior |
|---|---|---|
OverflowPolicy.RAISE |
"raise" |
Default once max_queue_size is set. send() raises QueueOverflowError; the gateway sheds load and alarms. |
OverflowPolicy.BLOCK |
"block" |
await send() suspends until the consumer frees a slot. For trusted in-process producers that can be slowed. |
OverflowPolicy.DROP_NEWEST |
"drop_newest" |
The incoming event is discarded with a WARNING log and PluginBase.on_event_dropped. The only policy that can lose an event; never the default. For telemetry where staleness beats backlog. |
OverflowPolicy is a str subclass, so plain strings ("raise", "block",
"drop_newest") are also accepted anywhere it is expected.
from xstate_statemachine import Interpreter, OverflowPolicy
service = await Interpreter(
machine, max_queue_size=1000, overflow_policy=OverflowPolicy.DROP_NEWEST
).start()
PendingInvocation(state_id, invoke_id, src) [wave 3]
class PendingInvocation(NamedTuple):
state_id: str
invoke_id: str
src: str
An invoke that is part of the active configuration but has no live task –
what .pending_invocations() returns (#44).
| Field | Type | Description |
|---|---|---|
state_id |
str |
The state that owns the invoke. |
invoke_id |
str |
The invoke’s id (explicit, or the parser default). |
src |
str |
The service key. |
Decorators
@action / @action("name")
Marks a function as a state machine action. Actions are side-effect functions that run during transitions, on state entry, or on state exit.
@action
def my_action_name(interpreter, context, event, action_def):
...
@action("customActionName")
def whatever(interpreter, context, event, action_def):
...
Signature target
(interpreter: BaseInterpreter, context: Dict, event: Event, action_def: ActionDefinition) -> None
| Parameter | Type | Description |
|---|---|---|
interpreter |
Interpreter or SyncInterpreter |
The running interpreter instance. |
context |
Dict[str, Any] |
The mutable machine context. Modify this dict to update state data. |
event |
Event |
The event that triggered this action. |
action_def |
ActionDefinition |
The action definition, including .type and .params. |
Auto-naming
When used without arguments (@action), the function name is auto-converted
from snake_case to camelCase:
| Python function name | Registered as |
|---|---|
increment_counter |
"incrementCounter" |
log_event |
"logEvent" |
reset |
"reset" |
When used with an explicit name (@action("myName")), the provided string is
used exactly as-is.
Examples
from xstate_statemachine import action
# Auto-named: registered as "incrementCounter"
@action
def increment_counter(interpreter, context, event, action_def):
context["count"] += 1
# Explicitly named: registered as "logIt"
@action("logIt")
def my_logger(interpreter, context, event, action_def):
print(f"Event: {event.type}, Context: {context}")
@guard / @guard("name")
Marks a function as a state machine guard. Guards are boolean predicate
functions that control whether a transition is taken. Guards MUST be
synchronous – async guards raise NotSupportedError at decoration time.
@guard
def is_valid(context, event):
return context.get("valid", False)
@guard("canProceed")
def check_proceed(context, event):
return context["step"] > 0
Signature target
(context: Dict, event: Event) -> bool
| Parameter | Type | Description |
|---|---|---|
context |
Dict[str, Any] |
The current machine context (read-only by convention). |
event |
Event |
The event being evaluated. |
Must return: bool – True to allow the transition, False to block it.
Auto-naming
Same snake_case to camelCase conversion as @action.
Examples
from xstate_statemachine import guard
# Auto-named: registered as "hasBalance"
@guard
def has_balance(context, event):
return context["balance"] > 0
# Explicitly named
@guard("isAdmin")
def check_admin(context, event):
return event.payload.get("role") == "admin"
# INVALID -- raises NotSupportedError immediately:
# @guard
# async def async_guard(context, event):
# return True
@service / @service("name")
Marks a function as a state machine service. Services are long-running
operations invoked when a state is entered (via the invoke config). They
can be synchronous or asynchronous.
@service
def fetch_user(interpreter, context, event):
return {"name": "Alice", "id": 42}
@service("loadData")
async def load(interpreter, context, event):
# async services only work with the async Interpreter
data = await some_api_call()
return data
Signature target
(interpreter: BaseInterpreter, context: Dict, event: Event) -> Any
| Parameter | Type | Description |
|---|---|---|
interpreter |
Interpreter or SyncInterpreter |
The running interpreter. |
context |
Dict[str, Any] |
The current machine context. |
event |
Event |
A synthetic event with invocation metadata in the payload. |
Returns: Any value. The return value becomes the data field on the
resulting DoneEvent.
Auto-naming
Same snake_case to camelCase conversion as @action.
Examples
from xstate_statemachine import service
# Auto-named: registered as "fetchData"
@service
def fetch_data(interpreter, context, event):
import requests
return requests.get("https://api.example.com/data").json()
# Explicitly named, async
@service("processOrder")
async def process(interpreter, context, event):
await asyncio.sleep(1) # simulate work
return {"status": "processed", "order_id": context["order_id"]}
Logic Binding
MachineLogic(actions=None, guards=None, services=None)
class MachineLogic(Generic[TContext]):
def __init__(
self,
actions: Optional[Mapping[str, ActionCallable]] = None,
guards: Optional[Mapping[str, GuardCallable]] = None,
services: Optional[Mapping[str, Union[ServiceCallable, MachineNode]]] = None,
delays: Optional[Mapping[str, Union[int, float, DelayCallable]]] = None,
) -> None: ...
# The callable blueprints pin ARITY and the guard's bool return:
ActionCallable = Callable[[Any, Any, Any, ActionDefinition], Union[None, Awaitable[None]]]
GuardCallable = Callable[[Any, Any], bool]
ServiceCallable = Callable[[Any, Any, Any], Any]
DelayCallable = Callable[[Any, Any], Union[int, float]]
[wave 3] A two-argument action or a guard returning str is now a type
error at the MachineLogic(...) call. The interpreter/context/event slots are
Any on purpose: annotate them as narrowly as you like on your own functions
(interp: SyncInterpreter[MyCtx]) and they still fit.
A container (“registry”) for the implementation logic of a state machine. Separates the declarative machine definition (JSON) from the imperative implementation (Python functions).
| Parameter | Type | Default | Description |
|---|---|---|---|
actions |
Dict[str, Callable] |
{} |
Map of action name to its callable implementation. Signature: (interpreter, context, event, action_def) -> None. |
guards |
Dict[str, Callable] |
{} |
Map of guard name to its callable. Signature: (context, event) -> bool. Must be synchronous. |
services |
Dict[str, Union[Callable, MachineNode]] |
{} |
Map of service name to callable or MachineNode (for actor spawning). Service signature: (interpreter, context, event) -> Any. |
Subclass example
from xstate_statemachine import MachineLogic
class MyLogic(MachineLogic):
def __init__(self):
super().__init__(
actions={
"logEvent": self._log_event,
"increment": self._increment,
},
guards={
"isReady": self._is_ready,
},
)
def _log_event(self, interpreter, context, event, action_def):
print(f"Event: {event.type}")
def _increment(self, interpreter, context, event, action_def):
context["count"] += 1
@staticmethod
def _is_ready(context, event):
return context.get("ready", False)
Dict-based example
from xstate_statemachine import MachineLogic
logic = MachineLogic(
actions={
"increment": lambda i, ctx, e, a: ctx.update({"count": ctx["count"] + 1}),
},
guards={
"isPositive": lambda ctx, e: ctx["count"] > 0,
},
services={
"fetchData": my_fetch_function,
},
)
LogicLoader
Singleton class that manages automatic discovery of actions, guards, and services from Python modules and provider class instances. Implements the Convention over Configuration principle.
Singleton access
LogicLoader.get_instance() -> LogicLoader
Returns the single shared instance of LogicLoader. Creates it on first
call.
register_logic_module(module)
loader = LogicLoader.get_instance()
loader.register_logic_module(my_module)
Registers a Python module for global logic discovery. Registered modules
are automatically included in every subsequent create_machine() call that
uses auto-discovery. Useful for large applications where logic is spread
across many files.
| Parameter | Type | Description |
|---|---|---|
module |
ModuleType |
A Python module object to register. |
Discovery rules
- Underscore prefix ignored: Functions/methods starting with
_are skipped during discovery. - Auto camelCase conversion: Every discovered function is registered
under both its original
snake_casename and itscamelCaseequivalent. For example,increment_counteris available as both"increment_counter"and"incrementCounter". - Provider methods override modules: When the same name appears in both a module and a provider instance, the provider’s method takes precedence.
- Fail-fast: If a required implementation cannot be found,
ImplementationMissingErroris raised immediately.
Usage with create_machine()
from xstate_statemachine import create_machine, LogicLoader
import my_actions_module
# Option A: Register globally (affects all future create_machine calls)
loader = LogicLoader.get_instance()
loader.register_logic_module(my_actions_module)
machine = create_machine(config) # auto-discovers from registered modules
# Option B: Pass modules per-call
machine = create_machine(config, logic_modules=[my_actions_module])
# Option C: Pass by import string
machine = create_machine(config, logic_modules=["my_app.actions"])
# Option D: Use provider instances
class MyProvider:
def increment_counter(self, interpreter, context, event, action_def):
context["count"] += 1
machine = create_machine(config, logic_providers=[MyProvider()])
Machine Inspection
MachineNode.get_state_by_id(state_id) -> StateNode | None
machine.get_state_by_id(state_id: str) -> Optional[StateNode]
Finds a state node by its fully qualified ID by traversing the state tree.
| Parameter | Type | Description |
|---|---|---|
state_id |
str |
The fully qualified state ID, e.g. "myMachine.parent.child". Must start with the machine’s root ID. |
Returns: The StateNode if found, otherwise None.
machine = create_machine(config)
node = machine.get_state_by_id("myMachine.active.loading")
if node:
print(f"Found: {node.id}, type: {node.type}")
MachineNode.get_next_state(from_state_id, event) -> Set[str] | None
machine.get_next_state(from_state_id: str, event: Event) -> Optional[Set[str]]
Calculates the target state(s) for an event without side effects. A pure function intended for testing your machine’s flow logic. Finds the first valid transition by bubbling up the state hierarchy.
Note: This utility does not evaluate guards. It assumes any guard would pass to show the potential transition target.
| Parameter | Type | Description |
|---|---|---|
from_state_id |
str |
The fully qualified ID of the starting state. |
event |
Event |
The Event object to process. |
Returns: A Set[str] containing the target state ID(s), or None if no
transition is found.
from xstate_statemachine import Event
targets = machine.get_next_state("myMachine.idle", Event(type="START"))
assert targets == {"myMachine.running"}
MachineNode.structure_hash
machine.structure_hash -> str
A 16-hex-character structural fingerprint of the machine’s behavior: states, transitions, guard/action names, invokes, and after delays. Stable across meta/description edits and key reordering; changes when a state, transition, guard, action, invoke, or delay is added, removed, or renamed. Lazily computed and cached on first access. Written into every snapshot as machine_hash and checked on restore — see Snapshots — Snapshot Envelope.
print(machine.structure_hash) # e.g. "f21b173044383a6d"
MachineNode.to_plantuml() -> str
Generates a PlantUML state diagram string from the machine definition. Useful for auto-generating documentation diagrams.
puml = machine.to_plantuml()
print(puml)
# @startuml
# hide empty description
# state "off" as toggle_off
# state "on" as toggle_on
# [*] --> toggle_off
# toggle_off --> toggle_on : TOGGLE
# toggle_on --> toggle_off : TOGGLE
# @enduml
MachineNode.to_mermaid() -> str
Generates a Mermaid.js state diagram string. Renders directly in GitHub markdown, MkDocs, and other tools that support Mermaid.
mmd = machine.to_mermaid()
print(mmd)
# stateDiagram-v2
# [*] --> off
# off --> on : TOGGLE
# on --> off : TOGGLE
MachineNode.is_known_event(event_type) -> bool [wave 3]
machine.is_known_event(event_type: str) -> bool
True if event_type matches a descriptor declared anywhere in the machine
(#51) – the check strict mode runs against a sent event. Honours the same
matching rules as dispatch: an exact on key, a partial "prefix.*" whose
prefix matches by dot-segment, or the bare "*" wildcard, which makes every
event known. Engine-synthesised events (done.*, error.*, after.*,
xstate.*, the init sentinel) are always known.
Backed by the machine.known_events property (FrozenSet[str]), which is
built once, lazily, from every state’s on keys, every after delay’s
generated type, and every invoke’s generated done.invoke.<id> /
error.platform.<id>.
machine = create_machine(config)
assert machine.is_known_event("OPEN")
assert not machine.is_known_event("TYPO_EVENT")
xstate_statemachine.persistence
The module that owns the snapshot format contract — used internally by get_snapshot() / from_snapshot(), and importable directly for tooling that needs to inspect or migrate snapshots.
from xstate_statemachine.persistence import SNAPSHOT_VERSION, structure_hash
| Member | Description |
|---|---|
SNAPSHOT_VERSION |
int constant — the current snapshot payload layout version. Bumped only when the layout changes, never on an ordinary package release. |
structure_hash(machine) |
(MachineNode) -> str — computes the 16-hex-char structural fingerprint backing MachineNode.structure_hash. |
Exceptions
All exceptions inherit from XStateMachineError, enabling broad error
handling with a single except clause, or fine-grained handling with
specific exception types.
| Exception | Description | Common trigger |
|---|---|---|
XStateMachineError |
Base exception for all library errors. Catch this to handle any library error. | – |
InvalidConfigError |
The machine configuration is structurally invalid. | Missing "id" or "states" in config; malformed transition; duplicate state names; final=True combined with parallel=True. |
StateNotFoundError |
A target state ID cannot be found in the machine definition. | Transition targets a non-existent state; snapshot restoration with an outdated state ID. |
ImplementationMissingError |
A referenced action, guard, or service has no Python implementation. | Config references "actions": ["doSomething"] but no function named doSomething is provided. |
ActorSpawningError |
Error spawning a child actor machine. | Service registered for spawn_ action is not a valid MachineNode or factory function. |
NotSupportedError |
An unsupported operation was attempted for the current interpreter mode. | Async action/service used with SyncInterpreter; async guard function. |
UnhandledEventError |
An event selected no transition and onUnhandled is "error". |
Sending an event no active state (or its ancestors) handles. |
TransitionFailedError |
An action raised and actionErrorPolicy is "fail". |
An action raises during a transition on a machine configured with actionErrorPolicy: "fail". |
WrongThreadError |
A loop-affine Interpreter method was called from a foreign thread. |
Calling interpreter.send() from a thread other than the one that started the interpreter; use send_threadsafe() instead. |
SnapshotVersionError |
A snapshot’s version is newer than this library’s SNAPSHOT_VERSION. |
Restoring a snapshot written by a newer release of the library. |
SnapshotDriftError |
A snapshot doesn’t belong to the machine restoring it. | The snapshot’s machine_id differs from the target machine’s, or (when verify_machine_hash=True) machine_hash no longer matches machine.structure_hash. |
QueueOverflowError [wave 3] |
send() refused an event because the bounded inbox is full. |
max_queue_size is set, overflow_policy=OverflowPolicy.RAISE (the default once a bound is set), and the inbox is at capacity (#38). |
UnknownEventError [wave 3] |
send() was called with an event type not declared anywhere in the machine. |
strict=True on the interpreter and the event type matches no on key, after delay, or invoke completion descriptor (#51). |
InvalidEventPayloadError [wave 3] |
An event’s payload failed its declared schema. | event_schemas is set on create_machine() and an incoming event’s payload does not satisfy the validator registered for its type (#51). |
InterpreterStoppedError [wave 3] |
A send(wait=True) receipt cannot resolve because the interpreter stopped, or dropped the event, before it was processed. |
interpreter.send(event, wait=True) is awaited/blocked on and the interpreter is stopped, or the event is dropped by an overflow policy, before that event is processed (#39). |
RestoredError |
Carries the error message recovered from a snapshot persisted in the error status; the original type cannot survive JSON. |
from_snapshot() of a machine that had failed; interpreter.last_error is a RestoredError. |
RunawayChainError [0.8.1] |
A self-generated event chain exceeded maxIterations and was cut (#77, #103). The machine stays running; the trip is reported here. |
An action raises the event that triggers it, or a cross-region always keeps re-arming an invoke. Surfaces as receipt.error / last_error, never as a raised exception. |
SnapshotMidStepError [0.8.1] |
get_persisted_snapshot() was called while a macrostep is in flight; the configuration has no leaf to persist (#102). |
Snapshotting from inside an action. Snapshot after send(wait=True), from on_transition, or after stop(drain=True). |
SnapshotCorruptError [0.8.1] |
A snapshot is structurally unusable (#110). | Missing key, non-object context, unknown status, or status="running" with an empty configuration. |
SnapshotSerializationError [0.8.1] |
A pending event’s data is not JSON-native (#131). | Decimal / datetime in a queued DoneEvent.data when get_snapshot() runs. |
InvalidEventError [0.8.1] |
send() was given something that is not an event: a non-str type, a dict without "type", … (#113). Also a TypeError, so pre-0.8.1 handlers still catch it. |
send(123), send({"kind": "X"}). |
RootTargetError [0.8.1] |
A transition targets the machine root, which would empty the configuration (#108). Subclass of InvalidConfigError. Raised regardless of strict_targets — the escape hatch downgrades unresolvable targets only, never this (#147). |
"always": "#machine" or "on": {"X": "#machine"} at create_machine(). |
QueueOverflowError attributes [wave 3]
| Attribute | Type | Description |
|---|---|---|
interpreter_id |
str |
Which machine refused the event. |
depth |
int |
Events queued at the moment of refusal. |
maxsize |
int |
The configured max_queue_size bound. |
UnknownEventError attributes [wave 3]
| Attribute | Type | Description |
|---|---|---|
event_type |
str |
The offending type. |
machine_id |
str |
The machine that refused it. |
known |
list[str] |
The declared descriptor set, sorted. |
InvalidEventPayloadError attributes [wave 3]
| Attribute | Type | Description |
|---|---|---|
event_type |
str |
The event whose payload was rejected. |
cause |
BaseException |
The exception the validator raised. |
StateNotFoundError attributes
| Attribute | Type | Description |
|---|---|---|
target |
str |
The state ID that could not be found. |
reference_id |
Optional[str] |
The source state ID from which the lookup was attempted. |
Exception hierarchy
Exception
+-- XStateMachineError
+-- StateNotFoundError
+-- ImplementationMissingError
+-- ActorSpawningError
+-- NotSupportedError
+-- UnhandledEventError
+-- TransitionFailedError
+-- WrongThreadError
+-- SnapshotVersionError
+-- SnapshotDriftError
+-- QueueOverflowError
+-- UnknownEventError
+-- InvalidEventPayloadError
+-- InterpreterStoppedError
+-- RestoredError
+-- RunawayChainError [0.8.1]
+-- SnapshotMidStepError [0.8.1]
+-- SnapshotCorruptError [0.8.1]
+-- SnapshotSerializationError [0.8.1]
+-- InvalidEventError [0.8.1] (also a TypeError)
+-- InvalidConfigError
+-- RootTargetError [0.8.1]
Error handling example
from xstate_statemachine import (
create_machine,
SyncInterpreter,
XStateMachineError,
InvalidConfigError,
ImplementationMissingError,
)
try:
machine = create_machine(config, logic=logic)
interp = SyncInterpreter(machine).start()
interp.send("SOME_EVENT")
except InvalidConfigError as e:
print(f"Config problem: {e}")
except ImplementationMissingError as e:
print(f"Missing logic: {e}")
except XStateMachineError as e:
print(f"General machine error: {e}")
Plugins
PluginBase
class PluginBase(Generic[TInterpreter]):
...
Abstract base class for creating interpreter plugins using the Observer pattern. Plugins hook into the interpreter’s lifecycle to add cross-cutting concerns (logging, analytics, persistence) without modifying core interpreter code.
PluginBase is generic over TInterpreter, enabling plugins that are
type-safe with a specific interpreter subclass:
from xstate_statemachine import PluginBase, Interpreter
class AsyncOnlyPlugin(PluginBase[Interpreter]):
def on_event_received(self, interpreter: Interpreter, event):
# `interpreter` is correctly typed as async Interpreter
print(f"Event: {event.type}")
Hook signatures
All hooks have empty default implementations – override only those you need.
| Hook | Signature | Called when |
|---|---|---|
on_interpreter_start |
(self, interpreter: TInterpreter) -> None |
start() begins. |
on_interpreter_stop |
(self, interpreter: TInterpreter) -> None |
stop() begins. |
on_event_received |
(self, interpreter: TInterpreter, event: Event) -> None |
An event is passed to the interpreter, before processing. |
on_transition |
(self, interpreter: TInterpreter, from_states: Set[StateNode], to_states: Set[StateNode], transition: TransitionDefinition) -> None |
After a state transition completes (both external and internal). |
on_action_execute |
(self, interpreter: TInterpreter, action: ActionDefinition) -> None |
Right before an action’s implementation is executed. |
on_guard_evaluated |
(self, interpreter: TInterpreter, guard_name: str, event: Event, result: bool) -> None |
After a guard condition is evaluated. |
on_service_start |
(self, interpreter: TInterpreter, invocation: InvokeDefinition) -> None |
An invoked service is about to start. |
on_service_done |
(self, interpreter: TInterpreter, invocation: InvokeDefinition, result: Any) -> None |
A service completes successfully. |
on_action_error |
A user action or built-in action creator raised; the error was contained | |
on_service_error |
(self, interpreter: TInterpreter, invocation: InvokeDefinition, error: Exception) -> None |
A service fails with an error. |
on_transition_failed |
(self, interpreter: TInterpreter, transition: TransitionDefinition, failed_actions: List[Tuple[ActionDefinition, BaseException]]) -> None |
A transition’s action list did not run to completion (actionErrorPolicy "rollback"/"fail"). |
on_guard_error |
(self, interpreter: TInterpreter, guard_name: str, event: Event, error: BaseException) -> None |
A guard raised instead of returning, before the substituted result (per guardErrorPolicy) is reported. |
on_unhandled_event |
(self, interpreter: TInterpreter, event: Event, active_state_ids: Set[str], disposition: str) -> None |
An event selects no transition. disposition is "ignored" (no handler declared), "guard_denied" [0.8.1] (a handler was declared but every guard refused — #153), "deferred", "errored", or "dropped". |
on_invalid_event [0.8.1] |
(self, interpreter: TInterpreter, error: BaseException, raw_event: Any) -> None |
send() refused a malformed event; fires immediately before the InvalidEventError propagates to the caller (#159). Observability, not containment. |
on_snapshot_error [0.8.1] |
(self, interpreter: TInterpreter, error: BaseException) -> None |
A snapshot was refused — SnapshotMidStepError or SnapshotSerializationError — fires immediately before it propagates (#159). |
on_event_dropped [wave 3, 0.8.1] |
(self, interpreter: TInterpreter, event: Event, reason: str) -> None |
An event was discarded unprocessed. reason is one of "queue_full" (bounded inbox, DROP_NEWEST), "not_running" (sent to a stopped/done/errored machine), "chain_budget" (maxIterations cut), "stopped" (abandoned by stop(), including producers parked on a full BLOCK inbox), "unresolved_target" (sendTo to no live actor). Fires on both engines for every loss site (#38, #123, #129, #133). Also logged at WARNING. |
on_resolve_error [0.8.1] |
(self, interpreter: TInterpreter, error: BaseException, event: Event) -> None |
A transition’s target could not be resolved at runtime (strict_targets=False only). The third per-transition failure category alongside on_action_error / on_guard_error (#134). |
on_plugin_error [0.8.1] |
(self, interpreter: TInterpreter, plugin: PluginBase, hook: str, error: BaseException) -> None |
Another plugin’s hook raised, or was async def and could not be awaited. Never fires for the plugin that failed. The same triple is on interpreter.last_plugin_error (#127). |
on_error |
(self, interpreter: TInterpreter, error: BaseException) -> None |
The interpreter enters the terminal "error" status. |
on_done |
(self, interpreter: TInterpreter, output: Any) -> None |
The machine reaches a top-level final state. |
LoggingInspector implements all five of these hooks in addition to the ones above.
Custom plugin example
from xstate_statemachine import PluginBase, SyncInterpreter
class MetricsPlugin(PluginBase[SyncInterpreter]):
def __init__(self):
self.event_count = 0
self.transition_count = 0
def on_event_received(self, interpreter, event):
self.event_count += 1
def on_transition(self, interpreter, from_states, to_states, transition):
self.transition_count += 1
def report(self):
print(f"Events: {self.event_count}, Transitions: {self.transition_count}")
# Usage
metrics = MetricsPlugin()
interpreter = SyncInterpreter(machine).use(metrics).start()
interpreter.send("GO")
metrics.report() # Events: 1, Transitions: 1
LoggingInspector(*, redact_keys=DEFAULT_REDACT_KEYS, log_context=True)
class LoggingInspector(PluginBase[Any]):
def __init__(
self,
*,
redact_keys: Tuple[str, ...] = DEFAULT_REDACT_KEYS,
log_context: bool = True,
) -> None: ...
A built-in plugin for detailed, real-time inspection of machine execution.
Works with both Interpreter and SyncInterpreter. All messages are
emitted through Python’s standard logging module at INFO level.
| Parameter | Default | Description |
|---|---|---|
redact_keys |
DEFAULT_REDACT_KEYS |
[0.8.1] (#126) Substrings matched case-insensitively against every key in a logged context or payload, recursively; matches are written as "***". The default list covers password, secret, token, api_key, authorization, credential, card, cvv, …. Extend with (*DEFAULT_REDACT_KEYS, "ssn"); pass () to opt out explicitly. |
log_context |
True |
Set False to skip the per-transition context dump on machines with a large context. |
The standalone redact(value, keys=DEFAULT_REDACT_KEYS) helper is exported for your own plugins.
Output format
All log messages are prefixed with a distinctive marker for easy filtering:
| Hook | Log format |
|---|---|
| Event received | 🕵️ [INSPECT] Event Received: <type> \| Data: <payload> |
| External transition | 🕵️ [INSPECT] Transition: [from_ids] -> [to_ids] on Event '<event>' |
| Internal transition | 🕵️ [INSPECT] Internal transition on Event '<event>' |
| Context update | 🕵️ [INSPECT] New Context: <context_dict> |
| Action execute | 🕵️ [INSPECT] Executing Action: <action_type> |
| Guard evaluated | 🕵️ [INSPECT] Guard '<name>' evaluated for event '<event>' -> ✅ Passed / ❌ Failed |
| Service start | 🚀 [INSPECT] Service '<src>' (ID: <id>) starting... |
| Service done | ✅ [INSPECT] Service '<src>' (ID: <id>) completed. Result: <result> |
| Service error | ❌ [INSPECT] Service '<src>' (ID: <id>) failed. Error: <error> (with traceback) |
Usage
import logging
from xstate_statemachine import SyncInterpreter, LoggingInspector
# Enable logging output
logging.basicConfig(level=logging.INFO)
interpreter = (
SyncInterpreter(machine)
.use(LoggingInspector())
.start()
)
interpreter.send("START")
# Output:
# INFO: 🕵️ [INSPECT] Event Received: START
# INFO: 🕵️ [INSPECT] Executing Action: logStart
# INFO: 🕵️ [INSPECT] Transition: ['myMachine.idle'] -> ['myMachine.running'] on Event 'START'
# INFO: 🕵️ [INSPECT] New Context: {'count': 0}
Internal Model Classes
These classes are part of the internal model layer. Users typically interact with them indirectly, but they appear in plugin hook signatures and interpreter properties.
StateNode
Represents a single state in the parsed machine graph. Created by
MachineNode during config parsing. Implements the Composite design pattern.
| Property | Type | Description |
|---|---|---|
.id |
str |
Fully qualified state ID (e.g. "myMachine.parent.child"). |
.key |
str |
Local state name within its parent. |
.type |
str |
One of "atomic", "compound", "parallel", "final". |
.parent |
Optional[StateNode] |
Parent state node, or None for the root. |
.states |
Dict[str, StateNode] |
Child states dictionary. |
.initial |
Optional[str] |
Key of the initial child state (compound states only). |
.on |
Dict[str, List[TransitionDefinition]] |
Event-to-transitions mapping. |
.entry |
List[ActionDefinition] |
Entry action definitions. |
.exit |
List[ActionDefinition] |
Exit action definitions. |
.is_atomic |
bool |
True if the state has no children. |
.is_final |
bool |
True if the state is a final state. |
MachineNode
The root node of a state machine. Extends StateNode with machine-wide
utilities. Created by create_machine().
| Attribute | Type | Description |
|---|---|---|
.logic |
MachineLogic |
The bound logic instance. |
.initial_context |
Dict |
The initial context (deep-copied for each interpreter). |
TransitionDefinition
Represents a parsed transition from the JSON config (internal model).
| Attribute | Type | Description |
|---|---|---|
.event |
str |
Triggering event name. |
.source |
StateNode |
Source state node. |
.target_str |
Optional[str] |
Target state string (may be None for internal transitions). |
.actions |
List[ActionDefinition] |
Actions to execute. |
.guard |
Optional[str] |
Guard name. |
.reenter |
bool |
Whether to re-enter the source state. |
InvokeDefinition
Represents an invoked service within a state.
| Attribute | Type | Description |
|---|---|---|
.id |
str |
Unique invocation ID. |
.src |
Optional[str] |
Service name from the logic registry. |
.input |
Any |
Static value, or a callable resolved per spawn by .resolve_input(). |
.on_done |
List[TransitionDefinition] |
Transitions on success. |
.on_error |
List[TransitionDefinition] |
Transitions on failure. |
InvokeDefinition.resolve_input(context, event) -> Any
Resolves this invoke’s input against the parent’s live state, per spawn. input may be:
- a static value — returned as-is (deep-copied);
fn(context, event)— the two-positional form;fn(args)— one mapping{"context": ..., "event": ...}(XState form).
Returns a deep copy of the resolved value so the child never aliases the parent’s context; returns None when no input is declared.
Type Aliases
The library defines several callable type aliases for documentation purposes:
| Alias | Signature | Used for |
|---|---|---|
ActionCallable |
(BaseInterpreter, TContext, Event, ActionDefinition) -> Union[None, Awaitable[None]] |
Action functions |
GuardCallable |
(TContext, Event) -> bool |
Guard functions (sync only) |
ServiceCallable |
(BaseInterpreter, TContext, Event) -> Union[Any, Awaitable[Any]] |
Service functions |
Built-in Action Creators
Added in v0.6.0. Each returns a plain action-definition dict, so they can be used directly in a config or written as raw JSON. See the Actions guide.
| Function | Signature |
|---|---|
assign |
assign(assignment: dict \| Callable) -> dict |
choose |
choose(conditions: list[dict]) -> dict |
pure |
pure(fn: Callable) -> dict |
enqueue_actions |
enqueue_actions(fn: Callable) -> dict |
raise_ |
raise_(event, *, delay=None) -> dict |
send_to |
send_to(target, event, *, delay=None, send_id=None) -> dict |
send_parent |
send_parent(event, *, delay=None) -> dict |
spawn_child |
spawn_child(src, *, actor_id=None, system_id=None, input=None) -> dict |
stop_child |
stop_child(actor_id) -> dict |
cancel |
cancel(send_id: str) -> dict |
emit |
emit(event) -> dict |
escalate |
escalate(error) -> dict |
forward_to |
forward_to(target: str) -> dict |
log |
log(expr='', *, label=None) -> dict |
ActionEnqueuer
The object passed as enqueue to an enqueue_actions callback. Methods:
assign, raise_, send_to, send_parent, spawn_child, stop_child,
emit, log, cancel.
from xstate_statemachine import enqueue_actions
def build(args):
enqueue = args["enqueue"] # ActionEnqueuer
enqueue.assign({"n": lambda x: x["context"]["n"] + 1})
enqueue.raise_({"type": "NEXT"})
{"actions": enqueue_actions(build)}
Note: the callback receives a single mapping containing
context,event,enqueue,checkandself— not separate positional arguments.
Pure API
Compute transitions with no side effects: no timers start, no services fire, nothing mutates. See Testing & The Pure API.
| Function | Returns |
|---|---|
initial_transition(machine, *, input=None) |
(PureSnapshot, list[ActionDefinition]) |
pure_transition(machine, snapshot, event) |
(PureSnapshot, list[ActionDefinition]) |
get_initial_snapshot(machine, *, input=None) |
PureSnapshot |
get_next_snapshot(machine, snapshot, event) |
PureSnapshot |
PureSnapshot
| Member | Description |
|---|---|
.state_ids |
Set of active state ids |
.context |
The context dict |
.status |
'running', 'done' or 'error' |
.output |
Machine output once a top-level final state is reached |
.configuration |
The active StateNode objects |
.matches(id) |
Test a state id, supporting nested paths |
from xstate_statemachine import initial_transition, pure_transition
snapshot, entry_actions = initial_transition(machine)
next_snapshot, actions = pure_transition(machine, snapshot, "GO")
Waiting Helpers
Poll a real predicate with a timeout instead of sleeping.
| Function | Description |
|---|---|
wait_for(interp, predicate, *, timeout=10.0, poll_interval=0.005) |
Async; resolves when the predicate is true |
wait_for_sync(interp, predicate, *, timeout=10.0, poll_interval=0.005) |
Blocking equivalent for SyncInterpreter |
to_promise(interp) |
Async; resolves with the machine output when it reaches a final state |
await wait_for(interp, lambda s: s.matches("fetch.success"), timeout=2)
wait_for_sync(interp, lambda s: s.matches("job.done"), timeout=5)
output = await to_promise(interp)
Both waiters raise on timeout rather than returning silently.
Version
from xstate_statemachine import __version__
print(__version__) # "0.7.0"