Skip to content

Commit

Permalink
Events are cancellable
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexandreDecan committed Aug 21, 2018
1 parent 11da70e commit f4d50d7
Show file tree
Hide file tree
Showing 6 changed files with 80 additions and 26 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Unreleased

- (Added) Some documentation about running multiple statecharts.
- (Added) An ``unbind`` method for an ``Interpreter``.
- (Added) A ``remove`` method on ``EventQueue`` and a corresponding ``cancel`` method on ``Interpreter``.
- (Changed) Meta-Event *step started* has a ``time`` attribute.
- (Fixed) Hook-errors reported by ``sismic-bdd`` CLI are a little bit more verbose (`#81 <https://github.com/AlexandreDecan/sismic/issues/81>`__).
- (Fixed) Optional transition for ``testing.transition_is_processed``, as previously promised by its documentation but not implemented.
Expand Down
17 changes: 9 additions & 8 deletions docs/execution.rst
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ For convenience, :py:meth:`~sismic.interpreter.Interpreter.queue` returns the in
interpreter.queue('click', 'clack').execute_once()

Notice that :py:meth:`~sismic.interpreter.Interpreter.execute_once` consumes at most one event at a time.
In this example, the *clack* event is not processed.
In the above example, the *clack* event is not yet processed.

To process all events **at once**, one can repeatedly call :py:meth:`~sismic.interpreter.Interpreter.execute_once` until
it returns a ``None`` value, meaning that nothing happened during the last call. For instance:
Expand All @@ -174,7 +174,7 @@ it returns a ``None`` value, meaning that nothing happened during the last call.
while interpreter.execute_once():
pass

For convenience, an interpreter has a :py:meth:`~sismic.interpreter.Interpreter.execute` method that repeatedly
For convenience, an interpreter has an :py:meth:`~sismic.interpreter.Interpreter.execute` method that repeatedly
call :py:meth:`~sismic.interpreter.Interpreter.execute_once` and that returns a list of its output (a list of
:py:class:`sismic.model.MacroStep`).

Expand All @@ -193,7 +193,7 @@ As a call to :py:meth:`~sismic.interpreter.Interpreter.execute` could lead to an
(see for example `simple/infinite.yaml <https://github.com/AlexandreDecan/sismic/blob/master/tests/yaml/infinite.yaml>`__),
an additional parameter ``max_steps`` can be specified to limit the number of steps that are computed
and executed by the method. By default, this parameter is set to ``-1``, meaning there is no limit on the number
of calls to :py:meth:`~sismic.interpreter.Interpreter.execute_once`.
of underlying calls to :py:meth:`~sismic.interpreter.Interpreter.execute_once`.

.. testcode:: interpreter

Expand All @@ -203,9 +203,9 @@ of calls to :py:meth:`~sismic.interpreter.Interpreter.execute_once`.
# 'clock' is not yet processed
assert len(interpreter.execute()) == 1

In these examples, none of *click*, *clack* or *clock* are expected to be received by the statechart.
The statechart was not written to react to those events, and thus sending them has no effect on the active
configuration.
The statechart used for these examples did not react to *click*, *clack* and *clock* because none of
these events are expected to be received by the statechart (or, in other words, the statechart was
not written to react to these events).

For convenience, a :py:class:`~sismic.model.Statechart` has an :py:meth:`~sismic.model.Statechart.events_for` method
that returns the list of all possible events that are expected by this statechart.
Expand All @@ -231,7 +231,7 @@ These parameters can be accessed by action code and guards in the statechart.
For example, the *floorSelecting* state of the *elevator* example has a transition
``floorSelected / destination = event.floor``.

Executing the statechart will make the elevator reaching first floor:
Executing the statechart will move the elevator to first floor:

.. testcode:: interpreter

Expand All @@ -245,10 +245,11 @@ Executing the statechart will make the elevator reaching first floor:
Current floor is 0
Current floor is 1

Notice how we can access to the current values of *internal variables* by use of ``context``.
Notice how we can access the current values of *internal variables* by use of ``interpreter.context``.
This attribute is a mapping between internal variable names and their current value.



.. _steps:

Macro and micro steps
Expand Down
14 changes: 12 additions & 2 deletions sismic/interpreter/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ def bind_property_statechart(self, statechart_or_interpreter: Union[Statechart,

def queue(self, event_or_name: Union[str, Event], *events_or_names: Union[str, Event]) -> 'Interpreter':
"""
Queue one or more events to the interpreter external queue.
Queue one or more events to the interpreter queue.
If a DelayedEvent is provided, its delay must be a positive number.
The provided event will be processed by the first call to `execute_once`
Expand All @@ -188,14 +188,24 @@ def queue(self, event_or_name: Union[str, Event], *events_or_names: Union[str, E
event = Event(event) if isinstance(event, str) else event

if isinstance(event, InternalEvent):
raise ValueError('Internal event cannot be queue, use Event or DelayedEvent instead.')
raise ValueError('Internal event cannot be queued, use Event or DelayedEvent instead.')
elif isinstance(event, Event):
self._event_queue.push(self.clock.time, event)
else:
raise ValueError('{} is not a string nor an Event instance.'.format(event))

return self

def cancel(self, event_or_name: Union[str, Event]) -> bool:
"""
Remove first occurrence of given event (or name) from the interpreter queue.
:param event_or_name: an *Event* instance of the name of an event to cancel.
:return: True if the event was found and removed, False otherwise.
"""
event = Event(event_or_name) if isinstance(event_or_name, str) else event_or_name
return self._event_queue.remove(event) is not None

def execute(self, max_steps: int = -1) -> List[MacroStep]:
"""
Repeatedly calls *execute_once* and return a list containing
Expand Down
24 changes: 18 additions & 6 deletions sismic/interpreter/queue.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import heapq

from typing import Tuple, List

from typing import Tuple, List, Optional
from itertools import count
from ..model import Event, InternalEvent, DelayedEvent


Expand All @@ -18,18 +18,17 @@ class EventQueue:
"""
def __init__(self, *, internal_first=True) -> None:
self._queue = [] # type: List[Tuple[float, bool, int, Event]]
self._nb = 0
self._nb = count()
self._internal_first = internal_first

def _get_event(self, t):
return (t[0], t[-1])

def _set_event(self, time, event):
self._nb += 1
return (
time + (event.delay if isinstance(event, DelayedEvent) else 0),
(1 - int(isinstance(event, InternalEvent))) if self._internal_first else 0,
self._nb,
next(self._nb),
event
)

Expand All @@ -53,6 +52,20 @@ def pop(self) -> Tuple[float, Event]:
"""
return self._get_event(heapq.heappop(self._queue))

def remove(self, event: Event) -> Optional[Tuple[float, Event]]:
"""
Remove the first occurrence of given event from the queue.
:param event: event to remove.
:return: A pair (time, event) or None if the event is not found.
"""
for i, (time, _, _, e) in enumerate(self._queue):
if e == event:
self._queue.pop(i)
heapq.heapify(self._queue)
return time, e
return None

@property
def first(self) -> Tuple[float, Event]:
"""
Expand All @@ -71,4 +84,3 @@ def empty(self) -> bool:

def __len__(self) -> int:
return len(self._queue)

16 changes: 10 additions & 6 deletions sismic/model/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@

class Event:
"""
Simple event with a name and (optionally) some data.
Unless the attribute already exists, each key from *data* is exposed as an attribute
of this class.
An event with a name and (optionally) some data passed as named parameters.
The list of defined attributes can be obtained using *dir(event)*.
The list of parameters can be obtained using *dir(event)*. Notice that
*name* and *data* are reserved names.
:param name: Name of the event
:param data: additional data (mapping, dict-like)
When two events are compared, they are considered equal if their names
and their data are equal.
:param name: name of the event.
:param data: additional data passed as named parameters.
"""

__slots__ = ['name', 'data']
Expand Down Expand Up @@ -63,6 +65,8 @@ class InternalEvent(Event):
class DelayedEvent(Event):
"""
Event that is delayed.
When used, *delay* is a reserved name (ie. cannot be used as event parameter).
"""

__slots__ = ['name', 'delay', 'data']
Expand Down
34 changes: 30 additions & 4 deletions tests/test_interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from sismic.exceptions import ExecutionError, NonDeterminismError, ConflictingTransitionsError
from sismic.code import DummyEvaluator
from sismic.interpreter import Interpreter, Event, InternalEvent, DelayedEvent
from sismic.interpreter import Interpreter, Event, InternalEvent, DelayedEvent, DelayedInternalEvent
from sismic.helpers import coverage_from_trace, log_trace, run_in_background
from sismic.model import Transition, MacroStep, MicroStep, MetaEvent
from sismic import testing
Expand Down Expand Up @@ -663,7 +663,7 @@ def test_interpreter_is_serialisable(microwave):
assert microwave.context == n_microwave.context


class TestDelayedEvent:
class TestEventQueue:
@pytest.fixture()
def interpreter(self, simple_statechart):
interpreter = Interpreter(simple_statechart, evaluator_klass=DummyEvaluator)
Expand Down Expand Up @@ -693,7 +693,7 @@ def test_consume_order(self, interpreter):
interpreter.queue(DelayedEvent('test2', delay=1))
interpreter.queue(DelayedEvent('test4', delay=2))
interpreter.queue(DelayedEvent('test5', delay=3))

event = interpreter._select_event(consume=True)
assert isinstance(event, DelayedEvent) and event == Event('test1')
assert interpreter._select_event(consume=True) is None
Expand Down Expand Up @@ -724,5 +724,31 @@ def test_internal_first(self, interpreter):
interpreter._time = 1
event = interpreter._select_event(consume=True)
assert isinstance(event, DelayedEvent) and event == Event('test3')

def test_cancel_event(self, interpreter):
interpreter.queue('test1')
interpreter.queue(Event('test2', x=1))
interpreter.queue('test3')

# Normal cancellation
assert interpreter.cancel('test1')
assert interpreter._select_event(consume=False) == Event('test2', x=1)

# Cancellation of unknown event
assert not interpreter.cancel('test4')


# Cancellation satisfies event parameters
assert not interpreter.cancel('test2')
assert interpreter._select_event(consume=False) == Event('test2', x=1)
assert not interpreter.cancel(Event('test2', x=2))
assert interpreter._select_event(consume=False) == Event('test2', x=1)
assert interpreter.cancel(Event('test2', x=1))
assert interpreter._select_event(consume=False) == Event('test3')

# Cancellation only cancels first occurrence
interpreter.queue('test3')
interpreter.queue('test3')
interpreter.cancel('test3')
assert interpreter._select_event(consume=True) == Event('test3')
assert interpreter._select_event(consume=True) == Event('test3')
assert interpreter._select_event(consume=True) is None

0 comments on commit f4d50d7

Please sign in to comment.