Skip to content

Commit af78785

Browse files
authored
chore: Adding test to cover scenario in #449 (#450)
* chore: Adding test to cover scenario in #449 * refac: Removing transition.execute * refac: Removing trigger callback in favor of trigger_data
1 parent 0070f48 commit af78785

7 files changed

Lines changed: 108 additions & 76 deletions

File tree

statemachine/event.py

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
1-
from functools import partial
21
from typing import TYPE_CHECKING
32

43
from statemachine.utils import run_async_from_sync
54

6-
from .event_data import EventData
75
from .event_data import TriggerData
8-
from .exceptions import TransitionNotAllowed
96

107
if TYPE_CHECKING:
118
from .statemachine import StateMachine
@@ -25,35 +22,16 @@ async def trigger(self, machine: "StateMachine", *args, **kwargs):
2522
args=args,
2623
kwargs=kwargs,
2724
)
28-
trigger_wrapper = partial(self._trigger, trigger_data=trigger_data)
2925

30-
return await machine._process(trigger_wrapper)
31-
32-
async def _trigger(self, trigger_data: TriggerData):
33-
event_data = None
34-
await trigger_data.machine._ensure_is_initialized()
35-
36-
state = trigger_data.machine.current_state
37-
for transition in state.transitions:
38-
if not transition.match(trigger_data.event):
39-
continue
40-
41-
event_data = EventData(trigger_data=trigger_data, transition=transition)
42-
if await transition.execute(event_data):
43-
event_data.executed = True
44-
break
45-
else:
46-
if not trigger_data.machine.allow_event_without_transition:
47-
raise TransitionNotAllowed(trigger_data.event, state)
48-
49-
return event_data.result if event_data else None
26+
return await machine._process(trigger_data)
5027

5128

5229
def trigger_event_factory(event_instance: Event):
5330
"""Build a method that sends specific `event` to the machine"""
5431

5532
def trigger_event(self, *args, **kwargs):
56-
return run_async_from_sync(event_instance.trigger(self, *args, **kwargs))
33+
coro = event_instance.trigger(self, *args, **kwargs)
34+
return run_async_from_sync(coro)
5735

5836
trigger_event.name = event_instance.name # type: ignore[attr-defined]
5937
trigger_event.identifier = event_instance.name # type: ignore[attr-defined]

statemachine/event_data.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,7 @@ def __post_init__(self):
5353
self.state = self.transition.source
5454
self.source = self.transition.source
5555
self.target = self.transition.target
56-
57-
@property
58-
def machine(self):
59-
return self.trigger_data.machine
56+
self.machine = self.trigger_data.machine
6057

6158
@property
6259
def event(self):

statemachine/events.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,5 @@ def add(self, events):
2727

2828
return self
2929

30-
def match(self, event):
31-
return any(t == event for t in self)
30+
def match(self, event: str):
31+
return any(e == event for e in self)

statemachine/statemachine.py

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,32 @@ def allowed_events(self):
287287
"""List of the current allowed events."""
288288
return [getattr(self, event) for event in self.current_state.transitions.unique_events]
289289

290-
async def _process(self, trigger):
290+
async def _trigger(self, trigger_data: TriggerData):
291+
event_data = None
292+
await self._ensure_is_initialized()
293+
294+
state = self.current_state
295+
for transition in state.transitions:
296+
if not transition.match(trigger_data.event):
297+
continue
298+
299+
event_data = EventData(trigger_data=trigger_data, transition=transition)
300+
args, kwargs = event_data.args, event_data.extended_kwargs
301+
await self._get_callbacks(transition.validators.key).call(*args, **kwargs)
302+
if not await self._get_callbacks(transition.cond.key).all(*args, **kwargs):
303+
continue
304+
305+
result = await self._activate(event_data)
306+
event_data.result = result
307+
event_data.executed = True
308+
break
309+
else:
310+
if not self.allow_event_without_transition:
311+
raise TransitionNotAllowed(trigger_data.event, state)
312+
313+
return event_data.result if event_data else None
314+
315+
async def _process(self, trigger_data: TriggerData):
291316
"""Process event triggers.
292317
293318
The simplest implementation is the non-RTC (synchronous),
@@ -308,11 +333,11 @@ async def _process(self, trigger):
308333
"""
309334
if not self.__rtc:
310335
# The machine is in "synchronous" mode
311-
return await trigger()
336+
return await self._trigger(trigger_data)
312337

