Skip to content

Commit 883854d

Browse files
committed
feat: Support for SCXML <cancel> tag. Allow cancelling delayed events
1 parent c12f8cf commit 883854d

File tree

12 files changed

+290
-11
lines changed

12 files changed

+290
-11
lines changed

statemachine/engines/base.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,23 @@ def clear(self):
4343
with self._external_queue.mutex:
4444
self._external_queue.queue.clear()
4545

46+
def cancel_event(self, send_id: str):
47+
"""Cancel the event with the given send_id."""
48+
49+
# We use the internal `queue` to make thins faster as the mutex
50+
# is protecting the block below
51+
with self._external_queue.mutex:
52+
self._external_queue.queue = [
53+
trigger_data
54+
for trigger_data in self._external_queue.queue
55+
if trigger_data.send_id != send_id
56+
]
57+
4658
def start(self):
4759
if self.sm.current_state_value is not None:
4860
return
4961

50-
trigger_data = TriggerData(
51-
machine=self.sm,
52-
event=BoundEvent("__initial__", _sm=self.sm),
53-
)
54-
self.put(trigger_data)
62+
BoundEvent("__initial__", _sm=self.sm).put(machine=self.sm)
5563

5664
def _initial_transition(self, trigger_data):
5765
transition = Transition(State(), self.sm._get_initial_state(), event="__initial__")

statemachine/event.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ def __get__(self, instance, owner):
111111
return self
112112
return BoundEvent(id=self.id, name=self.name, delay=self.delay, _sm=instance)
113113

114-
def put(self, *args, machine: "StateMachine", **kwargs):
114+
def put(self, *args, machine: "StateMachine", send_id: "str | None" = None, **kwargs):
115115
# The `__call__` is declared here to help IDEs knowing that an `Event`
116116
# can be called as a method. But it is not meant to be called without
117117
# an SM instance. Such SM instance is provided by `__get__` method when
@@ -123,10 +123,12 @@ def put(self, *args, machine: "StateMachine", **kwargs):
123123
trigger_data = TriggerData(
124124
machine=machine,
125125
event=self,
126+
send_id=send_id,
126127
args=args,
127128
kwargs=kwargs,
128129
)
129130
machine._put_nonblocking(trigger_data)
131+
return trigger_data
130132

