Skip to content

Commit ff14d62

Browse files
authored
fix: Fix nested events inside loops leaking memory by referencing the previous 'event_data' when calling the next event on queue (#485)
* fix: Fix recursion leaking memory by referencing the previous 'event_data' when calling the next event on queue
1 parent 25d6392 commit ff14d62

8 files changed

Lines changed: 219 additions & 171 deletions

File tree

.github/workflows/python-package.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ jobs:
4242
- name: Install dependencies
4343
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
4444
run: poetry install --no-interaction --no-root --all-extras
45+
- name: Install old pydot for 3.7 only
46+
if: matrix.python-version == 3.7
47+
run: |
48+
source .venv/bin/activate
49+
pip install pydot==2.0.0
4550
#----------------------------------------------
4651
# run ruff
4752
#----------------------------------------------

docs/actions.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,6 @@ Use the `enter` or `exit` params available on the `State` constructor.
135135

136136
```{hint}
137137
It's also possible to use an event name as action.
138-
139-
**Be careful to not introduce recursion errors** that will raise `RecursionError` exception.
140138
```
141139

142140
### Bind state actions using decorator syntax
@@ -221,8 +219,6 @@ using the patterns:
221219

222220
```{hint}
223221
It's also possible to use an event name as action to chain transitions.
224-
225-
**Be careful to not introduce recursion errors**, like `loop = initial.to.itself(after="loop")`, that will raise `RecursionError` exception.
226222
```
227223

228224
### Bind transition actions using decorator syntax

poetry.lock

Lines changed: 144 additions & 126 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 18 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,17 @@ name = "python-statemachine"
33
version = "2.3.6"
44
description = "Python Finite State Machines made easy."
55
authors = ["Fernando Macedo <fgmacedo@gmail.com>"]
6-
maintainers = [
7-
"Fernando Macedo <fgmacedo@gmail.com>",
8-
]
6+
maintainers = ["Fernando Macedo <fgmacedo@gmail.com>"]
97
license = "MIT license"
108
readme = "README.md"
119
homepage = "https://github.com/fgmacedo/python-statemachine"
12-
packages = [
13-
{include = "statemachine"},
14-
{include = "statemachine/**/*.py" },
15-
]
10+
packages = [{ include = "statemachine" }, { include = "statemachine/**/*.py" }]
1611
include = [
1712
{ path = "statemachine/locale/**/*.po", format = "sdist" },
18-
{ path = "statemachine/locale/**/*.mo", format = ["sdist", "wheel"] }
13+
{ path = "statemachine/locale/**/*.mo", format = [
14+
"sdist",
15+
"wheel",
16+
] },
1917
]
2018
classifiers = [
2119
"Intended Audience :: Developers",
@@ -35,7 +33,7 @@ classifiers = [
3533

3634
[tool.poetry.dependencies]
3735
python = ">=3.7"
38-
pydot = { version = ">=2.0.0", optional = true }
36+
pydot = { version = ">=2.0.0", optional = true, python = ">3.8" }
3937

4038
[tool.poetry.extras]
4139
diagrams = ["pydot"]
@@ -59,7 +57,7 @@ pytest-django = { version = "^4.8.0", python = ">3.8" }
5957
Sphinx = { version = "*", python = ">3.8" }
6058
myst-parser = { version = "*", python = ">3.8" }
6159
sphinx-gallery = { version = "*", python = ">3.8" }
62-
pillow = { version ="*", python = ">3.8" }
60+
pillow = { version = "*", python = ">3.8" }
6361
sphinx-autobuild = { version = "*", python = ">3.8" }
6462
furo = { version = "^2024.5.6", python = ">3.8" }
6563
sphinx-copybutton = { version = "^0.5.2", python = ">3.8" }
@@ -72,9 +70,7 @@ build-backend = "poetry.core.masonry.api"
7270
addopts = "--ignore=docs/conf.py --ignore=docs/auto_examples/ --ignore=docs/_build/ --ignore=tests/examples/ --cov --cov-config .coveragerc --doctest-glob='*.md' --doctest-modules --doctest-continue-on-failure --benchmark-autosave --benchmark-group-by=name"
7371
doctest_optionflags = "ELLIPSIS IGNORE_EXCEPTION_DETAIL NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL"
7472
asyncio_mode = "auto"
75-
markers = [
76-
"""slow: marks tests as slow (deselect with '-m "not slow"')""",
77-
]
73+
markers = ["""slow: marks tests as slow (deselect with '-m "not slow"')"""]
7874
python_files = ["tests.py", "test_*.py", "*_tests.py"]
7975

8076
[tool.mypy]
@@ -85,19 +81,11 @@ disable_error_code = "annotation-unchecked"
8581
mypy_path = "$MYPY_CONFIG_FILE_DIR/tests/django_project"
8682

8783
[[tool.mypy.overrides]]
88-
module = [
89-
'django.*',
90-
'pytest.*',
91-
'pydot.*',
92-
'sphinx_gallery.*',
93-
]
84+
module = ['django.*', 'pytest.*', 'pydot.*', 'sphinx_gallery.*']
9485
ignore_missing_imports = true
9586

9687
[tool.flake8]
97-
ignore = [
98-
"E231",
99-
"W503",
100-
]
88+
ignore = ["E231", "W503"]
10189
max-line-length = 99
10290

10391
[tool.ruff]
@@ -131,10 +119,10 @@ exclude = [
131119

132120
# Enable Pyflakes and pycodestyle rules.
133121
select = [
134-
"E", # pycodestyle errors
135-
"W", # pycodestyle warnings
136-
"F", # pyflakes
137-
"I", # isort
122+
"E", # pycodestyle errors
123+
"W", # pycodestyle warnings
124+
"F", # pyflakes
125+
"I", # isort
138126
"UP", # pyupgrade
139127
"C", # flake8-comprehensions
140128
"B", # flake8-bugbear
@@ -169,14 +157,8 @@ convention = "google"
169157
branch = true
170158
relative_files = true
171159
data_file = ".coverage"
172-
source = [
173-
"statemachine",
174-
]
175-
omit = [
176-
"*test*.py",
177-
"tmp/*",
178-
"pytest_cov",
179-
]
160+
source = ["statemachine"]
161+
omit = ["*test*.py", "tmp/*", "pytest_cov"]
180162
[tool.coverage.report]
181163
show_missing = true
182164
exclude_lines = [
@@ -190,7 +172,7 @@ exclude_lines = [
190172
# Don't complain if tests don't hit defensive assertion code:
191173
"raise AssertionError",
192174
"raise NotImplementedError",
193-
"if TYPE_CHECKING",
175+
"if TYPE_CHECKING",
194176
]
195177

196178
[tool.coverage.html]

statemachine/contrib/diagram.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,6 @@ def quickchart_write_svg(sm: StateMachine, path: str):
166166
>>> sm = OrderControl()
167167
>>> print(sm._graph().to_string())
168168
digraph list {
169-
fontname=Arial;
170-
fontsize=10;
171-
label=OrderControl;
172-
rankdir=LR;
173169
...
174170
175171
To give you an example, we included this method that will serialize the dot, request the graph

statemachine/event.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@
88
if TYPE_CHECKING:
99
from .statemachine import StateMachine
1010

11+
_event_data_kwargs = {
12+
"event_data",
13+
"machine",
14+
"event",
15+
"model",
16+
"transition",
17+
"state",
18+
"source",
19+
"target",
20+
}
21+
1122

1223
class Event:
1324
def __init__(self, name: str):
@@ -17,6 +28,7 @@ def __repr__(self):
1728
return f"{type(self).__name__}({self.name!r})"
1829

1930
def trigger(self, machine: "StateMachine", *args, **kwargs):
31+
kwargs = {k: v for k, v in kwargs.items() if k not in _event_data_kwargs}
2032
trigger_data = TriggerData(
2133
machine=machine,
2234
event=self.name,
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""
2+
Looping state machine
3+
=====================
4+
5+
This example demonstrates that you can call an event as a side-effect of another event.
6+
The event will be put on an internal queue and processed in the same loop after the previous event
7+
in the queue is processed.
8+
9+
"""
10+
11+
from statemachine import State
12+
from statemachine import StateMachine
13+
14+
15+
class MyStateMachine(StateMachine):
16+
startup = State(initial=True)
17+
test = State()
18+
19+
counter = 0
20+
do_startup = startup.to(test, after="do_test")
21+
do_test = test.to.itself(after="do_test")
22+
23+
def on_enter_state(self, target, event):
24+
self.counter += 1
25+
print(f"{self.counter:>3}: Entering {target} from {event}")
26+
27+
if self.counter >= 5:
28+
raise StopIteration
29+
30+
31+
# %%
32+
# Let's create an instance and test the machine.
33+
34+
sm = MyStateMachine()
35+
36+
try:
37+
sm.do_startup()
38+
except StopIteration:
39+
pass

tests/testcases/issue480.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Should be possible to trigger an event on the initial state activation handler.
1212
>>>
1313
>>> class MyStateMachine(StateMachine):
1414
... State_1 = State(initial=True)
15-
... State_2 = State()
15+
... State_2 = State(final=True)
1616
... Trans_1 = State_1.to(State_2)
1717
...
1818
... def __init__(self):

0 commit comments

Comments
 (0)