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.
Files changed (34) hide show
  1. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/PKG-INFO +3 -23
  2. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/README.md +2 -22
  3. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/pyproject.toml +11 -6
  4. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/__init__.py +1 -1
  5. python_statemachine-2.3.2/statemachine/callbacks.py +362 -0
  6. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/contrib/diagram.py +7 -4
  7. python_statemachine-2.3.2/statemachine/dispatcher.py +151 -0
  8. python_statemachine-2.3.2/statemachine/engines/__init__.py +0 -0
  9. python_statemachine-2.3.2/statemachine/engines/async_.py +136 -0
  10. python_statemachine-2.3.2/statemachine/engines/sync.py +139 -0
  11. python_statemachine-2.3.2/statemachine/event.py +53 -0
  12. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/event_data.py +2 -4
  13. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/events.py +2 -2
  14. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/factory.py +10 -11
  15. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/mixins.py +10 -4
  16. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/registry.py +5 -8
  17. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/signature.py +6 -5
  18. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/state.py +13 -17
  19. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/statemachine.py +111 -167
  20. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/states.py +4 -3
  21. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/transition.py +22 -45
  22. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/transition_list.py +6 -8
  23. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/utils.py +1 -1
  24. python_statemachine-2.3.1/statemachine/callbacks.py +0 -290
  25. python_statemachine-2.3.1/statemachine/dispatcher.py +0 -159
  26. python_statemachine-2.3.1/statemachine/event.py +0 -72
  27. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/LICENSE +0 -0
  28. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/contrib/__init__.py +0 -0
  29. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/exceptions.py +0 -0
  30. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/graph.py +0 -0
  31. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/i18n.py +0 -0
  32. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/locale/en/LC_MESSAGES/statemachine.po +0 -0
  33. {python_statemachine-2.3.1 → python_statemachine-2.3.2}/statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po +0 -0
  34. {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.1
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
- ... async def before_cycle(self, event: str, source: State, target: State, message: str = ""):
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
- async def before_cycle(self, event: str, source: State, target: State, message: str = ""):
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
- ... async def before_cycle(self, event: str, source: State, target: State, message: str = ""):
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
- async def before_cycle(self, event: str, source: State, target: State, message: str = ""):
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.1"
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
- sphinx-rtd-theme = "^2.0.0"
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 = [
@@ -3,6 +3,6 @@ from .statemachine import StateMachine
3
3
 
4
4
  __author__ = """Fernando Macedo"""
5
5
  __email__ = "fgmacedo@gmail.com"
6
- __version__ = "2.3.1"
6
+ __version__ = "2.3.2"
7
7
 
8
8
  __all__ = ["StateMachine", "State"]
@@ -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(x):
73
- return self.machine._callbacks(x)
72
+ def getter(grouper):
73
+ return self.machine._get_callbacks(grouper.key)
74
74
  else:
75
75
 
76
- def getter(x):
77
- return x
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)