python-statemachine 2.1.2__tar.gz → 2.2.0__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 (30) hide show
  1. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/PKG-INFO +4 -4
  2. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/pyproject.toml +43 -39
  3. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/__init__.py +1 -1
  4. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/callbacks.py +98 -73
  5. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/contrib/diagram.py +20 -6
  6. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/dispatcher.py +63 -31
  7. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/factory.py +71 -7
  8. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/graph.py +6 -0
  9. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/mixins.py +1 -3
  10. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/signature.py +2 -3
  11. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/state.py +22 -25
  12. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/statemachine.py +59 -84
  13. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/states.py +6 -4
  14. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/transition.py +31 -22
  15. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/utils.py +4 -0
  16. python_statemachine-2.1.2/statemachine/locale/en/LC_MESSAGES/statemachine.mo +0 -0
  17. python_statemachine-2.1.2/statemachine/locale/pt_BR/LC_MESSAGES/statemachine.mo +0 -0
  18. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/LICENSE +0 -0
  19. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/README.md +0 -0
  20. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/contrib/__init__.py +0 -0
  21. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/event.py +0 -0
  22. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/event_data.py +0 -0
  23. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/events.py +0 -0
  24. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/exceptions.py +0 -0
  25. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/i18n.py +0 -0
  26. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/locale/en/LC_MESSAGES/statemachine.po +0 -0
  27. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po +0 -0
  28. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/model.py +0 -0
  29. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/registry.py +0 -0
  30. {python_statemachine-2.1.2 → python_statemachine-2.2.0}/statemachine/transition_list.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-statemachine
3
- Version: 2.1.2
3
+ Version: 2.2.0
4
4
  Summary: Python Finite State Machines made easy.
5
5
  Home-page: https://github.com/fgmacedo/python-statemachine
6
6
  License: MIT
@@ -8,17 +8,17 @@ Author: Fernando Macedo
8
8
  Author-email: fgmacedo@gmail.com
9
9
  Maintainer: Fernando Macedo
10
10
  Maintainer-email: fgmacedo@gmail.com
11
- Requires-Python: >=3.7,<3.13
11
+ Requires-Python: >=3.9,<3.13
12
12
  Classifier: Intended Audience :: Developers
13
13
  Classifier: License :: OSI Approved :: MIT License
14
14
  Classifier: Natural Language :: English
15
15
  Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.7
17
- Classifier: Programming Language :: Python :: 3.8
18
16
  Classifier: Programming Language :: Python :: 3.9
19
17
  Classifier: Programming Language :: Python :: 3.10
20
18
  Classifier: Programming Language :: Python :: 3.11
21
19
  Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.7
21
+ Classifier: Programming Language :: Python :: 3.8
22
22
  Classifier: Topic :: Software Development :: Libraries
23
23
  Provides-Extra: diagrams
24
24
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "python-statemachine"
3
- version = "2.1.2"
3
+ version = "2.2.0"
4
4
  description = "Python Finite State Machines made easy."
5
5
  authors = ["Fernando Macedo <fgmacedo@gmail.com>"]
