Skip to content

feat: Nested states (compound / parallel) and support for SCXML (test suit) #501

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 45 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
e647c3d
feat: Basic support for SCXML test suit
fgmacedo Nov 22, 2024
067d4d8
feat: Support for onentry/raise/assign/log/if/elseif/else elements; W…
fgmacedo Nov 22, 2024
91ae843
fix: SCXML Parsing of if/else element
fgmacedo Nov 23, 2024
28ccd8b
feat: Support for SCXML foreach tag
fgmacedo Nov 23, 2024
6637692
fix: Trigger eventless transition at startup
fgmacedo Nov 26, 2024
4eefc41
refac: Preserving original scxml file names for passing testcases
fgmacedo Nov 27, 2024
6a4f31c
fix: Allowing event without transition
fgmacedo Nov 27, 2024
55bed8e
feat: Delayed events; Support for SCXML <send> tag
fgmacedo Nov 27, 2024
ca3ecf7
refac: Attempt to use PriorityQueue
fgmacedo Nov 30, 2024
ed8b639
refac: Using get/set state for pickling
fgmacedo Nov 30, 2024
b069740
feat: Support for SCXML <cancel> tag. Allow cancelling delayed events
fgmacedo Nov 30, 2024
f52cce6
feat: Support for SCXML <script>, all W3C tests imported and ~50% pas…
fgmacedo Dec 3, 2024
7664c1e
feat: Support for SCXML <_event.name>
fgmacedo Dec 3, 2024
d9024af
chore: Drop support for Python3.7 and 3.8 due to the lack of support …
fgmacedo Dec 3, 2024
2bcd054
fix: Fix merge with develop
fgmacedo Dec 4, 2024
37f9d2d
refac: Split the SCXML parser into schema definition, parser and acti…
fgmacedo Dec 5, 2024
a90fb74
fix: Fix type hints for Python 3.9 as the | operator is not supported
fgmacedo Dec 5, 2024
11f5e8b
feat: Add syntax for compound and parallel states (only parser)
fgmacedo Dec 6, 2024
f699d9c
chore: Remove support for non-RTC model; Preparing processing_loop fo…
fgmacedo Dec 6, 2024
f001a79
chore: Fix cmd params for test coverage
fgmacedo Dec 6, 2024
4a80a31
feat: SCXML processing model
fgmacedo Dec 8, 2024
d0bca2e
fix: Fix compatibility with py3.9
fgmacedo Dec 8, 2024
a7022d8
feat: Hierarquical statemachines with compose and parallel
fgmacedo Dec 10, 2024
7e4c141
feat: Internal transitions
fgmacedo Dec 10, 2024
a262856
chore: New SCXML fail mark with the contents of the errors
fgmacedo Dec 11, 2024
1724ba1
chore: Microwave example with parallel state working
fgmacedo Dec 12, 2024
09fcf66
feat: New callback
fgmacedo Dec 12, 2024
6a57fd2
docs: Release example of create_machine_class_from_definition
fgmacedo Dec 12, 2024
815892b
fix: Fix parser of sub-final states
fgmacedo Dec 12, 2024
89da156
fix: Fix parallel enter/exit and checks
fgmacedo Dec 14, 2024
0e43ed4
chore: SCXML failing xfail marks updated
fgmacedo Dec 15, 2024
d8526a2
fix: Executing <initial> content on default entry
fgmacedo Dec 15, 2024
60d547d
chore: Fix pyright complaining about event calls
fgmacedo Dec 20, 2024
f552171
fix: Fix test533, ordering of entering states and exit/enter of paral…
fgmacedo Dec 21, 2024
77362a1
fix: Better support for targetless transitions
fgmacedo Dec 22, 2024
7de2ad4
fix: Fix sm name from scxml element
fgmacedo Dec 22, 2024
d2a002a
fix: Fix 326, _ioprocessor is the same for the session
fgmacedo Dec 22, 2024
deb84b2
fix: Fix some edge cases of initial state configuration
fgmacedo Dec 23, 2024
705ecb3
fix: Log may contain only the 'label' attr
fgmacedo Dec 23, 2024
29c22fb
fix: Reading from file on <scxml.datamodel.data.src> tag
fgmacedo Dec 23, 2024
6cb83f5
fix: Initials of scxml is respected; Added validate_disconnected_stat…
fgmacedo Dec 23, 2024
2af1eca
fix: SCXML _event should be bound only after the first event; the ins…
fgmacedo Dec 23, 2024
b91306e
fix: Fix event tests on the SCXML suit
fgmacedo Dec 23, 2024
eec118a
feat: Support for History pseudo state
fgmacedo Dec 28, 2024
fbc26c1
chore: Declaring new base StateChart with new defaults and keeping St…
fgmacedo Jan 27, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions .github/ISSUE_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ Tell us what happened, what went wrong, and what you expected to happen.
Paste the command(s) you ran and the output.
If there was a crash, please include the traceback here.
```

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.
8 changes: 2 additions & 6 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]

steps:
- uses: actions/checkout@v4
Expand All @@ -33,10 +33,6 @@ jobs:
cache-suffix: "python${{ matrix.python-version }}"
- name: Install the project
run: uv sync --all-extras --dev
- name: Install old pydot for 3.7 only
if: matrix.python-version == 3.7
run: |
uv pip install pydot==2.0.0
#----------------------------------------------
# run ruff
#----------------------------------------------
Expand All @@ -50,7 +46,7 @@ jobs:
#----------------------------------------------
- name: Test with pytest
run: |
uv run pytest --cov-report=xml:coverage.xml
uv run pytest -n auto --cov --cov-report=xml:coverage.xml
uv run coverage xml
#----------------------------------------------
# upload coverage
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:

- name: Test
run: |
uv run pytest
uv run pytest -n auto --cov

- name: Build
run: |
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ repos:
pass_filenames: false
- id: pytest
name: Pytest
entry: uv run pytest
entry: uv run pytest -n auto
types: [python]
language: system
pass_filenames: false
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ Or get a complete state representation for debugging purposes:

```py
>>> sm.current_state
State('Yellow', id='yellow', value='yellow', initial=False, final=False)
State('Yellow', id='yellow', value='yellow', initial=False, final=False, parallel=False)

