From 2279678c862b2d8e3945c8ae5eda28aa259f52e5 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Tue, 13 Oct 2026 17:19:21 -0300 Subject: [PATCH] fix: name the event in the delayed+internal error and let send(delay=0) override The delayed+internal check ran in Event.__new__ before the class attribute name was known, so an Event declared without an explicit id reported its placeholder id. The check now runs when the factory rebinds the event under its attribute name, for top-level and State.Compound declarations alike. send() and raise_() treated delay=0 as "not given", so an explicit zero could not override a declared delay. Their default is now None. Fixes #679 Signed-off-by: Fernando Macedo --- docs/processing_model.md | 5 +- docs/releases/3.2.2.md | 11 ++-- docs/statechart.md | 3 +- pyproject.toml | 2 +- statemachine/event.py | 2 +- statemachine/state.py | 22 ++++---- statemachine/statemachine.py | 21 +++++--- tests/test_internal_triggers.py | 92 ++++++++++++++++++++++++++++++--- 8 files changed, 127 insertions(+), 31 deletions(-) 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"}