6
6
  maintainers = [
@@ -34,29 +34,27 @@ classifiers = [
34
34
  diagrams = ["pydot"]
35
35
 
36
36
  [tool.poetry.dependencies]
37
- python = ">=3.7, <3.13"
37
+ python = ">=3.9, <3.13"
38
38
 
39
39
  [tool.poetry.group.dev.dependencies]
40
- pytest = "^7.2.0"
41
- pytest-cov = "^4.0.0"
42
- pytest-sugar = "^0.9.6"
43
- pydot = "^1.4.2"
44
- ruff = "^0.0.257"
45
- pre-commit = "^2.21.0"
46
- mypy = "^0.991"
47
- black = "^22.12.0"
48
- pdbpp = "^0.10.3"
40
+ pytest = "^8.1.1"
41
+ pytest-cov = "^5.0.0"
42
+ pytest-sugar = "^1.0.0"
43
+ pydot = "^2.0.0"
44
+ ruff = "^0.3.7"
45
+ pre-commit = "^3.7.0"
46
+ mypy = "^1.9.0"
49
47
  pytest-mock = "^3.10.0"
50
48
  pytest-profiling = "^1.7.0"
51
49
  pytest-benchmark = "^4.0.0"
52
50
 
53
51
  [tool.poetry.group.docs.dependencies]
54
- Sphinx = "4.5.0"
55
- sphinx-rtd-theme = "1.1.1"
56
- myst-parser = "^0.18.1"
57
- sphinx-gallery = "^0.11.1"
58
- pillow = "^9.4.0"
59
- sphinx-autobuild = "^2021.3.14"
52
+ Sphinx = "7.2.6"
53
+ sphinx-rtd-theme = "2.0.0"
54
+ myst-parser = "^2.0.0"
55
+ sphinx-gallery = "^0.15.0"
56
+ pillow = "^10.3.0"
57
+ sphinx-autobuild = "^2024.4.16"
60
58
 
61
59
  [build-system]
62
60
  requires = ["poetry-core"]
@@ -91,24 +89,8 @@ max-line-length = 99
91
89
  [tool.ruff]
92
90
  src = ["statemachine"]
93
91
 
94
- # Enable Pyflakes and pycodestyle rules.
95
- select = [
96
- "E", # pycodestyle errors
97
- "W", # pycodestyle warnings
98
- "F", # pyflakes
99
- "I", # isort
100
- "UP", # pyupgrade
101
- "C", # flake8-comprehensions
102
- "B", # flake8-bugbear
103
- "PT", # flake8-pytest-style
104
- ]
105
- ignore = [
106
- "UP006", # `use-pep585-annotation` Requires Python3.9+
107
- "UP035", # `use-pep585-annotation` Requires Python3.9+
108
- "UP038", # `use-pep585-annotation` Requires Python3.9+
109
- ]
110
-
111
92
  line-length = 99
93
+ target-version = "py312"
112
94
 
113
95
  # Exclude a variety of commonly ignored directories.
114
96
  exclude = [
@@ -131,19 +113,41 @@ exclude = [
131
113
  "venv",
132
114
  ]
133
115
 
116
+ [tool.ruff.lint]
117
+
118
+ # Enable Pyflakes and pycodestyle rules.
119
+ select = [
120
+ "E", # pycodestyle errors
121
+ "W", # pycodestyle warnings
122
+ "F", # pyflakes
123
+ "I", # isort
124
+ "UP", # pyupgrade
125
+ "C", # flake8-comprehensions
126
+ "B", # flake8-bugbear
127
+ "PT", # flake8-pytest-style
128
+ ]
129
+ ignore = [
130
+ "UP006", # `use-pep585-annotation` Requires Python3.9+
131
+ "UP035", # `use-pep585-annotation` Requires Python3.9+
132
+ "UP038", # `use-pep585-annotation` Requires Python3.9+
133
+ ]
134
+
134
135
  # Allow unused variables when underscore-prefixed.
135
136
  dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
136
137
 
137
- # Assume Python 3.11.
138
- target-version = "py311"
138
+ [tool.ruff.lint.per-file-ignores]
139
+ # Ignore `E402` (import violations) in all `__init__.py` files, and in `path/to/file.py`.
140
+ "__init__.py" = ["E402"]
141
+ "path/to/file.py" = ["E402"]
142
+ "tests/examples/**.py" = ["B018"]
139
143
 
140
- [tool.ruff.mccabe]
144
+ [tool.ruff.lint.mccabe]
141
145
  max-complexity = 6
142
146
 
143
- [tool.ruff.isort]
147
+ [tool.ruff.lint.isort]
144
148
  force-single-line = true
145
149
 
146
- [tool.ruff.pydocstyle]
150
+ [tool.ruff.lint.pydocstyle]
147
151
  # Use Google-style docstrings.
148
152
  convention = "google"
149
153
 
@@ -3,6 +3,6 @@ from .statemachine import StateMachine
3
3
 
4
4
  __author__ = """Fernando Macedo"""
5
5
  __email__ = "fgmacedo@gmail.com"
6
- __version__ = "2.1.2"
6
+ __version__ = "2.2.0"
7
7
 
8
8
  __all__ = ["StateMachine", "State"]
@@ -1,7 +1,10 @@
1
+ from bisect import insort
1
2
  from collections import defaultdict
2
3
  from collections import deque
4
+ from enum import IntEnum
3
5
  from typing import Callable
4
6
  from typing import Dict
7
+ from typing import Generator
5
8
  from typing import List
6
9
 
7
10
  from .exceptions import AttrNotFound
@@ -9,27 +12,42 @@ from .i18n import _
9
12
  from .utils import ensure_iterable
10
13
 
11
14
 
15
+ class CallbackPriority(IntEnum):
16
+ GENERIC = 0
17
+ INLINE = 10
18
+ DECORATOR = 20
19
+ NAMING = 30
20
+ AFTER = 40
21
+
22
+
23
+ def allways_true(*args, **kwargs):
24
+ return True
25
+
26
+
12
27
  class CallbackWrapper:
13
28
  def __init__(
14
29
  self,
15
30
  callback: Callable,
16
31
  condition: Callable,
32
+ meta: "CallbackMeta",
17
33
  unique_key: str,
18
- expected_value: "bool | None" = None,
19
34
  ) -> None:
20
35
  self._callback = callback
21
36
  self.condition = condition
37
+ self.meta = meta
22
38
  self.unique_key = unique_key
23
- self.expected_value = expected_value
24
39
 
25
40
  def __repr__(self):
26
41
  return f"{type(self).__name__}({self.unique_key})"
27
42
 
43
+ def __str__(self):
44
+ return str(self.meta)
45
+
46
+ def __lt__(self, other):
47
+ return self.meta.priority < other.meta.priority
48
+
28
49
  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
50
+ return self._callback(*args, **kwargs)
33
51
 
34
52
 
35
53
  class CallbackMeta:
@@ -42,14 +60,22 @@ class CallbackMeta:
42
60
  call is performed, to allow the proper callback resolution.
43
61
  """
44
62
 
45
- def __init__(self, func, suppress_errors=False, cond=None, expected_value=None):
63
+ def __init__(
64
+ self,
65
+ func,
66
+ suppress_errors=False,
67
+ cond=None,
68
+ priority: CallbackPriority = CallbackPriority.NAMING,
69
+ expected_value=None,
70
+ ):
46
71
  self.func = func
47
72
  self.suppress_errors = suppress_errors
48
- self.cond = CallbackMetaList().add(cond)
73
+ self.cond = cond
49
74
  self.expected_value = expected_value
75
+ self.priority = priority
50
76
 
51
77
  def __repr__(self):
52
- return f"{type(self).__name__}({self.func!r})"
78
+ return f"{type(self).__name__}({self.func!r}, suppress_errors={self.suppress_errors!r})"
53
79
 
54
80
  def __str__(self):
55
81
  return getattr(self.func, "__name__", self.func)
@@ -63,7 +89,10 @@ class CallbackMeta:
63
89
  def _update_func(self, func):
64
90
  self.func = func
65
91
 
66
- def build(self, resolver) -> "CallbackWrapper | None":
92
+ def _wrap_callable(self, func, _expected_value):
93
+ return func
94
+
95
+ def build(self, resolver) -> Generator["CallbackWrapper", None, None]:
67
96
  """
68
97
  Resolves the `func` into a usable callable.
69
98
 
@@ -71,25 +100,14 @@ class CallbackMeta:
71
100
  resolver (callable): A method responsible to build and return a valid callable that
72
101
  can receive arbitrary parameters like `*args, **kwargs`.
73
102
  """
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,
103
+ for callback in resolver(self.func):
104
+ condition = next(resolver(self.cond)) if self.cond is not None else allways_true
105
+ yield CallbackWrapper(
106
+ callback=self._wrap_callable(callback, self.expected_value),
107
+ condition=condition,
108
+ meta=self,
82
109
  unique_key=callback.unique_key,
83
- expected_value=self.expected_value,
84
- )
85
-
86
- if not self.suppress_errors:
87
- raise AttrNotFound(
88
- _("Did not found name '{}' from model or statemachine").format(
89
- self.func
90
- )
91
110
  )
92
- return None
93
111
 
94
112
 
95
113
  class BoolCallbackMeta(CallbackMeta):
@@ -102,18 +120,32 @@ class BoolCallbackMeta(CallbackMeta):
102
120
  call is performed, to allow the proper callback resolution.
103
121
  """
104
122
 
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)
109
- self.expected_value = expected_value
123
+ def __init__(
124
+ self,
125
+ func,
126
+ suppress_errors=False,
127
+ cond=None,
128
+ priority: CallbackPriority = CallbackPriority.NAMING,
129
+ expected_value=True,
130
+ ):
131
+ super().__init__(
132
+ func, suppress_errors, cond, priority=priority, expected_value=expected_value
133
+ )
110
134
 
111
135
  def __str__(self):
112
136
  name = super().__str__()
113
137
  return name if self.expected_value else f"!{name}"
114
138
 
139
+ def _wrap_callable(self, func, expected_value):
140
+ def bool_wrapper(*args, **kwargs):
141
+ return bool(func(*args, **kwargs)) == expected_value
142
+
143
+ return bool_wrapper
144
+
115
145
 
116
146
  class CallbackMetaList:
147
+ """List of `CallbackMeta` instances"""
148
+
117
149
  def __init__(self, factory=CallbackMeta):
118
150
  self.items: List[CallbackMeta] = []
119
151
  self.factory = factory
@@ -157,6 +189,7 @@ class CallbackMetaList:
157
189
  return func
158
190
 
159
191
  def __call__(self, callback):
192
+ """Allows usage of the callback list as a decorator."""
160
193
  return self._add_unbounded_callback(callback)
161
194
 
162
195
  def __iter__(self):
@@ -165,18 +198,13 @@ class CallbackMetaList:
165
198
  def clear(self):
166
199
  self.items = []
167
200
 
168
- def _add(self, func, registry=None, prepend=False, **kwargs):
201
+ def _add(self, func, **kwargs):
169
202
  meta = self.factory(func, **kwargs)
170
- if registry is not None and not registry(self, meta, prepend=prepend):
171
- return
172
203
 
173
204
  if meta in self.items:
174
205
  return
175
206
 
176
- if prepend:
177
- self.items.insert(0, meta)
178
- else:
179
- self.items.append(meta)
207
+ self.items.append(meta)
180
208
  return meta
181
209
 
182
210
  def add(self, callbacks, **kwargs):
@@ -191,6 +219,8 @@ class CallbackMetaList:
191
219
 
192
220
 
193
221
  class CallbacksExecutor:
222
+ """A list of callbacks that can be executed in order."""
223
+
194
224
  def __init__(self):
195
225
  self.items: List[CallbackWrapper] = deque()
196
226
  self.items_already_seen = set()
@@ -201,34 +231,26 @@ class CallbacksExecutor:
201
231
  def __repr__(self):
202
232
  return f"{type(self).__name__}({self.items!r})"
203
233
 
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
234
+ def __str__(self):
235
+ return ", ".join(str(c) for c in self)
210
236
 
211
- if callback.unique_key in self.items_already_seen:
212
- return None
237
+ def _add(self, callback_meta: CallbackMeta, resolver: Callable):
238
+ for callback in callback_meta.build(resolver):
239
+ if callback.unique_key in self.items_already_seen:
240
+ continue
213
241
 
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
242
+ self.items_already_seen.add(callback.unique_key)
243
+ insort(self.items, callback)
220
244
 
221
245
  def add(self, items: CallbackMetaList, resolver: Callable):
222
246
  """Validate configurations"""
223
247
  for item in items:
224
- self.add_one(item, resolver)
248
+ self._add(item, resolver)
225
249
  return self
226
250
 
227
251
  def call(self, *args, **kwargs):
228
252
  return [
229
- callback(*args, **kwargs)
230
- for callback in self
231
- if callback.condition(*args, **kwargs)
253
+ callback(*args, **kwargs) for callback in self if callback.condition(*args, **kwargs)
232
254
  ]
233
255
 
234
256
  def all(self, *args, **kwargs):
@@ -237,24 +259,27 @@ class CallbacksExecutor:
237
259
 
238
260
  class CallbacksRegistry:
239
261
  def __init__(self) -> None:
240
- self._registry: Dict[CallbackMetaList, CallbacksExecutor] = defaultdict(
241
- CallbacksExecutor
242
- )
262
+ self._registry: Dict[CallbackMetaList, CallbacksExecutor] = defaultdict(CallbacksExecutor)
243
263
 
244
- def register(self, callbacks: CallbackMetaList, resolver):
245
- executor_list = self[callbacks]
246
- executor_list.add(callbacks, resolver)
264
+ def register(self, meta_list: CallbackMetaList, resolver):
265
+ executor_list = self[meta_list]
266
+ executor_list.add(meta_list, resolver)
247
267
  return executor_list
248
268
 
249
- def __getitem__(self, callbacks: CallbackMetaList) -> CallbacksExecutor:
250
- return self._registry[callbacks]
269
+ def clear(self):
270
+ self._registry.clear()
271
+
272
+ def __getitem__(self, meta_list: CallbackMetaList) -> CallbacksExecutor:
273
+ return self._registry[meta_list]
251
274
 
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)
275
+ def check(self, meta_list: CallbackMetaList):
276
+ executor = self[meta_list]
277
+ for meta in meta_list:
278
+ if meta.suppress_errors:
279
+ continue
259
280
 
260
- return register
281
+ if any(callback for callback in executor if callback.meta == meta):
282
+ continue
283
+ raise AttrNotFound(
284
+ _("Did not found name '{}' from model or statemachine").format(meta.func)
285
+ )
@@ -66,21 +66,35 @@ class DotGraphMachine:
66
66
  fontsize=self.transition_font_size,
67
67
  )
