python-statemachine 2.3.6__py3-none-any.whl → 2.4.0__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: python-statemachine
3
- Version: 2.3.6
3
+ Version: 2.4.0
4
4
  Summary: Python Finite State Machines made easy.
5
5
  Home-page: https://github.com/fgmacedo/python-statemachine
6
6
  License: MIT
@@ -23,7 +23,7 @@ Classifier: Programming Language :: Python :: 3.12
23
23
  Classifier: Programming Language :: Python :: 3.13
24
24
  Classifier: Topic :: Software Development :: Libraries
25
25
  Provides-Extra: diagrams
26
- Requires-Dist: pydot (>=2.0.0) ; extra == "diagrams"
26
+ Requires-Dist: pydot (>=2.0.0) ; (python_full_version > "3.8.0") and (extra == "diagrams")
27
27
  Description-Content-Type: text/markdown
28
28
 
29
29
  # Python StateMachine
@@ -405,7 +405,7 @@ There's a lot more to cover, please take a look at our docs:
405
405
  https://python-statemachine.readthedocs.io.
406
406
 
407
407
 
408
- ## Contributing to the project
408
+ ## Contributing
409
409
 
410
410
  * <a class="github-button" href="https://github.com/fgmacedo/python-statemachine" data-icon="octicon-star" aria-label="Star fgmacedo/python-statemachine on GitHub">Star this project</a>
411
411
  * <a class="github-button" href="https://github.com/fgmacedo/python-statemachine/issues" data-icon="octicon-issue-opened" aria-label="Issue fgmacedo/python-statemachine on GitHub">Open an Issue</a>
@@ -413,18 +413,18 @@ https://python-statemachine.readthedocs.io.
413
413
 
414
414
  - If you found this project helpful, please consider giving it a star on GitHub.
415
415
 
416
- - **Contribute code**: If you would like to contribute code to this project, please submit a pull
416
+ - **Contribute code**: If you would like to contribute code, please submit a pull
417
417
  request. For more information on how to contribute, please see our [contributing.md](contributing.md) file.
418
418
 
419
- - **Report bugs**: If you find any bugs in this project, please report them by opening an issue
419
+ - **Report bugs**: If you find any bugs, please report them by opening an issue
420
420
  on our GitHub issue tracker.
421
421
 
422
- - **Suggest features**: If you have a great idea for a new feature, please let us know by opening
423
- an issue on our GitHub issue tracker.
422
+ - **Suggest features**: If you have an idea for a new feature, of feels something being harder than it should be,
423
+ please let us know by opening an issue on our GitHub issue tracker.
424
424
 
425
- - **Documentation**: Help improve this project's documentation by submitting pull requests.
425
+ - **Documentation**: Help improve documentation by submitting pull requests.
426
426
 
427
- - **Promote the project**: Help spread the word about this project by sharing it on social media,
427
+ - **Promote the project**: Help spread the word by sharing on social media,
428
428
  writing a blog post, or giving a talk about it. Tag me on Twitter