131133
def __call__(self, *args, **kwargs):
132134
"""Send this event to the current state machine.

statemachine/event_data.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from time import time
44
from typing import TYPE_CHECKING
55
from typing import Any
6+
from uuid import uuid4
67

78
if TYPE_CHECKING:
89
from .event import Event
@@ -26,6 +27,12 @@ class TriggerData:
2627
event: "Event | None" = field(compare=False)
2728
"""The Event that was triggered."""
2829

30+
send_id: "str | None" = field(compare=False, default=None)
31+
"""A string literal to be used as the id of this instance of :ref:`TriggerData`.
32+
33+
Allow revoking a delayed :ref:`TriggerData` instance.
34+
"""
35+
2936
execution_time: float = field(default=0.0)
3037
"""The time at which the :ref:`Event` should run."""
3138

@@ -42,6 +49,8 @@ def __post_init__(self):
4249
self.model = self.machine.model
4350
delay = self.event.delay if self.event and self.event.delay else 0
4451
self.execution_time = time() + (delay / 1000)
52+
if self.send_id is None:
53+
self.send_id = uuid4().hex
4554

4655

4756
@dataclass
@@ -65,6 +74,9 @@ class EventData:
6574

6675
executed: bool = False
6776

77+
origintype: str = "http://www.w3.org/TR/scxml/#SCXMLEventProcessor"
78+
"""The origintype of the :ref:`Event` as specified by the SCXML namespace."""
79+
6880
def __post_init__(self):
6981
self.state = self.transition.source
7082
self.source = self.transition.source

statemachine/io/scxml.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ def parse_element(element):
5959
return parse_if(element)
6060
elif tag == "send":
6161
return parse_send(element)
62+
elif tag == "cancel":
63+
return parse_cancel(element)
6264
else:
6365
raise ValueError(f"Unknown tag: {tag}")
6466

@@ -74,6 +76,26 @@ def raise_action(*args, **kwargs):
7476
return raise_action
7577

7678

79+
def parse_cancel(element):
80+
"""Parses the <cancel> element into a callable."""
81+
sendid = element.attrib.get("sendid")
82+
sendidexpr = element.attrib.get("sendidexpr")
83+
84+
def cancel(*args, **kwargs):
85+
if sendid and sendidexpr:
86+
raise ValueError("<cancel> cannot have both a 'sendid' and 'sendidexpr' attribute")
87+
elif sendid:
88+
send_id = sendid
89+
elif sendidexpr:
90+
send_id = _eval(sendidexpr, **kwargs)
91+
else:
92+
raise ValueError("<cancel> must have either a 'sendid' or 'sendidexpr' attribute")
93+
machine = kwargs["machine"]
94+
machine.cancel_event(send_id)
95+
96+
return cancel
97+
98+
7799
def parse_log(element):
78100
"""Parses the <log> element into a callable."""
79101
label = element.attrib["label"]
@@ -360,7 +382,7 @@ def parse_send(element): # noqa: C901
360382
raise ValueError("<send> must have an 'event' or `eventexpr` attribute")
361383

362384
target_expr = element.attrib.get("target")
363-
type_expr = element.attrib.get("type")
385+
type_attr = element.attrib.get("type")
364386
id_attr = element.attrib.get("id")
365387
idlocation = element.attrib.get("idlocation")
366388
delay_attr = element.attrib.get("delay")
@@ -386,7 +408,10 @@ def send_action(*args, **kwargs):
386408
# Evaluate expressions
387409
event = event_attr or eval(event_expr, {}, context)
388410
_target = eval(target_expr, {}, context) if target_expr else None
389-
_event_type = eval(type_expr, {}, context) if type_expr else None
411+
if type_attr and type_attr != "http://www.w3.org/TR/scxml/#SCXMLEventProcessor":
412+
raise ValueError(
413+
"Only 'http://www.w3.org/TR/scxml/#SCXMLEventProcessor' event type is supported"
414+
)
390415

391416
if id_attr:
392417
send_id = id_attr
@@ -412,7 +437,12 @@ def send_action(*args, **kwargs):
412437
for name, expr in params.items():
413438
params_values[name] = eval(expr, {}, context)
414439

415-
Event(id=event, name=event, delay=delay).put(*content, machine=machine, **params_values)
440+
Event(id=event, name=event, delay=delay).put(
441+
*content,
442+
machine=machine,
443+
send_id=send_id,
444+
**params_values,
445+
)
416446

417447
return send_action
418448

statemachine/statemachine.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ def _put_nonblocking(self, trigger_data: TriggerData):
301301
"""Put the trigger on the queue without blocking the caller."""
302302
self._engine.put(trigger_data)
303303

304-
def send(self, event: str, *args, delay: float = 0, **kwargs):
304+
def send(self, event: str, *args, delay: float = 0, event_id: "str | None" = None, **kwargs):
305305
"""Send an :ref:`Event` to the state machine.
306306
307307
:param event: The trigger for the state machine, specified as an event id string.
@@ -319,11 +319,15 @@ def send(self, event: str, *args, delay: float = 0, **kwargs):
319319
delay if delay else know_event and know_event.delay or 0
320320
) # first the param, then the event, or 0
321321
event_instance = BoundEvent(id=event, name=event_name, delay=delay, _sm=self)
322-
result = event_instance(*args, **kwargs)
322+
result = event_instance(*args, event_id=event_id, **kwargs)
323323
if not isawaitable(result):
324324
return result
325325
return run_async_from_sync(result)
326326