68
68
 
69
+ def _actions_getter(self):
70
+ if isinstance(self.machine, StateMachine):
71
+
72
+ def getter(x):
73
+ return self.machine._callbacks(x)
74
+ else:
75
+
76
+ def getter(x):
77
+ return x
78
+
79
+ return getter
80
+
69
81
  def _state_actions(self, state):
70
- entry = ", ".join([str(action) for action in state.enter])
71
- exit = ", ".join([str(action) for action in state.exit])
82
+ getter = self._actions_getter()
83
+
84
+ entry = str(getter(state.enter))
85
+ exit_ = str(getter(state.exit))
72
86
  internal = ", ".join(
73
- f"{transition.event} / {transition.on!s}"
87
+ f"{transition.event} / {str(getter(transition.on))}"
74
88
  for transition in state.transitions
75
89
  if transition.internal
76
90
  )
77
91
 
78
92
  if entry:
79
93
  entry = f"entry / {entry}"
80
- if exit:
81
- exit = f"exit / {exit}"
94
+ if exit_:
95
+ exit_ = f"exit / {exit_}"
82
96
 
83
- actions = "\n".join(x for x in [entry, exit, internal] if x)
97
+ actions = "\n".join(x for x in [entry, exit_, internal] if x)
84
98
 
85
99
  if actions:
