Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/processing_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_()`.
Expand Down
11 changes: 8 additions & 3 deletions docs/releases/3.2.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion docs/statechart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"')""",
Expand Down
2 changes: 1 addition & 1 deletion statemachine/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
22 changes: 12 additions & 10 deletions statemachine/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
21 changes: 14 additions & 7 deletions statemachine/statemachine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -503,15 +503,22 @@ 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.

Events on the internal queue are processed immediately within the current
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::

Expand Down
92 changes: 86 additions & 6 deletions tests/test_internal_triggers.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,24 +302,70 @@


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}"):

Check warning on line 314 in tests/test_internal_triggers.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&issues=AaE28QpMB3fAm2nytHn8&open=AaE28QpMB3fAm2nytHn8&pullRequest=683
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}"):

Check warning on line 336 in tests/test_internal_triggers.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&issues=AaE28QpMB3fAm2nytHn9&open=AaE28QpMB3fAm2nytHn9&pullRequest=683
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}"):

Check warning on line 348 in tests/test_internal_triggers.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&issues=AaE28QpMB3fAm2nytHn-&open=AaE28QpMB3fAm2nytHn-&pullRequest=683

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}"):

Check warning on line 360 in tests/test_internal_triggers.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&issues=AaE28QpMB3fAm2nytHn_&open=AaE28QpMB3fAm2nytHn_&pullRequest=683

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",
Expand Down Expand Up @@ -379,3 +425,37 @@

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}"):

Check warning on line 458 in tests/test_internal_triggers.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&issues=AaE28QpMB3fAm2nytHoA&open=AaE28QpMB3fAm2nytHoA&pullRequest=683
await settle(sm.raise_("signal"))

assert sm.configuration_values == {"dark"}
Loading