327+
def cancel_event(self, send_id: str):
328+
"""Cancel all the delayed events with the given ``send_id``."""
329+
self._engine.cancel_event(send_id)
330+
327331
@property
328332
def is_terminated(self):
329333
return not self._engine._running
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!-- we test that specifying an illegal target for <send> causes the event error.execution to be raised. If it does,
3+
we succeed. Otherwise we eventually timeout and fail. -->
4+
<scxml xmlns="http://www.w3.org/2005/07/scxml" xmlns:conf="http://www.w3.org/2005/scxml-conformance" initial="s0" version="1.0" datamodel="ecmascript">
5+
<state id="s0">
6+
<onentry>
7+
<!-- should cause an error -->
8+
<send target="baz" event="event2"/>
9+
<!-- this will get added to the external event queue after the error has been raised -->
10+
<send event="timeout"/>
11+
</onentry>
12+
<!-- once we've entered the state, we should check for internal events first -->
13+
<transition event="error.execution" target="pass"/>
14+
<transition event="*" target="fail"/>
15+
</state>
16+
<final id="pass">
17+
<onentry>
18+
<log label="Outcome" expr="'pass'"/>
19+
</onentry>
20+
</final>
21+
<final id="fail">
22+
<onentry>
23+
<log label="Outcome" expr="'fail'"/>
24+
</onentry>
25+
</final>
26+
</scxml>
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!-- we test that if type is not provided <send> uses the scxml event i/o processor. The only way to
3+
tell
4+
what processor was used is to look at the origintype of the resulting event -->
5+
<scxml xmlns="http://www.w3.org/2005/07/scxml" xmlns:conf="http://www.w3.org/2005/scxml-conformance"
6+
initial="s0" version="1.0" datamodel="ecmascript">
7+
<state id="s0">
8+
<onentry>
9+
<send event="event1" />
10+
<send event="timeout" />
11+
</onentry>
12+
<transition event="event1"
13+
cond=" _event.origintype == 'http://www.w3.org/TR/scxml/#SCXMLEventProcessor'" target="pass" />
14+
<transition event="*" target="fail" />
15+
</state>
16+
<final id="pass">
17+
<onentry>
18+
<log label="Outcome" expr="'pass'" />
19+
</onentry>
20+
</final>
21+
<final id="fail">
22+
<onentry>
23+
<log label="Outcome" expr="'fail'" />
24+
</onentry>
25+
</final>
26+
</scxml>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!-- we test that using an invalid send type results in error.execution -->
3+
<scxml xmlns="http://www.w3.org/2005/07/scxml" xmlns:conf="http://www.w3.org/2005/scxml-conformance"
4+
initial="s0" version="1.0" datamodel="ecmascript">
5+
<state id="s0">
6+
<onentry>
7+
<send type="27" event="event1" />
8+
<send event="timeout" />
9+
</onentry>
10+
<transition event="error.execution" target="pass" />
11+
<transition event="*" target="fail" />
12+
</state>
13+
<final id="pass">
14+
<onentry>
15+
<log label="Outcome" expr="'pass'" />
16+
</onentry>
17+
</final>
18+
<final id="fail">
19+
<onentry>
20+
<log label="Outcome" expr="'fail'" />
21+
</onentry>
22+
</final>
23+
</scxml>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!-- we test that the processor supports the scxml event i/o processor -->
3+
<scxml xmlns="http://www.w3.org/2005/07/scxml" xmlns:conf="http://www.w3.org/2005/scxml-conformance"
4+
initial="s0" datamodel="ecmascript" version="1.0">
5+
<state id="s0">
6+
<onentry>
7+
<send type="http://www.w3.org/TR/scxml/#SCXMLEventProcessor" event="event1" />
8+
<send event="timeout" />
9+
</onentry>
10+
<transition event="event1" target="pass" />
11+
<transition event="*" target="fail" />
12+
</state>
13+
<final id="pass">
14+
<onentry>
15+
<log label="Outcome" expr="'pass'" />
16+
</onentry>
17+
</final>
18+
<final id="fail">
19+
<onentry>
20+
<log label="Outcome" expr="'fail'" />
21+
</onentry>
22+
</final>
23+
</scxml>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!-- we test that the processor doesn't change the message. We can't test that it never does this,
3+
but
4+
at least we can check that the event name and included data are the same as we sent. -->
5+
<scxml xmlns="http://www.w3.org/2005/07/scxml" xmlns:conf="http://www.w3.org/2005/scxml-conformance"
6+
initial="s0" version="1.0" datamodel="ecmascript">
7+
<datamodel>
8+
<data id="Var1" />
9+
</datamodel>
10+
<state id="s0">
11+
<onentry>
12+
<send event="event1">
13+
<param name="aParam" expr="1" />
14+
</send>
15+
<send event="timeout" />
16+
</onentry>
17+
<transition event="event1" target="s1">
18+
<assign location="Var1" expr="_event.data.aParam" />
19+
</transition>
20+
<transition event="*" target="fail" />
21+
</state>
22+
<state id="s1">
23+
<transition cond="Var1==1" target="pass" />
24+
<transition target="fail" />
25+
</state>
26+
<final id="pass">
27+
<onentry>
28+
<log label="Outcome" expr="'pass'" />
29+
</onentry>
30+
</final>
31+
<final id="fail">
32+
<onentry>
33+
<log label="Outcome" expr="'fail'" />
34+
</onentry>
35+
</final>
36+
</scxml>

0 commit comments

Comments
 (0)