```

Expand Down
28 changes: 28 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,31 @@ def has_dot_installed():
def requires_dot_installed(request, has_dot_installed):
if not has_dot_installed:
pytest.skip(f"Test {request.node.nodeid} requires 'dot' that is not installed.")


# @pytest.fixture(autouse=True, scope="module")
# def mock_dot_write(request):
# """
# This fixture avoids updating files while executing tests
# """

# def open_effect(
# filename,
# mode="r",
# *args,
# **kwargs,
# ):
# if mode in ("r", "rt", "rb"):
# return open(filename, mode, *args, **kwargs)
# elif filename.startswith("/tmp/"):
# return open(filename, mode, *args, **kwargs)
# elif "b" in mode:
# return io.BytesIO()
# else:
# return io.StringIO()

# # using global mock instead of the fixture mocker due to the ScopeMismatch
# # this fixture is module scoped and mocker is function scoped
# with mock.patch("pydot.core.io.open", spec=True) as m:
# m.side_effect = open_effect
# yield m
32 changes: 32 additions & 0 deletions docs/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ StateMachine in execution.
There are callbacks that you can specify that are generic and will be called
when something changes and are not bounded to a specific state or event:

- `prepare_event()`

- `before_transition()`

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

### Preparing events

You can use the `prepare_event` method to add custom information
that will be included in `**kwargs` to all other callbacks.

A not so usefull example:

```py
>>> class ExampleStateMachine(StateMachine):
... initial = State(initial=True)
...
... loop = initial.to.itself()
...
... def prepare_event(self):
... return {"foo": "bar"}
...
... def on_loop(self, foo):
... return f"On loop: {foo}"
...

>>> sm = ExampleStateMachine()

>>> sm.loop()
'On loop: bar'

```

## Ordering

Expand All @@ -314,6 +342,10 @@ Actions registered on the same group don't have order guaranties and are execute
- Action
- Current state
- Description
* - Preparation
- `prepare_event()`
- `source`
- Add custom event metadata.
* - Validators
- `validators()`
- `source`
Expand Down
2 changes: 1 addition & 1 deletion docs/diagram.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Graphviz. For example, on Debian-based systems (such as Ubuntu), you can use the
>>> dot = graph()

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

```

Expand Down
2 changes: 1 addition & 1 deletion docs/guards.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ A conditional transition occurs only if specific conditions or criteria are met.

When a transition is conditional, it includes a condition (also known as a _guard_) that must be satisfied for the transition to take place. If the condition is not met, the transition does not occur, and the state machine remains in its current state or follows an alternative path.

This feature allows for multiple transitions on the same {ref}`event`, with each {ref}`transition` checked in the order they are declared. A condition acts like a predicate (a function that evaluates to true/false) and is checked when a {ref}`statemachine` handles an {ref}`event` with a transition from the current state bound to this event. The first transition that meets the conditions (if any) is executed. If none of the transitions meet the conditions, the state machine either raises an exception or does nothing (see the `allow_event_without_transition` parameter of {ref}`StateMachine`).
This feature allows for multiple transitions on the same {ref}`event`, with each {ref}`transition` checked in the order they are declared. A condition acts like a predicate (a function that evaluates to true/false) and is checked when a {ref}`statemachine` handles an {ref}`event` with a transition from the current state bound to this event. The first transition that meets the conditions (if any) is executed. If none of the transitions meet the conditions, the state machine either raises an exception or does nothing (see the `allow_event_without_transition` class attribute of {ref}`StateMachine`).

