Skip to content

Commit d011271

Browse files
committed
Merge branch 'release/2.3.0'
2 parents 524df07 + 60221c3 commit d011271

38 files changed

+1551
-931
lines changed

.github/workflows/release.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ jobs:
2424
uses: actions/setup-python@v4
2525
with:
2626
python-version: ${{ matrix.python-version }}
27+
- name: Setup Graphviz
28+
uses: ts-graphviz/setup-graphviz@v1
2729
- name: Install Poetry
2830
uses: snok/install-poetry@v1
2931
with:

README.md

Lines changed: 83 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,52 @@
11
# Python StateMachine
22

33
[![pypi](https://img.shields.io/pypi/v/python-statemachine.svg)](https://pypi.python.org/pypi/python-statemachine)
4+
[![downloads total](https://static.pepy.tech/badge/python-statemachine)](https://pepy.tech/project/python-statemachine)
45
[![downloads](https://img.shields.io/pypi/dm/python-statemachine.svg)](https://pypi.python.org/pypi/python-statemachine)
5-
[![build status](https://github.com/fgmacedo/python-statemachine/actions/workflows/python-package.yml/badge.svg?branch=develop)](https://github.com/fgmacedo/python-statemachine/actions/workflows/python-package.yml?query=branch%3Adevelop)
66
[![Coverage report](https://codecov.io/gh/fgmacedo/python-statemachine/branch/develop/graph/badge.svg)](https://codecov.io/gh/fgmacedo/python-statemachine)
77
[![Documentation Status](https://readthedocs.org/projects/python-statemachine/badge/?version=latest)](https://python-statemachine.readthedocs.io/en/latest/?badge=latest)
88
[![GitHub commits since last release (main)](https://img.shields.io/github/commits-since/fgmacedo/python-statemachine/main/develop)](https://github.com/fgmacedo/python-statemachine/compare/main...develop)
99

1010

1111
Python [finite-state machines](https://en.wikipedia.org/wiki/Finite-state_machine) made easy.
1212

13+
<div align="center">
1314

14-
* Free software: MIT license
15-
* Documentation: https://python-statemachine.readthedocs.io.
15+
![](https://github.com/fgmacedo/python-statemachine/blob/develop/docs/images/python-statemachine.png?raw=true)
1616

17+
</div>
1718

18-
Welcome to python-statemachine, an intuitive and powerful state machine framework designed for a
19-
great developer experience.
19+
Welcome to python-statemachine, an intuitive and powerful state machine library designed for a
20+
great developer experience. We provide an _pythonic_ and expressive API for implementing state
21+
machines in sync or asynchonous Python codebases.
2022

21-
🚀 With StateMachine, you can easily create complex, dynamic systems with clean, readable code.
23+
## Features
2224

23-
💡 Our framework makes it easy to understand and reason about the different states, events and
24-
transitions in your system, so you can focus on building great products.
25+
-**Basic components**: Easily define **States**, **Events**, and **Transitions** to model your logic.
26+
- ⚙️ **Actions and handlers**: Attach actions and handlers to states, events, and transitions to control behavior dynamically.
27+
- 🛡️ **Conditional transitions**: Implement **Guards** and **Validators** to conditionally control transitions, ensuring they only occur when specific conditions are met.
28+
- 🚀 **Full async support**: Enjoy full asynchronous support. Await events, and dispatch callbacks asynchronously for seamless integration with async codebases.
29+
- 🔄 **Full sync support**: Use the same state machine from synchronous codebases without any modifications.
30+
- 🎨 **Declarative and simple API**: Utilize a clean, elegant, and readable API to define your state machine, making it easy to maintain and understand.
31+
- 👀 **Observer pattern support**: Register external and generic objects to watch events and register callbacks.
32+
- 🔍 **Decoupled design**: Separate concerns with a decoupled "state machine" and "model" design, promoting cleaner architecture and easier maintenance.
33+
-**Correctness guarantees**: Ensured correctness with validations at class definition time:
34+
- Ensures exactly one `initial` state.
35+
- Disallows transitions from `final` states.
36+
- Requires ongoing transitions for all non-final states.
37+
- Guarantees all non-final states have at least one path to a final state if final states are declared.
38+
- Validates the state machine graph representation has a single component.
39+
- 📦 **Flexible event dispatching**: Dispatch events with any extra data, making it available to all callbacks, including actions and guards.
40+
- 🔧 **Dependency injection**: Needed parameters are injected into callbacks.
41+
- 📊 **Graphical representation**: Generate and output graphical representations of state machines. Create diagrams from the command line, at runtime, or even in Jupyter notebooks.
42+
- 🌍 **Internationalization support**: Provides error messages in different languages, making the library accessible to a global audience.
43+
- 🛡️ **Robust testing**: Ensured reliability with a codebase that is 100% covered by automated tests, including all docs examples. Releases follow semantic versioning for predictable releases.
44+
- 🏛️ **Domain model integration**: Seamlessly integrate with domain models using Mixins.
45+
- 🔧 **Django integration**: Automatically discover state machines in Django applications.
2546

26-
🔒 python-statemachine also provides robust error handling and ensures that your system stays
27-
in a valid state at all times.
2847

2948

30-
A few reasons why you may consider using it:
31-
32-
* 📈 python-statemachine is designed to help you build scalable,
33-
maintainable systems that can handle any complexity.
34-
* 💪 You can easily create and manage multiple state machines within a single application.
35-
* 🚫 Prevents common mistakes and ensures that your system stays in a valid state at all times.
36-
37-
38-
## Getting started
39-
49+
## Installing
4050

4151
To install Python State Machine, run this command in your terminal:
4252

@@ -48,6 +58,8 @@ our docs for more details.
4858

4959
pip install python-statemachine[diagrams]
5060

61+
## First example
62+
5163
Define your state machine:
5264

5365
```py
@@ -65,7 +77,7 @@ Define your state machine:
6577
... | red.to(green)
6678
... )
6779
...
68-
... def before_cycle(self, event: str, source: State, target: State, message: str = ""):
80+
... async def before_cycle(self, event: str, source: State, target: State, message: str = ""):
6981
... message = ". " + message if message else ""
7082
... return f"Running {event} from {source.id} to {target.id}{message}"
7183
...
@@ -108,7 +120,27 @@ Then start sending events to your new state machine:
108120

109121
```
110122

111-
That's it. This is all an external object needs to know about your state machine: How to send events.
123+
You can use the exactly same state machine from an async codebase:
124+
125+
126+
```py
127+
>>> async def run_sm():
128+
... asm = TrafficLightMachine()
129+
... results = []
130+
... for _i in range(4):
131+
... result = await asm.send("cycle")
132+
... results.append(result)
133+
... return results
134+
135+
>>> asyncio.run(run_sm())
136+
Don't move.
137+
Go ahead!
138+
['Running cycle from green to yellow', 'Running cycle from yellow to red', ...
139+
140+
```
141+
142+
143+
**That's it.** This is all an external object needs to know about your state machine: How to send events.
112144
Ideally, all states, transitions, and actions should be kept internally and not checked externally to avoid unnecessary coupling.
113145

114146
But if your use case needs, you can inspect state machine properties, like the current state:
@@ -195,7 +227,7 @@ callback method.
195227
Note how `before_cycle` was declared:
196228

197229
```py
198-
def before_cycle(self, event: str, source: State, target: State, message: str = ""):
230+
async def before_cycle(self, event: str, source: State, target: State, message: str = ""):
199231
message = ". " + message if message else ""
200232
return f"Running {event} from {source.id} to {target.id}{message}"
201233
```
@@ -240,6 +272,34 @@ and in diagrams:
240272

241273
```
242274

275+
## Async support
276+
277+
We support native coroutine using `asyncio`, enabling seamless integration with asynchronous code.
278+
There's no change on the public API of the library to work on async codebases.
279+
280+
281+
```py
282+
>>> class AsyncStateMachine(StateMachine):
283+
... initial = State('Initial', initial=True)
284+
... final = State('Final', final=True)
285+
...
286+
... advance = initial.to(final)
287+
...
288+
... async def on_advance(self):
289+
... return 42
290+
291+
>>> async def run_sm():
292+
... sm = AsyncStateMachine()
293+
... result = await sm.advance()
294+
... print(f"Result is {result}")
295+
... print(sm.current_state)
296+
297+
>>> asyncio.run(run_sm())
298+
Result is 42
299+
Final
300+
301+
```
302+
243303
## A more useful example
244304

245305
A simple didactic state machine for controlling an `Order`:

conftest.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import pytest
2+
3+
4+
@pytest.fixture(autouse=True, scope="session")
5+
def add_doctest_context(doctest_namespace): # noqa: PT004
6+
from statemachine import State
7+
from statemachine import StateMachine
8+
from statemachine.utils import run_async_from_sync
9+
10+
class ContribAsyncio:
11+
"""
12+
Using `run_async_from_sync` to be injected in the doctests to better integration with an
13+
already running loop, as all of our examples are also automated executed as doctests.
14+
15+
On real life code you should use standard `import asyncio; asyncio.run(main())`.
16+
"""
17+
18+
def __init__(self):
19+
self.run = run_async_from_sync
20+
21+
doctest_namespace["State"] = State
22+
doctest_namespace["StateMachine"] = StateMachine
23+
doctest_namespace["asyncio"] = ContribAsyncio()

docs/actions.md

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ outside world, and indeed they are the main reason why they exist at all.
55

66
The main point of introducing a state machine is for the
77
actions to be invoked at the right times, depending on the sequence of events
8-
and the state of the guards.
8+
and the state of the {ref}`conditions`.
99

1010
Actions are most commonly performed on entry or exit of a state, although
1111
it is possible to add them before/after a transition.
@@ -79,7 +79,7 @@ After 'go', on the 'final' state.
7979

8080

8181
```{seealso}
82-
All actions and {ref}`guards` support multiple method signatures. They follow the
82+
All actions and {ref}`conditions` support multiple method signatures. They follow the
8383
{ref}`dynamic-dispatch` method calling implemented on this library.
8484
```
8585

@@ -298,7 +298,7 @@ On loop
298298
In addition to {ref}`actions`, you can specify {ref}`validators and guards` that are checked before a transition is started. They are meant to stop a transition to occur.
299299

300300
```{seealso}
301-
See {ref}`guards` and {ref}`validators`.
301+
See {ref}`conditions` and {ref}`validators`.
302302
```
303303

304304

@@ -377,21 +377,20 @@ For {ref}`RTC model`, only the main event will get its value list, while the cha
377377

378378

379379
(dynamic-dispatch)=
380-
## Dynamic dispatch
380+
(dynamic dispatch)=
381+
## Dependency injection
381382

382-
{ref}`statemachine` implements a custom dispatch mechanism on all those available Actions and
383-
Guards. This means that you can declare an arbitrary number of `*args` and `**kwargs`, and the
384-
library will match your method signature of what's expected to receive with the provided arguments.
383+
{ref}`statemachine` implements a dependency injection mechanism on all available {ref}`Actions` and
384+
{ref}`Conditions` that automatically inspects and matches the expected callback params with those available by the library in conjunction with any values informed when calling an event using `*args` and `**kwargs`.
385385

386-
This means that if on your `on_enter_<state.id>()` or `on_<event>()` method, you need to know
387-
the `source` ({ref}`state`), or the `event` ({ref}`event`), or access a keyword
388-
argument passed with the trigger, just add this parameter to the method and It will be passed
389-
by the dispatch mechanics.
386+
The library ensures that your method signatures match the expected arguments.
387+
388+
For example, if you need to access the source (state), the event (event), or any keyword arguments passed with the trigger in any method, simply include these parameters in the method. They will be automatically passed by the dependency injection dispatch mechanics.
390389

391390
In other words, if you implement a method to handle an event and don't declare any parameter,
392391
you'll be fine, if you declare an expected parameter, you'll also be covered.
393392

394-
For your convenience, all these parameters are available for you on any Action or Guard:
393+
For your convenience, all these parameters are available for you on any callback:
395394

396395

397396
`*args`

docs/async.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Async
2+
3+
```{versionadded} 2.3.0
4+
Support for async code was added!
5+
```
6+
7+
The {ref}`StateMachine` has full async suport. You can write async {ref}`actions`, {ref}`guards` and {ref}`event` triggers.
8+
9+
Keeping the same external API do interact both on sync or async codebases.
10+
11+
```{note}
12+
All the handlers will run on the same thread they're called. So it's not recommended to mix sync with async code unless
13+
you know what you're doing.
14+
```
15+
16+
## Asynchronous Support
17+
18+
We support native coroutine using asyncio, enabling seamless integration with asynchronous code.
19+
There's no change on the public API of the library to work on async codebases.
20+
21+
One requirement is that when running on an async code, you must manually await for the {ref}`initial state activation` to be able to check the current state.
22+
23+
24+
```{seealso}
25+
See {ref}`sphx_glr_auto_examples_air_conditioner_machine.py` for an example of
26+
async code with a state machine.
27+
```
28+
29+
30+
```py
31+
>>> class AsyncStateMachine(StateMachine):
32+
... initial = State('Initial', initial=True)
33+
... final = State('Final', final=True)
34+
...
35+
... advance = initial.to(final)
36+
...
37+
... async def on_advance(self):
38+
... return 42
39+
40+
>>> async def run_sm():
41+
... sm = AsyncStateMachine()
42+
... result = await sm.advance()
43+
... print(f"Result is {result}")
44+
... print(sm.current_state)
45+
46+
>>> asyncio.run(run_sm())
47+
Result is 42
48+
Final
49+
50+
```
51+
52+
## Sync codebase with async handlers
53+
54+
The same state machine can be executed on a sync codebase, even if it contains async handlers. The handlers will be
55+
awaited on an `asyncio.get_event_loop()` if needed.
56+
57+
```py
58+
>>> sm = AsyncStateMachine()
59+
>>> result = sm.advance()
60+
>>> print(f"Result is {result}")
61+
Result is 42
62+
>>> print(sm.current_state)
63+
Final
64+
65+
```
66+
67+
68+
(initial state activation)=
69+
## Initial State Activation for Async Code
70+
71+
When working with asynchronous state machines from async code, users must manually [activate initial state](statemachine.StateMachine.activate_initial_state) to be able to check the current state. This change ensures proper state initialization and
72+
execution flow given that Python don't allow awaiting at class initalization time and the initial state activation
73+
may contain async callbacks that must be awaited.
74+
75+
```py
76+
>>> async def initialize_sm():
77+
... sm = AsyncStateMachine()
78+
... await sm.activate_initial_state()
79+
... return sm
80+
81+
>>> sm = asyncio.run(initialize_sm())
82+
>>> print(sm.current_state)
83+
Initial
84+
85+
```

docs/conf.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import sys
1616

1717
import sphinx_rtd_theme
18+
from sphinx_gallery import gen_gallery
1819

1920
# If extensions (or modules to document with autodoc) are in another
2021
# directory, add these directories to sys.path here. If the directory is
@@ -271,3 +272,11 @@
271272
"image_scrapers": (MachineScraper(project_root),),
272273
"reset_modules": [],
273274
}
275+
276+
277+
def dummy_write_computation_times(gallery_conf, target_dir, costs):
278+
"patch gen_gallery to disable write_computation_times"
279+
pass
280+
281+
282+
gen_gallery.write_computation_times = dummy_write_computation_times

0 commit comments

Comments
 (0)