86
100
  actions = f"\n{actions}"
@@ -1,6 +1,7 @@
1
1
  from collections import namedtuple
2
2
  from operator import attrgetter
3
3
  from typing import Any
4
+ from typing import Generator
4
5
 
5
6
  from .signature import SignatureAdapter
6
7
 
@@ -18,17 +19,10 @@ class ObjectConfig(namedtuple("ObjectConfig", "obj skip_attrs resolver_id")):
18
19
  if isinstance(obj, ObjectConfig):
19
20
  return obj
20
21
  else:
21
- return cls(obj, set(skip_attrs) if skip_attrs else set(), str(id(obj)))
22
-
23
- def getattr(self, attr):
24
- if attr in self.skip_attrs:
25
- return
26
- return getattr(self.obj, attr, None)
22
+ return cls(obj, skip_attrs or set(), str(id(obj)))
27
23
 
28
24
 
29
25
  class WrapSearchResult:
30
- is_empty = False
31
-
32
26
  def __init__(self, attribute, resolver_id) -> None:
33
27
  self.attribute = attribute
34
28
  self.resolver_id = resolver_id
@@ -48,10 +42,6 @@ class WrapSearchResult:
48
42
  return self._cache(*args, **kwds)
49
43
 
50
44
 
51
- class EmptyWrapSearchResult(WrapSearchResult):
52
- is_empty = True
53
-
54
-
55
45
  class CallableSearchResult(WrapSearchResult):
