Python Finite State Machines made easy.
A library for building finite state machines in Python, with support for sync and async engines, Django integration, diagram generation, and a flexible callback/listener system.
- Source code:
statemachine/ - Tests:
tests/ - Documentation:
docs/(Sphinx + MyST Markdown, hosted on ReadTheDocs)
statemachine.py— CoreStateMachineandStateChartclassesfactory.py—StateMachineMetaclasshandles class construction, state/transition validationstate.py/event.py— Descriptor-basedStateandEventdefinitionstransition.py/transition_list.py— Transition logic and composition (|operator)callbacks.py— Priority-based callback registry (CallbackPriority,CallbackGroup)dispatcher.py— Listener/observer pattern,callable_methodwraps callables with signature adaptationsignature.py—SignatureAdapterfor dependency injection into callbacksengines/base.py— Shared engine logic (microstep, transition selection, error handling)engines/sync.py,engines/async_.py— Sync and async processing loopsregistry.py— Global state machine registry (used byMachineMixin)mixins.py—MachineMixinfor domain model integration (e.g., Django models)spec_parser.py— Boolean expression parser for condition guardscontrib/diagram.py— Diagram generation via pydot/Graphviz
The engine follows the SCXML run-to-completion (RTC) model with two processing levels:
- Microstep: atomic execution of one transition set (before → exit → on → enter → after).
- Macrostep: complete processing cycle for one external event; repeats microsteps until the machine reaches a stable configuration (no eventless transitions enabled, internal queue empty).
send()→ external queue (processed after current macrostep ends).raise_()→ internal queue (processed within the current macrostep, before external events).
StateCharthascatch_errors_as_events=Trueby default;StateMachinehasFalse.- Errors are caught at the block level (per onentry/onexit/transition
onblock), not per microstep. This meansaftercallbacks still run even when an action raises — makingafter_<event>()a natural finalize hook (runs on both success and failure paths). error.executionis dispatched as an internal event; define transitions for it to handle errors within the statechart.- Error during
error.executionhandling → ignored to prevent infinite loops.
Transition on content uses on_error only for non-error.execution events. During
error.execution processing, on_error is disabled for transition on content — errors
propagate to microstep() where _send_error_execution ignores them. This prevents infinite
loops in self-transition error handlers (e.g., error_execution = s1.to(s1, on="handler")
where handler raises). onentry/onexit blocks always use on_error regardless of the
current event.
- Bare transition statements (not assigned to a variable) are eventless — they fire automatically when their guard condition is met.
- Assigned transitions (e.g.,
go = s1.to(s2)) create named events. error_prefix naming convention:error_Xauto-registers botherror_Xanderror.Xevent names (explicitid=takes precedence).
- Generic callbacks (always available):
prepare_event(),before_transition(),on_transition(),on_exit_state(),on_enter_state(),after_transition(). - Event-specific:
before_<event>(),on_<event>(),after_<event>(). - State-specific:
on_enter_<state>(),on_exit_<state>(). on_error_execution()works via naming convention but only when a transition forerror.executionis declared — it is NOT a generic callback.
invoke.py—InvokeManageron the engine manages the lifecycle:mark_for_invoke(),cancel_for_state(),spawn_pending_sync/async(),send_to_child()._cleanup_terminated()only removes invocations that are both terminated and cancelled. A terminated-but-not-cancelled invocation means the handler'srun()returned but the owning state is still active — it must stay in_activesosend_to_child()can still route events.- Child machine constructor blocks in the processing loop. Use a listener pattern (e.g.,
_ChildRefSetter) to capture the child reference during the firston_enter_state, before the loop spins. #_<invokeid>send target: routed via_send_to_invoke()inio/scxml/actions.py→InvokeManager.send_to_child()→ handler'son_event().- Tests with blocking threads: use
threading.Event.wait(timeout=)instead oftime.sleep()for interruptible waits — avoids thread leak errors in teardown.
uv sync --all-extras --dev
pre-commit installAlways use uv to run commands. Also, use a timeout to avoid being stuck in the case of a leaked thread or infinite loop:
# Run all tests (parallel)
timeout 120 uv run pytest -n 4
# Run a specific test file
uv run pytest tests/test_signature.py
# Run a specific test
uv run pytest tests/test_signature.py::TestSignatureAdapter::test_wrap_fn_single_positional_parameter
# Skip slow tests
uv run pytest -m "not slow"When trying to run all tests, prefer to use xdist (-n) as some SCXML tests uses timeout of 30s to verify fallback mechanism.
Don't specify the directory tests/, because this will exclude doctests from both source modules (--doctest-modules) and markdown docs
(--doctest-glob=*.md) (enabled by default):
timeout 120 uv run pytest -n 4Testes normally run under 60s (~40s on average), so take a closer look if they take longer, it can be a regression.
When analyzing warnings or extensive output, run the tests once saving the output to a file
(> /tmp/pytest-output.txt 2>&1), then analyze the file — instead of running the suite
repeatedly with different greps.
Coverage is enabled by default (--cov is in pyproject.toml's addopts). To generate a
coverage report to a file, pass --cov-report in addition to --cov:
# JSON report (machine-readable, includes missing_lines per file)
timeout 120 uv run pytest -n auto --cov=statemachine --cov-report=json:cov.json
# Terminal report with missing lines
timeout 120 uv run pytest -n auto --cov=statemachine --cov-report=term-missingNote: --cov=statemachine is required to activate coverage collection; --cov-report
alone only changes the output format.
Use the sm_runner fixture (from tests/conftest.py) when you need to test the same
statechart on both sync and async engines. It is parametrized with ["sync", "async"]
and provides start() / send() helpers that handle engine selection automatically:
async def test_something(self, sm_runner):
sm = await sm_runner.start(MyStateChart)
await sm_runner.send(sm, "some_event")
assert "expected_state" in sm.configuration_valuesDo not manually add async no-op listeners or duplicate test classes — prefer sm_runner.
Follow a test-driven development approach: tests are not an afterthought — they are a first-class requirement that must be part of every implementation plan.
- Planning phase: every plan must include test tasks as explicit steps, not a final "add tests" bullet. Identify what needs to be tested (new branches, edge cases, error paths) while designing the implementation.
- 100% branch coverage is mandatory. The pre-commit hook enforces
--cov-fail-under=100with branch coverage enabled. Code that drops coverage will not pass CI. - Verify coverage before committing: after writing tests, run coverage on the affected
modules and check for missing lines/branches:
timeout 120 uv run pytest tests/<test_file>.py --cov=statemachine.<module> --cov-report=term-missing --cov-branch
- Use pytest fixtures (
tmp_path,monkeypatch, etc.) — never hardcode paths or use mutable global state when a fixture exists. - Unreachable defensive branches (e.g.,
ifguards that can never be True given the type system) may be marked withpragma: no cover, but prefer writing a test first.
# Lint
uv run ruff check .
# Auto-fix lint issues
uv run ruff check --fix .
# Format
uv run ruff format .
# Type check
uv run mypy statemachine/ tests/- Formatter/Linter: ruff (line length 99, target Python 3.9)
- Rules: pycodestyle, pyflakes, isort, pyupgrade, flake8-comprehensions, flake8-bugbear, flake8-pytest-style
- Imports: single-line, sorted by isort. Always prefer top-level imports — only use lazy (in-function) imports when strictly necessary to break circular dependencies
- Docstrings: Google convention
- Naming: PascalCase for classes, snake_case for functions/methods, UPPER_SNAKE_CASE for constants
- Type hints: used throughout;
TYPE_CHECKINGfor circular imports - Pre-commit hooks enforce ruff + mypy + pytest
- Use GRASP/SOLID patterns to guide decisions. When refactoring or designing, explicitly
apply patterns like Information Expert, Single Responsibility, and Law of Demeter to decide
where logic belongs — don't just pick a convenient location.
- Information Expert (GRASP): Place logic in the module/class that already has the knowledge it needs. If a method computes a result, it should signal or return it rather than forcing another method to recompute the same thing.
- Law of Demeter: Methods should depend only on the data they need, not on the
objects that contain it. Pass the specific value (e.g., a
Future) rather than the parent object (e.g.,TriggerData) — this reduces coupling and removes the need for null-checks on intermediate accessors. - Single Responsibility: Each module, class, and function should have one clear reason
to change. Functions and types belong in the module that owns their domain (e.g.,
event-name helpers belong in
event.py, not infactory.py). - Interface Segregation: Depend on narrow interfaces. If a helper only needs one field from a dataclass, accept that field directly.
- Decouple infrastructure from domain: Modules like
signature.pyanddispatcher.pyare general-purpose (signature adaptation, listener/observer pattern) and intentionally not coupled to the state machine domain. Prefer this separation even for modules that are only used internally — it keeps responsibilities clear and the code easier to reason about. - Favor small, focused modules: When adding new functionality, consider whether it can live in its own module with a well-defined boundary, rather than growing an existing one.
# Build HTML docs
uv run sphinx-build docs docs/_build/html
# Live reload for development
uv run sphinx-autobuild docs docs/_build/html --re-ignore "auto_examples/.*"All code examples in docs/*.md must be testable doctests (using ```py with
>>> prompts), not plain ```python blocks. The test suite collects them via
--doctest-glob=*.md. If an example cannot be expressed as a doctest (e.g., it requires
real concurrency), write it as a unit test in tests/ and reference it from the docs instead.
- Main branch:
develop - PRs target
develop - Releases are tagged as
v*.*.* - Signed commits preferred (
git commit -s) - Use Conventional Commits messages
(e.g.,
feat:,fix:,refactor:,test:,docs:,chore:,perf:)
- Do not commit secrets, credentials, or
.envfiles - Validate at system boundaries; trust internal code