python-statemachine 3.1.2__py3-none-any.whl → 3.2.1__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.
Files changed (60) hide show
  1. {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.1.dist-info}/METADATA +16 -3
  2. python_statemachine-3.2.1.dist-info/RECORD +72 -0
  3. {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.1.dist-info}/WHEEL +1 -1
  4. statemachine/__init__.py +1 -1
  5. statemachine/callbacks.py +5 -11
  6. statemachine/configuration.py +5 -6
  7. statemachine/contrib/diagram/extract.py +26 -27
  8. statemachine/contrib/diagram/formatter.py +5 -7
  9. statemachine/contrib/diagram/model.py +9 -11
  10. statemachine/contrib/diagram/renderers/dot.py +20 -26
  11. statemachine/contrib/diagram/renderers/mermaid.py +36 -40
  12. statemachine/contrib/diagram/renderers/table.py +7 -9
  13. statemachine/contrib/weighted.py +7 -11
  14. statemachine/dispatcher.py +18 -13
  15. statemachine/engines/async_.py +20 -9
  16. statemachine/engines/base.py +26 -17
  17. statemachine/event.py +1 -2
  18. statemachine/exceptions.py +2 -2
  19. statemachine/factory.py +11 -15
  20. statemachine/graph.py +2 -2
  21. statemachine/invoke.py +12 -11
  22. statemachine/io/__init__.py +52 -225
  23. statemachine/io/actions.py +627 -0
  24. statemachine/io/builder.py +195 -0
  25. statemachine/io/class_factory.py +236 -0
  26. statemachine/io/evaluators.py +303 -0
  27. statemachine/io/interpreter.py +128 -0
  28. statemachine/io/{scxml/invoke.py → invoke.py} +89 -49
  29. statemachine/io/json/__init__.py +1 -0
  30. statemachine/io/json/reader.py +27 -0
  31. statemachine/io/loader.py +175 -0
  32. statemachine/io/model.py +268 -0
  33. statemachine/io/native.py +402 -0
  34. statemachine/io/ports.py +83 -0
  35. statemachine/io/schemas/statechart.schema.json +258 -0
  36. statemachine/io/scxml/__init__.py +15 -0
  37. statemachine/io/scxml/processor.py +23 -253
  38. statemachine/io/scxml/{parser.py → reader.py} +88 -56
  39. statemachine/io/system_variables.py +199 -0
  40. statemachine/io/validation.py +44 -0
  41. statemachine/io/yaml/__init__.py +1 -0
  42. statemachine/io/yaml/reader.py +65 -0
  43. statemachine/locale/en/LC_MESSAGES/statemachine.po +19 -19
  44. statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po +19 -19
  45. statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po +19 -19
  46. statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po +19 -19
  47. statemachine/orderedset.py +15 -6
  48. statemachine/registry.py +1 -4
  49. statemachine/signature.py +2 -5
  50. statemachine/spec_parser.py +228 -45
  51. statemachine/state.py +7 -8
  52. statemachine/statemachine.py +18 -20
  53. statemachine/states.py +3 -5
  54. statemachine/transition.py +3 -4
  55. statemachine/transition_list.py +4 -5
  56. statemachine/transition_mixin.py +1 -1
  57. python_statemachine-3.1.2.dist-info/RECORD +0 -58
  58. statemachine/io/scxml/actions.py +0 -650
  59. statemachine/io/scxml/schema.py +0 -175
  60. {python_statemachine-3.1.2.dist-info → python_statemachine-3.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,8 +1,4 @@
1
1
  from dataclasses import dataclass
2
- from typing import Dict
3
- from typing import List
4
- from typing import Optional
5
- from typing import Set
6
2
 
7
3
  from ..model import ActionType
8
4
  from ..model import DiagramAction
@@ -34,14 +30,14 @@ class MermaidRenderer:
34
30
  Compound states outside parallel regions are left unchanged.
35
31
  """
36
32
 
37
- def __init__(self, config: Optional[MermaidRendererConfig] = None):
33
+ def __init__(self, config: MermaidRendererConfig | None = None):
38
34
  self.config = config or MermaidRendererConfig()
39
- self._active_ids: List[str] = []
40
- self._rendered_transitions: Set[tuple] = set()
41
- self._compound_ids: Set[str] = set()
42
- self._initial_child_map: Dict[str, str] = {}
43
- self._parallel_descendant_ids: Set[str] = set()
44
- self._all_descendants_map: Dict[str, Set[str]] = {}
35
+ self._active_ids: list[str] = []
36
+ self._rendered_transitions: set[tuple] = set()
37
+ self._compound_ids: set[str] = set()
38
+ self._initial_child_map: dict[str, str] = {}
39
+ self._parallel_descendant_ids: set[str] = set()
40
+ self._all_descendants_map: dict[str, set[str]] = {}
45
41
 
46
42
  def render(self, graph: DiagramGraph) -> str:
47
43
  """Render a DiagramGraph to a Mermaid stateDiagram-v2 string."""
@@ -52,7 +48,7 @@ class MermaidRenderer:
52
48
  self._parallel_descendant_ids = self._collect_parallel_descendants(graph.states)
53
49
  self._all_descendants_map = self._build_all_descendants_map(graph.states)
54
50
 
55
- lines: List[str] = []
51
+ lines: list[str] = []
56
52
  lines.append("stateDiagram-v2")
57
53
  lines.append(f" direction {self.config.direction}")
58
54
 
@@ -70,9 +66,9 @@ class MermaidRenderer:
70
66
 
71
67
  return "\n".join(lines) + "\n"
72
68
 
73
- def _build_initial_child_map(self, states: List[DiagramState]) -> Dict[str, str]:
69
+ def _build_initial_child_map(self, states: list[DiagramState]) -> dict[str, str]:
74
70
  """Build a map from compound state ID to its initial child ID (recursive)."""
75
- result: Dict[str, str] = {}
71
+ result: dict[str, str] = {}
76
72
  for state in states:
77
73
  if state.children:
78
74
  initial = next((c for c in state.children if c.is_initial), None)
@@ -83,11 +79,11 @@ class MermaidRenderer:
83
79
 
84
80
  @staticmethod
85
81
  def _collect_parallel_descendants(
86
- states: List[DiagramState],
82
+ states: list[DiagramState],
87
83
  inside_parallel: bool = False,
88
- ) -> Set[str]:
84
+ ) -> set[str]:
89
85
  """Collect IDs of all states that are descendants of a parallel state."""
90
- result: Set[str] = set()
86
+ result: set[str] = set()
91
87
  for state in states:
92
88
  if inside_parallel:
93
89
  result.add(state.id)
@@ -97,9 +93,9 @@ class MermaidRenderer:
97
93
  )
98
94
  return result
99
95
 
100
- def _build_all_descendants_map(self, states: List[DiagramState]) -> Dict[str, Set[str]]:
96
+ def _build_all_descendants_map(self, states: list[DiagramState]) -> dict[str, set[str]]:
101
97
  """Map each compound state ID to the set of all its descendant IDs."""
102
- result: Dict[str, Set[str]] = {}
98
+ result: dict[str, set[str]] = {}
103
99
  for state in states:
104
100
  if state.children:
105
101
  result[state.id] = self._collect_recursive_descendants(state.children)
@@ -107,9 +103,9 @@ class MermaidRenderer:
107
103
  return result
108
104
 
109
105
  @staticmethod
110
- def _collect_recursive_descendants(states: List[DiagramState]) -> Set[str]:
106
+ def _collect_recursive_descendants(states: list[DiagramState]) -> set[str]:
111
107
  """Collect all state IDs in a subtree recursively."""
112
- ids: Set[str] = set()
108
+ ids: set[str] = set()
113
109
  for s in states:
114
110
  ids.add(s.id)
115
111
  ids.update(MermaidRenderer._collect_recursive_descendants(s.children))
@@ -132,9 +128,9 @@ class MermaidRenderer:
132
128
 
133
129
  def _render_states(
134
130
  self,
135
- states: List[DiagramState],
136
- transitions: List[DiagramTransition],
137
- lines: List[str],
131
+ states: list[DiagramState],
132
+ transitions: list[DiagramTransition],
133
+ lines: list[str],
138
134
  indent: int,
139
135
  ) -> None:
140
136
  for state in states:
@@ -167,7 +163,7 @@ class MermaidRenderer:
167
163
  def _render_atomic_state(
168
164
  self,
169
165
  state: DiagramState,
170
- lines: List[str],
166
+ lines: list[str],
171
167
  indent: int,
172
168
  ) -> None:
173
169
  pad = " " * indent
@@ -186,8 +182,8 @@ class MermaidRenderer:
186
182
  def _render_compound_state(
187
183
  self,
188
184
  state: DiagramState,
189
- transitions: List[DiagramTransition],
190
- lines: List[str],
185
+ transitions: list[DiagramTransition],
186
+ lines: list[str],
191
187
  indent: int,
192
188
  ) -> None:
193
189
  pad = " " * indent
@@ -227,18 +223,18 @@ class MermaidRenderer:
227
223
  if state.is_active:
228
224
  self._active_ids.append(state.id)
229
225
 
230
- def _collect_all_descendant_ids(self, states: List[DiagramState]) -> Set[str]:
226
+ def _collect_all_descendant_ids(self, states: list[DiagramState]) -> set[str]:
231
227
  """Collect all state IDs in a subtree (direct children only for scope)."""
232
- ids: Set[str] = set()
228
+ ids: set[str] = set()
233
229
  for s in states:
234
230
  ids.add(s.id)
235
231
  return ids
236
232
 
237
233
  def _render_scope_transitions(
238
234
  self,
239
- transitions: List[DiagramTransition],
240
- scope_ids: Set[str],
241
- lines: List[str],
235
+ transitions: list[DiagramTransition],
236
+ scope_ids: set[str],
237
+ lines: list[str],
242
238
  indent: int,
243
239
  ) -> None:
244
240
  """Render transitions that belong to this scope level.
@@ -257,8 +253,8 @@ class MermaidRenderer:
257
253
  are redirected to the compound's initial child via ``_resolve_endpoint``.
258
254
  """
259
255
  # Build the descendant sets for compounds in this scope
260
- compound_descendants: Dict[str, Set[str]] = {}
261
- expanded: Set[str] = set(scope_ids)
256
+ compound_descendants: dict[str, set[str]] = {}
257
+ expanded: set[str] = set(scope_ids)
262
258
  for sid in scope_ids:
263
259
  if sid in self._all_descendants_map:
264
260
  compound_descendants[sid] = self._all_descendants_map[sid]
@@ -295,8 +291,8 @@ class MermaidRenderer:
295
291
  @staticmethod
296
292
  def _is_fully_internal(
297
293
  source: str,
298
- targets: List[str],
299
- compound_descendants: Dict[str, Set[str]],
294
+ targets: list[str],
295
+ compound_descendants: dict[str, set[str]],
300
296
  ) -> bool:
301
297
  """Check if all endpoints belong to the same compound's descendants."""
302
298
  for descendants in compound_descendants.values():
@@ -309,11 +305,11 @@ class MermaidRenderer:
309
305
  transition: DiagramTransition,
310
306
  source: str,
311
307
  target: str,
312
- lines: List[str],
308
+ lines: list[str],
313
309
  indent: int,
314
310
  ) -> None:
