Skip to content

Commit

Permalink
fix: SCXML _event should be bound only after the first event; the ins…
Browse files Browse the repository at this point in the history
…tance should keep the same
  • Loading branch information
fgmacedo committed Dec 23, 2024
1 parent 8c7db7b commit 0d57a45
Show file tree
Hide file tree
Showing 6 changed files with 38 additions and 92 deletions.
22 changes: 19 additions & 3 deletions statemachine/engines/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,15 @@ def __init__(self, sm: "StateMachine"):
self._sentinel = object()
self.running = True
self._processing = Lock()
self._cache: Dict = {} # Cache for _get_args_kwargs results

def empty(self):
return self.external_queue.is_empty()

def clear_cache(self):
"""Clears the cache. Should be called at the start of each processing loop."""
self._cache.clear()

def put(self, trigger_data: TriggerData, internal: bool = False, _delayed: bool = False):
"""Put the trigger on the queue without blocking the caller."""
if not self.running and not self.sm.allow_event_without_transition:
Expand Down Expand Up @@ -310,7 +315,13 @@ def microstep(self, transitions: List[Transition], trigger_data: TriggerData):
def _get_args_kwargs(
self, transition: Transition, trigger_data: TriggerData, target: "State | None" = None
):
# TODO: Ideally this method should be called only once per microstep/transition
# Generate a unique key for the cache, the cache is invalidated once per loop
cache_key = (id(transition), id(trigger_data), id(target))

# Check the cache for existing results
if cache_key in self._cache:
return self._cache[cache_key]

event_data = EventData(trigger_data=trigger_data, transition=transition)
if target:
event_data.state = target
Expand All @@ -321,6 +332,9 @@ def _get_args_kwargs(
result = self.sm._callbacks.call(self.sm.prepare.key, *args, **kwargs)
for new_kwargs in result:
kwargs.update(new_kwargs)

# Store the result in the cache
self._cache[cache_key] = (args, kwargs)
return args, kwargs

def _conditions_match(self, transition: Transition, trigger_data: TriggerData):
Expand All @@ -329,7 +343,9 @@ def _conditions_match(self, transition: Transition, trigger_data: TriggerData):
self.sm._callbacks.call(transition.validators.key, *args, **kwargs)
return self.sm._callbacks.all(transition.cond.key, *args, **kwargs)

def _exit_states(self, enabled_transitions: List[Transition], trigger_data: TriggerData):
def _exit_states(
self, enabled_transitions: List[Transition], trigger_data: TriggerData
) -> OrderedSet[State]:
"""Compute and process the states to exit for the given transitions."""
states_to_exit = self._compute_exit_set(enabled_transitions)

Expand All @@ -340,7 +356,7 @@ def _exit_states(self, enabled_transitions: List[Transition], trigger_data: Trig
ordered_states = sorted(
states_to_exit, key=lambda x: x.source and x.source.document_order or 0, reverse=True
)
result = OrderedSet([info.source for info in ordered_states])
result = OrderedSet([info.source for info in ordered_states if info.source])
logger.debug("States to exit: %s", result)

for info in ordered_states:
Expand Down
5 changes: 4 additions & 1 deletion statemachine/engines/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def processing_loop(self): # noqa: C901
try:
took_events = True
while took_events:
self.clear_cache()
took_events = False
# Execute the triggers in the queue in FIFO order until the queue is empty
# while self._running and not self.external_queue.is_empty():
Expand All @@ -74,6 +75,7 @@ def processing_loop(self): # noqa: C901

# handles eventless transitions and internal events
while not macrostep_done:
self.clear_cache()
internal_event = TriggerData(
self.sm, event=None
) # this one is a "null object"
Expand Down Expand Up @@ -101,10 +103,11 @@ def processing_loop(self): # noqa: C901
internal_event = self.internal_queue.pop()
enabled_transitions = self.select_transitions(internal_event)

Check warning on line 104 in statemachine/engines/sync.py

View check run for this annotation

Codecov / codecov/patch

statemachine/engines/sync.py#L103-L104

Added lines #L103 - L104 were not covered by tests
if enabled_transitions:
self.microstep(list(enabled_transitions))
self.microstep(list(enabled_transitions), internal_event)

Check warning on line 106 in statemachine/engines/sync.py

View check run for this annotation

Codecov / codecov/patch

statemachine/engines/sync.py#L106

Added line #L106 was not covered by tests

# Process external events
while not self.external_queue.is_empty():
self.clear_cache()
took_events = True
external_event = self.external_queue.pop()
current_time = time()
Expand Down
4 changes: 4 additions & 0 deletions statemachine/io/scxml/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ def __init__(self, event_data):
def __getattr__(self, name):
return getattr(self.event_data, name)

def __eq__(self, value):
"This makes SCXML test 329 pass. It assumes that the event is the same instance"
return isinstance(value, EventDataWrapper)

@property
def name(self):
return self.event_data.event
Expand Down
13 changes: 11 additions & 2 deletions statemachine/io/scxml/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import Dict
from typing import List

from ...event import Event
from ...exceptions import InvalidDefinition
from ...statemachine import StateMachine
from .. import StateDefinition
Expand Down Expand Up @@ -48,6 +49,7 @@ def location(self):
class SessionData:
machine: StateMachine
processor: IOProcessor
first_event_raised: bool = False

def __post_init__(self):
self.session_id = f"{self.machine.name}:{id(self.machine)}"
Expand Down Expand Up @@ -97,19 +99,26 @@ def process_definition(self, definition, location: str):
},
)

def _prepare_event(self, *args, **kwargs):
def _prepare_event(self, *args, event: Event, **kwargs):
machine = kwargs["machine"]
machine_weakref = getattr(machine, "__weakref__", None)
if machine_weakref:
machine = machine_weakref()

session_data = self._get_session(machine)

extra_params = {}
if not session_data.first_event_raised and event and not event == "__initial__":
session_data.first_event_raised = True

if session_data.first_event_raised:
extra_params = {"_event": EventDataWrapper(kwargs["event_data"])}

return {
"_name": machine.name,
"_sessionid": session_data.session_id,
"_ioprocessors": session_data.processor,
"_event": EventDataWrapper(kwargs["event_data"]),
**extra_params,
}

def _get_session(self, machine: StateMachine):
Expand Down
31 changes: 0 additions & 31 deletions tests/scxml/w3c/mandatory/test319.fail.md

This file was deleted.

55 changes: 0 additions & 55 deletions tests/scxml/w3c/mandatory/test329.fail.md

This file was deleted.

0 comments on commit 0d57a45

Please sign in to comment.