Skip to content

Commit 08dd542

Browse files
authored
feat: StateChart (support for compound / parallel / historical states including SCXML notation) (#501)
This PR introduces comprehensive SCXML support to the library, including the full processing model, hierarchical/parallel/history states, internal and multi-target transitions, delayed events (send/cancel), and donedata. It achieves sync/async engine parity, implements spec-compliant error handling (error.execution, error.communication) with proper isolation and safety guards, and significantly improves W3C SCXML test conformance. The change also includes major architectural refactors across the parser, engines, and state model, enhanced diagram generation for compound/parallel/history states, and expanded test coverage to ~100%, with Python ≥3.9 compatibility. Overall, this PR elevates the project to a fully functional, spec-aligned SCXML implementation with stronger reliability and internal design.
1 parent d062de9 commit 08dd542

343 files changed

Lines changed: 20342 additions & 1395 deletions

File tree

Some content is hidden

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

.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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ repos:
2727
pass_filenames: false
2828
- id: pytest
2929
name: Pytest
30-
entry: uv run pytest
30+
entry: uv run pytest -n auto
3131
types: [python]
3232
language: system
3333
pass_filenames: false

AGENTS.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,15 @@ uv run pytest tests/test_signature.py::TestSignatureAdapter::test_wrap_fn_single
5151
uv run pytest -m "not slow"
5252
```
5353

54-
Tests include doctests from both source modules (`--doctest-modules`) and markdown docs
55-
(`--doctest-glob=*.md`). Coverage is enabled by default.
54+
When trying to run all tests, prefer to use xdist (`-n`) as some SCXML tests uses timeout of 30s to verify fallback mechanism.
55+
Don't specify the directory `tests/`, because this will exclude doctests from both source modules (`--doctest-modules`) and markdown docs
56+
(`--doctest-glob=*.md`) (enabled by default):
57+
58+
```bash
59+
uv run pytest -n auto
60+
```
61+
62+
Coverage is enabled by default.
5663

5764
## Linting and formatting
5865

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ Or get a complete state representation for debugging purposes:
137137

138138
```py
139139
>>> sm.current_state
140-
State('Yellow', id='yellow', value='yellow', initial=False, final=False)
140+
State('Yellow', id='yellow', value='yellow', initial=False, final=False, parallel=False)
141141

142142
```
143143

docs/actions.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ StateMachine in execution.
1616
There are callbacks that you can specify that are generic and will be called
1717
when something changes, and are not bound to a specific state or event:
1818

19+
- `prepare_event()`
20+
1921
- `before_transition()`
2022

2123
- `on_exit_state()`
@@ -297,6 +299,32 @@ In addition to {ref}`actions`, you can specify {ref}`validators and guards` that
297299
See {ref}`conditions` and {ref}`validators`.
298300
```
299301

302+
### Preparing events
303+
304+
You can use the `prepare_event` method to add custom information
305+
that will be included in `**kwargs` to all other callbacks.
306+
307+
A not so usefull example:
308+
309+
```py
310+
>>> class ExampleStateMachine(StateMachine):
311+
... initial = State(initial=True)
312+
...
313+
... loop = initial.to.itself()
314+
...
315+
... def prepare_event(self):
316+
... return {"foo": "bar"}
317+
...
318+
... def on_loop(self, foo):
319+
... return f"On loop: {foo}"
320+
...
321+
322+
>>> sm = ExampleStateMachine()
323+
324+
>>> sm.loop()
325+
'On loop: bar'
326+
327+
```
300328

301329
## Ordering
302330

@@ -314,6 +342,10 @@ Actions registered on the same group don't have order guaranties and are execute
314342
- Action
315343
- Current state
316344
- Description
345+
* - Preparation
346+
- `prepare_event()`
347+
- `source`
348+
- Add custom event metadata.
317349
* - Validators
318350
- `validators()`
319351
- `source`

docs/api.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# API
22

3+
## StateChart
4+
5+
```{versionadded} 3.0.0
6+
```
7+
8+
```{eval-rst}
9+
.. autoclass:: statemachine.statemachine.StateChart
10+
:members:
11+
:undoc-members:
12+
```
13+
314
## StateMachine
415

516
```{eval-rst}
@@ -20,6 +31,16 @@
2031
:members:
2132
```
2233

34+
## HistoryState
35+
36+
```{versionadded} 3.0.0
37+
```
38+
39+
```{eval-rst}
40+
.. autoclass:: statemachine.state.HistoryState
41+
:members:
42+
```
43+
2344
## States (class)
2445

2546
```{eval-rst}
@@ -79,3 +100,37 @@
79100
.. autoclass:: statemachine.event_data.EventData
80101
:members:
81102
```
103+
104+
## Callback conventions
105+
106+
These are convention-based callbacks that you can define on your state machine
107+
subclass. They are not methods on the base class — define them in your subclass
108+
to enable the behavior.
109+
110+
### `prepare_event`
111+
112+
Called before every event is processed. Returns a `dict` of keyword arguments
113+
that will be merged into `**kwargs` for all subsequent callbacks (guards, actions,
114+
entry/exit handlers) during that event's processing:
115+
116+
```python
117+
class MyMachine(StateMachine):
118+
initial = State(initial=True)
119+
loop = initial.to.itself()
120+
121+
def prepare_event(self):
122+
return {"request_id": generate_id()}
123+
124+
def on_loop(self, request_id):
125+
# request_id is available here
126+
...
127+
```
128+
129+
## create_machine_class_from_definition
130+
131+
```{versionadded} 3.0.0
132+
```
133+
134+
```{eval-rst}
135+
.. autofunction:: statemachine.io.create_machine_class_from_definition
136+
```

docs/async.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,31 @@ before the event is handled:
184184
Initial
185185

186186
```
187+
188+
## StateChart async support
189+
190+
```{versionadded} 3.0.0
191+
```
192+
193+
`StateChart` works identically with the async engine. All statechart features —
194+
compound states, parallel states, history pseudo-states, eventless transitions,
195+
and `done.state` events — are fully supported in async code. The same
196+
`activate_initial_state()` pattern applies:
197+
198+
```python
199+
async def run():
200+
sm = MyStateChart()
201+
await sm.activate_initial_state()
202+
await sm.send("event")
203+
```
204+
205+
### Async-specific limitations
206+
207+
- **Initial state activation**: In async code, you must `await sm.activate_initial_state()`
208+
before inspecting `sm.configuration` or `sm.current_state`. In sync code this happens
209+
automatically at instantiation time.
210+
- **Delayed events**: Both sync and async engines support `delay=` on `send()`. The async
211+
engine uses `asyncio.sleep()` internally, so it integrates naturally with event loops.
212+
- **Thread safety**: The processing loop uses a non-blocking lock (`_processing.acquire`).
213+
All callbacks run on the same thread they are called from — do not share a state machine
214+
instance across threads without external synchronization.

docs/diagram.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ Graphviz. For example, on Debian-based systems (such as Ubuntu), you can use the
4242
>>> dot = graph()
4343

4444
>>> dot.to_string() # doctest: +ELLIPSIS
45-
'digraph list {...
45+
'digraph OrderControl {...
4646

4747
```
4848

0 commit comments

Comments
 (0)