315
311
  pad = " " * indent
316
- label_parts: List[str] = []
312
+ label_parts: list[str] = []
317
313
  if transition.event:
318
314
  label_parts.append(transition.event)
319
315
  if transition.guards:
@@ -333,8 +329,8 @@ class MermaidRenderer:
333
329
 
334
330
  def _render_initial_and_final(
335
331
  self,
336
- states: List[DiagramState],
337
- lines: List[str],
332
+ states: list[DiagramState],
333
+ lines: list[str],
338
334
  indent: int,
339
335
  ) -> None:
340
336
  """Render top-level [*] --> initial and final --> [*] arrows."""
@@ -1,5 +1,3 @@
1
- from typing import List
2
-
3
1
  from ..model import DiagramGraph
4
2
  from ..model import DiagramState
5
3
  from ..model import DiagramTransition
@@ -26,11 +24,11 @@ class TransitionTableRenderer:
26
24
 
27
25
  def _collect_rows(
28
26
  self,
29
- states: List[DiagramState],
30
- transitions: List[DiagramTransition],
31
- ) -> "List[tuple[str, str, str, str]]":
27
+ states: list[DiagramState],
28
+ transitions: list[DiagramTransition],
29
+ ) -> "list[tuple[str, str, str, str]]":
32
30
  """Collect (State, Event, Guard, Target) tuples from the IR."""
