Skip to main content

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

statem-0.1.0.tar.gz (72.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

statem-0.1.0-py3-none-any.whl (12.8 kB view details)

Uploaded Python 3

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

Hashes for statem-0.1.0.tar.gz
Algorithm Hash digest
SHA256 b135e976547af40c0ed3bb3b0041dedc6b0c7324703dee970933f21ee1f515f2
MD5 ae8b8794680dea8ac2e45d70eeeef494
BLAKE2b-256 1f77f017e8bb97e1766da1d75b01b443184a4668e04bbe8df1a8ddd1cf61d5f6

See more details on using hashes here.

Provenance

The following attestation bundles were made for statem-0.1.0.tar.gz:

Publisher: publish.yml on RahulDas-dev/statem

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for statem-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 60b584f29ec0d4fb7fddc0e2ed49372865cb5800ca075427a768261528e0285f
MD5 e5c17bd9c46e80a0326ecec99ef7c7b1
BLAKE2b-256 b13c22a4a779e09ed28b74e3717a795cc548bc51f8b85c0abb5b6d21ab7fc5ef

See more details on using hashes here.

Provenance

The following attestation bundles were made for statem-0.1.0-py3-none-any.whl:

Publisher: publish.yml on RahulDas-dev/statem

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page