python-statemachine 2.3.1__tar.gz → 2.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/PKG-INFO +3 -23
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/README.md +2 -22
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/pyproject.toml +11 -6
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/__init__.py +1 -1
- python_statemachine-2.3.2/statemachine/callbacks.py +362 -0
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/contrib/diagram.py +7 -4
- python_statemachine-2.3.2/statemachine/dispatcher.py +151 -0
- python_statemachine-2.3.2/statemachine/engines/__init__.py +0 -0
- python_statemachine-2.3.2/statemachine/engines/async_.py +136 -0
- python_statemachine-2.3.2/statemachine/engines/sync.py +139 -0
- python_statemachine-2.3.2/statemachine/event.py +53 -0
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/event_data.py +2 -4
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/events.py +2 -2
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/factory.py +10 -11
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/mixins.py +10 -4
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/registry.py +5 -8
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/signature.py +6 -5
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/state.py +13 -17
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/statemachine.py +111 -167
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/states.py +4 -3
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/transition.py +22 -45
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/transition_list.py +6 -8
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/utils.py +1 -1
- python_statemachine-2.3.1/statemachine/callbacks.py +0 -290
- python_statemachine-2.3.1/statemachine/dispatcher.py +0 -159
- python_statemachine-2.3.1/statemachine/event.py +0 -72
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/LICENSE +0 -0
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/contrib/__init__.py +0 -0
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/exceptions.py +0 -0
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/graph.py +0 -0
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/i18n.py +0 -0
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/locale/en/LC_MESSAGES/statemachine.po +0 -0
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po +0 -0
- {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/model.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: python-statemachine
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.2
|
|
4
4
|
Summary: Python Finite State Machines made easy.
|
|
5
5
|
Home-page: https://github.com/fgmacedo/python-statemachine
|
|
6
6
|
License: MIT
|
|
@@ -103,7 +103,7 @@ Define your state machine:
|
|
|
103
103
|
... | red.to(green)
|
|
104
104
|
... )
|
|
105
105
|
...
|
|
106
|
-
...
|
|
106
|
+
... def before_cycle(self, event: str, source: State, target: State, message: str = ""):
|
|
107
107
|
... message = ". " + message if message else ""
|
|
108
108
|
... return f"Running {event} from {source.id} to {target.id}{message}"
|
|
109
109
|
...
|
|
@@ -146,26 +146,6 @@ Then start sending events to your new state machine:
|
|
|
146
146
|
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
-
You can use the exactly same state machine from an async codebase:
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
```py
|
|
153
|
-
>>> async def run_sm():
|
|
154
|
-
... asm = TrafficLightMachine()
|
|
155
|
-
... results = []
|
|
156
|
-
... for _i in range(4):
|
|
157
|
-
... result = await asm.send("cycle")
|
|
158
|
-
... results.append(result)
|
|
159
|
-
... return results
|
|
160
|
-
|
|
161
|
-
>>> asyncio.run(run_sm())
|
|
162
|
-
Don't move.
|
|
163
|
-
Go ahead!
|
|
164
|
-
['Running cycle from green to yellow', 'Running cycle from yellow to red', ...
|
|
165
|
-
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
|
|
169
149
|
**That's it.** This is all an external object needs to know about your state machine: How to send events.
|
|
170
150
|
Ideally, all states, transitions, and actions should be kept internally and not checked externally to avoid unnecessary coupling.
|
|
171
151
|
|
|
@@ -253,7 +233,7 @@ callback method.
|
|
|
253
233
|
Note how `before_cycle` was declared:
|
|
254
234
|
|
|
255
235
|
```py
|
|
256
|
-
|
|
236
|
+
def before_cycle(self, event: str, source: State, target: State, message: str = ""):
|
|
257
237
|
message = ". " + message if message else ""
|
|
258
238
|
return f"Running {event} from {source.id} to {target.id}{message}"
|
|
259
239
|
```
|
|
@@ -77,7 +77,7 @@ Define your state machine:
|
|
|
77
77
|
... | red.to(green)
|
|
78
78
|
... )
|
|
79
79
|
...
|
|
80
|
-
...
|
|
80
|
+
... def before_cycle(self, event: str, source: State, target: State, message: str = ""):
|
|
81
81
|
... message = ". " + message if message else ""
|
|
82
82
|
... return f"Running {event} from {source.id} to {target.id}{message}"
|
|
83
83
|
...
|
|
@@ -120,26 +120,6 @@ Then start sending events to your new state machine:
|
|
|
120
120
|
|
|
121
121
|
```
|
|
122
122
|
|
|
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
123
|
**That's it.** This is all an external object needs to know about your state machine: How to send events.
|
|
144
124
|
Ideally, all states, transitions, and actions should be kept internally and not checked externally to avoid unnecessary coupling.
|
|
145
125
|
|
|
@@ -227,7 +207,7 @@ callback method.
|
|
|
227
207
|
Note how `before_cycle` was declared:
|
|
228
208
|
|
|
229
209
|
```py
|
|
230
|
-
|
|
210
|
+
def before_cycle(self, event: str, source: State, target: State, message: str = ""):
|
|
231
211
|
message = ". " + message if message else ""
|
|
232
212
|
return f"Running {event} from {source.id} to {target.id}{message}"
|
|
233
213
|
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "python-statemachine"
|
|
3
|
-
version = "2.3.
|
|
3
|
+
version = "2.3.2"
|
|
4
4
|
description = "Python Finite State Machines made easy."
|
|
5
5
|
authors = ["Fernando Macedo <fgmacedo@gmail.com>"]
|
|
6
6
|
maintainers = [
|
|
@@ -39,18 +39,21 @@ diagrams = ["pydot"]
|
|
|
39
39
|
python = ">=3.7"
|
|
40
40
|
|
|
41
41
|
[tool.poetry.group.dev.dependencies]
|
|
42
|
-
pytest = "*"
|
|
43
|
-
pytest-cov = "*"
|
|
44
|
-
pytest-sugar = "^1.0.0"
|
|
45
42
|
pydot = "^2.0.0"
|
|
46
43
|
ruff = "^0.4.8"
|
|
47
44
|
pre-commit = "*"
|
|
48
45
|
mypy = "*"
|
|
46
|
+
|
|
47
|
+
[tool.poetry.group.tests.dependencies]
|
|
48
|
+
pytest = "*"
|
|
49
|
+
pytest-cov = "*"
|
|
50
|
+
pytest-sugar = "^1.0.0"
|
|
49
51
|
pytest-mock = "^3.10.0"
|
|
50
52
|
pytest-profiling = "^1.7.0"
|
|
51
53
|
pytest-benchmark = "^4.0.0"
|
|
52
54
|
pytest-asyncio = "*"
|
|
53
|
-
|
|
55
|
+
django = { version = "^5.0.3", python = ">3.10" }
|
|
56
|
+
pytest-django = { version = "^4.8.0", python = ">3.8" }
|
|
54
57
|
|
|
55
58
|
[tool.poetry.group.docs.dependencies]
|
|
56
59
|
Sphinx = "*"
|
|
@@ -65,18 +68,20 @@ requires = ["poetry-core"]
|
|
|
65
68
|
build-backend = "poetry.core.masonry.api"
|
|
66
69
|
|
|
67
70
|
[tool.pytest.ini_options]
|
|
68
|
-
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"
|
|
71
|
+
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"
|
|
69
72
|
doctest_optionflags = "ELLIPSIS IGNORE_EXCEPTION_DETAIL NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL"
|
|
70
73
|
asyncio_mode = "auto"
|
|
71
74
|
markers = [
|
|
72
75
|
"""slow: marks tests as slow (deselect with '-m "not slow"')""",
|
|
73
76
|
]
|
|
77
|
+
python_files = ["tests.py", "test_*.py", "*_tests.py"]
|
|
74
78
|
|
|
75
79
|
[tool.mypy]
|
|
76
80
|
python_version = "3.12"
|
|
77
81
|
warn_return_any = true
|
|
78
82
|
warn_unused_configs = true
|
|
79
83
|
disable_error_code = "annotation-unchecked"
|
|
84
|
+
mypy_path = "$MYPY_CONFIG_FILE_DIR/tests/django_project"
|
|
80
85
|
|
|
81
86
|
[[tool.mypy.overrides]]
|
|
82
87
|
module = [
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from bisect import insort
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from collections import deque
|
|
6
|
+
from enum import IntEnum
|
|
7
|
+
from enum import auto
|
|
8
|
+
from inspect import isawaitable
|
|
9
|
+
from inspect import iscoroutinefunction
|
|
10
|
+
from typing import Callable
|
|
11
|
+
from typing import Dict
|
|
12
|
+
from typing import Generator
|
|
13
|
+
from typing import Iterable
|
|
14
|
+
from typing import List
|
|
15
|
+
from typing import Type
|
|
16
|
+
|
|
17
|
+
from .exceptions import AttrNotFound
|
|
18
|
+
from .i18n import _
|
|
19
|
+
from .utils import ensure_iterable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CallbackPriority(IntEnum):
|
|
23
|
+
GENERIC = 0
|
|
24
|
+
INLINE = 10
|
|
25
|
+
DECORATOR = 20
|
|
26
|
+
NAMING = 30
|
|
27
|
+
AFTER = 40
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SpecReference(IntEnum):
|
|
31
|
+
NAME = 1
|
|
32
|
+
CALLABLE = 2
|
|
33
|
+
PROPERTY = 3
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CallbackGroup(IntEnum):
|
|
37
|
+
ENTER = auto()
|
|
38
|
+
EXIT = auto()
|
|
39
|
+
VALIDATOR = auto()
|
|
40
|
+
BEFORE = auto()
|
|
41
|
+
ON = auto()
|
|
42
|
+
AFTER = auto()
|
|
43
|
+
COND = auto()
|
|
44
|
+
|
|
45
|
+
def build_key(self, specs: "CallbackSpecList") -> str:
|
|
46
|
+
return f"{self.name}@{id(specs)}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def allways_true(*args, **kwargs):
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CallbackSpec:
|
|
54
|
+
"""Specs about callbacks.
|
|
55
|
+
|
|
56
|
+
At first, `func` can be a name (string), a property or a callable.
|
|
57
|
+
|
|
58
|
+
Names, properties and unbounded callables should be resolved to a callable
|
|
59
|
+
before any real call is performed.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
func,
|
|
65
|
+
group: CallbackGroup,
|
|
66
|
+
is_convention=False,
|
|
67
|
+
cond=None,
|
|
68
|
+
priority: CallbackPriority = CallbackPriority.NAMING,
|
|
69
|
+
expected_value=None,
|
|
70
|
+
):
|
|
71
|
+
self.func = func
|
|
72
|
+
self.group = group
|
|
73
|
+
self.is_convention = is_convention
|
|
74
|
+
self.cond = cond
|
|
75
|
+
self.expected_value = expected_value
|
|
76
|
+
self.priority = priority
|
|
77
|
+
|
|
78
|
+
if isinstance(func, property):
|
|
79
|
+
self.reference = SpecReference.PROPERTY
|
|
80
|
+
self.attr_name: str = func and func.fget and func.fget.__name__ or ""
|
|
81
|
+
elif callable(func):
|
|
82
|
+
self.reference = SpecReference.CALLABLE
|
|
83
|
+
self.is_bounded = hasattr(func, "__self__")
|
|
84
|
+
self.attr_name = func.__name__
|
|
85
|
+
else:
|
|
86
|
+
self.reference = SpecReference.NAME
|
|
87
|
+
self.attr_name = func
|
|
88
|
+
|
|
89
|
+
def __repr__(self):
|
|
90
|
+
return f"{type(self).__name__}({self.func!r}, is_convention={self.is_convention!r})"
|
|
91
|
+
|
|
92
|
+
def __str__(self):
|
|
93
|
+
name = getattr(self.func, "__name__", self.func)
|
|
94
|
+
if self.expected_value is False:
|
|
95
|
+
name = f"!{name}"
|
|
96
|
+
return name
|
|
97
|
+
|
|
98
|
+
def __eq__(self, other):
|
|
99
|
+
return self.func == other.func and self.group == other.group
|
|
100
|
+
|
|
101
|
+
def __hash__(self):
|
|
102
|
+
return id(self)
|
|
103
|
+
|
|
104
|
+
def _update_func(self, func: Callable, attr_name: str):
|
|
105
|
+
self.func = func
|
|
106
|
+
self.reference = SpecReference.CALLABLE
|
|
107
|
+
self.attr_name = attr_name
|
|
108
|
+
|
|
109
|
+
def build(self, resolver) -> Generator["CallbackWrapper", None, None]:
|
|
110
|
+
"""
|
|
111
|
+
Resolves the `func` into a usable callable.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
resolver (callable): A method responsible to build and return a valid callable that
|
|
115
|
+
can receive arbitrary parameters like `*args, **kwargs`.
|
|
116
|
+
"""
|
|
117
|
+
for callback in resolver.search(self):
|
|
118
|
+
condition = self.cond if self.cond is not None else allways_true
|
|
119
|
+
yield CallbackWrapper(
|
|
120
|
+
callback=callback,
|
|
121
|
+
condition=condition,
|
|
122
|
+
meta=self,
|
|
123
|
+
unique_key=callback.unique_key,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class SpecListGrouper:
|
|
128
|
+
def __init__(
|
|
129
|
+
self, list: "CallbackSpecList", group: CallbackGroup, factory=CallbackSpec
|
|
130
|
+
) -> None:
|
|
131
|
+
self.list = list
|
|
132
|
+
self.group = group
|
|
133
|
+
self.factory = factory
|
|
134
|
+
self.key = group.build_key(list)
|
|
135
|
+
|
|
136
|
+
def add(self, callbacks, **kwargs):
|
|
137
|
+
self.list.add(callbacks, group=self.group, factory=self.factory, **kwargs)
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def __call__(self, callback):
|
|
141
|
+
return self.list._add_unbounded_callback(callback, group=self.group, factory=self.factory)
|
|
142
|
+
|
|
143
|
+
def _add_unbounded_callback(self, func, is_event=False, transitions=None, **kwargs):
|
|
144
|
+
self.list._add_unbounded_callback(
|
|
145
|
+
func,
|
|
146
|
+
is_event=is_event,
|
|
147
|
+
transitions=transitions,
|
|
148
|
+
group=self.group,
|
|
149
|
+
factory=self.factory,
|
|
150
|
+
**kwargs,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def __iter__(self):
|
|
154
|
+
return (item for item in self.list if item.group == self.group)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class CallbackSpecList:
|
|
158
|
+
"""List of {ref}`CallbackSpec` instances"""
|
|
159
|
+
|
|
160
|
+
def __init__(self, factory=CallbackSpec):
|
|
161
|
+
self.items: List[CallbackSpec] = []
|
|
162
|
+
self.conventional_specs = set()
|
|
163
|
+
self.factory = factory
|
|
164
|
+
|
|
165
|
+
def __repr__(self):
|
|
166
|
+
return f"{type(self).__name__}({self.items!r}, factory={self.factory!r})"
|
|
167
|
+
|
|
168
|
+
def _add_unbounded_callback(self, func, is_event=False, transitions=None, **kwargs):
|
|
169
|
+
"""This list was a target for adding a func using decorator
|
|
170
|
+
`@<state|event>[.on|before|after|enter|exit]` syntax.
|
|
171
|
+
|
|
172
|
+
If we assign ``func`` directly as callable on the ``items`` list,
|
|
173
|
+
this will result in an `unbounded method error`, with `func` expecting a parameter
|
|
174
|
+
``self`` not defined.
|
|
175
|
+
|
|
176
|
+
The implemented solution is to resolve the collision giving the func a reference method.
|
|
177
|
+
To update It's callback when the name is resolved on the
|
|
178
|
+
:func:`StateMachineMetaclass.add_from_attributes`.
|
|
179
|
+
If the ``func`` is bounded It will be used directly, if not, it's ref will be replaced
|
|
180
|
+
by the given attr name and on `statemachine._setup()` the dynamic name will be resolved
|
|
181
|
+
properly.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
func (callable): The decorated method to add on the transitions occurs.
|
|
185
|
+
is_event (bool): If the func is also an event, we'll create a trigger and link the
|
|
186
|
+
event name to the transitions.
|
|
187
|
+
transitions (TransitionList): If ``is_event``, the transitions to be attached to the
|
|
188
|
+
event.
|
|
189
|
+
|
|
190
|
+
"""
|
|
191
|
+
spec = self._add(func, **kwargs)
|
|
192
|
+
if not getattr(func, "_specs_to_update", None):
|
|
193
|
+
func._specs_to_update = set()
|
|
194
|
+
if is_event:
|
|
195
|
+
func._specs_to_update.add(spec._update_func)
|
|
196
|
+
func._transitions = transitions
|
|
197
|
+
|
|
198
|
+
return func
|
|
199
|
+
|
|
200
|
+
def __iter__(self):
|
|
201
|
+
return iter(self.items)
|
|
202
|
+
|
|
203
|
+
def clear(self):
|
|
204
|
+
self.items = []
|
|
205
|
+
|
|
206
|
+
def grouper(
|
|
207
|
+
self, group: CallbackGroup, factory: Type[CallbackSpec] = CallbackSpec
|
|
208
|
+
) -> SpecListGrouper:
|
|
209
|
+
return SpecListGrouper(self, group, factory=factory)
|
|
210
|
+
|
|
211
|
+
def _add(self, func, group: CallbackGroup, factory=None, **kwargs):
|
|
212
|
+
if factory is None:
|
|
213
|
+
factory = self.factory
|
|
214
|
+
spec = factory(func, group, **kwargs)
|
|
215
|
+
|
|
216
|
+
if spec in self.items:
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
self.items.append(spec)
|
|
220
|
+
if spec.is_convention:
|
|
221
|
+
self.conventional_specs.add(spec.func)
|
|
222
|
+
return spec
|
|
223
|
+
|
|
224
|
+
def add(self, callbacks, group: CallbackGroup, **kwargs):
|
|
225
|
+
if callbacks is None:
|
|
226
|
+
return self
|
|
227
|
+
|
|
228
|
+
unprepared = ensure_iterable(callbacks)
|
|
229
|
+
for func in unprepared:
|
|
230
|
+
self._add(func, group=group, **kwargs)
|
|
231
|
+
|
|
232
|
+
return self
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class CallbackWrapper:
|
|
236
|
+
def __init__(
|
|
237
|
+
self,
|
|
238
|
+
callback: Callable,
|
|
239
|
+
condition: Callable,
|
|
240
|
+
meta: "CallbackSpec",
|
|
241
|
+
unique_key: str,
|
|
242
|
+
) -> None:
|
|
243
|
+
self._callback = callback
|
|
244
|
+
self._iscoro = iscoroutinefunction(callback)
|
|
245
|
+
self.condition = condition
|
|
246
|
+
self.meta = meta
|
|
247
|
+
self.unique_key = unique_key
|
|
248
|
+
self.expected_value = self.meta.expected_value
|
|
249
|
+
|
|
250
|
+
def __repr__(self):
|
|
251
|
+
return f"{type(self).__name__}({self.unique_key})"
|
|
252
|
+
|
|
253
|
+
def __str__(self):
|
|
254
|
+
return str(self.meta)
|
|
255
|
+
|
|
256
|
+
def __lt__(self, other):
|
|
257
|
+
return self.meta.priority < other.meta.priority
|
|
258
|
+
|
|
259
|
+
async def __call__(self, *args, **kwargs):
|
|
260
|
+
value = self._callback(*args, **kwargs)
|
|
261
|
+
if isawaitable(value):
|
|
262
|
+
value = await value
|
|
263
|
+
|
|
264
|
+
if self.expected_value is not None:
|
|
265
|
+
return bool(value) == self.expected_value
|
|
266
|
+
return value
|
|
267
|
+
|
|
268
|
+
def call(self, *args, **kwargs):
|
|
269
|
+
value = self._callback(*args, **kwargs)
|
|
270
|
+
if self.expected_value is not None:
|
|
271
|
+
return bool(value) == self.expected_value
|
|
272
|
+
return value
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class CallbacksExecutor:
|
|
276
|
+
"""A list of callbacks that can be executed in order."""
|
|
277
|
+
|
|
278
|
+
def __init__(self):
|
|
279
|
+
self.items: List[CallbackWrapper] = deque()
|
|
280
|
+
self.items_already_seen = set()
|
|
281
|
+
|
|
282
|
+
def __iter__(self):
|
|
283
|
+
return iter(self.items)
|
|
284
|
+
|
|
285
|
+
def __repr__(self):
|
|
286
|
+
return f"{type(self).__name__}({self.items!r})"
|
|
287
|
+
|
|
288
|
+
def __str__(self):
|
|
289
|
+
return ", ".join(str(c) for c in self)
|
|
290
|
+
|
|
291
|
+
def _add(self, spec: CallbackSpec, resolver: Callable):
|
|
292
|
+
for callback in spec.build(resolver):
|
|
293
|
+
if callback.unique_key in self.items_already_seen:
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
self.items_already_seen.add(callback.unique_key)
|
|
297
|
+
insort(self.items, callback)
|
|
298
|
+
|
|
299
|
+
def add(self, items: Iterable[CallbackSpec], resolver: Callable):
|
|
300
|
+
"""Validate configurations"""
|
|
301
|
+
for item in items:
|
|
302
|
+
self._add(item, resolver)
|
|
303
|
+
return self
|
|
304
|
+
|
|
305
|
+
async def async_call(self, *args, **kwargs):
|
|
306
|
+
return await asyncio.gather(
|
|
307
|
+
*(
|
|
308
|
+
callback(*args, **kwargs)
|
|
309
|
+
for callback in self
|
|
310
|
+
if callback.condition(*args, **kwargs)
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
async def async_all(self, *args, **kwargs):
|
|
315
|
+
coros = [condition(*args, **kwargs) for condition in self]
|
|
316
|
+
for coro in asyncio.as_completed(coros):
|
|
317
|
+
if not await coro:
|
|
318
|
+
return False
|
|
319
|
+
return True
|
|
320
|
+
|
|
321
|
+
def call(self, *args, **kwargs):
|
|
322
|
+
return [
|
|
323
|
+
callback.call(*args, **kwargs)
|
|
324
|
+
for callback in self
|
|
325
|
+
if callback.condition(*args, **kwargs)
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
def all(self, *args, **kwargs):
|
|
329
|
+
for condition in self:
|
|
330
|
+
if not condition.call(*args, **kwargs):
|
|
331
|
+
return False
|
|
332
|
+
return True
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class CallbacksRegistry:
|
|
336
|
+
def __init__(self) -> None:
|
|
337
|
+
self._registry: Dict[str, CallbacksExecutor] = defaultdict(CallbacksExecutor)
|
|
338
|
+
self._method_types: Counter = Counter()
|
|
339
|
+
|
|
340
|
+
def clear(self):
|
|
341
|
+
self._registry.clear()
|
|
342
|
+
|
|
343
|
+
def __getitem__(self, key: str) -> CallbacksExecutor:
|
|
344
|
+
return self._registry[key]
|
|
345
|
+
|
|
346
|
+
def check(self, specs: CallbackSpecList):
|
|
347
|
+
for meta in specs:
|
|
348
|
+
if meta.is_convention:
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
if any(
|
|
352
|
+
callback for callback in self[meta.group.build_key(specs)] if callback.meta == meta
|
|
353
|
+
):
|
|
354
|
+
continue
|
|
355
|
+
raise AttrNotFound(
|
|
356
|
+
_("Did not found name '{}' from model or statemachine").format(meta.func)
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
def async_or_sync(self):
|
|
360
|
+
self._method_types.update(
|
|
361
|
+
callback._iscoro for executor in self._registry.values() for callback in executor
|
|
362
|
+
)
|
|
@@ -69,12 +69,15 @@ class DotGraphMachine:
|
|
|
69
69
|
def _actions_getter(self):
|
|
70
70
|
if isinstance(self.machine, StateMachine):
|
|
71
71
|
|
|
72
|
-
def getter(
|
|
73
|
-
return self.machine.
|
|
72
|
+
def getter(grouper):
|
|
73
|
+
return self.machine._get_callbacks(grouper.key)
|
|
74
74
|
else:
|
|
75
75
|
|
|
76
|
-
def getter(
|
|
77
|
-
|
|
76
|
+
def getter(grouper):
|
|
77
|
+
all_names = set(dir(self.machine))
|
|
78
|
+
return ", ".join(
|
|
79
|
+
str(c) for c in grouper if not c.is_convention or c.func in all_names
|
|
80
|
+
)
|
|
78
81
|
|
|
79
82
|
return getter
|
|
80
83
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from operator import attrgetter
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
from typing import Any
|
|
5
|
+
from typing import Callable
|
|
6
|
+
from typing import Generator
|
|
7
|
+
from typing import Iterable
|
|
8
|
+
from typing import Set
|
|
9
|
+
from typing import Tuple
|
|
10
|
+
|
|
11
|
+
from statemachine.callbacks import SpecReference
|
|
12
|
+
|
|
13
|
+
from .signature import SignatureAdapter
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .callbacks import CallbackSpec
|
|
17
|
+
from .callbacks import CallbackSpecList
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Listener:
|
|
22
|
+
"""Object reference that provides attributes to be used as callbacks.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
obj: Any object that will serve as lookup for attributes.
|
|
26
|
+
skip_attrs: Protected attrs that will be ignored on the search.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
obj: object
|
|
30
|
+
all_attrs: Set[str]
|
|
31
|
+
resolver_id: str
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_obj(cls, obj, skip_attrs=None) -> "Listener":
|
|
35
|
+
if isinstance(obj, Listener):
|
|
36
|
+
return obj
|
|
37
|
+
else:
|
|
38
|
+
if skip_attrs is None:
|
|
39
|
+
skip_attrs = set()
|
|
40
|
+
all_attrs = set(dir(obj)) - skip_attrs
|
|
41
|
+
return cls(obj, all_attrs, str(id(obj)))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Listeners:
|
|
46
|
+
"""Listeners that provides attributes to be used as callbacks."""
|
|
47
|
+
|
|
48
|
+
items: Tuple[Listener, ...]
|
|
49
|
+
all_attrs: Set[str]
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_listeners(cls, listeners: Iterable["Listener"]) -> "Listeners":
|
|
53
|
+
listeners = tuple(listeners)
|
|
54
|
+
all_attrs = set().union(*(listener.all_attrs for listener in listeners))
|
|
55
|
+
return cls(listeners, all_attrs)
|
|
56
|
+
|
|
57
|
+
def resolve(self, specs: "CallbackSpecList", registry):
|
|
58
|
+
found_convention_specs = specs.conventional_specs & self.all_attrs
|
|
59
|
+
filtered_specs = [
|
|
60
|
+
spec for spec in specs if not spec.is_convention or spec.func in found_convention_specs
|
|
61
|
+
]
|
|
62
|
+
if not filtered_specs:
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
for spec in filtered_specs:
|
|
66
|
+
registry[spec.group.build_key(specs)]._add(spec, self)
|
|
67
|
+
|
|
68
|
+
def search(self, spec: "CallbackSpec") -> Generator["Callable", None, None]:
|
|
69
|
+
if spec.reference is SpecReference.NAME:
|
|
70
|
+
yield from self._search_name(spec.func)
|
|
71
|
+
return
|
|
72
|
+
elif spec.reference is SpecReference.CALLABLE:
|
|
73
|
+
yield self._search_callable(spec)
|
|
74
|
+
return
|
|
75
|
+
elif spec.reference is SpecReference.PROPERTY:
|
|
76
|
+
result = self._search_property(spec)
|
|
77
|
+
if result is not None:
|
|
78
|
+
yield result
|
|
79
|
+
return
|
|
80
|
+
else: # never reached here from tests but put an exception for safety. pragma: no cover
|
|
81
|
+
raise ValueError(f"Invalid reference {spec.reference}")
|
|
82
|
+
|
|
83
|
+
def _search_property(self, spec) -> "Callable | None":
|
|
84
|
+
# if the attr is a property, we'll try to find the object that has the
|
|
85
|
+
# property on the configs
|
|
86
|
+
attr_name = spec.attr_name
|
|
87
|
+
if attr_name not in self.all_attrs:
|
|
88
|
+
return None
|
|
89
|
+
for config in self.items:
|
|
90
|
+
func = getattr(type(config.obj), attr_name, None)
|
|
91
|
+
if func is not None and func is spec.func:
|
|
92
|
+
return attr_method(attr_name, config.obj, config.resolver_id)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def _search_callable(self, spec) -> "Callable":
|
|
96
|
+
# if the attr is an unbounded method, we'll try to find the bounded method
|
|
97
|
+
# on the self
|
|
98
|
+
if not spec.is_bounded:
|
|
99
|
+
for config in self.items:
|
|
100
|
+
func = getattr(config.obj, spec.attr_name, None)
|
|
101
|
+
if func is not None and func.__func__ is spec.func:
|
|
102
|
+
return callable_method(spec.attr_name, func, config.resolver_id)
|
|
103
|
+
|
|
104
|
+
return callable_method(spec.func, spec.func, None)
|
|
105
|
+
|
|
106
|
+
def _search_name(self, name) -> Generator["Callable", None, None]:
|
|
107
|
+
for config in self.items:
|
|
108
|
+
if name not in config.all_attrs:
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
func = getattr(config.obj, name)
|
|
112
|
+
if not callable(func):
|
|
113
|
+
yield attr_method(name, config.obj, config.resolver_id)
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
if getattr(func, "_is_sm_event", False):
|
|
117
|
+
yield event_method(name, func, config.resolver_id)
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
yield callable_method(name, func, config.resolver_id)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def callable_method(attribute, a_callable, resolver_id) -> Callable:
|
|
124
|
+
method = SignatureAdapter.wrap(a_callable)
|
|
125
|
+
method.unique_key = f"{attribute}@{resolver_id}" # type: ignore[attr-defined]
|
|
126
|
+
method.__name__ = a_callable.__name__
|
|
127
|
+
method.__doc__ = a_callable.__doc__
|
|
128
|
+
return method
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def attr_method(attribute, obj, resolver_id) -> Callable:
|
|
132
|
+
getter = attrgetter(attribute)
|
|
133
|
+
|
|
134
|
+
def method(*args, **kwargs):
|
|
135
|
+
return getter(obj)
|
|
136
|
+
|
|
137
|
+
method.unique_key = f"{attribute}@{resolver_id}" # type: ignore[attr-defined]
|
|
138
|
+
return method
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def event_method(attribute, func, resolver_id) -> Callable:
|
|
142
|
+
def method(*args, **kwargs):
|
|
143
|
+
kwargs.pop("machine", None)
|
|
144
|
+
return func(*args, **kwargs)
|
|
145
|
+
|
|
146
|
+
method.unique_key = f"{attribute}@{resolver_id}" # type: ignore[attr-defined]
|
|
147
|
+
return method
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def resolver_factory_from_objects(*objects: Tuple[Any, ...]):
|
|
151
|
+
return Listeners.from_listeners(Listener.from_obj(o) for o in objects)
|
|
File without changes
|