56
46
  def __init__(self, attribute, a_callable, resolver_id) -> None:
57
47
  self.a_callable = a_callable
@@ -93,32 +83,74 @@ class EventSearchResult(WrapSearchResult):
93
83
  return wrapper
94
84
 
95
85
 
96
- def search_callable(attr, *configs) -> WrapSearchResult:
97
- if callable(attr) or isinstance(attr, property):
98
- return CallableSearchResult(attr, attr, None)
86
+ def _search_callable_attr_is_property(
87
+ attr, configs: tuple[ObjectConfig]
88
+ ) -> "WrapSearchResult | None":
89
+ # if the attr is a property, we'll try to find the object that has the
90
+ # property on the configs
91
+ attr_name = attr.fget.__name__
92
+ for obj, _skip_attrs, resolver_id in configs:
93
+ func = getattr(type(obj), attr_name, None)
94
+ if func is not None and func is attr:
95
+ return AttributeCallableSearchResult(attr_name, obj, resolver_id)
96
+ return None
99
97
 
100
- for config in configs:
101
- func = config.getattr(attr)
102
- if func is not None:
103
- if not callable(func):
104
- return AttributeCallableSearchResult(
105
- attr, config.obj, config.resolver_id
106
- )
107
98
 
108
- if getattr(func, "_is_sm_event", False):
109
- return EventSearchResult(attr, func, config.resolver_id)
99
+ def _search_callable_attr_is_callable(attr, configs: tuple[ObjectConfig]) -> WrapSearchResult:
100
+ # if the attr is an unbounded method, we'll try to find the bounded method
101
+ # on the configs
102
+ if not hasattr(attr, "__self__"):
103
+ for obj, _skip_attrs, resolver_id in configs:
104
+ func = getattr(obj, attr.__name__, None)
105
+ if func is not None and func.__func__ is attr:
106
+ return CallableSearchResult(attr.__name__, func, resolver_id)
110
107
 