33
- rows: List[tuple[str, str, str, str]] = []
31
+ rows: list[tuple[str, str, str, str]] = []
34
32
  state_names = self._build_state_name_map(states)
35
33
 
36
34
  for t in transitions:
@@ -50,7 +48,7 @@ class TransitionTableRenderer:
50
48
 
51
49
  return rows
52
50
 
53
- def _build_state_name_map(self, states: List[DiagramState]) -> dict:
51
+ def _build_state_name_map(self, states: list[DiagramState]) -> dict:
54
52
  """Build a mapping from state ID to display name, recursively."""
55
53
  result: dict = {}
56
54
  for state in states:
@@ -59,7 +57,7 @@ class TransitionTableRenderer:
59
57
  result.update(self._build_state_name_map(state.children))
60
58
  return result
61
59
 
62
- def _render_md(self, rows: "List[tuple[str, str, str, str]]") -> str:
60
+ def _render_md(self, rows: "list[tuple[str, str, str, str]]") -> str:
63
61
  """Render as a markdown table."""
64
62
  headers = ("State", "Event", "Guard", "Target")
65
63
  col_widths = [len(h) for h in headers]
@@ -79,7 +77,7 @@ class TransitionTableRenderer:
79
77
 
80
78
  return "\n".join(lines) + "\n"
