Skip to content

Commit b31d830

Browse files
committed
Merge branch 'release/3.0.0'
2 parents a18a396 + 9a16dac commit b31d830

File tree

378 files changed

+33548
-3717
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

378 files changed

+33548
-3717
lines changed

.github/ISSUE_TEMPLATE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@ Tell us what happened, what went wrong, and what you expected to happen.
1313
Paste the command(s) you ran and the output.
1414
If there was a crash, please include the traceback here.
1515
```
16+
17+
If you're reporting a bug, consider providing a complete example that can be used directly in the automated tests. We allways write tests to reproduce the issue in order to avoid future regressions.

.github/workflows/python-package.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
strategy:
1616
fail-fast: false
1717
matrix:
18-
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
18+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
1919

2020
steps:
2121
- uses: actions/checkout@v4
@@ -46,7 +46,7 @@ jobs:
4646
#----------------------------------------------
4747
- name: Test with pytest
4848
run: |
49-
uv run pytest --cov-report=xml:coverage.xml
49+
uv run pytest -n auto --cov --cov-report=xml:coverage.xml
5050
uv run coverage xml
5151
#----------------------------------------------
5252
# upload coverage

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333

3434
- name: Test
3535
run: |
36-
uv run pytest
36+
uv run pytest -n auto --cov
3737
3838
- name: Build
3939
run: |

.pre-commit-config.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,15 @@ repos:
2525
types: [python]
2626
language: system
2727
pass_filenames: false
28+
- id: pyright
29+
name: Pyright
30+
entry: uv run pyright statemachine/
31+
types: [python]
32+
language: system
33+
pass_filenames: false
2834
- id: pytest
2935
name: Pytest
30-
entry: uv run pytest
36+
entry: uv run pytest -n auto --cov-fail-under=100
3137
types: [python]
3238
language: system
3339
pass_filenames: false

AGENTS.md

Lines changed: 136 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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
45111
uv run pytest tests/test_signature.py
@@ -51,8 +117,48 @@ uv run pytest tests/test_signature.py::TestSignatureAdapter::test_wrap_fn_single
51117
uv 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
99221
uv 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

Comments
 (0)