statem
A minimal async state machine engine for Python, built on Pydantic.
State/transition config is a plain dict, validated by Pydantic at construction time; guard and
action behavior is ordinary Python (sync or async) code, registered by name. Ships a py.typed
marker and is tested to 100% branch coverage (see Development).
Full documentation: https://rahuldas-dev.github.io/statem/
Philosophy
The state graph is data; behavior is code — and they're kept apart. config is a plain,
JSON/YAML-friendly dict validated by Pydantic. It can be authored by hand, generated, loaded from
a file or a database, or produced by an LLM — the engine doesn't care where it came from. Guards
and actions are ordinary Python functions, registered by name and swapped in independently of the
graph. Neither side needs to know about the other's implementation details.
Fail fast, not deep in production. Every transition target, and (if you supply
action_dict/guard_dict) every referenced guard/action name, is validated at construction
time — a typo in your config raises immediately, not three hops into a live run.
Async where it matters, sync where it doesn't. Guards and actions may be sync or async in any
combination; the engine detects and awaits each correctly, so you're never forced to wrap trivial
sync logic in async def just to satisfy the type system.
The engine owns transitions, not your data. session is opaque — a dict, a dataclass, an ORM
row, anything. The machine never inspects or reshapes it; it's simply threaded through to your
guards/actions via ctx.session. statem is not a state-storage or persistence layer, and
doesn't try to be one.
No subclassing, no base classes. A StateMachine is a plain object built with
StateMachine.from_dict(...) and driven with await machine.run(...). There's no framework
lifecycle to inherit into and no required web/ORM integration.
Every run is traceable. Each guard/action evaluated during a run() call is recorded in
order (accessible via a directly-built ExecutionContext, see the guide),
and a run_id (yours, or an auto-generated one) ties every log line from one run together —
because in practice, reconstructing why a machine ended up in a given state is the actual hard
part.
Hooks
Four lifecycle hooks can carry guards and/or actions on any state:
| Hook | Fires when | Carries |
|---|---|---|
on |
An external Signal (event) is dispatched and matches this state's on map (or its "*" wildcard) |
Candidates tried in order: guard (optional, must return bool) gates the candidate; its actions run if it fires |
always |
Automatically, right after any state entry (including the initial one) — no external signal needed | Same shape as on, minus the event name; used to auto-advance once a condition becomes true |
entry |
A state is entered (after the firing transition's own actions) |
Action names only |
exit |
A state is left, before entering the next one | Action names only |
A fifth field, error_state, isn't a hook itself but a per-state escape hatch: if an
on-transition's action raises, the engine catches it and — if error_state is set — transitions
there instead of propagating the exception.
always re-checks after every entry it causes, so a single run() call can chain through several
states automatically (capped at 100 hops, to catch runaway loops). See the
guide for the full config shape and shorthand
forms.
Install
pip install statem
The importable package is statem:
from statem import StateMachine, Signal
Quickstart
import asyncio
from statem import Signal, StateMachine
config = {
"idle": {"on": {"START": {"target": "running", "guard": "can_start"}}},
"running": {"on": {"STOP": "idle"}},
}
def can_start(ctx, signal) -> bool:
return True
async def main() -> None:
machine = StateMachine.from_dict(config, guard_dict={"can_start": can_start})
state = await machine.run(state_name="idle", events=Signal(event="START"), session={})
print(state) # "running"
asyncio.run(main())
See examples/baking/example.py for a fuller example (guards, actions, error_state, and an
always-transition), and the guide for a
full walkthrough of the config shape.
Development
uv sync
uv run python -m unittest discover -s tests
uv run coverage run -m unittest discover -s tests && uv run coverage report --fail-under=100
uv run ruff check tests statem
License
MIT — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file statem-0.1.0.tar.gz.
File metadata
- Download URL: statem-0.1.0.tar.gz
- Upload date:
- Size: 72.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b135e976547af40c0ed3bb3b0041dedc6b0c7324703dee970933f21ee1f515f2
|
|
| MD5 |
ae8b8794680dea8ac2e45d70eeeef494
|
|
| BLAKE2b-256 |
1f77f017e8bb97e1766da1d75b01b443184a4668e04bbe8df1a8ddd1cf61d5f6
|
Provenance
The following attestation bundles were made for statem-0.1.0.tar.gz:
Publisher:
publish.yml on RahulDas-dev/statem
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
statem-0.1.0.tar.gz -
Subject digest:
b135e976547af40c0ed3bb3b0041dedc6b0c7324703dee970933f21ee1f515f2 - Sigstore transparency entry: 2310528723
- Sigstore integration time:
-
Permalink:
RahulDas-dev/statem@9e60f31aeaa3a3734ecdc8be74066807a886dec2 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/RahulDas-dev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@9e60f31aeaa3a3734ecdc8be74066807a886dec2 -
Trigger Event:
release
-
Statement type:
File details
Details for the file statem-0.1.0-py3-none-any.whl.
File metadata
- Download URL: statem-0.1.0-py3-none-any.whl
- Upload date:
- Size: 12.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
60b584f29ec0d4fb7fddc0e2ed49372865cb5800ca075427a768261528e0285f
|
|
| MD5 |
e5c17bd9c46e80a0326ecec99ef7c7b1
|
|
| BLAKE2b-256 |
b13c22a4a779e09ed28b74e3717a795cc548bc51f8b85c0abb5b6d21ab7fc5ef
|
Provenance
The following attestation bundles were made for statem-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on RahulDas-dev/statem
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
statem-0.1.0-py3-none-any.whl -
Subject digest:
60b584f29ec0d4fb7fddc0e2ed49372865cb5800ca075427a768261528e0285f - Sigstore transparency entry: 2310528745
- Sigstore integration time:
-
Permalink:
RahulDas-dev/statem@9e60f31aeaa3a3734ecdc8be74066807a886dec2 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/RahulDas-dev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@9e60f31aeaa3a3734ecdc8be74066807a886dec2 -
Trigger Event:
release
-
Statement type: