Skip to content

Commit f213a52

Browse files
committed
feat: Delayed events; Support for SCXML <send> tag
1 parent 0387cc2 commit f213a52

24 files changed

Lines changed: 772 additions & 42 deletions

statemachine/engines/async_.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import asyncio
2+
import heapq
3+
from time import time
14
from typing import TYPE_CHECKING
25

36
from ..event_data import EventData
@@ -61,7 +64,12 @@ async def processing_loop(self):
6164
try:
6265
# Execute the triggers in the queue in FIFO order until the queue is empty
6366
while self._external_queue:
64-
trigger_data = self._external_queue.popleft()
67+
trigger_data = heapq.heappop(self._external_queue)
68+
current_time = time()
69+
if trigger_data.execution_time > current_time:
70+
self.put(trigger_data)
71+
await asyncio.sleep(0.001)
72+
continue
6573
try:
6674
result = await self._trigger(trigger_data)
6775
if first_result is self._sentinel:

statemachine/engines/base.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from collections import deque
1+
import heapq
22
from threading import Lock
33
from typing import TYPE_CHECKING
44
from weakref import proxy
@@ -16,7 +16,7 @@
1616
class BaseEngine:
1717
def __init__(self, sm: "StateMachine", rtc: bool = True):
1818
self.sm: StateMachine = proxy(sm)
19-
self._external_queue: deque = deque()
19+
self._external_queue: list = []
2020
self._sentinel = object()
2121
self._rtc = rtc
2222
self._processing = Lock()
@@ -26,7 +26,8 @@ def put(self, trigger_data: TriggerData):
2626
"""Put the trigger on the queue without blocking the caller."""
2727
if not self._running and not self.sm.allow_event_without_transition:
2828
raise TransitionNotAllowed(trigger_data.event, self.sm.current_state)
29-
self._external_queue.append(trigger_data)
29+
30+
heapq.heappush(self._external_queue, trigger_data)
3031

3132
def start(self):
3233
if self.sm.current_state_value is not None:

statemachine/engines/sync.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import heapq
2+
from time import sleep
3+
from time import time
14
from typing import TYPE_CHECKING
25

36
from ..event_data import EventData
@@ -47,7 +50,7 @@ def processing_loop(self):
4750
"""
4851
if not self._rtc:
4952
# The machine is in "synchronous" mode
50-
trigger_data = self._external_queue.popleft()
53+
trigger_data = heapq.heappop(self._external_queue)
5154
return self._trigger(trigger_data)
5255

5356
# We make sure that only the first event enters the processing critical section,
@@ -62,7 +65,12 @@ def processing_loop(self):
6265
try:
6366
# Execute the triggers in the queue in FIFO order until the queue is empty
6467
while self._running and self._external_queue:
65-
trigger_data = self._external_queue.popleft()
68+
trigger_data = heapq.heappop(self._external_queue)
69+
current_time = time()
70+
if trigger_data.execution_time > current_time:
71+
self.put(trigger_data)
72+
sleep(0.001)
73+
continue
6674
try:
6775
result = self._trigger(trigger_data)
6876
if first_result is self._sentinel:

statemachine/event.py

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from inspect import isawaitable
21
from typing import TYPE_CHECKING
32
from typing import List
43
from uuid import uuid4
@@ -8,7 +7,6 @@
87
from .exceptions import InvalidDefinition
98
from .i18n import _
109
from .transition_mixin import AddCallbacksMixin
11-
from .utils import run_async_from_sync
1210

1311
if TYPE_CHECKING:
1412
from .statemachine import StateMachine
@@ -44,6 +42,9 @@ class Event(AddCallbacksMixin, str):
4442
name: str
4543
"""The event name."""
4644

45+
delay: float = 0
46+
"""The delay in milliseconds before the event is triggered. Default is 0."""
47+
4748
_sm: "StateMachine | None" = None
4849
"""The state machine instance."""
4950

@@ -55,6 +56,7 @@ def __new__(
5556
transitions: "str | TransitionList | None" = None,
5657
id: "str | None" = None,
5758
name: "str | None" = None,
59+
delay: float = 0,
5860
_sm: "StateMachine | None" = None,
5961
):
6062
if isinstance(transitions, str):
@@ -66,6 +68,7 @@ def __new__(
6668

6769
instance = super().__new__(cls, id)
6870
instance.id = id
71+
instance.delay = delay
6972
if name:
7073
instance.name = name
7174
elif _has_real_id:
@@ -106,19 +109,13 @@ def __get__(self, instance, owner):
106109
"""
107110
if instance is None:
108111
return self
109-
return BoundEvent(id=self.id, name=self.name, _sm=instance)
112+
return BoundEvent(id=self.id, name=self.name, delay=self.delay, _sm=instance)
110113