81
79
 
82
- def _render_rst(self, rows: "List[tuple[str, str, str, str]]") -> str:
80
+ def _render_rst(self, rows: "list[tuple[str, str, str, str]]") -> str:
83
81
  """Render as an RST grid table."""
84
82
  headers = ("State", "Event", "Guard", "Target")
85
83
  col_widths = [len(h) for h in headers]
@@ -1,10 +1,7 @@
1
1
  import random
2
2
  from typing import TYPE_CHECKING
3
3
  from typing import Any
4
- from typing import Dict
5
- from typing import List
6
- from typing import Tuple
7
- from typing import Union
4
+ from typing import TypeAlias
8
5
 
9
6
  from statemachine.callbacks import CallbackPriority
10
7
  from statemachine.transition_list import TransitionList
@@ -20,7 +17,7 @@ class _WeightedGroup:
20
17
  the selected index. Subsequent conds check against the cache.
21
18
  """
22
19
 
23
- def __init__(self, weights: List[float], seed: "int | float | str | None" = None):
20
+ def __init__(self, weights: list[float], seed: "int | float | str | None" = None):
24
21
  self.weights = weights
25
22
  self.rng = random.Random(seed)
26
23
  self._selected: "int | None" = None
@@ -60,10 +57,9 @@ def _make_weighted_cond(index: int, group: _WeightedGroup, weight: float, total_
60
57
 
61
58
  # Type alias for a weighted destination:
62
59
  # (target, weight) or (target, weight, kwargs_dict)
63
- _WeightedDest = Union[
64
- Tuple["State", Union[int, float]],
65
- Tuple["State", Union[int, float], Dict[str, Any]],
66
- ]
60
+ _WeightedDest: TypeAlias = (
61
+ tuple["State", int | float] | tuple["State", int | float, dict[str, Any]]
62
+ )
67
63
 
68
64
 
69
65
  def to(target: "State", weight: "int | float", **kwargs: Any) -> _WeightedDest:
@@ -97,7 +93,7 @@ def to(target: "State", weight: "int | float", **kwargs: Any) -> _WeightedDest:
97
93
  return (target, weight, kwargs)
98
94
 
99
95
 
100
- def _validate_dest(i: int, item: Any) -> "Tuple[State, float, Dict[str, Any]]":
96
+ def _validate_dest(i: int, item: Any) -> "tuple[State, float, dict[str, Any]]":
101
97
  """Validate and normalize a single ``(target, weight[, kwargs])`` tuple."""
102
98
  from statemachine.state import State
103
99
 
@@ -109,7 +105,7 @@ def _validate_dest(i: int, item: Any) -> "Tuple[State, float, Dict[str, Any]]":
109
105
 
110
106
  if len(item) == 2:
111
107
  target, weight = item
112
- kwargs: Dict[str, Any] = {}
108
+ kwargs: dict[str, Any] = {}
113
109
  else:
114
110
  target, weight, kwargs = item
115
111
  if not isinstance(kwargs, dict):
@@ -1,14 +1,11 @@
1
+ from collections.abc import Callable
2
+ from collections.abc import Iterable
1
3
  from dataclasses import dataclass
2
4
  from functools import partial
3
5
  from functools import reduce
4
6
  from operator import attrgetter
5
7
  from typing import TYPE_CHECKING
6
8
  from typing import Any
7
- from typing import Callable
8
- from typing import Iterable
9
- from typing import List
10
- from typing import Set
11
- from typing import Tuple
12
9
 
13
10
  from .callbacks import SPECS_ALL
14
11
  from .callbacks import SpecReference
@@ -17,6 +14,7 @@ from .event import Event
17
14
  from .exceptions import InvalidDefinition
18
15
  from .i18n import _
19
16
  from .signature import SignatureAdapter
17
+ from .spec_parser import UnsupportedExpression
20
18
  from .spec_parser import custom_and
21
19
  from .spec_parser import operator_mapping
22
20
  from .spec_parser import parse_boolean_expr
@@ -37,7 +35,7 @@ class Listener:
37
35
  """
