From b392cbd68d852a5de20f1d3c1edeca90de9a8ba9 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sat, 14 Dec 2024 13:18:48 -0300 Subject: [PATCH] fix: Fix parallel enter/exit and checks --- docs/releases/3.0.0.md | 92 +++++++++++++++--- docs/transitions.md | 4 +- statemachine/contrib/diagram.py | 1 + statemachine/engines/base.py | 105 +++++++++++++++------ statemachine/engines/sync.py | 6 +- statemachine/factory.py | 14 ++- statemachine/io/scxml/actions.py | 5 +- statemachine/io/scxml/parser.py | 3 - statemachine/spec_parser.py | 10 +- statemachine/state.py | 11 ++- statemachine/statemachine.py | 41 ++++++-- statemachine/transition.py | 2 +- tests/conftest.py | 6 ++ tests/examples/persistent_model_machine.py | 2 +- tests/scxml/conftest.py | 5 + tests/scxml/test_scxml_cases.py | 44 ++++++--- tests/scxml/w3c/mandatory/test294.fail.md | 48 +++------- tests/scxml/w3c/mandatory/test298.fail.md | 47 +++------ tests/scxml/w3c/mandatory/test310.fail.md | 59 ------------ tests/scxml/w3c/mandatory/test343.fail.md | 47 +++------ tests/scxml/w3c/mandatory/test409.scxml | 3 +- tests/scxml/w3c/mandatory/test411.fail.md | 28 ------ tests/scxml/w3c/mandatory/test488.fail.md | 47 +++------ tests/scxml/w3c/mandatory/test527.fail.md | 48 +++------- tests/scxml/w3c/mandatory/test528.fail.md | 47 +++------ tests/scxml/w3c/mandatory/test529.fail.md | 48 +++------- tests/scxml/w3c/mandatory/test570.fail.md | 56 +++++------ tests/scxml/w3c/optional/test451.fail.md | 59 ------------ tests/test_multiple_destinations.py | 4 +- tests/test_transitions.py | 2 +- 30 files changed, 403 insertions(+), 491 deletions(-) delete mode 100644 tests/scxml/w3c/mandatory/test310.fail.md delete mode 100644 tests/scxml/w3c/mandatory/test411.fail.md delete mode 100644 tests/scxml/w3c/optional/test451.fail.md diff --git a/docs/releases/3.0.0.md b/docs/releases/3.0.0.md index e225db90..870397d1 100644 --- a/docs/releases/3.0.0.md +++ b/docs/releases/3.0.0.md @@ -4,11 +4,21 @@ ## What's new in 3.0.0 -Statecharts are there! Now the library has support for Compound and Parallel states. +Statecharts are there! 🎉 -### Python compatibility in 3.0.0 +Statecharts are a powerful extension to state machines, in a way to organize complex reactive systems as a hierarchical state machine. They extend the concept of state machines by adding two new kinds of states: **parallel states** and **compound states**. -StateMachine 3.0.0 supports Python 3.9, 3.10, 3.11, 3.12, and 3.13. +**Parallel states** are states that can be active at the same time. They are useful for separating the state machine in multiple orthogonal state machines that can be active at the same time. + +**Compound states** are states that have inner states. They are useful for breaking down complex state machines into multiple simpler ones. + +The support for statecharts in this release follows the [SCXML specification](https://www.w3.org/TR/scxml/)*, which is a W3C standard for statecharts notation. Adhering as much as possible to this specification ensures compatibility with other tools and platforms that also implement SCXML, but more important, +sets a standard on the expected behaviour that the library should assume on various edge cases, enabling easier integration and interoperability in complex systems. + +To verify the standard adoption, now the automated tests suite includes several `.scxml` testcases provided by the W3C group. Many thanks for this amazing work! Some of the tests are still failing, and some of the tags are still not implemented like `` , in such cases, we've added an `xfail` mark by including a `test.scxml.md` markdown file with details of the execution output. + +While these are exiting news for the library and our community, it also introduces several backwards incompatible changes. Due to the major version release, the new behaviour is assumed by default, but we put +a lot of effort to minimize the changes needed in your codebase, and also introduced a few configuration options that you can enable to restore the old behaviour when possible. The following sections navigate to the new features and includes a migration guide. ### Create state machine class from a dict definition @@ -73,9 +83,9 @@ A not so usefull example: ### Event matching following SCXML spec -Now events matching follows the SCXML spec. +Now events matching follows the [SCXML spec](https://www.w3.org/TR/scxml/#events): -For example, a transition with an `event` attribute of `"error foo"` will match event names `error`, `error.send`, `error.send.failed`, etc. (or `foo`, `foo.bar` etc.) +> For example, a transition with an `event` attribute of `"error foo"` will match event names `error`, `error.send`, `error.send.failed`, etc. (or `foo`, `foo.bar` etc.) but would not match events named `errors.my.custom`, `errorhandler.mistake`, `error.send` or `foobar`. An event designator consisting solely of `*` can be used as a wildcard matching any sequence of tokens, and thus any event. @@ -90,6 +100,7 @@ TODO: Example of delayed events Also, delayed events can be revoked by it's `send_id`. + ## Bugfixes in 3.0.0 - Fixes [#XXX](https://github.com/fgmacedo/python-statemachine/issues/XXX). @@ -100,22 +111,27 @@ TODO. ## Backward incompatible changes in 3.0 -- Dropped support for Python `3.7` and `3.8`. If you need support for these versios use the 2.* series. + +### Python compatibility in 3.0.0 + +We've dropped support for Python `3.7` and `3.8`. If you need support for these versios use the 2.* series. + +StateMachine 3.0.0 supports Python 3.9, 3.10, 3.11, 3.12, and 3.13. -## Non-RTC model removed +### Non-RTC model removed This option was deprecated on version 2.3.2. Now all new events are put on a queue before being processed. -## Multiple current states +### Multiple current states Due to the support of compound and parallel states, it's now possible to have multiple active states at the same time. -This introduces an impedance mismatch into the old public API, specifically, `sm.current_state` is deprecated and `sm.current_state_value` can returns a flat value if no compound state or a `list` instead. +This introduces an impedance mismatch into the old public API, specifically, `sm.current_state` is deprecated and `sm.current_state_value` can returns a flat value if no compound state or a `set` instead. ```{note} -To allow a smooth migration, these properties still work as before if there's no compound states in the state machine definition. +To allow a smooth migration, these properties still work as before if there's no compound/parallel states in the state machine definition. ``` Old @@ -130,9 +146,63 @@ New def current_state(self) -> "State | MutableSet[State]": ``` -We recomend using the new `sm.configuration` that has a stable API returning an `OrderedSet` on all cases: +We **strongly** recomend using the new `sm.configuration` that has a stable API returning an `OrderedSet` on all cases: ```py @property def configuration(self) -> OrderedSet["State"]: ``` + +### Entering and exiting states + +Previous versions performed an atomic update of the active state just after the execution of the transition `on` actions. + +Now, we follow the [SCXML spec](https://www.w3.org/TR/scxml/#SelectingTransitions): + +> To execute a microstep, the SCXML Processor MUST execute the transitions in the corresponding optimal enabled transition set. To execute a set of transitions, the SCXML Processor MUST first exit all the states in the transitions' exit set in exit order. It MUST then execute the executable content contained in the transitions in document order. It MUST then enter the states in the transitions' entry set in entry order. + +This introduces backward-incompatible changes, as previously, the `current_state` was never empty, allowing queries on `sm.current_state` or `sm..is_active` even while executing an `on` transition action. + +Now, by default, during a transition, all states in the exit set are exited first, performing the `before` and `exit` callbacks. The `on` callbacks are then executed in an intermediate state that contains only the states that will not be exited, which can be an empty set. Following this, the states in the enter set are entered, with `enter` callbacks executed for each state in document order, and finally, the `after` callbacks are executed with the state machine in the final new configuration. + +We have added two new keyword arguments available only in the `on` callbacks to assist with queries that were performed against `sm.current_state` or active states using `.is_active`: + +- `previous_configuration: OrderedSet[State]`: Contains the set of states that were active before the microstep was taken. +- `new_configuration: OrderedSet[State]`: Contains the set of states that will be active after the microstep finishes. + +Additionally, you can create a state machine instance by passing `atomic_configuration_update=True` (default `False`) to restore the old behavior. When set to `False`, the `sm.configuration` will be updated only once per microstep, just after the `on` callbacks with the `new_configuration`, the set of states that should be active after the microstep. + + +Consider this example that needs to be upgraded: + +```py +class ApprovalMachine(StateMachine): + "A workflow" + + requested = State(initial=True) + accepted = State() + rejected = State() + completed = State(final=True) + + validate = ( + requested.to(accepted, cond="is_ok") | requested.to(rejected) | accepted.to(completed) + ) + retry = rejected.to(requested) + + def on_validate(self): + if self.accepted.is_active and self.model.is_ok(): + return "congrats!" + +``` +The `validate` event is bound to several transitions, and the `on_validate` is expected to return `congrats` only when the state machine was with the `accepted` state active before the event occurs. In the old behavior, checking for `accepted.is_active` evaluates to `True` because the state were not exited before the `on` callback. + +Due to the new behaviour, at the time of the `on_validate` call, the state machine configuration (a.k.a the current set of active states) is empty. So at this point in time `accepted.is_active` evaluates to `False`. To mitigate this case, now you can request one of the two new keyword arguments: `previous_configuration` and `new_configration` in `on` callbacks. + +New way using `previous_configuration`: + +```py +def on_validate(self, previous_configuration): + if self.accepted in previous_configuration and self.model.is_ok(): + return "congrats!" + +``` diff --git a/docs/transitions.md b/docs/transitions.md index a0818adf..07b23bcf 100644 --- a/docs/transitions.md +++ b/docs/transitions.md @@ -84,7 +84,7 @@ Syntax: >>> draft = State("Draft") >>> draft.to.itself() -TransitionList([Transition('Draft', 'Draft', event=[], internal=False)]) +TransitionList([Transition('Draft', 'Draft', event=[], internal=False, initial=False)]) ``` @@ -101,7 +101,7 @@ Syntax: >>> draft = State("Draft") >>> draft.to.itself(internal=True) -TransitionList([Transition('Draft', 'Draft', event=[], internal=True)]) +TransitionList([Transition('Draft', 'Draft', event=[], internal=True, initial=False)]) ``` diff --git a/statemachine/contrib/diagram.py b/statemachine/contrib/diagram.py index 7ecbf1ca..b2a0e9bd 100644 --- a/statemachine/contrib/diagram.py +++ b/statemachine/contrib/diagram.py @@ -176,6 +176,7 @@ def get_graph(self): return graph def _graph_states(self, state, graph, is_root=False): + # TODO: handle parallel states in diagram initial_node = self._initial_node(state) initial_subgraph = pydot.Subgraph( graph_name=f"{initial_node.get_name()}_initial", diff --git a/statemachine/engines/base.py b/statemachine/engines/base.py index 166dc853..5efa8f59 100644 --- a/statemachine/engines/base.py +++ b/statemachine/engines/base.py @@ -79,7 +79,7 @@ def __init__(self, sm: "StateMachine"): def empty(self): return self.external_queue.is_empty() - def put(self, trigger_data: TriggerData, internal: bool = False): + 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: raise TransitionNotAllowed(trigger_data.event, self.sm.configuration) @@ -89,6 +89,13 @@ def put(self, trigger_data: TriggerData, internal: bool = False): else: self.external_queue.put(trigger_data) + if not _delayed: + logger.debug( + "New event '%s' put on the '%s' queue", + trigger_data.event, + "internal" if internal else "external", + ) + def pop(self): return self.external_queue.pop() @@ -268,14 +275,19 @@ def microstep(self, transitions: List[Transition], trigger_data: TriggerData): """Process a single set of transitions in a 'lock step'. This includes exiting states, executing transition content, and entering states. """ - result = self._execute_transition_content( - transitions, trigger_data, lambda t: t.before.key - ) + previous_configuration = self.sm.configuration + try: + result = self._execute_transition_content( + transitions, trigger_data, lambda t: t.before.key + ) - states_to_exit = self._exit_states(transitions, trigger_data) - logger.debug("States to exit: %s", states_to_exit) - result += self._execute_transition_content(transitions, trigger_data, lambda t: t.on.key) - self._enter_states(transitions, trigger_data, states_to_exit) + states_to_exit = self._exit_states(transitions, trigger_data) + result += self._enter_states( + transitions, trigger_data, states_to_exit, previous_configuration + ) + except Exception: + self.sm.configuration = previous_configuration + raise self._execute_transition_content( transitions, trigger_data, @@ -291,12 +303,13 @@ def microstep(self, transitions: List[Transition], trigger_data: TriggerData): return result def _get_args_kwargs( - self, transition: Transition, trigger_data: TriggerData, set_target_as_state: bool = False + self, transition: Transition, trigger_data: TriggerData, target: "State | None" = None ): # TODO: Ideally this method should be called only once per microstep/transition event_data = EventData(trigger_data=trigger_data, transition=transition) - if set_target_as_state: - event_data.state = transition.target + if target: + event_data.state = target + event_data.target = target args, kwargs = event_data.args, event_data.extended_kwargs @@ -319,10 +332,13 @@ def _exit_states(self, enabled_transitions: List[Transition], trigger_data: Trig # for state in states_to_exit: # self.states_to_invoke.discard(state) - # TODO: Sort states to exit in exit order - # states_to_exit = sorted(states_to_exit, key=self.exit_order) + 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]) + logger.debug("States to exit: %s", result) - for info in states_to_exit: + for info in ordered_states: args, kwargs = self._get_args_kwargs(info.transition, trigger_data) # # TODO: Update history @@ -342,9 +358,10 @@ def _exit_states(self, enabled_transitions: List[Transition], trigger_data: Trig # self.cancel_invoke(invocation) # Remove state from configuration - # self.sm.configuration -= {info.source} # .discard(info.source) + if not self.sm.atomic_configuration_update: + self.sm.configuration -= {info.source} # .discard(info.source) - return OrderedSet([info.source for info in states_to_exit]) + return result def _execute_transition_content( self, @@ -352,12 +369,17 @@ def _execute_transition_content( trigger_data: TriggerData, get_key: Callable[[Transition], str], set_target_as_state: bool = False, + **kwargs_extra, ): result = [] for transition in enabled_transitions: + target = transition.target if set_target_as_state else None args, kwargs = self._get_args_kwargs( - transition, trigger_data, set_target_as_state=set_target_as_state + transition, + trigger_data, + target=target, ) + kwargs.update(kwargs_extra) result += self.sm._callbacks.call(get_key(transition), *args, **kwargs) @@ -368,6 +390,7 @@ def _enter_states( enabled_transitions: List[Transition], trigger_data: TriggerData, states_to_exit: OrderedSet[State], + previous_configuration: OrderedSet[State], ): """Enter the states as determined by the given transitions.""" states_to_enter = OrderedSet[StateTransition]() @@ -379,29 +402,44 @@ def _enter_states( enabled_transitions, states_to_enter, states_for_default_entry, default_history_content ) + ordered_states = sorted( + states_to_enter, key=lambda x: x.source and x.source.document_order or 0 + ) + # We update the configuration atomically - states_targets_to_enter = OrderedSet( - info.target for info in states_to_enter if info.target + states_targets_to_enter = OrderedSet(info.target for info in ordered_states if info.target) + + new_configuration = cast( + OrderedSet[State], (previous_configuration - states_to_exit) | states_targets_to_enter ) logger.debug("States to enter: %s", states_targets_to_enter) - configuration = self.sm.configuration - self.sm.configuration = cast( - OrderedSet[State], (configuration - states_to_exit) | states_targets_to_enter + result = self._execute_transition_content( + enabled_transitions, + trigger_data, + lambda t: t.on.key, + previous_configuration=previous_configuration, + new_configuration=new_configuration, ) + if self.sm.atomic_configuration_update: + self.sm.configuration = new_configuration + # Sort states to enter in entry order # for state in sorted(states_to_enter, key=self.entry_order): # TODO: order of states_to_enter # noqa: E501 - for info in states_to_enter: + for info in ordered_states: target = info.target assert target transition = info.transition args, kwargs = self._get_args_kwargs( - transition, trigger_data, set_target_as_state=True + transition, + trigger_data, + target=target, ) # Add state to the configuration - # self.sm.configuration |= {target} + if not self.sm.atomic_configuration_update: + self.sm.configuration |= {target} # TODO: Add state to states_to_invoke # self.states_to_invoke.add(state) @@ -412,7 +450,7 @@ def _enter_states( # state.is_first_entry = False # Execute `onentry` handlers - self.sm._callbacks.call(target.enter.key, *args, **kwargs) + on_entry_result = self.sm._callbacks.call(target.enter.key, *args, **kwargs) # Handle default initial states # TODO: Handle default initial states @@ -431,15 +469,24 @@ def _enter_states( parent = target.parent grandparent = parent.parent + donedata = {} + for item in on_entry_result: + if not item: + continue + donedata.update(item) + BoundEvent( f"done.state.{parent.id}", _sm=self.sm, internal=True, - ).put() + ).put(donedata=donedata) - if grandparent.parallel: + if grandparent and grandparent.parallel: if all(child.final for child in grandparent.states): - BoundEvent(f"done.state.{parent.id}", _sm=self.sm, internal=True).put() + BoundEvent(f"done.state.{parent.id}", _sm=self.sm, internal=True).put( + donedata=donedata + ) + return result def compute_entry_set( self, transitions, states_to_enter, states_for_default_entry, default_history_content diff --git a/statemachine/engines/sync.py b/statemachine/engines/sync.py index fff7e4af..b31bb561 100644 --- a/statemachine/engines/sync.py +++ b/statemachine/engines/sync.py @@ -38,7 +38,7 @@ def activate_initial_state(self): transition = self._initial_transition(trigger_data) self._processing.acquire(blocking=False) try: - self._enter_states([transition], trigger_data, {}) + self._enter_states([transition], trigger_data, OrderedSet(), OrderedSet()) finally: self._processing.release() return self.processing_loop() @@ -109,8 +109,8 @@ def processing_loop(self): # noqa: C901 external_event = self.external_queue.pop() current_time = time() if external_event.execution_time > current_time: - self.put(external_event) - sleep(0.001) + self.put(external_event, _delayed=True) + sleep(self.sm._loop_sleep_in_ms) continue logger.debug("External event: %s", external_event.event) diff --git a/statemachine/factory.py b/statemachine/factory.py index c125ed61..dd7a85e6 100644 --- a/statemachine/factory.py +++ b/statemachine/factory.py @@ -87,11 +87,15 @@ def __init__( def __getattr__(self, attribute: str) -> Any: ... - def _initials_by_document_order(cls, states: List[State], parent: "State | None" = None): + def _initials_by_document_order( + cls, states: List[State], parent: "State | None" = None, order: int = 1 + ): """Set initial state by document order if no explicit initial state is set""" initial: "State | None" = None for s in states: - cls._initials_by_document_order(s.states, s) + s.document_order = order + order += 1 + cls._initials_by_document_order(s.states, s, order) if s.initial: initial = s if not initial and states: @@ -105,6 +109,12 @@ def _initials_by_document_order(cls, states: List[State], parent: "State | None" ): parent.to(initial, initial=True) + if parent and parent.parallel: + for state in states: + state._initial = True + if not any(t for t in parent.transitions if t.initial and t.target == state): + parent.to(state, initial=True) + def _unpack_builders_callbacks(cls): callbacks = {} for state in iterate_states(cls.states): diff --git a/statemachine/io/scxml/actions.py b/statemachine/io/scxml/actions.py index be85e828..8d475430 100644 --- a/statemachine/io/scxml/actions.py +++ b/statemachine/io/scxml/actions.py @@ -7,10 +7,10 @@ from typing import Callable from uuid import uuid4 -from statemachine.exceptions import InvalidDefinition - from ...event import Event from ...event import _event_data_kwargs +from ...exceptions import InvalidDefinition +from ...spec_parser import InState from ...statemachine import StateMachine from .parser import Action from .parser import AssignAction @@ -134,6 +134,7 @@ def _eval(expr: str, **kwargs) -> Any: if k not in protected_attrs } ) + kwargs["In"] = InState(kwargs["machine"]) return eval(expr, {}, kwargs) diff --git a/statemachine/io/scxml/parser.py b/statemachine/io/scxml/parser.py index f02a9a21..d43c28c0 100644 --- a/statemachine/io/scxml/parser.py +++ b/statemachine/io/scxml/parser.py @@ -140,15 +140,12 @@ def parse_state( for child_state_elem in state_elem.findall("state"): child_state = parse_state(child_state_elem, initial_states=initial_states) - child_state.initial = child_state.initial state.states[child_state.id] = child_state for child_state_elem in state_elem.findall("final"): child_state = parse_state(child_state_elem, initial_states=initial_states, is_final=True) - child_state.initial = child_state.initial state.states[child_state.id] = child_state for child_state_elem in state_elem.findall("parallel"): state = parse_state(child_state_elem, initial_states=initial_states, is_parallel=True) - child_state.initial = child_state.initial state.states[child_state.id] = child_state return state diff --git a/statemachine/spec_parser.py b/statemachine/spec_parser.py index 303e19e2..5a4678a2 100644 --- a/statemachine/spec_parser.py +++ b/statemachine/spec_parser.py @@ -89,13 +89,21 @@ def get(cls, id): return cls.registry[id] +class InState: + def __init__(self, machine): + self.machine = machine + + def __call__(self, *state_ids: str): + return set(state_ids).issubset({s.id for s in self.machine.configuration}) + + @Functions.register("in") def build_in_call(*state_ids: str) -> Callable: state_ids_set = set(state_ids) def decorated(*args, **kwargs): machine = kwargs["machine"] - return state_ids_set.issubset({s.id for s in machine.configuration}) + return InState(machine)(*state_ids) decorated.__name__ = f"in({state_ids_set})" decorated.unique_key = f"in({state_ids_set})" # type: ignore[attr-defined] diff --git a/statemachine/state.py b/statemachine/state.py index 77b8df95..34bcaeb4 100644 --- a/statemachine/state.py +++ b/statemachine/state.py @@ -129,7 +129,7 @@ class State: Transitions are declared using the :func:`State.to` or :func:`State.from_` (reversed) methods. >>> draft.to(producing) - TransitionList([Transition('Draft', 'Producing', event=[], internal=False)]) + TransitionList([Transition('Draft', 'Producing', event=[], internal=False, initial=False)]) The result is a :ref:`TransitionList`. Don't worry about this internal class. @@ -154,7 +154,7 @@ class State: expressed using an alternative syntax: >>> draft.to.itself() - TransitionList([Transition('Draft', 'Draft', event=[], internal=False)]) + TransitionList([Transition('Draft', 'Draft', event=[], internal=False, initial=False)]) You can even pass a list of target states to declare at once all transitions starting from the same state. @@ -208,6 +208,7 @@ def __init__( self.exit = self._specs.grouper(CallbackGroup.EXIT).add( exit, priority=CallbackPriority.INLINE ) + self.document_order = 0 self._init_states() def _init_states(self): @@ -370,7 +371,7 @@ def id(self) -> str: @property def is_active(self): - return self._machine().current_state == self + return self.value in self._machine().configuration_values @property def is_atomic(self): @@ -392,6 +393,10 @@ def parallel(self): def is_compound(self): return self._state().is_compound + @property + def document_order(self): + return self._state().document_order + class AnyState(State): """A special state that works as a "ANY" placeholder. diff --git a/statemachine/statemachine.py b/statemachine/statemachine.py index 4026d977..477889d8 100644 --- a/statemachine/statemachine.py +++ b/statemachine/statemachine.py @@ -54,6 +54,16 @@ class StateMachine(metaclass=StateMachineMetaclass): the state entry/exit actions will not be executed. If `True`, the state entry actions will be executed, which is conformant with the SCXML spec. + atomic_configuration_update: If `False` (default), the state machine will follow the SCXML + specification, that means in a microstep, it will first exit and execute exit callbacks + for all the states in the exit set in reversed document order, then execute the + transition content (on callbaks), then enter all the states in the enter set in + document order. + + If `True`, the state machine will execute the exit callbacks, the on transition + callbacks, then atomically update the configuration of exited and entered states, then + execute the enter callbacks. + listeners: An optional list of objects that provies attributes to be used as callbacks. See :ref:`listeners` for more details. @@ -70,6 +80,8 @@ class StateMachine(metaclass=StateMachineMetaclass): pass """ + _loop_sleep_in_ms = 0.001 + def __init__( self, model: Any = None, @@ -77,6 +89,7 @@ def __init__( start_value: Any = None, allow_event_without_transition: bool = False, enable_self_transition_entries: bool = False, + atomic_configuration_update: bool = False, listeners: "List[object] | None" = None, ): self.model = model if model else Model() @@ -84,6 +97,7 @@ def __init__( self.start_value = start_value self.allow_event_without_transition = allow_event_without_transition self.enable_self_transition_entries = enable_self_transition_entries + self.atomic_configuration_update = atomic_configuration_update self._callbacks = CallbacksRegistry() self._states_for_instance: Dict[State, State] = {} self._listeners: Dict[int, Any] = {} @@ -246,12 +260,21 @@ def _graph(self): return DotGraphMachine(self).get_graph() + @property + def configuration_values(self) -> OrderedSet[Any]: + """The state configuration values is the set of currently active states's values + (or ids if no custom value is defined).""" + if isinstance(self.current_state_value, OrderedSet): + return self.current_state_value + return OrderedSet([self.current_state_value]) + @property def configuration(self) -> OrderedSet["State"]: + """The set of currently active states.""" if self.current_state_value is None: return OrderedSet() - if not isinstance(self.current_state_value, list): + if not isinstance(self.current_state_value, MutableSet): return OrderedSet( [ self.states_map[self.current_state_value].for_instance( @@ -272,13 +295,13 @@ def configuration(self) -> OrderedSet["State"]: ) @configuration.setter - def configuration(self, value: OrderedSet["State"]): - if len(value) == 0: + def configuration(self, new_configuration: OrderedSet["State"]): + if len(new_configuration) == 0: self.current_state_value = None - elif len(value) == 1: - self.current_state_value = value.pop().value + elif len(new_configuration) == 1: + self.current_state_value = new_configuration.pop().value else: - self.current_state_value = [s.value for s in value] + self.current_state_value = OrderedSet(s.value for s in new_configuration) @property def current_state_value(self): @@ -291,7 +314,11 @@ def current_state_value(self): @current_state_value.setter def current_state_value(self, value): - if value is not None and not isinstance(value, list) and value not in self.states_map: + if ( + value is not None + and not isinstance(value, MutableSet) + and value not in self.states_map + ): raise InvalidStateValue(value) setattr(self.model, self.state_field, value) diff --git a/statemachine/transition.py b/statemachine/transition.py index 816258cd..a23f5e99 100644 --- a/statemachine/transition.py +++ b/statemachine/transition.py @@ -90,7 +90,7 @@ def __init__( def __repr__(self): return ( f"{type(self).__name__}({self.source.name!r}, {self.target.name!r}, " - f"event={self._events!r}, internal={self.internal!r})" + f"event={self._events!r}, internal={self.internal!r}, initial={self.initial!r})" ) def __str__(self): diff --git a/tests/conftest.py b/tests/conftest.py index 5d77df0b..134e653a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,12 @@ def pytest_addoption(parser): default=False, help="Update marks for failing tests", ) + parser.addoption( + "--gen-diagram", + action="store_true", + default=False, + help="Generate a diagram of the SCXML machine", + ) @pytest.fixture() diff --git a/tests/examples/persistent_model_machine.py b/tests/examples/persistent_model_machine.py index 5bb8cf94..66c127a3 100644 --- a/tests/examples/persistent_model_machine.py +++ b/tests/examples/persistent_model_machine.py @@ -102,7 +102,7 @@ def _read_state(self): def _write_state(self, value): self.file.seek(0) self.file.truncate(0) - self.file.write(value) + self.file.write(value or "") # %% diff --git a/tests/scxml/conftest.py b/tests/scxml/conftest.py index ae000268..d38a5a1d 100644 --- a/tests/scxml/conftest.py +++ b/tests/scxml/conftest.py @@ -11,6 +11,11 @@ def update_fail_mark(request): return request.config.getoption("--upd-fail") +@pytest.fixture(scope="session") +def should_generate_debug_diagram(request): + return request.config.getoption("--gen-diagram") + + @pytest.fixture() def processor(testcase_path: Path): """ diff --git a/tests/scxml/test_scxml_cases.py b/tests/scxml/test_scxml_cases.py index 2952658a..aa21462f 100644 --- a/tests/scxml/test_scxml_cases.py +++ b/tests/scxml/test_scxml_cases.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from dataclasses import field from pathlib import Path +from typing import Any from statemachine import State from statemachine import StateMachine @@ -19,21 +20,28 @@ """ # noqa: E501 -@dataclass(frozen=True, unsafe_hash=True, kw_only=True) -class DebugEvent: +@dataclass(frozen=True, unsafe_hash=True) +class OnTransition: source: str event: str data: str target: str -@dataclass(frozen=True, unsafe_hash=True, kw_only=True) +@dataclass(frozen=True, unsafe_hash=True) +class OnEnterState: + state: str + event: str + data: str + + +@dataclass(frozen=True, unsafe_hash=True) class DebugListener: - events: list[DebugEvent] = field(default_factory=list) + events: list[Any] = field(default_factory=list) def on_transition(self, event: Event, source: State, target: State, event_data): self.events.append( - DebugEvent( + OnTransition( source=f"{source and source.id}", event=f"{event and event.id}", data=f"{event_data.trigger_data.kwargs}", @@ -41,11 +49,20 @@ def on_transition(self, event: Event, source: State, target: State, event_data): ) ) + def on_enter_state(self, event: Event, state: State, event_data): + self.events.append( + OnEnterState( + state=f"{state.id}", + event=f"{event and event.id}", + data=f"{event_data.trigger_data.kwargs}", + ) + ) -@dataclass(kw_only=True) + +@dataclass class FailedMark: reason: str - events: list[DebugEvent] + events: list[OnTransition] is_assertion_error: bool exception: Exception logs: str @@ -108,12 +125,11 @@ def write_fail_markdown(self, testcase_path: Path): fail_file.write(report) -def test_scxml_usecase(testcase_path: Path, update_fail_mark, caplog): - # from statemachine.contrib.diagram import DotGraphMachine +def test_scxml_usecase( + testcase_path: Path, update_fail_mark, should_generate_debug_diagram, caplog +): + from statemachine.contrib.diagram import DotGraphMachine - # DotGraphMachine(sm_class).get_graph().write_png( - # testcase_path.parent / f"{testcase_path.stem}.png" - # ) sm: "StateMachine | None" = None try: debug = DebugListener() @@ -121,6 +137,10 @@ def test_scxml_usecase(testcase_path: Path, update_fail_mark, caplog): processor.parse_scxml_file(testcase_path) sm = processor.start(listeners=[debug]) + if should_generate_debug_diagram: + DotGraphMachine(sm).get_graph().write_png( + testcase_path.parent / f"{testcase_path.stem}.png" + ) assert isinstance(sm, StateMachine) assert "pass" in {s.id for s in sm.configuration}, debug except Exception as e: diff --git a/tests/scxml/w3c/mandatory/test294.fail.md b/tests/scxml/w3c/mandatory/test294.fail.md index af673c6c..90d60543 100644 --- a/tests/scxml/w3c/mandatory/test294.fail.md +++ b/tests/scxml/w3c/mandatory/test294.fail.md @@ -1,50 +1,32 @@ # Testcase: test294 -InvalidDefinition: The state machine has a definition error +AssertionError: Assertion failed. -Final configuration: `No configuration` +Final configuration: `['fail']` --- ## Logs ```py -No logs +DEBUG statemachine.engines.base:base.py:386 States to enter: {S0, S01} +DEBUG statemachine.engines.sync:sync.py:64 Processing loop started: ['s0', 's01'] +DEBUG statemachine.engines.sync:sync.py:89 Eventless/internal queue: {transition from S01 to S02} +DEBUG statemachine.engines.base:base.py:276 States to exit: {S01} +DEBUG statemachine.engines.base:base.py:386 States to enter: {S02} +DEBUG statemachine.io.scxml.actions:actions.py:179 Cond _event.data.get('Var1')==1 -> False +DEBUG statemachine.engines.sync:sync.py:89 Eventless/internal queue: {transition done.state.s0 from S0 to Fail} +DEBUG statemachine.engines.base:base.py:276 States to exit: {S0, S02} +DEBUG statemachine.engines.base:base.py:386 States to enter: {Fail} + ``` ## "On transition" events ```py -No events +DebugEvent(source='s01', event='None', data='{}', target='s02') +DebugEvent(source='s0', event='done.state.s0', data="{'donedata': {}}", target='fail') ``` ## Traceback ```py -Traceback (most recent call last): - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 114, in _add - sc_class = create_machine_class_from_definition(location, **definition) - File "/home/macedo/projects/python-statemachine/statemachine/io/__init__.py", line 115, in create_machine_class_from_definition - target = states_instances[transition_data["target"]] - ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^ -KeyError: 's02' - -The above exception was the direct cause of the following exception: - -Traceback (most recent call last): - File "/home/macedo/projects/python-statemachine/tests/scxml/test_scxml_cases.py", line 114, in test_scxml_usecase - processor.parse_scxml_file(testcase_path) - ~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^ - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 30, in parse_scxml_file - return self.parse_scxml(path.stem, scxml_content) - ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 34, in parse_scxml - self.process_definition(definition, location=sm_name) - ~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 49, in process_definition - self._add(location, {"states": states_dict}) - ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 118, in _add - raise InvalidDefinition( - f"Failed to create state machine class: {e} from definition: {definition}" - ) from e -statemachine.exceptions.InvalidDefinition: Failed to create state machine class: 's02' from definition: {'states': {'s0': {'initial': True, 'enter': [.datamodel at 0x7fd4c529e200>], 'states': [State('S01', id='s01', value='s01', initial=True, final=False)]}, 'pass': {'final': True, 'enter': [ExecuteBlock(ExecutableContent(actions=[LogAction(label='Outcome', expr="'pass'")]))]}, 'fail': {'final': True, 'enter': [ExecuteBlock(ExecutableContent(actions=[LogAction(label='Outcome', expr="'fail'")]))]}}} - +Assertion of the testcase failed. ``` diff --git a/tests/scxml/w3c/mandatory/test298.fail.md b/tests/scxml/w3c/mandatory/test298.fail.md index ff34fba8..9c07990e 100644 --- a/tests/scxml/w3c/mandatory/test298.fail.md +++ b/tests/scxml/w3c/mandatory/test298.fail.md @@ -1,50 +1,31 @@ # Testcase: test298 -InvalidDefinition: The state machine has a definition error +AssertionError: Assertion failed. -Final configuration: `No configuration` +Final configuration: `['fail']` --- ## Logs ```py -No logs +DEBUG statemachine.engines.base:base.py:386 States to enter: {S0, S01} +DEBUG statemachine.engines.sync:sync.py:64 Processing loop started: ['s0', 's01'] +DEBUG statemachine.engines.sync:sync.py:89 Eventless/internal queue: {transition from S01 to S02} +DEBUG statemachine.engines.base:base.py:276 States to exit: {S01} +DEBUG statemachine.engines.base:base.py:386 States to enter: {S02} +DEBUG statemachine.engines.sync:sync.py:89 Eventless/internal queue: {transition * from S0 to Fail} +DEBUG statemachine.engines.base:base.py:276 States to exit: {S0, S02} +DEBUG statemachine.engines.base:base.py:386 States to enter: {Fail} + ``` ## "On transition" events ```py -No events +DebugEvent(source='s01', event='None', data='{}', target='s02') +DebugEvent(source='s0', event='done.state.s0', data="{'donedata': {}}", target='fail') ``` ## Traceback ```py -Traceback (most recent call last): - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 114, in _add - sc_class = create_machine_class_from_definition(location, **definition) - File "/home/macedo/projects/python-statemachine/statemachine/io/__init__.py", line 115, in create_machine_class_from_definition - target = states_instances[transition_data["target"]] - ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^ -KeyError: 's02' - -The above exception was the direct cause of the following exception: - -Traceback (most recent call last): - File "/home/macedo/projects/python-statemachine/tests/scxml/test_scxml_cases.py", line 114, in test_scxml_usecase - processor.parse_scxml_file(testcase_path) - ~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^ - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 30, in parse_scxml_file - return self.parse_scxml(path.stem, scxml_content) - ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 34, in parse_scxml - self.process_definition(definition, location=sm_name) - ~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 49, in process_definition - self._add(location, {"states": states_dict}) - ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 118, in _add - raise InvalidDefinition( - f"Failed to create state machine class: {e} from definition: {definition}" - ) from e -statemachine.exceptions.InvalidDefinition: Failed to create state machine class: 's02' from definition: {'states': {'s0': {'initial': True, 'enter': [.datamodel at 0x7f0749608720>], 'states': [State('S01', id='s01', value='s01', initial=True, final=False)]}, 'pass': {'final': True, 'enter': [ExecuteBlock(ExecutableContent(actions=[LogAction(label='Outcome', expr="'pass'")]))]}, 'fail': {'final': True, 'enter': [ExecuteBlock(ExecutableContent(actions=[LogAction(label='Outcome', expr="'fail'")]))]}}} - +Assertion of the testcase failed. ``` diff --git a/tests/scxml/w3c/mandatory/test310.fail.md b/tests/scxml/w3c/mandatory/test310.fail.md deleted file mode 100644 index 81b5258f..00000000 --- a/tests/scxml/w3c/mandatory/test310.fail.md +++ /dev/null @@ -1,59 +0,0 @@ -# Testcase: test310 - -InvalidDefinition: The state machine has a definition error - -Final configuration: `No configuration` - ---- - -## Logs -```py -No logs -``` - -## "On transition" events -```py -No events -``` - -## Traceback -```py -Traceback (most recent call last): - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 114, in _add - sc_class = create_machine_class_from_definition(location, **definition) - File "/home/macedo/projects/python-statemachine/statemachine/io/__init__.py", line 140, in create_machine_class_from_definition - return StateMachineMetaclass(name, (StateMachine,), attrs_mapper) # type: ignore[return-value] - File "/home/macedo/projects/python-statemachine/statemachine/factory.py", line 76, in __init__ - cls._check() - ~~~~~~~~~~^^ - File "/home/macedo/projects/python-statemachine/statemachine/factory.py", line 121, in _check - cls._check_disconnected_state() - ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^ - File "/home/macedo/projects/python-statemachine/statemachine/factory.py", line 190, in _check_disconnected_state - raise InvalidDefinition( - ...<5 lines>... - ) -statemachine.exceptions.InvalidDefinition: There are unreachable states. The statemachine graph should have a single component. Disconnected states: ['pass', 'fail'] - -The above exception was the direct cause of the following exception: - -Traceback (most recent call last): - File "/home/macedo/projects/python-statemachine/tests/scxml/test_scxml_cases.py", line 114, in test_scxml_usecase - processor.parse_scxml_file(testcase_path) - ~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^ - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 30, in parse_scxml_file - return self.parse_scxml(path.stem, scxml_content) - ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 34, in parse_scxml - self.process_definition(definition, location=sm_name) - ~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 49, in process_definition - self._add(location, {"states": states_dict}) - ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 118, in _add - raise InvalidDefinition( - f"Failed to create state machine class: {e} from definition: {definition}" - ) from e -statemachine.exceptions.InvalidDefinition: Failed to create state machine class: There are unreachable states. The statemachine graph should have a single component. Disconnected states: ['pass', 'fail'] from definition: {'states': {'p': {'initial': True, 'parallel': True, 'states': [State('S0', id='s0', value='s0', initial=True, final=False, parallel=False), State('S1', id='s1', value='s1', initial=True, final=False, parallel=False)]}, 'pass': {'final': True, 'enter': [ExecuteBlock(ExecutableContent(actions=[LogAction(label='Outcome', expr="'pass'")]))]}, 'fail': {'final': True, 'enter': [ExecuteBlock(ExecutableContent(actions=[LogAction(label='Outcome', expr="'fail'")]))]}}} - -``` diff --git a/tests/scxml/w3c/mandatory/test343.fail.md b/tests/scxml/w3c/mandatory/test343.fail.md index 0f247e9a..b491991e 100644 --- a/tests/scxml/w3c/mandatory/test343.fail.md +++ b/tests/scxml/w3c/mandatory/test343.fail.md @@ -1,50 +1,31 @@ # Testcase: test343 -InvalidDefinition: The state machine has a definition error +AssertionError: Assertion failed. -Final configuration: `No configuration` +Final configuration: `['fail']` --- ## Logs ```py -No logs +DEBUG statemachine.engines.base:base.py:386 States to enter: {S0, S01} +DEBUG statemachine.engines.sync:sync.py:64 Processing loop started: ['s0', 's01'] +DEBUG statemachine.engines.sync:sync.py:89 Eventless/internal queue: {transition from S01 to S02} +DEBUG statemachine.engines.base:base.py:276 States to exit: {S01} +DEBUG statemachine.engines.base:base.py:386 States to enter: {S02} +DEBUG statemachine.engines.sync:sync.py:89 Eventless/internal queue: {transition done.state.s0 from S0 to Fail} +DEBUG statemachine.engines.base:base.py:276 States to exit: {S0, S02} +DEBUG statemachine.engines.base:base.py:386 States to enter: {Fail} + ``` ## "On transition" events ```py -No events +DebugEvent(source='s01', event='None', data='{}', target='s02') +DebugEvent(source='s0', event='done.state.s0', data="{'donedata': {}}", target='fail') ``` ## Traceback ```py -Traceback (most recent call last): - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 114, in _add - sc_class = create_machine_class_from_definition(location, **definition) - File "/home/macedo/projects/python-statemachine/statemachine/io/__init__.py", line 115, in create_machine_class_from_definition - target = states_instances[transition_data["target"]] - ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^ -KeyError: 's02' - -The above exception was the direct cause of the following exception: - -Traceback (most recent call last): - File "/home/macedo/projects/python-statemachine/tests/scxml/test_scxml_cases.py", line 114, in test_scxml_usecase - processor.parse_scxml_file(testcase_path) - ~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^ - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 30, in parse_scxml_file - return self.parse_scxml(path.stem, scxml_content) - ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 34, in parse_scxml - self.process_definition(definition, location=sm_name) - ~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 49, in process_definition - self._add(location, {"states": states_dict}) - ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/home/macedo/projects/python-statemachine/statemachine/io/scxml/processor.py", line 118, in _add - raise InvalidDefinition( - f"Failed to create state machine class: {e} from definition: {definition}" - ) from e -statemachine.exceptions.InvalidDefinition: Failed to create state machine class: 's02' from definition: {'states': {'s0': {'initial': True, 'states': [State('S01', id='s01', value='s01', initial=True, final=False)]}, 's1': {}, 'pass': {'final': True, 'enter': [ExecuteBlock(ExecutableContent(actions=[LogAction(label='Outcome', expr="'pass'")]))]}, 'fail': {'final': True, 'enter': [ExecuteBlock(ExecutableContent(actions=[LogAction(label='Outcome', expr="'fail'")]))]}}} - +Assertion of the testcase failed. ``` diff --git a/tests/scxml/w3c/mandatory/test409.scxml b/tests/scxml/w3c/mandatory/test409.scxml index 5b7ddd5e..10551864 100644 --- a/tests/scxml/w3c/mandatory/test409.scxml +++ b/tests/scxml/w3c/mandatory/test409.scxml @@ -1,4 +1,5 @@ -