111-
def __call__(self, *args, **kwargs):
112-
"""Send this event to the current state machine.
113-
114-
Triggering an event on a state machine means invoking or sending a signal, initiating the
115-
process that may result in executing a transition.
116-
"""
114+
def put(self, *args, machine: "StateMachine", **kwargs):
117115
# The `__call__` is declared here to help IDEs knowing that an `Event`
118116
# can be called as a method. But it is not meant to be called without
119117
# an SM instance. Such SM instance is provided by `__get__` method when
120118
# used as a property descriptor.
121-
machine = self._sm
122119
if machine is None:
123120
raise RuntimeError(_("Event {} cannot be called without a SM instance").format(self))
124121

@@ -130,10 +127,20 @@ def __call__(self, *args, **kwargs):
130127
kwargs=kwargs,
131128
)
132129
machine._put_nonblocking(trigger_data)
133-
result = machine._processing_loop()
134-
if not isawaitable(result):
135-
return result
136-
return run_async_from_sync(result)
130+
131+
def __call__(self, *args, **kwargs):
132+
"""Send this event to the current state machine.
133+
134+
Triggering an event on a state machine means invoking or sending a signal, initiating the
135+
process that may result in executing a transition.
136+
"""
137+
# The `__call__` is declared here to help IDEs knowing that an `Event`
138+
# can be called as a method. But it is not meant to be called without
139+
# an SM instance. Such SM instance is provided by `__get__` method when
140+
# used as a property descriptor.
141+
machine = self._sm
142+
self.put(*args, machine=machine, **kwargs)
143+
return machine._processing_loop()
137144

138145
def split( # type: ignore[override]
139146
self, sep: "str | None" = None, maxsplit: int = -1

statemachine/event_data.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from dataclasses import dataclass
22
from dataclasses import field
3+
from time import time
34
from typing import TYPE_CHECKING
45
from typing import Any
56

@@ -11,23 +12,36 @@
1112

1213

1314
@dataclass
15+
class _Data:
16+
kwargs: dict
17+
18+
def __getattr__(self, name):
19+
return self.kwargs.get(name, None)
20+
21+
22+
@dataclass(order=True)
1423
class TriggerData:
15-
machine: "StateMachine"
24+
machine: "StateMachine" = field(compare=False)
1625

17-
event: "Event | None"
26+
event: "Event | None" = field(compare=False)
1827
"""The Event that was triggered."""
1928

20-
model: Any = field(init=False)
29+
execution_time: float = field(default=0.0)
30+
"""The time at which the :ref:`Event` should run."""
31+
32+
model: Any = field(init=False, compare=False)
2133
"""A reference to the underlying model that holds the current :ref:`State`."""
2234

23-
args: tuple = field(default_factory=tuple)
35+
args: tuple = field(default_factory=tuple, compare=False)
2436
"""All positional arguments provided on the :ref:`Event`."""
2537

26-
kwargs: dict = field(default_factory=dict)
38+
kwargs: dict = field(default_factory=dict, compare=False)
2739
"""All keyword arguments provided on the :ref:`Event`."""
2840

2941
def __post_init__(self):
3042
self.model = self.machine.model
43+
delay = self.event.delay if self.event and self.event.delay else 0
44+
self.execution_time = time() + (delay / 1000)
3145

3246

3347
@dataclass
@@ -77,3 +91,13 @@ def extended_kwargs(self):
7791
kwargs["source"] = self.source
7892
kwargs["target"] = self.target
7993
return kwargs
94+
95+
@property
96+
def data(self):
97+
"Property used by the SCXML namespace"
98+
if self.trigger_data.kwargs:
99+
return _Data(self.trigger_data.kwargs)
100+
elif self.trigger_data.args and len(self.trigger_data.args) == 1:
101+
return self.trigger_data.args[0]
102+
else:
103+
return self.trigger_data.args

0 commit comments

Comments
 (0)