38
36
 
39
37
  obj: object
40
- all_attrs: Set[str]
38
+ all_attrs: set[str]
41
39
  resolver_id: str
42
40
 
43
41
  @classmethod
@@ -58,8 +56,8 @@ class Listener:
58
56
  class Listeners:
59
57
  """Listeners that provides attributes to be used as callbacks."""
60
58
 
61
- items: Tuple[Listener, ...]
62
- all_attrs: Set[str]
59
+ items: tuple[Listener, ...]
60
+ all_attrs: set[str]
63
61
 
64
62
  @classmethod
65
63
  def from_listeners(cls, listeners: Iterable["Listener"]) -> "Listeners":
@@ -86,7 +84,7 @@ class Listeners:
86
84
  executor.add(key, spec, builder)
87
85
 
88
86
  def _take_callback(self, name: str, names_not_found_handler: Callable) -> Callable:
89
- callbacks: List[Callable] = []
87
+ callbacks: list[Callable] = []
90
88
  for key, builder in self.search_name(name):
91
89
  callback = builder()
92
90
  callback.unique_key = key # type: ignore[attr-defined]
@@ -114,14 +112,17 @@ class Listeners:
114
112
 
115
113
  # Resolves boolean expressions
116
114
 
117
- names_not_found: Set[str] = set()
115
+ names_not_found: set[str] = set()
118
116
  take_callback_partial = partial(
119
117
  self._take_callback, names_not_found_handler=names_not_found.add
120
118
  )
121
119
 
122
120
  try:
123
121
  expression = parse_boolean_expr(spec.func, take_callback_partial, operator_mapping)
124
- except SyntaxError as err:
122
+ except (SyntaxError, UnsupportedExpression) as err:
123
+ # ``UnsupportedExpression`` comes from the AST allowlist rejecting a node
124
+ # kind that cannot appear in a boolean expression (e.g. ``"user.age"``).
125
+ # Errors raised while resolving names are not parse errors and propagate.
125
126
  raise InvalidDefinition(
126
127
  _("Failed to parse boolean expression '{}'").format(spec.func)
127
128
  ) from err
@@ -162,7 +163,11 @@ class Listeners:
162
163
  if not spec.is_bounded:
163
164
  for listener in self.items:
164
165
  func = getattr(listener.obj, spec.attr_name, None)
165
- if func is not None and func.__func__ is spec.func:
166
+ # ``getattr`` may return a non-method that happens to share the name
167
+ # (e.g. a model attribute named like a compiled guard); it is not the
168
+ # unbounded method we are rebinding, so skip it instead of accessing
169
+ # ``__func__`` (which would raise on a plain value).
170
+ if getattr(func, "__func__", None) is spec.func:
166
171
  yield listener.build_key(spec.attr_name), partial(callable_method, func)
167
172
  return
168
173
 
@@ -228,5 +233,5 @@ def event_method(func) -> Callable:
228
233
  return method
229
234
 
230
235
 
231
- def resolver_factory_from_objects(*objects: Tuple[Any, ...]):
236
+ def resolver_factory_from_objects(*objects: tuple[Any, ...]):
232
237
  return Listeners.from_listeners(Listener.from_obj(o) for o in objects)