313338
# The machine is in "queued" mode
314339
# Add the trigger to queue and start processing in a loop.
315-
self._external_queue.append(trigger)
340+
self._external_queue.append(trigger_data)
316341

317342
# We make sure that only the first event enters the processing critical section,
318343
# next events will only be put on the queue and processed by the same loop.
@@ -332,9 +357,9 @@ async def _processing_loop(self):
332357
first_result = sentinel
333358
try:
334359
while self._external_queue:
335-
trigger = self._external_queue.popleft()
360+
trigger_data = self._external_queue.popleft()
336361
try:
337-
result = await trigger()
362+
result = await self._trigger(trigger_data)
338363
if first_result is sentinel:
339364
first_result = result
340365
except Exception:
@@ -347,32 +372,24 @@ async def _processing_loop(self):
347372
return first_result if first_result is not sentinel else None
348373

349374
async def _activate(self, event_data: EventData):
375+
args, kwargs = event_data.args, event_data.extended_kwargs
350376
transition = event_data.transition
351377
source = event_data.state
352378
target = transition.target
353379

354-
result = await self._get_callbacks(transition.before.key).call(
355-
*event_data.args, **event_data.extended_kwargs
356-
)
380+
result = await self._get_callbacks(transition.before.key).call(*args, **kwargs)
357381
if source is not None and not transition.internal:
358-
await self._get_callbacks(source.exit.key).call(
359-
*event_data.args, **event_data.extended_kwargs
360-
)
382+
await self._get_callbacks(source.exit.key).call(*args, **kwargs)
361383

362-
result += await self._get_callbacks(transition.on.key).call(
363-
*event_data.args, **event_data.extended_kwargs
364-
)
384+
result += await self._get_callbacks(transition.on.key).call(*args, **kwargs)
365385

366386
self.current_state = target
367387
event_data.state = target
388+
kwargs["state"] = target
368389

369390
if not transition.internal:
370-
await self._get_callbacks(target.enter.key).call(
371-
*event_data.args, **event_data.extended_kwargs
372-
)
373-
await self._get_callbacks(transition.after.key).call(
374-
*event_data.args, **event_data.extended_kwargs
375-
)
391+
await self._get_callbacks(target.enter.key).call(*args, **kwargs)
392+
await self._get_callbacks(transition.after.key).call(*args, **kwargs)
376393

377394
if len(result) == 0:
378395
result = None
@@ -392,7 +409,8 @@ def send(self, event: str, *args, **kwargs):
392409
See: :ref:`triggering events`.
393410
394411
"""
395-
return run_async_from_sync(self.async_send(event, *args, **kwargs))
412+
coro = self.async_send(event, *args, **kwargs)
413+
return run_async_from_sync(coro)
396414

397415
async def async_send(self, event: str, *args, **kwargs):
398416
"""Send an :ref:`Event` to the state machine.

statemachine/transition.py

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
from typing import TYPE_CHECKING
2-
31
from .callbacks import BoolCallbackSpec
42
from .callbacks import CallbackGroup
53
from .callbacks import CallbackPriority
@@ -8,9 +6,6 @@
86
from .events import Events
97
from .exceptions import InvalidDefinition
108

11-
if TYPE_CHECKING:
12-
from .event_data import EventData
13-
149

