Skip to content

Commit

Permalink
feat: Support for SCXML <cancel> tag. Allow cancelling delayed events
Browse files Browse the repository at this point in the history
  • Loading branch information
fgmacedo committed Nov 30, 2024
1 parent 1e534c8 commit 7efb429
Show file tree
Hide file tree
Showing 12 changed files with 290 additions and 11 deletions.
18 changes: 13 additions & 5 deletions statemachine/engines/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,23 @@ def clear(self):
with self._external_queue.mutex:
self._external_queue.queue.clear()

def cancel_event(self, send_id: str):
"""Cancel the event with the given send_id."""

# We use the internal `queue` to make thins faster as the mutex
# is protecting the block below
with self._external_queue.mutex:
self._external_queue.queue = [
trigger_data
for trigger_data in self._external_queue.queue
if trigger_data.send_id != send_id
]

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

trigger_data = TriggerData(
machine=self.sm,
event=BoundEvent("__initial__", _sm=self.sm),
)
self.put(trigger_data)
BoundEvent("__initial__", _sm=self.sm).put(machine=self.sm)

def _initial_transition(self, trigger_data):
transition = Transition(State(), self.sm._get_initial_state(), event="__initial__")
Expand Down
4 changes: 3 additions & 1 deletion statemachine/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def __get__(self, instance, owner):
return self
return BoundEvent(id=self.id, name=self.name, delay=self.delay, _sm=instance)

def put(self, *args, machine: "StateMachine", **kwargs):
def put(self, *args, machine: "StateMachine", send_id: "str | None" = None, **kwargs):
# The `__call__` is declared here to help IDEs knowing that an `Event`
# can be called as a method. But it is not meant to be called without
# an SM instance. Such SM instance is provided by `__get__` method when
Expand All @@ -123,10 +123,12 @@ def put(self, *args, machine: "StateMachine", **kwargs):
trigger_data = TriggerData(
machine=machine,
event=self,
send_id=send_id,
args=args,
kwargs=kwargs,
)
machine._put_nonblocking(trigger_data)
return trigger_data

def __call__(self, *args, **kwargs):
"""Send this event to the current state machine.
Expand Down
12 changes: 12 additions & 0 deletions statemachine/event_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from time import time
from typing import TYPE_CHECKING
from typing import Any
from uuid import uuid4

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

send_id: "str | None" = field(compare=False, default=None)
"""A string literal to be used as the id of this instance of :ref:`TriggerData`.
Allow revoking a delayed :ref:`TriggerData` instance.
"""

execution_time: float = field(default=0.0)
"""The time at which the :ref:`Event` should run."""

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


@dataclass
Expand All @@ -65,6 +74,9 @@ class EventData:

executed: bool = False

origintype: str = "http://www.w3.org/TR/scxml/#SCXMLEventProcessor"
"""The origintype of the :ref:`Event` as specified by the SCXML namespace."""

def __post_init__(self):
self.state = self.transition.source
self.source = self.transition.source
Expand Down
36 changes: 33 additions & 3 deletions statemachine/io/scxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ def parse_element(element):
return parse_if(element)
elif tag == "send":
return parse_send(element)
elif tag == "cancel":
return parse_cancel(element)
else:
raise ValueError(f"Unknown tag: {tag}")

Check warning on line 65 in statemachine/io/scxml.py

View check run for this annotation

Codecov / codecov/patch

statemachine/io/scxml.py#L65

Added line #L65 was not covered by tests

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


def parse_cancel(element):
"""Parses the <cancel> element into a callable."""
sendid = element.attrib.get("sendid")
sendidexpr = element.attrib.get("sendidexpr")

def cancel(*args, **kwargs):
if sendid and sendidexpr:
raise ValueError("<cancel> cannot have both a 'sendid' and 'sendidexpr' attribute")

Check warning on line 86 in statemachine/io/scxml.py

View check run for this annotation

Codecov / codecov/patch

statemachine/io/scxml.py#L86

Added line #L86 was not covered by tests
elif sendid:
send_id = sendid
elif sendidexpr:
send_id = _eval(sendidexpr, **kwargs)

Check warning on line 90 in statemachine/io/scxml.py

View check run for this annotation

Codecov / codecov/patch

statemachine/io/scxml.py#L90

Added line #L90 was not covered by tests
else:
raise ValueError("<cancel> must have either a 'sendid' or 'sendidexpr' attribute")

Check warning on line 92 in statemachine/io/scxml.py

View check run for this annotation

Codecov / codecov/patch

statemachine/io/scxml.py#L92

Added line #L92 was not covered by tests
machine = kwargs["machine"]
machine.cancel_event(send_id)

return cancel


def parse_log(element):
"""Parses the <log> element into a callable."""
label = element.attrib["label"]
Expand Down Expand Up @@ -360,7 +382,7 @@ def parse_send(element): # noqa: C901
raise ValueError("<send> must have an 'event' or `eventexpr` attribute")

Check warning on line 382 in statemachine/io/scxml.py

View check run for this annotation

Codecov / codecov/patch

statemachine/io/scxml.py#L382

Added line #L382 was not covered by tests

target_expr = element.attrib.get("target")
type_expr = element.attrib.get("type")
type_attr = element.attrib.get("type")
id_attr = element.attrib.get("id")
idlocation = element.attrib.get("idlocation")
delay_attr = element.attrib.get("delay")
Expand All @@ -386,7 +408,10 @@ def send_action(*args, **kwargs):
# Evaluate expressions
event = event_attr or eval(event_expr, {}, context)
_target = eval(target_expr, {}, context) if target_expr else None
_event_type = eval(type_expr, {}, context) if type_expr else None
if type_attr and type_attr != "http://www.w3.org/TR/scxml/#SCXMLEventProcessor":
raise ValueError(
"Only 'http://www.w3.org/TR/scxml/#SCXMLEventProcessor' event type is supported"
)

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

Event(id=event, name=event, delay=delay).put(*content, machine=machine, **params_values)
Event(id=event, name=event, delay=delay).put(
*content,
machine=machine,
send_id=send_id,
**params_values,
)

return send_action

Expand Down
8 changes: 6 additions & 2 deletions statemachine/statemachine.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ def _put_nonblocking(self, trigger_data: TriggerData):
"""Put the trigger on the queue without blocking the caller."""
self._engine.put(trigger_data)

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

def cancel_event(self, send_id: str):
"""Cancel all the delayed events with the given ``send_id``."""
self._engine.cancel_event(send_id)

@property
def is_terminated(self):
return not self._engine._running
26 changes: 26 additions & 0 deletions tests/w3c_tests/testcases/test194.scxml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- we test that specifying an illegal target for <send> causes the event error.execution to be raised. If it does,
we succeed. Otherwise we eventually timeout and fail. -->
<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">
<state id="s0">
<onentry>
<!-- should cause an error -->
<send target="baz" event="event2"/>
<!-- this will get added to the external event queue after the error has been raised -->
<send event="timeout"/>
</onentry>
<!-- once we've entered the state, we should check for internal events first -->
<transition event="error.execution" target="pass"/>
<transition event="*" target="fail"/>
</state>
<final id="pass">
<onentry>
<log label="Outcome" expr="'pass'"/>
</onentry>
</final>
<final id="fail">
<onentry>
<log label="Outcome" expr="'fail'"/>
</onentry>
</final>
</scxml>
26 changes: 26 additions & 0 deletions tests/w3c_tests/testcases/test198.scxml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- we test that if type is not provided <send> uses the scxml event i/o processor. The only way to
tell
what processor was used is to look at the origintype of the resulting event -->
<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">
<state id="s0">
<onentry>
<send event="event1" />
<send event="timeout" />
</onentry>
<transition event="event1"
cond=" _event.origintype == 'http://www.w3.org/TR/scxml/#SCXMLEventProcessor'" target="pass" />
<transition event="*" target="fail" />
</state>
<final id="pass">
<onentry>
<log label="Outcome" expr="'pass'" />
</onentry>
</final>
<final id="fail">
<onentry>
<log label="Outcome" expr="'fail'" />
</onentry>
</final>
</scxml>
23 changes: 23 additions & 0 deletions tests/w3c_tests/testcases/test199.scxml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- we test that using an invalid send type results in error.execution -->
<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">
<state id="s0">
<onentry>
<send type="27" event="event1" />
<send event="timeout" />
</onentry>
<transition event="error.execution" target="pass" />
<transition event="*" target="fail" />
</state>
<final id="pass">
<onentry>
<log label="Outcome" expr="'pass'" />
</onentry>
</final>
<final id="fail">
<onentry>
<log label="Outcome" expr="'fail'" />
</onentry>
</final>
</scxml>
23 changes: 23 additions & 0 deletions tests/w3c_tests/testcases/test200.scxml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- we test that the processor supports the scxml event i/o processor -->
<scxml xmlns="http://www.w3.org/2005/07/scxml" xmlns:conf="http://www.w3.org/2005/scxml-conformance"
initial="s0" datamodel="ecmascript" version="1.0">
<state id="s0">
<onentry>
<send type="http://www.w3.org/TR/scxml/#SCXMLEventProcessor" event="event1" />
<send event="timeout" />
</onentry>
<transition event="event1" target="pass" />
<transition event="*" target="fail" />
</state>
<final id="pass">
<onentry>
<log label="Outcome" expr="'pass'" />
</onentry>
</final>
<final id="fail">
<onentry>
<log label="Outcome" expr="'fail'" />
</onentry>
</final>
</scxml>
36 changes: 36 additions & 0 deletions tests/w3c_tests/testcases/test205.scxml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- we test that the processor doesn't change the message. We can't test that it never does this,
but
at least we can check that the event name and included data are the same as we sent. -->
<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">
<datamodel>
<data id="Var1" />
</datamodel>
<state id="s0">
<onentry>
<send event="event1">
<param name="aParam" expr="1" />
</send>
<send event="timeout" />
</onentry>
<transition event="event1" target="s1">
<assign location="Var1" expr="_event.data.aParam" />
</transition>
<transition event="*" target="fail" />
</state>
<state id="s1">
<transition cond="Var1==1" target="pass" />
<transition target="fail" />
</state>
<final id="pass">
<onentry>
<log label="Outcome" expr="'pass'" />
</onentry>
</final>
<final id="fail">
<onentry>
<log label="Outcome" expr="'fail'" />
</onentry>
</final>
</scxml>
63 changes: 63 additions & 0 deletions tests/w3c_tests/testcases/test207-fail.scxml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<scxml datamodel="ecmascript" initial="s0" name="ScxmlTest207" version="1.0"
xmlns="http://www.w3.org/2005/07/scxml" xmlns:conf="http://www.w3.org/2005/scxml-conformance"><!--We
test that that we can't cancel an event in another session.
We invoke a child process. It notifies us when it has generated
a delayed event with sendid `foo`. We try to cancel `foo`.
The child process sends us event.
Event success if the event is not cancelled, event fail otherwise.
This doesn't test that there is absolutely no way to cancel an event
raised in another session, but the spec doesn't define any way
to refer to an event in another process-->
<state id="s0" initial="s01">
<onentry>
<send delayexpr="'2s'" event="timeout" />
</onentry>
<invoke type="scxml">
<content>
<scxml datamodel="ecmascript" initial="sub0" name="ScxmlSub" version="1.0"
xmlns="http://www.w3.org/2005/07/scxml">
<state id="sub0">
<onentry>
<send delayexpr="'1s'" event="event1" id="foo" />
<send delayexpr="'1.5s'" event="event2" />
<send event="childToParent" target="#_parent" />
</onentry>
<transition event="event1" target="subFinal">
<send target="#_parent" event="pass" />
</transition>
<transition event="*" target="subFinal">
<send target="#_parent" event="fail" />
</transition>
</state>
<final id="subFinal" />
</scxml><!--when
invoked, we raise a delayed event1 with sendid 'foo' and notify our parent. Then we
wait.
If event1 occurs, the parent hasn't succeeded in canceling it and we return pass. If event2 occurs
it means event1 was canceled (because event2 is delayed longer than event1) and we return
'fail'.-->
</content>
</invoke>
<transition event="timeout" target="fail" />
<state id="s01">
<transition event="childToParent" target="s02">
<cancel sendid="foo" />
</transition>
</state>
<state id="s02">
<transition event="pass" target="pass" />
<transition event="fail" target="fail" />
</state>
</state>
<final id="pass">
<onentry>
<log expr="'pass'" label="Outcome" />
</onentry>
</final>
<final id="fail">
<onentry>
<log expr="'fail'" label="Outcome" />
</onentry>
</final>
</scxml>
Loading

0 comments on commit 7efb429

Please sign in to comment.