@@ -1,10 +1,9 @@
1
1
  import asyncio
2
2
  import contextvars
3
+ from collections.abc import Callable
3
4
  from itertools import chain
4
5
  from time import time
5
6
  from typing import TYPE_CHECKING
6
- from typing import Callable
7
- from typing import List
8
7
 
9
8
  from ..event_data import EventData
10
9
  from ..event_data import TriggerData
@@ -72,17 +71,27 @@ class AsyncEngine(BaseEngine):
72
71
  # --- Callback dispatch overrides (async versions of BaseEngine methods) ---
73
72
 
74
73
  async def _get_args_kwargs(
75
- self, transition: "Transition", trigger_data: TriggerData, target: "State | None" = None
74
+ self,
75
+ transition: "Transition",
76
+ trigger_data: TriggerData,
77
+ target: "State | None" = None,
78
+ source: "State | None" = None,
76
79
  ):
77
- cache_key = (id(transition), id(trigger_data), id(target))
80
+ cache_key = (id(transition), id(trigger_data), id(target), id(source))
78
81
 
79
82
  if cache_key in self._cache:
80
83
  return self._cache[cache_key]
81
84
 
82
85
  event_data = EventData(trigger_data=trigger_data, transition=transition)
86
+ # See the sync engine for the rationale: bind `state`/`target` to the
87
+ # entered state and `state`/`source` to the exited state, keeping the
88
+ # generic enter/exit callbacks symmetric across compound boundaries.
83
89
  if target:
84
90
  event_data.state = target
85
91
  event_data.target = target
92
+ if source:
93
+ event_data.state = source
94
+ event_data.source = source
86
95
 
87
96
  args, kwargs = event_data.args, event_data.extended_kwargs
88
97
 
@@ -143,7 +152,7 @@ class AsyncEngine(BaseEngine):
143
152
 
