Skip to content

Commit c817b3e

Browse files
committed
chore: merge develop into macedo/scxml
Resolve conflicts from Python 3.14 support merge (PR #552): - CI matrix: keep scxml's min Python 3.9, add 3.14 - pyproject.toml: add 3.14 classifier, update ruff ignore list - statemachine.py: use `is not None` check, keep history_values and id-keyed listeners from scxml branch - docs/guards.md: keep expanded declaration order docs from develop - Fix trailing whitespace and unused imports from scxml branch
2 parents fbc26c1 + 7b5fb68 commit c817b3e

42 files changed

Lines changed: 919 additions & 578 deletions

Some content is hidden

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

.github/workflows/python-package.yml

Lines changed: 3 additions & 3 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.9", "3.10", "3.11", "3.12", "3.13"]
18+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
1919

2020
steps:
2121
- uses: actions/checkout@v4
@@ -37,7 +37,7 @@ jobs:
3737
# run ruff
3838
#----------------------------------------------
3939
- name: Linter with ruff
40-
if: matrix.python-version == 3.13
40+
if: matrix.python-version == 3.14
4141
run: |
4242
uv run ruff check .
4343
uv run ruff format --check .
@@ -53,7 +53,7 @@ jobs:
5353
#----------------------------------------------
5454
- name: Upload coverage to Codecov
5555
uses: codecov/codecov-action@v4
56-
if: matrix.python-version == 3.13
56+
if: matrix.python-version == 3.14
5757
with:
5858
token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos
5959
directory: .

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
- name: Setup Python
1919
uses: actions/setup-python@v5
2020
with:
21-
python-version: '3.13'
21+
python-version: '3.14'
2222

2323
- name: Setup Graphviz
2424
uses: ts-graphviz/setup-graphviz@v2

.readthedocs.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ version: 2
88
build:
99
os: "ubuntu-22.04"
1010
tools:
11-
python: "3.12"
11+
python: "3.14"
1212
apt_packages:
1313
- graphviz
1414
jobs:

AGENTS.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# python-statemachine
2+
3+
Python Finite State Machines made easy.
4+
5+
## Project overview
6+
7+
A library for building finite state machines in Python, with support for sync and async engines,
8+
Django integration, diagram generation, and a flexible callback/listener system.
9+
10+
- **Source code:** `statemachine/`
11+
- **Tests:** `tests/`
12+
- **Documentation:** `docs/` (Sphinx + MyST Markdown, hosted on ReadTheDocs)
13+
14+
## Architecture
15+
16+
- `statemachine.py` — Core `StateMachine` class
17+
- `factory.py``StateMachineMetaclass` handles class construction, state/transition validation
18+
- `state.py` / `event.py` — Descriptor-based `State` and `Event` definitions
19+
- `transition.py` / `transition_list.py` — Transition logic and composition (`|` operator)
20+
- `callbacks.py` — Priority-based callback registry (`CallbackPriority`, `CallbackGroup`)
21+
- `dispatcher.py` — Listener/observer pattern, `callable_method` wraps callables with signature adaptation
22+
- `signature.py``SignatureAdapter` for dependency injection into callbacks
23+
- `engines/sync.py`, `engines/async_.py` — Sync and async run-to-completion engines
24+
- `registry.py` — Global state machine registry (used by `MachineMixin`)
25+
- `mixins.py``MachineMixin` for domain model integration (e.g., Django models)
26+
- `spec_parser.py` — Boolean expression parser for condition guards
27+
- `contrib/diagram.py` — Diagram generation via pydot/Graphviz
28+
29+
## Environment setup
30+
31+
```bash
32+
uv sync --all-extras --dev
33+
pre-commit install
34+
```
35+
36+
## Running tests
37+
38+
Always use `uv` to run commands:
39+
40+
```bash
41+
# Run all tests (parallel)
42+
uv run pytest -n auto
43+
44+
# Run a specific test file
45+
uv run pytest tests/test_signature.py
46+
47+
# Run a specific test
48+
uv run pytest tests/test_signature.py::TestSignatureAdapter::test_wrap_fn_single_positional_parameter
49+
50+
# Skip slow tests
51+
uv run pytest -m "not slow"
52+
```
53+
54+
Tests include doctests from both source modules (`--doctest-modules`) and markdown docs
55+
(`--doctest-glob=*.md`). Coverage is enabled by default.
56+
57+
## Linting and formatting
58+
59+
```bash
60+
# Lint
61+
uv run ruff check .
62+
63+
# Auto-fix lint issues
64+
uv run ruff check --fix .
65+
66+
# Format
67+
uv run ruff format .
68+
69+
# Type check
70+
uv run mypy statemachine/ tests/
71+
```
72+
73+
## Code style
74+
75+
- **Formatter/Linter:** ruff (line length 99, target Python 3.14)
76+
- **Rules:** pycodestyle, pyflakes, isort, pyupgrade, flake8-comprehensions, flake8-bugbear, flake8-pytest-style
77+
- **Imports:** single-line, sorted by isort
78+
- **Docstrings:** Google convention
79+
- **Naming:** PascalCase for classes, snake_case for functions/methods, UPPER_SNAKE_CASE for constants
80+
- **Type hints:** used throughout; `TYPE_CHECKING` for circular imports
81+
- Pre-commit hooks enforce ruff + mypy + pytest
82+
83+
## Design principles
84+
85+
- **Decouple infrastructure from domain:** Modules like `signature.py` and `dispatcher.py` are
86+
general-purpose (signature adaptation, listener/observer pattern) and intentionally not coupled
87+
to the state machine domain. Prefer this separation even for modules that are only used
88+
internally — it keeps responsibilities clear and the code easier to reason about.
89+
- **Favor small, focused modules:** When adding new functionality, consider whether it can live in
90+
its own module with a well-defined boundary, rather than growing an existing one.
91+
92+
## Building documentation
93+
94+
```bash
95+
# Build HTML docs
96+
uv run sphinx-build docs docs/_build/html
97+
98+
# Live reload for development
99+
uv run sphinx-autobuild docs docs/_build/html --re-ignore "auto_examples/.*"
100+
```
101+
102+
## Git workflow
103+
104+
- Main branch: `develop`
105+
- PRs target `develop`
106+
- Releases are tagged as `v*.*.*`
107+
- Signed commits preferred (`git commit -s`)
108+
- Use [Conventional Commits](https://www.conventionalcommits.org/) messages
109+
(e.g., `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`, `perf:`)
110+
111+
## Security
112+
113+
- Do not commit secrets, credentials, or `.env` files
114+
- Validate at system boundaries; trust internal code

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AGENTS.md

conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66

77
@pytest.fixture(autouse=True, scope="session")
88
def add_doctest_context(doctest_namespace): # noqa: PT004
9+
from statemachine.utils import run_async_from_sync
10+
911
from statemachine import State
1012
from statemachine import StateMachine
11-
from statemachine.utils import run_async_from_sync
1213

1314
class ContribAsyncio:
1415
"""

docs/actions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ There are several action callbacks that you can define to interact with a
1414
StateMachine in execution.
1515

1616
There are callbacks that you can specify that are generic and will be called
17-
when something changes and are not bounded to a specific state or event:
17+
when something changes, and are not bound to a specific state or event:
1818

1919
- `prepare_event()`
2020

@@ -28,7 +28,7 @@ when something changes and are not bounded to a specific state or event:
2828

2929
- `after_transition()`
3030

31-
The following example can get you an overview of the "generic" callbacks available:
31+
The following example offers an overview of the "generic" callbacks available:
3232

3333
```py
3434
>>> from statemachine import StateMachine, State

docs/guards.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,37 @@ A conditional transition occurs only if specific conditions or criteria are met.
2222

2323
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.
2424

25-
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`).
25+
This feature allows for multiple transitions on the same {ref}`event`, with each {ref}`transition` checked in **declaration order** — that is, the order in which the transitions themselves were created using `state.to()`. 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`).
26+
27+
````{important}
28+
**Evaluation order is based on declaration order, not composition order.**
29+
30+
When using conditional transitions, the order of evaluation is determined by **when each transition was created** (the order of `state.to()` calls), **not** by the order they appear when combined with the `|` operator.
31+
32+
For example:
33+
34+
```python
35+
# These are evaluated in DECLARATION ORDER (when state.to() was called):
36+
created_first = state_a.to(state_x) # Created FIRST → Checked FIRST
37+
created_second = state_a.to(state_y) # Created SECOND → Checked SECOND
38+
created_third = state_a.to(state_z) # Created THIRD → Checked THIRD
39+
40+
# The | operator does NOT change evaluation order:
41+
my_event = created_third | created_second | created_first
42+
# Evaluation order is still: created_first → created_second → created_third
43+
```
44+
45+
To control the evaluation order, declare transitions in the desired order:
46+
47+
```python
48+
# Declare in the order you want them checked:
49+
first = state_a.to(state_b, cond="check1") # Checked FIRST
50+
second = state_a.to(state_c, cond="check2") # Checked SECOND
51+
third = state_a.to(state_d, cond="check3") # Checked THIRD
52+
53+
my_event = first | second | third # Order matches declaration
54+
```
55+
````
2656

2757
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.
2858

docs/transitions.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ the event name is used to describe the transition.
166166
## Events
167167

168168
An event is an external signal that something has happened.
169-
They are send to a state machine and allow the state machine to react.
169+
They are sent to a state machine and allow the state machine to react.
170170

171171
An event starts a {ref}`transition`, which can be thought of as a "cause" that
172172
initiates a change in the state of the system.
@@ -176,7 +176,7 @@ In `python-statemachine`, an event is specified as an attribute of the state mac
176176

177177
### Declaring events
178178

179-
The simplest way to declare an {ref}`event` is by assiging a transitions list to a name at the
179+
The simplest way to declare an {ref}`event` is by assigning a transitions list to a name at the
180180
State machine class level. The name will be converted to an {ref}`Event`:
181181

182182
```py
@@ -196,7 +196,7 @@ True
196196
```
197197

198198
```{versionadded} 2.4.0
199-
You can also explict declare an {ref}`Event` instance, this helps IDEs to know that the event is callable and also with transtation strings.
199+
You can also explictly declare an {ref}`Event` instance, this helps IDEs to know that the event is callable, and also with translation strings.
200200
```
201201

202202
To declare an explicit event you must also import the {ref}`Event`:

pyproject.toml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ classifiers = [
1717
"Programming Language :: Python :: 3.11",
1818
"Programming Language :: Python :: 3.12",
1919
"Programming Language :: Python :: 3.13",
20+
"Programming Language :: Python :: 3.14",
2021
"Programming Language :: Python :: 3.9",
2122
"Topic :: Home Automation",
2223
"Topic :: Software Development :: Libraries",
@@ -35,7 +36,8 @@ dev = [
3536
"pre-commit",
3637
"mypy",
3738
"pytest",
38-
"pytest-cov",
39+
"pytest-cov >=6.0.0; python_version >='3.9'",
40+
"pytest-cov; python_version <'3.9'",
3941
"pytest-sugar >=1.0.0",
4042
"pytest-mock >=3.10.0",
4143
"pytest-benchmark >=4.0.0",
@@ -118,7 +120,7 @@ directory = "tmp/htmlcov"
118120
show_contexts = true
119121

120122
[tool.mypy]
121-
python_version = "3.13"
123+
python_version = "3.14"
122124
warn_return_any = true
123125
warn_unused_configs = true
124126
disable_error_code = "annotation-unchecked"
@@ -132,7 +134,7 @@ ignore_missing_imports = true
132134
src = ["statemachine"]
133135

134136
line-length = 99
135-
target-version = "py313"
137+
target-version = "py314"
136138

137139
# Exclude a variety of commonly ignored directories.
138140
exclude = [
@@ -171,8 +173,8 @@ select = [
171173
ignore = [
172174
"UP006", # `use-pep585-annotation` Requires Python3.9+
173175
"UP035", # `use-pep585-annotation` Requires Python3.9+
174-
"UP037", # `use-pep586-annotation` Requires Python3.9+
175-
"UP038", # `use-pep585-annotation` Requires Python3.9+
176+
"UP037", # `remove-quotes-from-type-annotation` Not safe without `from __future__ import annotations`
177+
"UP042", # `use-str-enum` Requires Python3.11+
176178
]
177179

178180
# Allow unused variables when underscore-prefixed.

0 commit comments

Comments
 (0)