1510
class Transition:
1611
"""A transition holds reference to the source and target state.
@@ -119,7 +114,7 @@ def _setup(self):
119114
is_convention=True,
120115
)
121116

122-
def match(self, event):
117+
def match(self, event: str):
123118
return self._events.match(event)
124119

125120
@property
@@ -132,14 +127,3 @@ def events(self):
132127

133128
def add_event(self, value):
134129
self._events.add(value)
135-
136-
async def execute(self, event_data: "EventData"):
137-
machine = event_data.machine
138-
args, kwargs = event_data.args, event_data.extended_kwargs
139-
await machine._get_callbacks(self.validators.key).call(*args, **kwargs)
140-
if not await machine._get_callbacks(self.cond.key).all(*args, **kwargs):
141-
return False
142-
143-
result = await machine._activate(event_data)
144-
event_data.result = result
145-
return True

statemachine/transition_list.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,22 @@
1-
from typing import TYPE_CHECKING
21
from typing import Callable
32
from typing import Iterable
43
from typing import List
54

5+
from .transition import Transition
66
from .utils import ensure_iterable
77

8-
if TYPE_CHECKING:
9-
from .transition import Transition
10-
118

129
class TransitionList:
1310
"""A list-like container of :ref:`transitions` with callback functions."""
1411

15-
def __init__(self, transitions: "Iterable | None" = None):
12+
def __init__(self, transitions: "Iterable[Transition] | None" = None):
1613
"""
1714
Args:
1815
transitions: An iterable of `Transition` objects.
1916
Defaults to `None`.
2017
2118
"""
22-
self.transitions = list(transitions) if transitions else []
19+
self.transitions: List[Transition] = list(transitions) if transitions else []
2320

2421
def __repr__(self):
2522
"""Return a string representation of the :ref:`TransitionList`."""
@@ -53,11 +50,12 @@ def add_transitions(self, transition: "Transition | TransitionList | Iterable"):
5350
transitions = ensure_iterable(transition)
5451

5552
for transition in transitions:
53+
assert isinstance(transition, Transition) # makes mypy happy
5654
self.transitions.append(transition)
5755

5856
return self
5957

60-
def __getitem__(self, index: int):
58+
def __getitem__(self, index: int) -> "Transition":
6159
"""Returns the :ref:`transition` at the specified ``index``.
6260
6361
Args:

tests/testcases/issue449.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
2+
### Issue 449
3+
4+
A StateMachine that exercises the example given on issue
5+
#[449](https://github.com/fgmacedo/python-statemachine/issues/449).
6+
7+
8+
9+
```py
10+
>>> from statemachine import StateMachine, State
11+
12+
>>> class ExampleStateMachine(StateMachine):
13+
... initial = State(initial=True)
14+
... second = State()
15+
... third = State()
16+
... fourth = State()
17+
... final = State(final=True)
18+
...
19+
... initial_to_second = initial.to(second)
20+
... second_to_third = second.to(third)
21+
... third_to_fourth = third.to(fourth)
22+
... completion = fourth.to(final)
23+
...
24+
... def on_enter_state(self, target: State, event: str):
25+
... print(f"Entering state {target.id}. Event: {event}")
26+
... if event == "initial_to_second":
27+
... self.send("second_to_third")
28+
... if event == "second_to_third":
29+
... self.send("third_to_fourth")
30+
... if event == "third_to_fourth":
31+
... print("third_to_fourth on on_enter_state worked")
32+
33+
34+
```
35+
36+
Exercise:
37+
38+
39+
```py
40+
>>> import pytest
41+
>>> pytest.xfail("This test is a regression on 2.3.0+ due to asyncio support.")
42+
>>> example = ExampleStateMachine()
43+
Entering state initial. Event: __initial__
44+
45+
>>> print(example.current_state)
46+
Initial
47+
48+
>>> example.send("initial_to_second") # this will call second_to_third and third_to_fourth
49+
Entering state second. Event: initial_to_second
50+
Entering state third. Event: second_to_third
51+
Entering state fourth. Event: third_to_fourth
52+
third_to_fourth on on_enter_state worked
53+
54+
>>> print("My current state is", example.current_state)
55+
My current state is Fourth
56+
57+
```

0 commit comments

Comments
 (0)