144
153
  async def _execute_transition_content(
145
154
  self,
146
- enabled_transitions: "List[Transition]",
155
+ enabled_transitions: "list[Transition]",
147
156
  trigger_data: TriggerData,
148
157
  get_key: "Callable[[Transition], str]",
149
158
  set_target_as_state: bool = False,
@@ -164,7 +173,7 @@ class AsyncEngine(BaseEngine):
164
173
  return result
165
174
 
166
175
  async def _exit_states( # type: ignore[override]
167
- self, enabled_transitions: "List[Transition]", trigger_data: TriggerData
176
+ self, enabled_transitions: "list[Transition]", trigger_data: TriggerData
168
177
  ) -> "OrderedSet[State]":
169
178
  ordered_states, result = self._prepare_exit_states(enabled_transitions)
170
179
  on_error = self._on_error_handler()
@@ -174,7 +183,9 @@ class AsyncEngine(BaseEngine):
174
183
  if info.state is not None: # pragma: no branch
175
184
  self._invoke_manager.cancel_for_state(info.state)
176
185
 
177
- args, kwargs = await self._get_args_kwargs(info.transition, trigger_data)
186
+ args, kwargs = await self._get_args_kwargs(
187
+ info.transition, trigger_data, source=info.state
188
+ )
178
189
 
179
190
  if info.state is not None: # pragma: no branch
180
191
  self._debug("%s Exiting state: %s", self._log_id, info.state)
@@ -188,7 +199,7 @@ class AsyncEngine(BaseEngine):
188
199
 
189
200
  async def _enter_states( # noqa: C901
190
201
  self,
191
- enabled_transitions: "List[Transition]",
202
+ enabled_transitions: "list[Transition]",
192
203
  trigger_data: TriggerData,
193
204
  states_to_exit: "OrderedSet[State]",
194
205
  previous_configuration: "OrderedSet[State]",
@@ -270,7 +281,7 @@ class AsyncEngine(BaseEngine):
270
281
 
271
282
  return result
272
283
 
273
- async def microstep(self, transitions: "List[Transition]", trigger_data: TriggerData):
284
+ async def microstep(self, transitions: "list[Transition]", trigger_data: TriggerData):
274
285
  self._microstep_count += 1
275
286
  self._debug(
276
287
  "%s macro:%d micro:%d transitions: %s",
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ from collections.abc import Callable
2
3
  from dataclasses import dataclass
3
4
  from dataclasses import field
4
5
  from itertools import chain
@@ -7,9 +8,6 @@ from queue import Queue
7
8
  from threading import Lock
8
9
  from typing import TYPE_CHECKING
9
10
  from typing import Any
10
- from typing import Callable
11
- from typing import Dict
12
- from typing import List
13
11
  from typing import cast
14
12
 
15
13
  from ..event import BoundEvent
@@ -91,7 +89,7 @@ class BaseEngine:
91
89
  self._sentinel = object()
92
90
  self.running = True
93
91
  self._processing = Lock()
94
- self._cache: Dict = {} # Cache for _get_args_kwargs results
92
+ self._cache: dict = {} # Cache for _get_args_kwargs results
95
93
  self._invoke_manager = InvokeManager(self)
96
94
  self._macrostep_count: int = 0
97
95
  self._microstep_count: int = 0
@@ -243,7 +241,7 @@ class BaseEngine:
243
241
 
244
242
  return filtered_transitions
245
243
 
246
- def _compute_exit_set(self, transitions: List[Transition]) -> OrderedSet[StateTransition]:
244
+ def _compute_exit_set(self, transitions: list[Transition]) -> OrderedSet[StateTransition]:
247
245
  """Compute the exit set for a transition."""
248
246
 
249
247
  states_to_exit = OrderedSet[StateTransition]()
@@ -286,7 +284,7 @@ class BaseEngine:
286
284
  return self.find_lcca([transition.source] + list(states))
287
285
 
288
286
  @staticmethod
289
- def find_lcca(states: List[State]) -> "State | None":
287
+ def find_lcca(states: list[State]) -> "State | None":
290
288
  """
291
289
  Find the Least Common Compound Ancestor (LCCA) of the given list of states.
292
290
 
@@ -371,7 +369,7 @@ class BaseEngine:
371
369
 
372
370
  return self._filter_conflicting_transitions(enabled_transitions)
373
371
 
374
- def microstep(self, transitions: List[Transition], trigger_data: TriggerData):
372
+ def microstep(self, transitions: list[Transition], trigger_data: TriggerData):
375
373
  """Process a single set of transitions in a 'lock step'.
376
374
  This includes exiting states, executing transition content, and entering states.
377
375
  """
@@ -421,19 +419,30 @@ class BaseEngine:
421
419
  return result
422
420
 
423
421
  def _get_args_kwargs(
424
- self, transition: Transition, trigger_data: TriggerData, target: "State | None" = None
422
+ self,
423
+ transition: Transition,
424
+ trigger_data: TriggerData,
425
+ target: "State | None" = None,
426
+ source: "State | None" = None,
425
427
  ):
426
428
  # Generate a unique key for the cache, the cache is invalidated once per loop
427
- cache_key = (id(transition), id(trigger_data), id(target))
429
+ cache_key = (id(transition), id(trigger_data), id(target), id(source))
428
430
 
429
431
  # Check the cache for existing results
430
432
  if cache_key in self._cache:
431
433
  return self._cache[cache_key]
432
434
 
433
435
  event_data = EventData(trigger_data=trigger_data, transition=transition)
436
+ # Bind `state`/`target` to the individual state being entered, and
437
+ # `state`/`source` to the individual state being exited, so the generic
438
+ # `on_enter_state`/`on_exit_state` callbacks stay symmetric when crossing
439
+ # compound state boundaries (the transition only knows its own endpoints).
434
440
  if target:
435
441
  event_data.state = target
436
442
  event_data.target = target
443
+ if source:
444
+ event_data.state = source
445
+ event_data.source = source
437
446
 
438
447
  args, kwargs = event_data.args, event_data.extended_kwargs
439
448
 
@@ -454,7 +463,7 @@ class BaseEngine:
454
463
 
455
464
  def _prepare_exit_states(
456
465
  self,
457
- enabled_transitions: List[Transition],
466
+ enabled_transitions: list[Transition],
458
467
  ) -> "tuple[list[StateTransition], OrderedSet[State]]":
459
468
  """Compute exit set, sort, and update history. Pure computation, no callbacks."""
460
469
  states_to_exit = self._compute_exit_set(enabled_transitions)
@@ -491,7 +500,7 @@ class BaseEngine:
491
500
  self.sm._config.discard(state)
492
501
 
493
502
  def _exit_states(
494
- self, enabled_transitions: List[Transition], trigger_data: TriggerData
503
+ self, enabled_transitions: list[Transition], trigger_data: TriggerData
495
504
  ) -> OrderedSet[State]:
496
505
  """Compute and process the states to exit for the given transitions."""
497
506
  ordered_states, result = self._prepare_exit_states(enabled_transitions)
@@ -502,7 +511,7 @@ class BaseEngine:
502
511
  if info.state is not None: # pragma: no branch
503
512
  self._invoke_manager.cancel_for_state(info.state)
504
513
 
505
- args, kwargs = self._get_args_kwargs(info.transition, trigger_data)
514
+ args, kwargs = self._get_args_kwargs(info.transition, trigger_data, source=info.state)
506
515
 
507
516
  # Execute `onexit` handlers — same per-block error isolation as onentry.
508
517
  if info.state is not None: # pragma: no branch
@@ -515,7 +524,7 @@ class BaseEngine:
515
524
 
516
525
  def _execute_transition_content(
517
526
  self,
518
- enabled_transitions: List[Transition],
527
+ enabled_transitions: list[Transition],
519
528
  trigger_data: TriggerData,
520
529
  get_key: Callable[[Transition], str],
521
530
  set_target_as_state: bool = False,
@@ -537,10 +546,10 @@ class BaseEngine:
537
546
 
538
547
  def _prepare_entry_states(
539
548
  self,
540
- enabled_transitions: List[Transition],
549
+ enabled_transitions: list[Transition],
541
550
  states_to_exit: OrderedSet[State],
542
551
  previous_configuration: OrderedSet[State],
543
- ) -> "tuple[list[StateTransition], OrderedSet[StateTransition], Dict[str, Any], OrderedSet[State]]": # noqa: E501
552
+ ) -> "tuple[list[StateTransition], OrderedSet[StateTransition], dict[str, Any], OrderedSet[State]]": # noqa: E501
544
553
  """Compute entry set, ordering, and new configuration. Pure computation, no callbacks.
545
554
 
546
555
  Returns:
@@ -548,7 +557,7 @@ class BaseEngine:
548
557
  """
549
558
  states_to_enter = OrderedSet[StateTransition]()
550
559
  states_for_default_entry = OrderedSet[StateTransition]()
551
- default_history_content: Dict[str, Any] = {}
560
+ default_history_content: dict[str, Any] = {}
552
561
 
553
562
  self.compute_entry_set(
554
563
  enabled_transitions, states_to_enter, states_for_default_entry, default_history_content
@@ -626,7 +635,7 @@ class BaseEngine:
626
635
 
627
636
  def _enter_states( # noqa: C901
628
637
  self,
629
- enabled_transitions: List[Transition],
638
+ enabled_transitions: list[Transition],
630
639
  trigger_data: TriggerData,
631
640
  states_to_exit: OrderedSet[State],
632
641
  previous_configuration: OrderedSet[State],
statemachine/event.py CHANGED
@@ -1,6 +1,5 @@
1
1
  from typing import TYPE_CHECKING
2
2
  from typing import Any
3
- from typing import List
4
3
  from typing import cast
5
4
  from uuid import uuid4
6
5
 
@@ -189,7 +188,7 @@ class Event(AddCallbacksMixin, str):
189
188
 
190
189
  def split( # type: ignore[override]
191
190
  self, sep: "str | None" = None, maxsplit: int = -1
192
- ) -> List["Event"]:
191
+ ) -> list["Event"]:
193
192
  result = super().split(sep, maxsplit)
194
193
  if len(result) == 1:
195
194
  return [self]
@@ -1,5 +1,5 @@
1
+ from collections.abc import MutableSet
1
2
  from typing import TYPE_CHECKING
2
- from typing import MutableSet
3
3
 
4
4
  from .i18n import _
5
5
 
@@ -36,7 +36,7 @@ class TransitionNotAllowed(StateMachineError):
36
36
  def __init__(self, event: "Event | None", configuration: MutableSet["State"]):
37
37
  self.event = event
38
38
  self.configuration = configuration
39
- name = ", ".join([s.name for s in configuration])
39
+ name = ", ".join(str(s.name) for s in configuration)
40
40
  msg = _("Can't {} when in {}.").format(
41
41
  self.event and self.event.name or "transition", name
42
42
  )