diff --git a/docs/processing_model.md b/docs/processing_model.md index 7dd99a58..a272f79c 100644 --- a/docs/processing_model.md +++ b/docs/processing_model.md @@ -119,8 +119,9 @@ to the external queue, and the call returns its result like `send()`. A delay cannot be internal, since a macrostep takes no time. Declaring or sending an event with both `delay` and `internal=True`, or calling -`raise_()` with a `delay`, raises {class}`~statemachine.exceptions.InvalidDefinition`. -Use `send(..., delay=...)` to deliver an event later. +`raise_()` with a `delay`, given or declared on the event, raises +{class}`~statemachine.exceptions.InvalidDefinition`. Use `send(..., delay=...)` to deliver an +event later, and pass `delay=0` to override a declared delay. ```{seealso} See {ref}`sending-events` for examples of `send()` vs `raise_()`. diff --git a/docs/releases/3.2.2.md b/docs/releases/3.2.2.md index 02c26e82..ecb13919 100644 --- a/docs/releases/3.2.2.md +++ b/docs/releases/3.2.2.md @@ -334,15 +334,20 @@ InvalidDefinition: Event 'light' cannot be both delayed and internal: ... ``` +The error names the event by its attribute name, or by its explicit `id`, inside a +`State.Compound` body too. + Drop `internal` to deliver the event later on the external queue, or drop `delay` to run it -within the current macrostep. An explicit `send(..., internal=False)` overrides the declaration, so -`send("light", delay=50, internal=False)` delivers a declared-internal event later. +within the current macrostep. An explicit argument to `send()` overrides the declaration: +`send("light", delay=50, internal=False)` delivers a declared-internal event later, and +`send("signal", delay=0)` or `raise_("signal", delay=0)` runs an event declared with a delay now. Contributed by [@chiliec](https://github.com/chiliec). [#655](https://github.com/fgmacedo/python-statemachine/issues/655), [#663](https://github.com/fgmacedo/python-statemachine/pull/663), -[#668](https://github.com/fgmacedo/python-statemachine/pull/668). +[#668](https://github.com/fgmacedo/python-statemachine/pull/668), +[#679](https://github.com/fgmacedo/python-statemachine/issues/679). ### `is_active` on a state nested in a parallel region diff --git a/docs/statechart.md b/docs/statechart.md index 3c8e3fe4..a6c99776 100644 --- a/docs/statechart.md +++ b/docs/statechart.md @@ -135,7 +135,8 @@ On the async engine, a coroutine awaiting the `send()` of a cancelled event gets Only `send()` takes a delay. An internal event runs within the current macrostep, which takes no time, so `raise_(..., delay=...)` and `send(..., delay=..., internal=True)` -raise {class}`~statemachine.exceptions.InvalidDefinition`. +raise {class}`~statemachine.exceptions.InvalidDefinition`. An explicit `delay=0` overrides a +delay declared on the event. ```{note} The delay is **blocking** in the sync engine — the processing loop sleeps until the diff --git a/pyproject.toml b/pyproject.toml index ef7011b2..bdaf88dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,7 +86,7 @@ addopts = [ "--benchmark-group-by=name", "--pdbcls=pdbr:RichPdb", ] -doctest_optionflags = "ELLIPSIS IGNORE_EXCEPTION_DETAIL NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL" +doctest_optionflags = "ELLIPSIS IGNORE_EXCEPTION_DETAIL NORMALIZE_WHITESPACE" asyncio_mode = "auto" markers = [ """slow: marks tests as slow (deselect with '-m "not slow"')""", diff --git a/statemachine/event.py b/statemachine/event.py index ccd355ca..8ea33588 100644 --- a/statemachine/event.py +++ b/statemachine/event.py @@ -114,7 +114,7 @@ def __new__( _has_real_id = id is not None id = str(id) if _has_real_id else f"__event__{uuid4().hex}" - if delay and internal: + if delay and internal and _has_real_id: raise_delayed_internal(id) instance = super().__new__(cls, id) diff --git a/statemachine/state.py b/statemachine/state.py index 399bbd6a..7f48b7d7 100644 --- a/statemachine/state.py +++ b/statemachine/state.py @@ -95,17 +95,19 @@ def __new__( # type: ignore [misc] # noqa: C901 for event_id in _expand_event_id(key): value.add_event(event_id) elif isinstance(value, Event): + event_ids = [value.id] if value._has_real_id else _expand_event_id(key) + events = [ + Event( + id=event_id, + name=value.name, + delay=value.delay, + internal=value.internal, + ) + for event_id in event_ids + ] if value._transitions is not None: - event_ids = [value.id] if value._has_real_id else _expand_event_id(key) - for event_id in event_ids: - value._transitions.add_event( - Event( - id=event_id, - name=value.name, - delay=value.delay, - internal=value.internal, - ) - ) + for event in events: + value._transitions.add_event(event) elif getattr(value, "attr_name", None): if value.is_event: for event_id in _expand_event_id(key): diff --git a/statemachine/statemachine.py b/statemachine/statemachine.py index fa805ed9..639a21e0 100644 --- a/statemachine/statemachine.py +++ b/statemachine/statemachine.py @@ -465,7 +465,7 @@ def send( self, event: str, *args, - delay: float = 0, + delay: "float | None" = None, send_id: "str | None" = None, internal: "bool | None" = None, **kwargs, @@ -474,7 +474,8 @@ def send( :param event: The trigger for the state machine, specified as an event id string. :param args: Additional positional arguments to pass to the event. - :param delay: A time delay in milliseconds to process the event. Default is 0. + :param delay: A time delay in milliseconds to process the event. Defaults to the + event's declaration, or 0. An explicit ``0`` overrides a declared delay. :param send_id: An identifier for the event, used with ``cancel_event()`` to cancel delayed events. :param internal: Put the event on the internal queue, so it runs within the current @@ -489,9 +490,8 @@ def send( """ know_event = getattr(self, event, None) event_name = know_event.name if know_event else event - delay = ( - delay if delay else know_event and know_event.delay or 0 - ) # first the param, then the event, or 0 + if delay is None: + delay = cast(float, know_event.delay if know_event else 0) if internal is None: internal = bool(know_event and know_event.internal) event_instance = BoundEvent( @@ -503,7 +503,12 @@ def send( return run_async_from_sync(result) def raise_( - self, event: str, *args, delay: float = 0, send_id: "str | None" = None, **kwargs + self, + event: str, + *args, + delay: "float | None" = None, + send_id: "str | None" = None, + **kwargs, ) -> Any: """Send an :ref:`Event` to the state machine in the internal event queue. @@ -511,7 +516,9 @@ def raise_( macrostep, before any pending external events. This is equivalent to calling ``send(..., internal=True)``: called from outside a callback, there is no macrostep to join, so the event goes to the external queue and its result is returned. A - ``delay`` other than 0 is an :class:`~statemachine.exceptions.InvalidDefinition`. + ``delay`` other than 0, given or declared on the event, is an + :class:`~statemachine.exceptions.InvalidDefinition`; pass ``delay=0`` to override a + declared one. .. seealso:: diff --git a/tests/test_internal_triggers.py b/tests/test_internal_triggers.py index f0132d6c..f1ac0955 100644 --- a/tests/test_internal_triggers.py +++ b/tests/test_internal_triggers.py @@ -302,24 +302,70 @@ def douse_other(self): class TestDelayedInternal: - def test_a_declaration_cannot_be_both_delayed_and_internal(self): - with pytest.raises(InvalidDefinition, match=DELAYED_INTERNAL): + @pytest.mark.parametrize( + ("attribute", "kwargs", "event_id"), + [ + pytest.param("light", {}, "light", id="attribute"), + pytest.param("light", {"id": "kindle"}, "kindle", id="explicit-id"), + pytest.param("error_light", {}, "error_light", id="prefixed"), + ], + ) + def test_a_declaration_cannot_be_both_delayed_and_internal(self, attribute, kwargs, event_id): + with pytest.raises(InvalidDefinition, match=f"^Event '{event_id}' {DELAYED_INTERNAL}"): + type( + "DelayedBeacons", + (StateChart,), + { + "dark": (dark := State(initial=True)), + "lit": (lit := State(final=True)), + attribute: Event(dark.to(lit), delay=50, internal=True, **kwargs), + }, + ) + + @pytest.mark.parametrize( + ("attribute", "kwargs", "event_id"), + [ + pytest.param("light", {}, "light", id="attribute"), + pytest.param("light", {"id": "kindle"}, "kindle", id="explicit-id"), + pytest.param("error_light", {}, "error_light", id="prefixed"), + ], + ) + def test_a_compound_declaration_cannot_be_both_delayed_and_internal( + self, attribute, kwargs, event_id + ): + with pytest.raises(InvalidDefinition, match=f"^Event '{event_id}' {DELAYED_INTERNAL}"): + type( + "chain", + (State.Compound,), + { + "dark": (dark := State(initial=True)), + "lit": (lit := State(final=True)), + attribute: Event(dark.to(lit), delay=50, internal=True, **kwargs), + }, + ) + + def test_a_declaration_without_transitions_cannot_be_both_delayed_and_internal(self): + with pytest.raises(InvalidDefinition, match=f"^Event 'light' {DELAYED_INTERNAL}"): class DelayedBeacons(StateChart): dark = State(initial=True) lit = State(final=True) - light = Event(dark.to(lit), delay=50, internal=True) + kindle = dark.to(lit) + light = Event(delay=50, internal=True) - def test_a_compound_declaration_cannot_be_both_delayed_and_internal(self): - with pytest.raises(InvalidDefinition, match=DELAYED_INTERNAL): + def test_a_compound_declaration_without_transitions_cannot_be_both_delayed_and_internal( + self, + ): + with pytest.raises(InvalidDefinition, match=f"^Event 'light' {DELAYED_INTERNAL}"): class DelayedBeacons(StateChart): class chain(State.Compound): dark = State(initial=True) lit = State(final=True) - light = Event(dark.to(lit), delay=50, internal=True) + kindle = dark.to(lit) + light = Event(delay=50, internal=True) @pytest.mark.parametrize( "trigger", @@ -379,3 +425,37 @@ def test_a_native_send_to_internal_with_a_delay_fails_to_load(self, validate): with pytest.raises(InvalidDefinition): load(json.dumps(document), format="json", validate=validate) + + +class DelayedBeacons(StateChart): + dark = State(initial=True) + lit = State(final=True) + + signal = Event(dark.to(lit, on="kindle"), delay=60_000) + + def kindle(self): + return "kindled" + + +class TestExplicitZeroDelay: + @pytest.mark.timeout(10) + async def test_send_with_delay_zero_overrides_the_declared_delay(self, sm_runner): + sm = await sm_runner.start(DelayedBeacons) + + assert await sm_runner.send(sm, "signal", delay=0) == "kindled" + assert sm.configuration_values == {"lit"} + + @pytest.mark.timeout(10) + async def test_raise_with_delay_zero_overrides_the_declared_delay(self, sm_runner): + sm = await sm_runner.start(DelayedBeacons) + + assert await settle(sm.raise_("signal", delay=0)) == "kindled" + assert sm.configuration_values == {"lit"} + + async def test_raise_without_delay_keeps_the_declared_delay(self, sm_runner): + sm = await sm_runner.start(DelayedBeacons) + + with pytest.raises(InvalidDefinition, match=f"^Event 'signal' {DELAYED_INTERNAL}"): + await settle(sm.raise_("signal")) + + assert sm.configuration_values == {"dark"}