429
429
  [@fgmacedo](https://twitter.com/fgmacedo) so I can share it too!
430
430
 
@@ -0,0 +1,35 @@
1
+ statemachine/__init__.py,sha256=NLqMYmezhWHlgs3OJHd_zmKBsVEQAA3jhLIHAf4bFGM,226
2
+ statemachine/callbacks.py,sha256=5Onm2HxhSfFiilL_1PCUxIsLdcmEOQD80CArurIx9rs,12974
3
+ statemachine/contrib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ statemachine/contrib/diagram.py,sha256=1z0d_Ln0k4-jLaEYry8si5pK25lsViyPWMT7KkDeDSc,7131
5
+ statemachine/dispatcher.py,sha256=4ewPGs-nOVSPMrDMhcuwkkc0c_AqLyw2Pv495bpfjXU,5407
6
+ statemachine/engines/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ statemachine/engines/async_.py,sha256=deZ5oYeoIYX9zOsFEOCT4KXsz5V1F9fbaMDOBATnTkE,5572
8
+ statemachine/engines/sync.py,sha256=cGy23Ivx0Ul4NWSix8E8PNA1Ji4V9qvOd3K_yodD_UI,5523
9
+ statemachine/event.py,sha256=ZyU6YmoLlKndBSohcmMp6v0tGSY2RG711JtJZJ6JXGY,3861
10
+ statemachine/event_data.py,sha256=H9lp_XnvHSK9YErUOCvMK3ZBjWhC-xDSOJ2gZxtmrq8,2261
11
+ statemachine/events.py,sha256=QVx1SsoPMwDI6J5mcuk8SoqMDIrPvZgQLOx4m_MNghI,1031
12
+ statemachine/exceptions.py,sha256=vHVQPTTMMkVvySNbN6XZPciBryvpY608LDe3MCnmxFU,1124
13
+ statemachine/factory.py,sha256=hVCLJIITv5ccXVPpg5H5B7_tfGZMqk7zSALTCAiXiWk,9318
14
+ statemachine/graph.py,sha256=KtwB1CYckaLjTgQD9tEeuaEzJje9q3fPVpBViW5TgSk,487
15
+ statemachine/i18n.py,sha256=NLvGseaORmQ0G-V_J8tkjoxh_piWMOm2CI6mBQpLamc,362
16
+ statemachine/locale/en/LC_MESSAGES/statemachine.po,sha256=4Pk-h5nk7twOTHcRQ_Chanfdi5EtFi9aTAzGkVJuCo8,2424
17
+ statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po,sha256=Bs5bbIxDrYtODSNJKNl4FH8ZtjEJwPIidRwbAxMwu5E,4941
18
+ statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po,sha256=gmnhc15-6YVDCLWYT0ZQL5jfXHgIOYq5p5rJLqNPaxE,3593
19
+ statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po,sha256=cDbRHDYpi3pwJkFmaSn79q5KmX9cGmHFHw1ndj37vRw,3325
20
+ statemachine/mixins.py,sha256=Y1fa52Cj20JaGkyNk3P7Gpqkt4cGTjJ0YyV_VQyCl0M,1231
21
+ statemachine/model.py,sha256=OylI3FjMiHpYyDl9mtK1zEJMeSvemaN4giQDonpc8kI,211
22
+ statemachine/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
+ statemachine/registry.py,sha256=HmV9sUGkYVrNUZxJYoZo-trSUis7dIun_WcGktblgc8,922
24
+ statemachine/signature.py,sha256=HwuyWX3p_GZwKKrGbt9wk4-bHpymqs_pgqW7OlDgGRc,7877
25
+ statemachine/spec_parser.py,sha256=Mqi9VMnlwJqEAcDnO-RMNv8kq-VKkHEqMgS_aeldcOI,3020
26
+ statemachine/state.py,sha256=DlFAuQb8HN6pKOyaRgLvYRHJZCFYo2LFLbikGzbE9Jw,8526
27
+ statemachine/statemachine.py,sha256=02qe_x2IAcLameakllXPviQ2pkAs9x9QA_ws-po-hWg,11372
28
+ statemachine/states.py,sha256=pPROZwIyE3_tlBGL3DJXnM3gr1pWsWICEMMo2c742RY,4889
29
+ statemachine/transition.py,sha256=4_dq2rR4x2at4d3B-3zuaP5mTa68P89DpFO2moPTTPM,4657
30
+ statemachine/transition_list.py,sha256=4xxMaD265GuSCXA8gzeEF06lh-eRO0O_X4YgsRZYWVA,6120
31
+ statemachine/utils.py,sha256=DpcrGqlbrnT-ogh-BogG0L07EG3KirHOsKORHlspDlI,1041
32
+ python_statemachine-2.4.0.dist-info/LICENSE,sha256=zcP7TsJMqaFxuTvLRZPT7nJl3_ppjxR9Z76BE9pL5zc,1074
33
+ python_statemachine-2.4.0.dist-info/METADATA,sha256=qZJym13d9zQll_snjRZ53kl982Mcf0NTVxfq7P0OOT8,14036
34
+ python_statemachine-2.4.0.dist-info/WHEEL,sha256=Zb28QaM1gQi8f4VCBhsUklF61CTlNYfs9YAZn-TOGFk,88
35
+ python_statemachine-2.4.0.dist-info/RECORD,,
statemachine/__init__.py CHANGED
@@ -1,8 +1,9 @@
1
+ from .event import Event
1
2
  from .state import State
2
3
  from .statemachine import StateMachine
3
4
 
4
5
  __author__ = """Fernando Macedo"""
5
6
  __email__ = "fgmacedo@gmail.com"
6
- __version__ = "2.3.6"
7
+ __version__ = "2.4.0"
7
8
 
8
- __all__ = ["StateMachine", "State"]
9
+ __all__ = ["StateMachine", "State", "Event"]
statemachine/callbacks.py CHANGED
@@ -5,19 +5,30 @@ from collections import deque
5
5
  from enum import IntEnum
6
6
  from enum import IntFlag
7
7
  from enum import auto
8
+ from functools import partial
9
+ from functools import reduce
8
10
  from inspect import isawaitable
9
11
  from inspect import iscoroutinefunction
12
+ from typing import TYPE_CHECKING
10
13
  from typing import Callable
11
14
  from typing import Dict
12
15
  from typing import Generator
13
16
  from typing import Iterable
14
17
  from typing import List
18
+ from typing import Set
15
19
  from typing import Type
16
20
 
17
21
  from .exceptions import AttrNotFound
22
+ from .exceptions import InvalidDefinition
18
23
  from .i18n import _
24
+ from .spec_parser import custom_and
25
+ from .spec_parser import operator_mapping
26
+ from .spec_parser import parse_boolean_expr
19
27
  from .utils import ensure_iterable
20
28
 
29
+ if TYPE_CHECKING:
30
+ from statemachine.dispatcher import Listeners
31
+
21
32
 
22
33
  class CallbackPriority(IntEnum):
23
34
  GENERIC = 0
@@ -54,6 +65,17 @@ def allways_true(*args, **kwargs):
54
65
  return True
55
66
 
56
67
 
68
+ def take_callback(name: str, resolver: "Listeners", not_found_handler: Callable) -> Callable:
69
+ callbacks = list(resolver.search_name(name))
70
+ if len(callbacks) == 0:
71
+ not_found_handler(name)
72
+ return allways_true
73
+ elif len(callbacks) == 1:
74
+ return callbacks[0]
75
+ else:
76
+ return reduce(custom_and, callbacks)
77
+
78
+
57
79
  class CallbackSpec:
58
80
  """Specs about callbacks.
59
81
 
@@ -110,7 +132,16 @@ class CallbackSpec:
110
132
  self.reference = SpecReference.CALLABLE
111
133
  self.attr_name = attr_name
112
134
 
113
- def build(self, resolver) -> Generator["CallbackWrapper", None, None]:
135
+ def _wrap(self, callback):
136
+ condition = self.cond if self.cond is not None else allways_true
137
+ return CallbackWrapper(
138
+ callback=callback,
139
+ condition=condition,
140
+ meta=self,
141
+ unique_key=callback.unique_key,
142
+ )
143
+
144
+ def build(self, resolver: "Listeners") -> Generator["CallbackWrapper", None, None]:
114
145
  """
115
146
  Resolves the `func` into a usable callable.
116
147
 
@@ -118,14 +149,29 @@ class CallbackSpec:
118
149
  resolver (callable): A method responsible to build and return a valid callable that
119
150
  can receive arbitrary parameters like `*args, **kwargs`.
120
151
  """
121
- for callback in resolver.search(self):
122
- condition = self.cond if self.cond is not None else allways_true
123
- yield CallbackWrapper(
124
- callback=callback,
125
- condition=condition,
126
- meta=self,
127
- unique_key=callback.unique_key,
152
+ if (
153
+ not self.is_convention
154
+ and self.group == CallbackGroup.COND
155
+ and self.reference == SpecReference.NAME
156
+ ):
157
+ names_not_found: Set[str] = set()
158
+ take_callback_partial = partial(
159
+ take_callback, resolver=resolver, not_found_handler=names_not_found.add
128
160
  )
161
+ try:
162
+ expression = parse_boolean_expr(self.func, take_callback_partial, operator_mapping)
163
+ except SyntaxError as err:
164
+ raise InvalidDefinition(
165
+ _("Failed to parse boolean expression '{}'").format(self.func)
166
+ ) from err
167
+ if not expression or names_not_found:
168
+ self.names_not_found = names_not_found
169
+ return
170
+ yield self._wrap(expression)
171
+ return
172
+
173
+ for callback in resolver.search(self):
174
+ yield self._wrap(callback)
129
175
 
130
176
 
131
177
  class SpecListGrouper:
@@ -292,7 +338,7 @@ class CallbacksExecutor:
292
338
  def __str__(self):
293
339
  return ", ".join(str(c) for c in self)
294
340
 
295
- def _add(self, spec: CallbackSpec, resolver: Callable):
341
+ def _add(self, spec: CallbackSpec, resolver: "Listeners"):
296
342
  for callback in spec.build(resolver):
297
343
  if callback.unique_key in self.items_already_seen:
298
344
  continue
@@ -300,7 +346,7 @@ class CallbacksExecutor:
300
346
  self.items_already_seen.add(callback.unique_key)
301
347
  insort(self.items, callback)
302
348
 
303
- def add(self, items: Iterable[CallbackSpec], resolver: Callable):
349
+ def add(self, items: Iterable[CallbackSpec], resolver: "Listeners"):
304
350
  """Validate configurations"""
305
351
  for item in items:
306
352
  self._add(item, resolver)
@@ -356,6 +402,12 @@ class CallbacksRegistry:
356
402
  callback for callback in self[meta.group.build_key(specs)] if callback.meta == meta
357
403
  ):
358
404
  continue
405
+ if hasattr(meta, "names_not_found"):
406
+ raise AttrNotFound(
407
+ _("Did not found name '{}' from model or statemachine").format(
408
+ ", ".join(meta.names_not_found)
409
+ ),
410
+ )
359
411
  raise AttrNotFound(
360
412
  _("Did not found name '{}' from model or statemachine").format(meta.func)
361
413
  )
@@ -166,10 +166,6 @@ def quickchart_write_svg(sm: StateMachine, path: str):
166
166
  >>> sm = OrderControl()
167
167
  >>> print(sm._graph().to_string())
168
168
  digraph list {
169
- fontname=Arial;
170
- fontsize=10;
171
- label=OrderControl;
172
- rankdir=LR;
173
169
  ...
174
170
 
175
171
  To give you an example, we included this method that will serialize the dot, request the graph
@@ -10,6 +10,7 @@ from typing import Tuple
10
10
 
11
11
  from .callbacks import SPECS_ALL
12
12
  from .callbacks import SpecReference
13
+ from .event import Event
13
14
  from .signature import SignatureAdapter
14
15
 
15
16
  if TYPE_CHECKING:
@@ -75,7 +76,7 @@ class Listeners:
75
76
 
76
77
  def search(self, spec: "CallbackSpec") -> Generator["Callable", None, None]:
77
78
  if spec.reference is SpecReference.NAME:
78
- yield from self._search_name(spec.func)
79
+ yield from self.search_name(spec.func)
79
80
  return
80
81
  elif spec.reference is SpecReference.CALLABLE:
81
82
  yield self._search_callable(spec)
@@ -111,7 +112,7 @@ class Listeners:
111
112
 
112
113
  return callable_method(spec.attr_name, spec.func, None)
113
114
 
114
- def _search_name(self, name) -> Generator["Callable", None, None]:
115
+ def search_name(self, name) -> Generator["Callable", None, None]:
115
116
  for config in self.items:
116
117
  if name not in config.all_attrs:
117
118
  continue
@@ -121,7 +122,7 @@ class Listeners:
121
122
  yield attr_method(name, config.obj, config.resolver_id)
122
123
  continue
123
124
 
124
- if getattr(func, "_is_sm_event", False):
125
+ if isinstance(func, Event):
125
126
  yield event_method(name, func, config.resolver_id)
126
127
  continue
127
128
 
@@ -143,6 +144,7 @@ def attr_method(attribute, obj, resolver_id) -> Callable:
143
144
  return getter(obj)
144
145
 
145
146
  method.unique_key = f"{attribute}@{resolver_id}" # type: ignore[attr-defined]
147
+ method.__name__ = attribute
146
148
  return method
147
149
 
148
150
 
statemachine/event.py CHANGED
@@ -1,5 +1,7 @@
1
1
  from inspect import isawaitable
2
2
  from typing import TYPE_CHECKING
3
+ from typing import List
4
+ from uuid import uuid4
3
5
 
4
6
  from statemachine.utils import run_async_from_sync
5
7
 
@@ -7,47 +9,123 @@ from .event_data import TriggerData
7
9
 
8
10
  if TYPE_CHECKING:
9
11
  from .statemachine import StateMachine
12
+ from .transition_list import TransitionList
10
13
 
11
14
 
12
- class Event:
13
- def __init__(self, name: str):
14
- self.name: str = name
15
+ _event_data_kwargs = {
16
+ "event_data",
17
+ "machine",
18
+ "event",
19
+ "model",
20
+ "transition",
21
+ "state",
22
+ "source",
23
+ "target",
24
+ }
25
+
26
+
27
+ class Event(str):
28
+ """An event is triggers a signal that something has happened.
29
+
30
+ They are send to a state machine and allow the state machine to react.
31
+
32
+ An event starts a :ref:`Transition`, which can be thought of as a “cause” that initiates a
33
+ change in the state of the system.
34
+
35
+ See also :ref:`events`.
36
+ """
37
+
38
+ id: str
39
+ """The event identifier."""
40
+
41
+ name: str
42
+ """The event name."""
43
+
44
+ _sm: "StateMachine | None" = None
45
+ """The state machine instance."""
46
+
47
+ _transitions: "TransitionList | None" = None
48
+ _has_real_id = False
49
+
50
+ def __new__(
51
+ cls,
52
+ transitions: "str | TransitionList | None" = None,
53
+ id: "str | None" = None,
54
+ name: "str | None" = None,
55
+ _sm: "StateMachine | None" = None,
56
+ ):
57
+ if isinstance(transitions, str):
58
+ id = transitions
59
+ transitions = None
60
+
61
+ _has_real_id = id is not None
62
+ id = str(id) if _has_real_id else f"__event__{uuid4().hex}"
63
+
64
+ instance = super().__new__(cls, id)
65
+ instance.id = id
66
+ if name:
67
+ instance.name = name
68
+ elif _has_real_id:
69
+ instance.name = str(id).replace("_", " ").capitalize()
70
+ else:
71
+ instance.name = ""
72
+ if transitions:
73
+ instance._transitions = transitions
74
+ instance._has_real_id = _has_real_id
75
+ instance._sm = _sm
76
+ return instance
15
77
 
16
78
  def __repr__(self):
17
- return f"{type(self).__name__}({self.name!r})"
79
+ return f"{type(self).__name__}({self.id!r})"
18
80
 
19
- def trigger(self, machine: "StateMachine", *args, **kwargs):
81
+ def is_same_event(self, *_args, event: "str | None" = None, **_kwargs) -> bool:
82
+ return self == event
83
+
84
+ def __get__(self, instance, owner):
85
+ """By implementing this method `Event` can be used as a property descriptor
86
+
87
+ When attached to a SM class, if the user tries to get the `Event` instance,
88
+ we intercept here and return a `BoundEvent` instance, so the user can call
89
+ it as a method with the correct SM instance.
90
+
91
+ """
92
+ if instance is None:
93
+ return self
94
+ return BoundEvent(id=self.id, name=self.name, _sm=instance)
95
+
96
+ def __call__(self, *args, **kwargs):
97
+ """Send this event to the current state machine.
98
+
99
+ Triggering an event on a state machine means invoking or sending a signal, initiating the
100
+ process that may result in executing a transition.
101
+ """
102
+ # The `__call__` is declared here to help IDEs knowing that an `Event`
103
+ # can be called as a method. But it is not meant to be called without
104
+ # an SM instance. Such SM instance is provided by `__get__` method when
105
+ # used as a property descriptor.
106
+
107
+ machine = self._sm
108
+ kwargs = {k: v for k, v in kwargs.items() if k not in _event_data_kwargs}
20
109
  trigger_data = TriggerData(
21
110
  machine=machine,
22
- event=self.name,
111
+ event=self,
23
112
  args=args,
24
113
  kwargs=kwargs,
25
114
  )
26
115
  machine._put_nonblocking(trigger_data)
27
- return machine._processing_loop()
28
-
29
-
30
- def trigger_event_factory(event_instance: Event):
31
- """Build a method that sends specific `event` to the machine"""
32
-
33
- def trigger_event(self, *args, **kwargs):
34
- result = event_instance.trigger(self, *args, **kwargs)
116
+ result = machine._processing_loop()
35
117
  if not isawaitable(result):
36
118
  return result
37
119
  return run_async_from_sync(result)
38
120
 
39
- trigger_event.name = event_instance.name # type: ignore[attr-defined]
40
- trigger_event.identifier = event_instance.name # type: ignore[attr-defined]
41
- trigger_event._is_sm_event = True # type: ignore[attr-defined]
42
- return trigger_event
43
-
44
-
45
- def same_event_cond_builder(expected_event: str):
46
- """
47
- Builds a condition method that evaluates to ``True`` when the expected event is received.
48
- """
121
+ def split( # type: ignore[override]
122
+ self, sep: "str | None" = None, maxsplit: int = -1
123
+ ) -> List["Event"]:
124
+ result = super().split(sep, maxsplit)
125
+ if len(result) == 1:
126
+ return [self]
127
+ return [Event(event) for event in result]
49
128
 
50
- def cond(*args, event: "str | None" = None, **kwargs) -> bool:
51
- return event == expected_event
52
129
 
53
- return cond
130
+ class BoundEvent(Event):
131
+ pass
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
4
4
  from typing import Any
5
5
 
6
6
  if TYPE_CHECKING:
7
+ from .event import Event
7
8
  from .state import State
8
9
  from .statemachine import StateMachine
9
10
  from .transition import Transition
@@ -13,7 +14,7 @@ if TYPE_CHECKING:
13
14
  class TriggerData:
14
15
  machine: "StateMachine"
15
16
 
16
- event: str
17
+ event: "Event"
17
18
  """The Event that was triggered."""
18
19
 
19
20
  model: Any = field(init=False)
statemachine/events.py CHANGED
@@ -1,3 +1,5 @@
1
+ from statemachine.event import Event
2
+
1
3
  from .utils import ensure_iterable
2
4
 
3
5
 
@@ -5,14 +7,14 @@ class Events:
5
7
  """A collection of event names."""
6
8
 
7
9
  def __init__(self):
8
- self.items = []
10
+ self._items: list[Event] = []
9
11
 
10
12
  def __repr__(self):
11
- sep = " " if len(self.items) > 1 else ""
12
- return sep.join(item for item in self.items)
13
+ sep = " " if len(self._items) > 1 else ""
14
+ return sep.join(item for item in self._items)
13
15
 
14
16
  def __iter__(self):
15
- return iter(self.items)
17
+ return iter(self._items)
16
18
 
17
19
  def add(self, events):
18
20
  if events is None:
@@ -21,11 +23,18 @@ class Events:
21
23
  unprepared = ensure_iterable(events)
22
24
  for events in unprepared:
23
25
  for event in events.split(" "):
24
- if event in self.items:
26
+ if event in self._items:
25
27
  continue
26
- self.items.append(event)
28
+ if isinstance(event, Event):
29
+ self._items.append(event)
30
+ else:
31
+ self._items.append(Event(id=event, name=event))
27
32
 
28
33
  return self
29
34
 
30
35
  def match(self, event: str):
31
36
  return any(e == event for e in self)
37
+
38
+ def _replace(self, old, new):
39
+ self._items.remove(old)
40
+ self._items.append(new)
@@ -3,6 +3,7 @@ from typing import TYPE_CHECKING
3
3
  from .i18n import _
4
4
 
5
5
  if TYPE_CHECKING:
6
+ from .event import Event
6
7
  from .state import State
7
8
 
8
9
 
@@ -31,8 +32,8 @@ class AttrNotFound(InvalidDefinition):
31
32
  class TransitionNotAllowed(StateMachineError):
32
33
  "Raised when there's no transition that can run from the current :ref:`state`."
33
34
 
34
- def __init__(self, event: str, state: "State"):
35
+ def __init__(self, event: "Event", state: "State"):
35
36
  self.event = event
36
37
  self.state = state
37
- msg = _("Can't {} when in {}.").format(self.event, self.state.name)
38
+ msg = _("Can't {} when in {}.").format(self.event.name, self.state.name)
38
39
  super().__init__(msg)
statemachine/factory.py CHANGED
@@ -8,7 +8,6 @@ from uuid import uuid4
8
8
 
9
9
  from . import registry
10
10
  from .event import Event
11
- from .event import trigger_event_factory
12
11
  from .exceptions import InvalidDefinition
13
12
  from .graph import iterate_states_and_transitions
14
13
  from .graph import visit_connected_states
@@ -38,11 +37,13 @@ class StateMachineMetaclass(type):
38
37
 
39
38
  cls._abstract = True
40
39
  cls._strict_states = strict_states
41
- cls._events: Dict[str, Event] = {}
40
+ cls._events: Dict[Event, None] = {} # used Dict to preserve order and avoid duplicates
42
41
  cls._protected_attrs: set = set()
42
+ cls._events_to_update: Dict[Event, Event | None] = {}
43
43
 
44
44
  cls.add_inherited(bases)
45
45
  cls.add_from_attributes(attrs)
46
+ cls._update_event_references()
46
47
 
47
48
  try:
48
49
  cls.initial_state: State = next(s for s in cls.states if s.initial)
@@ -174,17 +175,26 @@ class StateMachineMetaclass(type):
174
175
  cls.add_state(state.id, state)
175
176
 
176
177
  events = getattr(base, "_events", {})
177
- for event in events.values():
178
- cls.add_event(event.name)
178
+ for event in events:
179
+ cls.add_event(event=Event(id=event.id, name=event.name))
179
180
 
180
- def add_from_attributes(cls, attrs):
181
+ def add_from_attributes(cls, attrs): # noqa: C901
181
182
  for key, value in sorted(attrs.items(), key=lambda pair: pair[0]):
182
183
  if isinstance(value, States):
183
184
  cls._add_states_from_dict(value)
184
185
  if isinstance(value, State):
185
186
  cls.add_state(key, value)
186
187
  elif isinstance(value, (Transition, TransitionList)):
187
- cls.add_event(key, value)
188
+ cls.add_event(event=Event(transitions=value, id=key, name=key))
189
+ elif isinstance(value, (Event,)):
190
+ cls.add_event(
191
+ event=Event(
192
+ transitions=value._transitions,
193
+ id=key,
194
+ name=value.name,
195
+ ),
196
+ old_event=value,
197
+ )
188
198
  elif getattr(value, "_specs_to_update", None):
189
199
  cls._add_unbounded_callback(key, value)
190
200
 
@@ -196,7 +206,7 @@ class StateMachineMetaclass(type):
196
206
  # if func is an event, the `attr_name` will be replaced by an event trigger,
197
207
  # so we'll also give the ``func`` a new unique name to be used by the callback
198
208
  # machinery.
199
- cls.add_event(attr_name, func._transitions)
209
+ cls.add_event(event=Event(func._transitions, id=attr_name, name=attr_name))
200
210
  attr_name = f"_{attr_name}_{uuid4().hex}"
201
211
  setattr(cls, attr_name, func)
202
212
 
@@ -214,17 +224,42 @@ class StateMachineMetaclass(type):
214
224
  for event in state.transitions.unique_events:
215
225
  cls.add_event(event)
216
226
 
217
- def add_event(cls, event, transitions=None):
227
+ def add_event(
228
+ cls,
229
+ event: Event,
230
+ old_event: "Event | None" = None,
231
+ ):
232
+ if not event._has_real_id:
233
+ if event not in cls._events_to_update:
234
+ cls._events_to_update[event] = None
235
+ return
236
+
237
+ transitions = event._transitions
218
238
  if transitions is not None:
219
239
  transitions.add_event(event)
220
240
 
221
241
  if event not in cls._events:
222
- event_instance = Event(event)
223
- cls._events[event] = event_instance
224
- setattr(cls, event, trigger_event_factory(event_instance))
242
+ cls._events[event] = None
243
+ setattr(cls, event.id, event)
244
+
245
+ if old_event is not None:
246
+ cls._events_to_update[old_event] = event
225
247
 
226
248
  return cls._events[event]
227
249
 
250
+ def _update_event_references(cls):
251
+ for old_event, new_event in cls._events_to_update.items():
252
+ for state in cls.states:
253
+ for transition in state.transitions:
254
+ if transition._events.match(old_event):
255
+ if new_event is None:
256
+ raise InvalidDefinition(
257
+ _("An event in the '{}' has no id.").format(transition)
258
+ )
259
+ transition.events._replace(old_event, new_event)
260
+
261
+ cls._events_to_update = {}
262
+
228
263
  @property
229
264
  def events(self):
230
- return list(self._events.values())
265
+ return list(self._events)