@@ -13,19 +13,85 @@ Django integration, diagram generation, and a flexible callback/listener system.
1313
1414## Architecture
1515
16- - ` statemachine.py ` — Core ` StateMachine ` class
16+ - ` statemachine.py ` — Core ` StateMachine ` and ` StateChart ` classes
1717- ` factory.py ` — ` StateMachineMetaclass ` handles class construction, state/transition validation
1818- ` state.py ` / ` event.py ` — Descriptor-based ` State ` and ` Event ` definitions
1919- ` transition.py ` / ` transition_list.py ` — Transition logic and composition (` | ` operator)
2020- ` callbacks.py ` — Priority-based callback registry (` CallbackPriority ` , ` CallbackGroup ` )
2121- ` dispatcher.py ` — Listener/observer pattern, ` callable_method ` wraps callables with signature adaptation
2222- ` signature.py ` — ` SignatureAdapter ` for dependency injection into callbacks
23- - ` engines/sync.py ` , ` engines/async_.py ` — Sync and async run-to-completion engines
23+ - ` engines/base.py ` — Shared engine logic (microstep, transition selection, error handling)
24+ - ` engines/sync.py ` , ` engines/async_.py ` — Sync and async processing loops
2425- ` registry.py ` — Global state machine registry (used by ` MachineMixin ` )
2526- ` mixins.py ` — ` MachineMixin ` for domain model integration (e.g., Django models)
2627- ` spec_parser.py ` — Boolean expression parser for condition guards
2728- ` contrib/diagram.py ` — Diagram generation via pydot/Graphviz
2829
30+ ## Processing model
31+
32+ The engine follows the SCXML run-to-completion (RTC) model with two processing levels:
33+
34+ - ** Microstep** : atomic execution of one transition set (before → exit → on → enter → after).
35+ - ** Macrostep** : complete processing cycle for one external event; repeats microsteps until
36+ the machine reaches a ** stable configuration** (no eventless transitions enabled, internal
37+ queue empty).
38+
39+ ### Event queues
40+
41+ - ` send() ` → ** external queue** (processed after current macrostep ends).
42+ - ` raise_() ` → ** internal queue** (processed within the current macrostep, before external events).
43+
44+ ### Error handling (` catch_errors_as_events ` )
45+
46+ - ` StateChart ` has ` catch_errors_as_events=True ` by default; ` StateMachine ` has ` False ` .
47+ - Errors are caught at the ** block level** (per onentry/onexit/transition ` on ` block), not per
48+ microstep. This means ` after ` callbacks still run even when an action raises — making
49+ ` after_<event>() ` a natural ** finalize** hook (runs on both success and failure paths).
50+ - ` error.execution ` is dispatched as an internal event; define transitions for it to handle
51+ errors within the statechart.
52+ - Error during ` error.execution ` handling → ignored to prevent infinite loops.
53+
54+ #### ` on_error ` asymmetry: transition ` on ` vs onentry/onexit
55+
56+ Transition ` on ` content uses ` on_error ` ** only for non-` error.execution ` events** . During
57+ ` error.execution ` processing, ` on_error ` is disabled for transition ` on ` content — errors
58+ propagate to ` microstep() ` where ` _send_error_execution ` ignores them. This prevents infinite
59+ loops in self-transition error handlers (e.g., ` error_execution = s1.to(s1, on="handler") `
60+ where ` handler ` raises). ` onentry ` /` onexit ` blocks always use ` on_error ` regardless of the
61+ current event.
62+
63+ ### Eventless transitions
64+
65+ - Bare transition statements (not assigned to a variable) are ** eventless** — they fire
66+ automatically when their guard condition is met.
67+ - Assigned transitions (e.g., ` go = s1.to(s2) ` ) create ** named events** .
68+ - ` error_ ` prefix naming convention: ` error_X ` auto-registers both ` error_X ` and ` error.X `
69+ event names (explicit ` id= ` takes precedence).
70+
71+ ### Callback conventions
72+
73+ - Generic callbacks (always available): ` prepare_event() ` , ` before_transition() ` ,
74+ ` on_transition() ` , ` on_exit_state() ` , ` on_enter_state() ` , ` after_transition() ` .
75+ - Event-specific: ` before_<event>() ` , ` on_<event>() ` , ` after_<event>() ` .
76+ - State-specific: ` on_enter_<state>() ` , ` on_exit_<state>() ` .
77+ - ` on_error_execution() ` works via naming convention but ** only** when a transition for
78+ ` error.execution ` is declared — it is NOT a generic callback.
79+
80+ ### Invoke (` <invoke> ` )
81+
82+ - ` invoke.py ` — ` InvokeManager ` on the engine manages the lifecycle: ` mark_for_invoke() ` ,
83+ ` cancel_for_state() ` , ` spawn_pending_sync/async() ` , ` send_to_child() ` .
84+ - ` _cleanup_terminated() ` only removes invocations that are both terminated ** and** cancelled.
85+ A terminated-but-not-cancelled invocation means the handler's ` run() ` returned but the owning
86+ state is still active — it must stay in ` _active ` so ` send_to_child() ` can still route events.
87+ - ** Child machine constructor blocks** in the processing loop. Use a listener pattern (e.g.,
88+ ` _ChildRefSetter ` ) to capture the child reference during the first ` on_enter_state ` , before
89+ the loop spins.
90+ - ` #_<invokeid> ` send target: routed via ` _send_to_invoke() ` in ` io/scxml/actions.py ` →
91+ ` InvokeManager.send_to_child() ` → handler's ` on_event() ` .
92+ - ** Tests with blocking threads** : use ` threading.Event.wait(timeout=) ` instead of
93+ ` time.sleep() ` for interruptible waits — avoids thread leak errors in teardown.
94+
2995## Environment setup
3096
3197``` bash
@@ -35,11 +101,11 @@ pre-commit install
35101
36102## Running tests
37103
38- Always use ` uv ` to run commands:
104+ Always use ` uv ` to run commands. Also, use a timeout to avoid being stuck in the case of a leaked thread or infinite loop :
39105
40106``` bash
41107# Run all tests (parallel)
42- uv run pytest -n auto
108+ timeout 120 uv run pytest -n 4
43109
44110# Run a specific test file
45111uv run pytest tests/test_signature.py
@@ -51,8 +117,48 @@ uv run pytest tests/test_signature.py::TestSignatureAdapter::test_wrap_fn_single
51117uv run pytest -m " not slow"
52118```
53119
54- Tests include doctests from both source modules (` --doctest-modules ` ) and markdown docs
55- (` --doctest-glob=*.md ` ). Coverage is enabled by default.
120+ When trying to run all tests, prefer to use xdist (` -n ` ) as some SCXML tests uses timeout of 30s to verify fallback mechanism.
121+ Don't specify the directory ` tests/ ` , because this will exclude doctests from both source modules (` --doctest-modules ` ) and markdown docs
122+ (` --doctest-glob=*.md ` ) (enabled by default):
123+
124+ ``` bash
125+ timeout 120 uv run pytest -n 4
126+ ```
127+
128+ Testes normally run under 60s (~ 40s on average), so take a closer look if they take longer, it can be a regression.
129+
130+ When analyzing warnings or extensive output, run the tests ** once** saving the output to a file
131+ (` > /tmp/pytest-output.txt 2>&1 ` ), then analyze the file — instead of running the suite
132+ repeatedly with different greps.
133+
134+ Coverage is enabled by default (` --cov ` is in ` pyproject.toml ` 's ` addopts ` ). To generate a
135+ coverage report to a file, pass ` --cov-report ` ** in addition to** ` --cov ` :
136+
137+ ``` bash
138+ # JSON report (machine-readable, includes missing_lines per file)
139+ timeout 120 uv run pytest -n auto --cov=statemachine --cov-report=json:cov.json
140+
141+ # Terminal report with missing lines
142+ timeout 120 uv run pytest -n auto --cov=statemachine --cov-report=term-missing
143+ ```
144+
145+ Note: ` --cov=statemachine ` is required to activate coverage collection; ` --cov-report `
146+ alone only changes the output format.
147+
148+ ### Testing both sync and async engines
149+
150+ Use the ` sm_runner ` fixture (from ` tests/conftest.py ` ) when you need to test the same
151+ statechart on both sync and async engines. It is parametrized with ` ["sync", "async"] `
152+ and provides ` start() ` / ` send() ` helpers that handle engine selection automatically:
153+
154+ ``` python
155+ async def test_something (self , sm_runner ):
156+ sm = await sm_runner.start(MyStateChart)
157+ await sm_runner.send(sm, " some_event" )
158+ assert " expected_state" in sm.configuration_values
159+ ```
160+
161+ Do ** not** manually add async no-op listeners or duplicate test classes — prefer ` sm_runner ` .
56162
57163## Linting and formatting
58164
@@ -74,14 +180,30 @@ uv run mypy statemachine/ tests/
74180
75181- ** Formatter/Linter:** ruff (line length 99, target Python 3.9)
76182- ** Rules:** pycodestyle, pyflakes, isort, pyupgrade, flake8-comprehensions, flake8-bugbear, flake8-pytest-style
77- - ** Imports:** single-line, sorted by isort
183+ - ** Imports:** single-line, sorted by isort. ** Always prefer top-level imports** — only use
184+ lazy (in-function) imports when strictly necessary to break circular dependencies
78185- ** Docstrings:** Google convention
79186- ** Naming:** PascalCase for classes, snake_case for functions/methods, UPPER_SNAKE_CASE for constants
80187- ** Type hints:** used throughout; ` TYPE_CHECKING ` for circular imports
81188- Pre-commit hooks enforce ruff + mypy + pytest
82189
83190## Design principles
84191
192+ - ** Use GRASP/SOLID patterns to guide decisions.** When refactoring or designing, explicitly
193+ apply patterns like Information Expert, Single Responsibility, and Law of Demeter to decide
194+ where logic belongs — don't just pick a convenient location.
195+ - ** Information Expert (GRASP):** Place logic in the module/class that already has the
196+ knowledge it needs. If a method computes a result, it should signal or return it rather
197+ than forcing another method to recompute the same thing.
198+ - ** Law of Demeter:** Methods should depend only on the data they need, not on the
199+ objects that contain it. Pass the specific value (e.g., a ` Future ` ) rather than the
200+ parent object (e.g., ` TriggerData ` ) — this reduces coupling and removes the need for
201+ null-checks on intermediate accessors.
202+ - ** Single Responsibility:** Each module, class, and function should have one clear reason
203+ to change. Functions and types belong in the module that owns their domain (e.g.,
204+ event-name helpers belong in ` event.py ` , not in ` factory.py ` ).
205+ - ** Interface Segregation:** Depend on narrow interfaces. If a helper only needs one field
206+ from a dataclass, accept that field directly.
85207- ** Decouple infrastructure from domain:** Modules like ` signature.py ` and ` dispatcher.py ` are
86208 general-purpose (signature adaptation, listener/observer pattern) and intentionally not coupled
87209 to the state machine domain. Prefer this separation even for modules that are only used
@@ -99,6 +221,13 @@ uv run sphinx-build docs docs/_build/html
99221uv run sphinx-autobuild docs docs/_build/html --re-ignore " auto_examples/.*"
100222```
101223
224+ ### Documentation code examples
225+
226+ All code examples in ` docs/*.md ` ** must** be testable doctests (using ```` ```py ```` with
227+ ` >>> ` prompts), not plain ```` ```python ```` blocks. The test suite collects them via
228+ ` --doctest-glob=*.md ` . If an example cannot be expressed as a doctest (e.g., it requires
229+ real concurrency), write it as a unit test in ` tests/ ` and reference it from the docs instead.
230+
102231## Git workflow
103232
104233- Main branch: ` develop `
0 commit comments