Skip to content

Commit dbfc4e9

Browse files
committed
Merge branch 'release/2.1.2'
2 parents 0b2c6cd + 2d9b259 commit dbfc4e9

23 files changed

+1066
-618
lines changed

.github/workflows/python-package.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ jobs:
2929
- name: Install Poetry
3030
uses: snok/install-poetry@v1
3131
with:
32+
version: 1.5.1
3233
virtualenvs-create: true
3334
virtualenvs-in-project: true
3435
installer-parallel: true

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ pip-log.txt
4040
pip-delete-this-directory.txt
4141

4242
# Unit test / coverage reports
43+
prof/
44+
.benchmarks/
4345
htmlcov/
4446
.tox/
4547
.coverage

docs/actions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ All actions and {ref}`guards` support multiple method signatures. They follow th
8787

8888
For each defined {ref}`state`, you can declare `enter` and `exit` callbacks.
8989

90-
### Declare state actions by naming convention
90+
### Bind state actions by naming convention
9191

9292
Callbacks by naming convention will be searched on the StateMachine and on the
9393
model, using the patterns:

docs/releases/2.1.2.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# StateMachine 2.1.2
2+
3+
*October 6, 2023*
4+
5+
This release improves the setup performance of the library by a 10x factor, with a major
6+
refactoring on how we handle the callbacks registry and validations.
7+
8+
See [#401](https://github.com/fgmacedo/python-statemachine/issues/401) for the technical details.
9+
10+
11+
## Python compatibility 2.1.2
12+
13+
StateMachine 2.1.2 supports Python 3.7, 3.8, 3.9, 3.10, 3.11 and 3.12.
14+
15+
On the next major release (3.0.0), we will drop support for Python 3.7.
16+
17+
## Bugfixes in 2.1.2
18+
19+
- Fixes [#406](https://github.com/fgmacedo/python-statemachine/issues/406) action callback being
20+
called twice when mixing decorator syntax combined with the naming convention.

docs/releases/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ Below are release notes through StateMachine and its patch releases.
1313
### 2.0 releases
1414

1515
```{toctree}
16-
:maxdepth: 1
16+
:maxdepth: 2
1717
18+
2.1.2
1819
2.1.1
1920
2.1.0
2021
2.0.0

poetry.lock

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

pyproject.toml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "python-statemachine"
3-
version = "2.1.1"
3+
version = "2.1.2"
44
description = "Python Finite State Machines made easy."
55
authors = ["Fernando Macedo <[email protected]>"]
66
maintainers = [
@@ -26,14 +26,15 @@ classifiers = [
2626
"Programming Language :: Python :: 3.9",
2727
"Programming Language :: Python :: 3.10",
2828
"Programming Language :: Python :: 3.11",
29+
"Programming Language :: Python :: 3.12",
2930
"Topic :: Software Development :: Libraries"
3031
]
3132

3233
[tool.poetry.extras]
3334
diagrams = ["pydot"]
3435

3536
[tool.poetry.dependencies]
36-
python = ">=3.7, <3.12"
37+
python = ">=3.7, <3.13"
3738

3839
[tool.poetry.group.dev.dependencies]
3940
pytest = "^7.2.0"
@@ -46,6 +47,8 @@ mypy = "^0.991"
4647
black = "^22.12.0"
4748
pdbpp = "^0.10.3"
4849
pytest-mock = "^3.10.0"
50+
pytest-profiling = "^1.7.0"
51+
pytest-benchmark = "^4.0.0"
4952

5053
[tool.poetry.group.docs.dependencies]
5154
Sphinx = "4.5.0"
@@ -60,13 +63,14 @@ requires = ["poetry-core"]
6063
build-backend = "poetry.core.masonry.api"
6164

6265
[tool.pytest.ini_options]
63-
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"
66+
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"
6467
doctest_optionflags = "ELLIPSIS IGNORE_EXCEPTION_DETAIL NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL"
6568

6669
[tool.mypy]
67-
python_version = "3.11"
70+
python_version = "3.12"
6871
warn_return_any = true
6972
warn_unused_configs = true
73+
disable_error_code = "annotation-unchecked"
7074

7175
[[tool.mypy.overrides]]
7276
module = [

statemachine/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@
33

44
__author__ = """Fernando Macedo"""
55
__email__ = "[email protected]"
6-
__version__ = "2.1.1"
6+
__version__ = "2.1.2"
77

88
__all__ = ["StateMachine", "State"]

statemachine/callbacks.py

Lines changed: 146 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,39 @@
1+
from collections import defaultdict
2+
from collections import deque
3+
from typing import Callable
4+
from typing import Dict
5+
from typing import List
6+
17
from .exceptions import AttrNotFound
2-
from .exceptions import InvalidDefinition
38
from .i18n import _
49
from .utils import ensure_iterable
510

611

712
class CallbackWrapper:
8-
"""A thin wrapper that ensures the target callback is a proper callable.
13+
def __init__(
14+
self,
15+
callback: Callable,
16+
condition: Callable,
17+
unique_key: str,
18+
expected_value: "bool | None" = None,
19+
) -> None:
20+
self._callback = callback
21+
self.condition = condition
22+
self.unique_key = unique_key
23+
self.expected_value = expected_value
24+
25+
def __repr__(self):
26+
return f"{type(self).__name__}({self.unique_key})"
27+
28+
def __call__(self, *args, **kwargs):
29+
result = self._callback(*args, **kwargs)
30+
if self.expected_value is not None:
31+
return bool(result) == self.expected_value
32+
return result
33+
34+
35+
class CallbackMeta:
36+
"""A thin wrapper that register info about actions and guards.
937
1038
At first, `func` can be a string or a callable, and even if it's already
1139
a callable, his signature can mismatch.
@@ -14,12 +42,11 @@ class CallbackWrapper:
1442
call is performed, to allow the proper callback resolution.
1543
"""
1644

17-
def __init__(self, func, suppress_errors=False, cond=None):
45+
def __init__(self, func, suppress_errors=False, cond=None, expected_value=None):
1846
self.func = func
1947
self.suppress_errors = suppress_errors
20-
self.cond = Callbacks(factory=ConditionWrapper).add(cond)
21-
self._callback = None
22-
self._resolver_id = None
48+
self.cond = CallbackMetaList().add(cond)
49+
self.expected_value = expected_value
2350

2451
def __repr__(self):
2552
return f"{type(self).__name__}({self.func!r})"
@@ -28,57 +55,67 @@ def __str__(self):
2855
return getattr(self.func, "__name__", self.func)
2956

3057
def __eq__(self, other):
31-
return self.func == other.func and self._resolver_id == other._resolver_id
58+
return self.func == other.func
3259

3360
def __hash__(self):
3461
return id(self)
3562

3663
def _update_func(self, func):
3764
self.func = func
3865

39-
def setup(self, resolver):
66+
def build(self, resolver) -> "CallbackWrapper | None":
4067
"""
4168
Resolves the `func` into a usable callable.
4269
4370
Args:
4471
resolver (callable): A method responsible to build and return a valid callable that
4572
can receive arbitrary parameters like `*args, **kwargs`.
4673
"""
47-
self.cond.setup(resolver)
48-
try:
49-
self._resolver_id = getattr(resolver, "id", id(resolver))
50-
self._callback = resolver(self.func)
51-
return True
52-
except AttrNotFound:
53-
if not self.suppress_errors:
54-
raise
55-
return False
74+
callback = resolver(self.func)
75+
if not callback.is_empty:
76+
conditions = CallbacksExecutor()
77+
conditions.add(self.cond, resolver)
78+
79+
return CallbackWrapper(
80+
callback=callback,
81+
condition=conditions.all,
82+
unique_key=callback.unique_key,
83+
expected_value=self.expected_value,
84+
)
5685

57-
def __call__(self, *args, **kwargs):
58-
if self._callback is None:
59-
raise InvalidDefinition(
60-
_("Callback {!r} not property configured.").format(self)
86+
if not self.suppress_errors:
87+
raise AttrNotFound(
88+
_("Did not found name '{}' from model or statemachine").format(
89+
self.func
90+
)
6191
)
62-
return self._callback(*args, **kwargs)
92+
return None
93+
6394

95+
class BoolCallbackMeta(CallbackMeta):
96+
"""A thin wrapper that register info about actions and guards.
97+
98+
At first, `func` can be a string or a callable, and even if it's already
99+
a callable, his signature can mismatch.
100+
101+
After instantiation, `.setup(resolver)` must be called before any real
102+
call is performed, to allow the proper callback resolution.
103+
"""
64104

65-
class ConditionWrapper(CallbackWrapper):
66-
def __init__(self, func, suppress_errors=False, expected_value=True):
67-
super().__init__(func, suppress_errors)
105+
def __init__(self, func, suppress_errors=False, cond=None, expected_value=True):
106+
self.func = func
107+
self.suppress_errors = suppress_errors
108+
self.cond = CallbackMetaList().add(cond)
68109
self.expected_value = expected_value
69110

70111
def __str__(self):
71112
name = super().__str__()
72113
return name if self.expected_value else f"!{name}"
73114

74-
def __call__(self, *args, **kwargs):
75-
return bool(super().__call__(*args, **kwargs)) == self.expected_value
76-
77115

78-
class Callbacks:
79-
def __init__(self, resolver=None, factory=CallbackWrapper):
80-
self.items = []
81-
self._resolver = resolver
116+
class CallbackMetaList:
117+
def __init__(self, factory=CallbackMeta):
118+
self.items: List[CallbackMeta] = []
82119
self.factory = factory
83120

84121
def __repr__(self):
@@ -87,13 +124,6 @@ def __repr__(self):
87124
def __str__(self):
88125
return ", ".join(str(c) for c in self)
89126

90-
def setup(self, resolver):
91-
"""Validate configurations"""
92-
self._resolver = resolver
93-
self.items = [
94-
callback for callback in self.items if callback.setup(self._resolver)
95-
]
96-
97127
def _add_unbounded_callback(self, func, is_event=False, transitions=None, **kwargs):
98128
"""This list was a target for adding a func using decorator
99129
`@<state|event>[.on|before|after|enter|exit]` syntax.
@@ -135,31 +165,19 @@ def __iter__(self):
135165
def clear(self):
136166
self.items = []
137167

138-
def call(self, *args, **kwargs):
139-
return [
140-
callback(*args, **kwargs)
141-
for callback in self.items
142-
if callback.cond.all(*args, **kwargs)
143-
]
144-
145-
def all(self, *args, **kwargs):
146-
return all(condition(*args, **kwargs) for condition in self)
147-
148-
def _add(self, func, resolver=None, prepend=False, **kwargs):
149-
resolver = resolver or self._resolver
150-
151-
callback = self.factory(func, **kwargs)
152-
if resolver is not None and not callback.setup(resolver):
168+
def _add(self, func, registry=None, prepend=False, **kwargs):
169+
meta = self.factory(func, **kwargs)
170+
if registry is not None and not registry(self, meta, prepend=prepend):
153171
return
154172

155-
if callback in self.items:
173+
if meta in self.items:
156174
return
157175

158176
if prepend:
159-
self.items.insert(0, callback)
177+
self.items.insert(0, meta)
160178
else:
161-
self.items.append(callback)
162-
return callback
179+
self.items.append(meta)
180+
return meta
163181

164182
def add(self, callbacks, **kwargs):
165183
if callbacks is None:
@@ -170,3 +188,73 @@ def add(self, callbacks, **kwargs):
170188
self._add(func, **kwargs)
171189

172190
return self
191+
192+
193+
class CallbacksExecutor:
194+
def __init__(self):
195+
self.items: List[CallbackWrapper] = deque()
196+
self.items_already_seen = set()
197+
198+
def __iter__(self):
199+
return iter(self.items)
200+
201+
def __repr__(self):
202+
return f"{type(self).__name__}({self.items!r})"
203+
204+
def add_one(
205+
self, callback_info: CallbackMeta, resolver: Callable, prepend: bool = False
206+
) -> "CallbackWrapper | None":
207+
callback = callback_info.build(resolver)
208+
if callback is None:
209+
return None
210+
211+
if callback.unique_key in self.items_already_seen:
212+
return None
213+
214+
self.items_already_seen.add(callback.unique_key)
215+
if prepend:
216+
self.items.insert(0, callback)
217+
else:
218+
self.items.append(callback)
219+
return callback
220+
221+
def add(self, items: CallbackMetaList, resolver: Callable):
222+
"""Validate configurations"""
223+
for item in items:
224+
self.add_one(item, resolver)
225+
return self
226+
227+
def call(self, *args, **kwargs):
228+
return [
229+
callback(*args, **kwargs)
230+
for callback in self
231+
if callback.condition(*args, **kwargs)
232+
]
233+
234+
def all(self, *args, **kwargs):
235+
return all(condition(*args, **kwargs) for condition in self)
236+
237+
238+
class CallbacksRegistry:
239+
def __init__(self) -> None:
240+
self._registry: Dict[CallbackMetaList, CallbacksExecutor] = defaultdict(
241+
CallbacksExecutor
242+
)
243+
244+
def register(self, callbacks: CallbackMetaList, resolver):
245+
executor_list = self[callbacks]
246+
executor_list.add(callbacks, resolver)
247+
return executor_list
248+
249+
def __getitem__(self, callbacks: CallbackMetaList) -> CallbacksExecutor:
250+
return self._registry[callbacks]
251+
252+
def build_register_function_for_resolver(self, resolver):
253+
def register(
254+
meta_list: CallbackMetaList,
255+
meta: CallbackMeta,
256+
prepend: bool = False,
257+
):
258+
return self[meta_list].add_one(meta, resolver, prepend=prepend)
259+
260+
return register

0 commit comments

Comments
 (0)