When {ref}`transitions` have guards, it is possible to define two or more transitions for the same {ref}`event` from the same {ref}`state`. When the {ref}`event` occurs, the guarded transitions are checked one by one, and the first transition whose guard is true will be executed, while the others will be ignored.

Expand Down
Binary file modified docs/images/order_control_machine_initial.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/order_control_machine_initial_300dpi.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/order_control_machine_processing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/readme_trafficlightmachine.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/test_state_machine_internal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
69 changes: 5 additions & 64 deletions docs/processing_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,8 @@ In the literature, It's expected that all state-machine events should execute on

The main point is: What should happen if the state machine triggers nested events while processing a parent event?

```{hint}
The importance of this decision depends on your state machine definition. Also the difference between RTC
and non-RTC processing models is more pronounced in a multi-threaded system than in a single-threaded system.
In other words, even if you run in {ref}`Non-RTC model`, only one external {ref}`event` will be
handled at a time and all internal events will run before the next external event is called,
so you only notice the difference if your state machine definition has nested event triggers while
processing these external events.
```

There are two distinct models for processing events in the library. The default is to run in
{ref}`RTC model` to be compliant with the specs, where the {ref}`event` is put on a
queue before processing. You can also configure your state machine to run in
{ref}`Non-RTC model`, where the {ref}`event` will be run immediately.
This library atheres to the {ref}`RTC model` to be compliant with the specs, where the {ref}`event` is put on a
queue before processing.

Consider this state machine:

Expand Down Expand Up @@ -60,13 +49,13 @@ Consider this state machine:

In a run-to-completion (RTC) processing model (**default**), the state machine executes each event to completion before processing the next event. This means that the state machine completes all the actions associated with an event before moving on to the next event. This guarantees that the system is always in a consistent state.

If the machine is in `rtc` mode, the event is put on a queue.
Internally, the events are put on a queue before processing.

```{note}
While processing the queue items, if others events are generated, they will be processed sequentially.
While processing the queue items, if others events are generated, they will be processed sequentially in FIFO order.
```

Running the above state machine will give these results on the RTC model:
Running the above state machine will give these results:

```py
>>> sm = ServerConnection()
Expand All @@ -88,51 +77,3 @@ after 'connection_succeed' from 'connecting' to 'connected'
```{note}
Note that the events `connect` and `connection_succeed` are executed sequentially, and the `connect.after` runs on the expected order.
```

## Non-RTC model

```{deprecated} 2.3.2
`StateMachine.rtc` option is deprecated. We'll keep only the **run-to-completion** (RTC) model.
```

In contrast, in a non-RTC (synchronous) processing model, the state machine starts executing nested events
while processing a parent event. This means that when an event is triggered, the state machine
chains the processing when another event was triggered as a result of the first event.

```{warning}
This can lead to complex and unpredictable behavior in the system if your state-machine definition triggers **nested
events**.
```

If your state machine does not trigger nested events while processing a parent event,
and you plan to use the API in an _imperative programming style_, you can consider using the synchronous mode (non-RTC).

In this model, you can think of events as analogous to simple method calls.

```{note}
While processing the {ref}`event`, if others events are generated, they will also be processed immediately, so a **nested** behavior happens.
```

Running the above state machine will give these results on the non-RTC (synchronous) model:

```py
>>> sm = ServerConnection(rtc=False)
enter 'disconnected' from '' given '__initial__'

>>> sm.send("connect")
exit 'disconnected' to 'connecting' given 'connect'
on 'connect' from 'disconnected' to 'connecting'
enter 'connecting' from 'disconnected' given 'connect'
exit 'connecting' to 'connected' given 'connection_succeed'
on 'connection_succeed' from 'connecting' to 'connected'
enter 'connected' from 'connecting' given 'connection_succeed'
after 'connection_succeed' from 'connecting' to 'connected'
after 'connect' from 'disconnected' to 'connecting'
['on_transition', 'on_connect']

```

```{note}
Note that the events `connect` and `connection_succeed` are nested, and the `connect.after`
unexpectedly only runs after `connection_succeed.after`.
```
5 changes: 4 additions & 1 deletion docs/releases/2.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,10 @@ including tolerance to unknown {ref}`event` triggers.
The default value is ``False``, that keeps the backward compatible behavior of when an
event does not result in a {ref}`transition`, an exception ``TransitionNotAllowed`` will be raised.

```py
```
>>> import pytest
>>> pytest.skip("Since 3.0.0 `allow_event_without_transition` is now a class attribute.")

>>> sm = ApprovalMachine(allow_event_without_transition=True)

>>> sm.send("unknow_event_name")
Expand Down
Loading
Loading