111
- return CallableSearchResult(attr, func, config.resolver_id)
108
+ return CallableSearchResult(attr, attr, None)
112
109
 
113
- return EmptyWrapSearchResult(attr, None)
114
110
 
111
+ def _search_callable_in_configs(
112
+ attr, configs: tuple[ObjectConfig]
113
+ ) -> Generator[WrapSearchResult, None, None]:
114
+ for obj, skip_attrs, resolver_id in configs:
115
+ if attr in skip_attrs:
116
+ continue
115
117
 
116
- def resolver_factory(*objects):
117
- """Factory that returns a configured resolver."""
118
+ if not hasattr(obj, attr):
119
+ continue
120
+
121
+ func = getattr(obj, attr)
122
+ if not callable(func):
123
+ yield AttributeCallableSearchResult(attr, obj, resolver_id)
124
+
125
+ if getattr(func, "_is_sm_event", False):
126
+ yield EventSearchResult(attr, func, resolver_id)
127
+
128
+ yield CallableSearchResult(attr, func, resolver_id)
118
129
 
119
- objects = [ObjectConfig.from_obj(obj) for obj in objects]
120
130
 
121
- def wrapper(attr):
122
- return search_callable(attr, *objects)
131
+ def search_callable(attr, configs: tuple) -> Generator[WrapSearchResult, None, None]: # noqa: C901
132
+ if isinstance(attr, property):
133
+ result = _search_callable_attr_is_property(attr, configs)
134
+ if result is not None:
135
+ yield result
136
+ return
137
+
138
+ if callable(attr):
139
+ yield _search_callable_attr_is_callable(attr, configs)
140
+ return
141
+
142
+ yield from _search_callable_in_configs(attr, configs)
143
+
144
+
145
+ def resolver_factory(objects: tuple[ObjectConfig]):
146
+ """Factory that returns a configured resolver."""
147
+
148
+ def wrapper(attr) -> Generator[WrapSearchResult, None, None]:
149
+ yield from search_callable(attr, objects)
123
150
 
124
151
  return wrapper
152
+
153
+
154
+ def resolver_factory_from_objects(*objects: tuple[Any]):
155
+ configs = tuple(ObjectConfig.from_obj(o) for o in objects)
156
+ return resolver_factory(configs)