From 94470bbbfcc71d40c89cbf028915de83735d1c9e Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sun, 16 Aug 2026 13:21:34 -0300 Subject: [PATCH 1/7] fix: support event declarations inside State.Compound bodies A nested state class body only understood the assignment form of an event declaration. The `Event` class and the `@.to()` decorator both fell through to the generic callable branch, so the name was bound to a detached object and the transition it wrapped stayed eventless, firing as soon as its source state became active. Handle both forms in the nested class body scanner, which is extracted from `NestedStateFactory.__new__` into `_collect_nested_members`. Closes #643 Signed-off-by: Fernando Macedo --- docs/events.md | 25 ++++ docs/releases/3.2.2.md | 80 +++++++++++++ docs/releases/index.md | 1 + statemachine/state.py | 81 +++++++++---- tests/test_statechart_compound.py | 189 ++++++++++++++++++++++++++++++ 5 files changed, 355 insertions(+), 21 deletions(-) create mode 100644 docs/releases/3.2.2.md diff --git a/docs/events.md b/docs/events.md index d3414d98..18a1155f 100644 --- a/docs/events.md +++ b/docs/events.md @@ -71,6 +71,31 @@ human-readable display name, use the `Event` class explicitly: ``` +The same declaration works inside a {ref}`compound state ` body: + +```py +>>> class Journey(StateChart): +... class shire(State.Compound): +... bag_end = State(initial=True) +... green_dragon = State() +... +... visit_pub = Event(bag_end.to(green_dragon), name="Visit the pub") +... +... road = State(final=True) +... depart = Event(shire.to(road)) + +>>> sm = Journey() +>>> sm.send("visit_pub") +>>> set(sm.configuration_values) == {"shire", "green_dragon"} +True + +``` + +```{versionchanged} 3.2.2 +Before this release, an `Event` declared inside a `State.Compound` body was +ignored and its transition became {ref}`eventless `. +``` + (event-identity)= diff --git a/docs/releases/3.2.2.md b/docs/releases/3.2.2.md new file mode 100644 index 00000000..a361b2e2 --- /dev/null +++ b/docs/releases/3.2.2.md @@ -0,0 +1,80 @@ +# StateChart 3.2.2 + +*Not released yet* + +## Bug fixes in 3.2.2 + +### Event declarations inside `State.Compound` + +A `State.Compound` (or `State.Parallel`) class body only understood the assignment form of an +event declaration (`visit_pub = bag_end.to(green_dragon)`). The two other documented forms were +silently dropped: the name was bound to a detached object and the transition it wrapped stayed +{ref}`eventless `, firing as soon as its source state became active. + +The `Event` class now works inside a nested state body: + +```py +>>> from statemachine import Event, State, StateChart + +>>> class Journey(StateChart): +... class shire(State.Compound): +... bag_end = State(initial=True) +... green_dragon = State() +... +... visit_pub = Event(bag_end.to(green_dragon), name="Visit the pub") +... +... road = State(final=True) +... depart = Event(shire.to(road)) + +>>> sm = Journey() +>>> set(sm.configuration_values) == {"shire", "bag_end"} +True + +>>> sm.send("visit_pub") +>>> set(sm.configuration_values) == {"shire", "green_dragon"} +True + +>>> Journey.visit_pub.name +'Visit the pub' + +``` + +Before this fix, `Journey` started already in `green_dragon` and `visit_pub` was not among its +events. The attribute name now becomes the event `id`, an explicit `id` takes precedence (with +the attribute name still resolving to the event), `name` is kept as the display name, and the +`error_` / `done_state_` / `done_invoke_` prefixes expand to their dotted form. + +The same applies to the `@.to()` decorator, which declares an event and its +inline action at once. Inside a compound body it registered no event and never ran its body: + +```py +>>> class Gate(StateChart): +... class gate(State.Compound): +... locked = State(initial=True) +... unlocked = State() +... +... push = unlocked.to(locked) +... +... @locked.to(unlocked) +... def coin(self): +... return "accepted" +... +... broken = State(final=True) +... smash = gate.to(broken) + +>>> sm = Gate() +>>> sm.send("coin") +'accepted' + +>>> set(sm.configuration_values) == {"gate", "unlocked"} +True + +``` + +An `Event` declared inside a nested body with no transitions at all (`knock = Event()`) still +differs from the top-level form: it is reachable as a class attribute but is not added to the +machine's event list. + +Reported by [@Dolecor](https://github.com/Dolecor). + +[#643](https://github.com/fgmacedo/python-statemachine/issues/643). diff --git a/docs/releases/index.md b/docs/releases/index.md index 28929df3..f62455f7 100644 --- a/docs/releases/index.md +++ b/docs/releases/index.md @@ -16,6 +16,7 @@ Requires Python 3.10+. ```{toctree} :maxdepth: 2 +3.2.2 3.2.1 3.2.0 3.1.2 diff --git a/statemachine/state.py b/statemachine/state.py index 065cc52a..4d73a429 100644 --- a/statemachine/state.py +++ b/statemachine/state.py @@ -8,6 +8,7 @@ from .callbacks import CallbackGroup from .callbacks import CallbackPriority from .callbacks import CallbackSpecList +from .event import Event from .event import _expand_event_id from .exceptions import InvalidDefinition from .i18n import _ @@ -55,6 +56,64 @@ def __call__(self, *states: "State | NestedStateFactory", **kwargs): return transitions +def _bind_declared_event(key: str, declared: "Event") -> "Event": + """Register an ``Event`` declared inside a nested state class body. + + The declared instance is a placeholder: unless an explicit ``id`` was given, it holds a + generated one. Its transitions are bound to an event carrying the attribute name, mirroring + what :class:`StateMachineMetaclass` does for top-level declarations. + """ + event_id = declared.id if declared._has_real_id else _expand_event_id(key) + event = Event(id=event_id, name=declared.name) + if declared._transitions is not None: + declared._transitions.add_event(event) + return event + + +def _bind_decorated_event(key: str, func: Any) -> Any: + """Bind a callback declared with the ``@.to()`` decorator syntax. + + Mirrors ``StateMachineMetaclass._add_unbounded_callback``: the attribute name becomes the + event, so the callback itself is kept under the mangled name the callback machinery expects. + """ + if func.is_event: + func._transitions.add_event(_expand_event_id(key)) + return func + + +def _collect_nested_members(attrs: dict) -> "tuple[list[State], list[HistoryState], dict]": + """Split a nested state class body into states, history states and callbacks.""" + # Lazy import to avoid circular dependency (states.py imports state.py) + from .states import States + + states: list[State] = [] + history: list[HistoryState] = [] + callbacks: dict = {} + for key, value in attrs.items(): + if isinstance(value, States): + for state_id, state in value.items(): + state._set_id(state_id) + states.append(state) + elif isinstance(value, HistoryState): + value._set_id(key) + history.append(value) + elif isinstance(value, State): + value._set_id(key) + states.append(value) + elif isinstance(value, TransitionList): + value.add_event(_expand_event_id(key)) + elif isinstance(value, Event): + # `Event` is callable, so it must be handled before the `callable` branch, + # otherwise its transitions would be left eventless. + callbacks[key] = _bind_declared_event(key, value) + elif getattr(value, "attr_name", None): + callbacks[value.attr_name] = _bind_decorated_event(key, value) + elif callable(value): + callbacks[key] = value + + return states, history, callbacks + + class NestedStateFactory(type): def __new__( # type: ignore [misc] cls, classname, bases, attrs, name="", **kwargs @@ -70,27 +129,7 @@ def __new__( # type: ignore [misc] inherited_kwargs.update(getattr(base, "_factory_kwargs", {})) inherited_kwargs.update(kwargs) - # Lazy import to avoid circular dependency (states.py imports state.py) - from .states import States - - states = [] - history = [] - callbacks = {} - for key, value in attrs.items(): - if isinstance(value, States): - for state_id, state in value.items(): - state._set_id(state_id) - states.append(state) - elif isinstance(value, HistoryState): - value._set_id(key) - history.append(value) - elif isinstance(value, State): - value._set_id(key) - states.append(value) - elif isinstance(value, TransitionList): - value.add_event(_expand_event_id(key)) - elif callable(value): - callbacks[key] = value + states, history, callbacks = _collect_nested_members(attrs) return State( name=name, states=states, history=history, _callbacks=callbacks, **inherited_kwargs diff --git a/tests/test_statechart_compound.py b/tests/test_statechart_compound.py index 1506e1f5..6b4a386a 100644 --- a/tests/test_statechart_compound.py +++ b/tests/test_statechart_compound.py @@ -13,6 +13,7 @@ import pytest from statemachine.states import States +from statemachine import Event from statemachine import State from statemachine import StateChart from tests.machines.compound.middle_earth_journey import MiddleEarthJourney @@ -302,3 +303,191 @@ class inner(State.Compound): await sm_runner.send(sm, "inner_to_baz_bar") assert {OuterStates.BAR} == set(sm.configuration_values) + + +@pytest.mark.timeout(5) +class TestEventClassInsideCompound: + """The ``Event`` class inside a ``State.Compound`` body (#643).""" + + async def test_event_class_declares_a_named_event(self, sm_runner): + """``Event()`` binds the event instead of leaving it eventless.""" + + class QuirkyJourney(StateChart): + class shire(State.Compound): + bag_end = State(initial=True) + green_dragon = State() + + visit_pub = Event(bag_end.to(green_dragon)) + + road = State(final=True) + depart = Event(shire.to(road)) + + assert [event.id for event in QuirkyJourney.events] == ["visit_pub", "depart"] + + sm = await sm_runner.start(QuirkyJourney) + assert {"shire", "bag_end"} == set(sm.configuration_values) + + await sm_runner.send(sm, "visit_pub") + assert {"shire", "green_dragon"} == set(sm.configuration_values) + + def test_display_name_is_preserved(self): + class NamedEvent(StateChart): + class shire(State.Compound): + bag_end = State(initial=True) + green_dragon = State(final=True) + + visit_pub = Event(bag_end.to(green_dragon), name="Visit the pub") + + assert NamedEvent.visit_pub.id == "visit_pub" + assert NamedEvent.visit_pub.name == "Visit the pub" + + async def test_explicit_id_is_reachable_by_both_names(self, sm_runner): + """An explicit ``id`` wins over the attribute name, which still resolves.""" + + class ExplicitId(StateChart): + class shire(State.Compound): + bag_end = State(initial=True) + green_dragon = State(final=True) + + visit_pub = Event(bag_end.to(green_dragon), id="pub.visit") + + assert [event.id for event in ExplicitId.events] == ["pub.visit"] + assert ExplicitId.visit_pub.id == "pub.visit" + + sm = await sm_runner.start(ExplicitId) + await sm_runner.send(sm, "pub.visit") + assert {"shire", "green_dragon"} == set(sm.configuration_values) + + async def test_combined_transitions(self, sm_runner): + class Wandering(StateChart): + class shire(State.Compound): + bag_end = State(initial=True) + green_dragon = State() + + wander = Event(bag_end.to(green_dragon) | green_dragon.to(bag_end)) + + road = State(final=True) + depart = Event(shire.to(road)) + + sm = await sm_runner.start(Wandering) + await sm_runner.send(sm, "wander") + assert "green_dragon" in sm.configuration_values + + await sm_runner.send(sm, "wander") + assert "bag_end" in sm.configuration_values + + async def test_event_id_expansion_conventions(self, sm_runner): + """The ``error_`` prefix expands to its dotted form.""" + + def raise_error(): + raise RuntimeError("boom") + + class ErrorInCompound(StateChart): + class active(State.Compound): + ok = State(initial=True) + failing = State() + errored = State(final=True) + + trigger = Event(ok.to(failing, on=raise_error)) + error_execution = Event(failing.to(errored)) + + assert "error.execution" in [event.id for event in ErrorInCompound.events] + + sm = await sm_runner.start(ErrorInCompound) + assert "ok" in sm.configuration_values + + await sm_runner.send(sm, "trigger") + assert "errored" in sm.configuration_values + + async def test_event_inside_parallel_region(self, sm_runner): + class WarOfTheRing(StateChart): + class war(State.Parallel): + class quest(State.Compound): + start = State(initial=True) + end = State(final=True) + + go = Event(start.to(end)) + + class battle(State.Compound): + fighting = State(initial=True) + won = State(final=True) + + victory = Event(fighting.to(won)) + + sm = await sm_runner.start(WarOfTheRing) + assert {"war", "quest", "start", "battle", "fighting"} == set(sm.configuration_values) + + await sm_runner.send(sm, "go") + await sm_runner.send(sm, "victory") + assert {"war", "quest", "end", "battle", "won"} == set(sm.configuration_values) + + def test_event_without_transitions_is_reachable_but_unregistered(self): + """A transition-less ``Event`` gets its id from the attribute name. + + Having no transitions, it never reaches the machine's event list -- unlike the + top-level form, which registers it. + """ + + class Placeholder(StateChart): + class shire(State.Compound): + bag_end = State(initial=True) + green_dragon = State(final=True) + + visit_pub = bag_end.to(green_dragon) + knock = Event(name="Knock on the door") + + assert Placeholder.knock.id == "knock" + assert Placeholder.knock.name == "Knock on the door" + assert "knock" not in [event.id for event in Placeholder.events] + + +@pytest.mark.timeout(5) +class TestDecoratorEventInsideCompound: + """The ``@.to()`` decorator inside a ``State.Compound`` body.""" + + async def test_decorator_declares_a_named_event(self, sm_runner): + """The decorated name becomes the event, and its body runs as the ``on`` action.""" + + class Gate(StateChart): + class gate(State.Compound): + locked = State(initial=True) + unlocked = State() + + push = unlocked.to(locked) + + @locked.to(unlocked) + def coin(self): + return "accepted" + + broken = State(final=True) + smash = gate.to(broken) + + assert "coin" in [event.id for event in Gate.events] + + sm = await sm_runner.start(Gate) + assert "locked" in sm.configuration_values + + assert await sm_runner.send(sm, "coin") == "accepted" + assert "unlocked" in sm.configuration_values + + async def test_decorated_callback_is_not_an_event(self, sm_runner): + """``@.on`` keeps declaring a plain callback, not a new event.""" + + log = [] + + class Gate(StateChart): + class gate(State.Compound): + locked = State(initial=True) + unlocked = State(final=True) + + coin = locked.to(unlocked) + + @coin.on + def clink(self): + log.append("clink") + + assert [event.id for event in Gate.events] == ["coin"] + + sm = await sm_runner.start(Gate) + await sm_runner.send(sm, "coin") + assert log == ["clink"] From e4e850d250ec3c29621091383f216188bbd91d12 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Mon, 17 Aug 2026 14:40:04 -0300 Subject: [PATCH 2/7] refactor: read statechart class bodies through one shared reader The two kinds of statechart class body, a StateChart subclass and a nested State.Compound / State.Parallel, accept the same declaration forms, but each had its own copy of the recognition table. That is how #643 happened: the nested copy never learned about `Event`, so an event declared there silently became an eventless transition. Recognition now lives once in `class_body.read`, which dispatches to a reader supplying only what each side does with a form. The reader interface is a Protocol, so a form added to one side and forgotten on the other is a type error rather than a silent gap. Drop the `error_` prefix expansion the previous commit gave to nested decorated events: it is not what the top-level path does, and the two must agree. Signed-off-by: Fernando Macedo --- statemachine/class_body.py | 59 +++++++++++++++++ statemachine/factory.py | 79 +++++++++++------------ statemachine/state.py | 102 ++++++++++++++---------------- tests/test_state.py | 9 +++ tests/test_statechart_compound.py | 36 +++-------- 5 files changed, 167 insertions(+), 118 deletions(-) create mode 100644 statemachine/class_body.py diff --git a/statemachine/class_body.py b/statemachine/class_body.py new file mode 100644 index 00000000..0d8d185d --- /dev/null +++ b/statemachine/class_body.py @@ -0,0 +1,59 @@ +from typing import TYPE_CHECKING +from typing import Any + +from .event import Event +from .state import HistoryState +from .state import State +from .states import States +from .transition import Transition +from .transition_list import TransitionList + +if TYPE_CHECKING: + from typing import Protocol + + class ClassBodyReader(Protocol): + """One method per declaration form that :func:`read` recognizes. + + Adding a form here is what forces every reader to answer for it. + """ + + def on_states(self, states: States) -> None: ... + + def on_history(self, key: str, state: HistoryState) -> None: ... + + def on_state(self, key: str, state: State) -> None: ... + + def on_transitions(self, key: str, transitions: "Transition | TransitionList") -> None: ... + + def on_event(self, key: str, declared: Event) -> None: ... + + def on_decorated(self, key: str, func: Any) -> None: ... + + def on_other(self, key: str, value: Any) -> None: ... + + +def read(attrs: "dict[str, Any]", reader: "ClassBodyReader") -> None: + """Dispatch each entry of a statechart class body to ``reader``. + + A statechart is declared in two kinds of class body: a :ref:`StateChart` subclass and a + nested ``State.Compound`` / ``State.Parallel``. Both accept the same forms, so recognition + lives here once and each reader supplies only what it does with a form. + + Order is significant: a ``HistoryState`` is a ``State``, and an ``Event`` is a callable + ``str``, so both would be captured by a later branch. + """ + for key, value in attrs.items(): + if isinstance(value, States): + reader.on_states(value) + elif isinstance(value, HistoryState): + reader.on_history(key, value) + elif isinstance(value, State): + reader.on_state(key, value) + elif isinstance(value, (Transition, TransitionList)): + reader.on_transitions(key, value) + elif isinstance(value, Event): + reader.on_event(key, value) + elif getattr(value, "attr_name", None): + reader.on_decorated(key, value) + else: + reader.on_other(key, value) diff --git a/statemachine/factory.py b/statemachine/factory.py index 3bced620..811f10ce 100644 --- a/statemachine/factory.py +++ b/statemachine/factory.py @@ -5,6 +5,7 @@ from .callbacks import CallbackGroup from .callbacks import CallbackPriority from .callbacks import CallbackSpecList +from .class_body import read from .event import Event from .event import _expand_event_id from .exceptions import InvalidDefinition @@ -15,8 +16,6 @@ from .i18n import _ from .state import State from .states import States -from .transition import Transition -from .transition_list import TransitionList class StateMachineMetaclass(type): @@ -298,43 +297,8 @@ def add_inherited(cls, bases): for event in events: cls.add_event(event=Event(id=event.id, name=event.name)) - def add_from_attributes(cls, attrs): # noqa: C901 - for key, value in attrs.items(): - if isinstance(value, States): - cls._add_states_from_dict(value) - if isinstance(value, State): - cls.add_state(key, value) - elif isinstance(value, (Transition, TransitionList)): - event_id = _expand_event_id(key) - cls.add_event(event=Event(transitions=value, id=event_id)) - elif isinstance(value, (Event,)): - if value._has_real_id: - event_id = value.id - else: - event_id = _expand_event_id(key) - new_event = Event( - transitions=value._transitions, - id=event_id, - name=value.name, - ) - cls.add_event(event=new_event, old_event=value) - # Ensure the event is accessible by the Python attribute name - if event_id != key: - setattr(cls, key, new_event) - elif getattr(value, "attr_name", None): - cls._add_unbounded_callback(key, value) - - def _add_states_from_dict(cls, states): - for state_id, state in states.items(): - cls.add_state(state_id, state) - - def _add_unbounded_callback(cls, attr_name, func): - # if func is an event, the `attr_name` will be replaced by an event trigger, - # so we'll also give the ``func`` a new unique name to be used by the callback - # machinery that is stored at ``func.attr_name`` - setattr(cls, func.attr_name, func) - if func.is_event: - cls.add_event(event=Event(func._transitions, id=attr_name)) + def add_from_attributes(cls, attrs): + read(attrs, _StateChartBody(cls)) def add_state(cls, id, state: State): state._set_id(id) @@ -390,3 +354,40 @@ def _update_event_references(cls): @property def events(self): return list(self._events) + + +class _StateChartBody: + """Registers a statechart class body on the class under construction.""" + + def __init__(self, cls: "StateMachineMetaclass") -> None: + self.cls = cls + + def on_states(self, states: States) -> None: + for state_id, state in states.items(): + self.cls.add_state(state_id, state) + + def on_state(self, key: str, state: State) -> None: + self.cls.add_state(key, state) + + on_history = on_state + """A top-level history state is registered as an ordinary state.""" + + def on_transitions(self, key: str, transitions: Any) -> None: + self.cls.add_event(event=Event(transitions=transitions, id=_expand_event_id(key))) + + def on_event(self, key: str, declared: Event) -> None: + event_id = declared.id if declared._has_real_id else _expand_event_id(key) + new_event = Event(transitions=declared._transitions, id=event_id, name=declared.name) + self.cls.add_event(event=new_event, old_event=declared) + if event_id != key: + setattr(self.cls, key, new_event) + + def on_decorated(self, key: str, func: Any) -> None: + # The attribute name is taken over by the event trigger, so the callback machinery + # reaches the function through the unique name it stored at ``func.attr_name``. + setattr(self.cls, func.attr_name, func) + if func.is_event: + self.cls.add_event(event=Event(func._transitions, id=key)) + + def on_other(self, key: str, value: Any) -> None: + """Anything else is already a plain class attribute.""" diff --git a/statemachine/state.py b/statemachine/state.py index 4d73a429..422c52ae 100644 --- a/statemachine/state.py +++ b/statemachine/state.py @@ -56,62 +56,50 @@ def __call__(self, *states: "State | NestedStateFactory", **kwargs): return transitions -def _bind_declared_event(key: str, declared: "Event") -> "Event": - """Register an ``Event`` declared inside a nested state class body. +class _NestedBody: + """Collects a nested state class body into the arguments of the :ref:`State` it becomes. - The declared instance is a placeholder: unless an explicit ``id`` was given, it holds a - generated one. Its transitions are bound to an event carrying the attribute name, mirroring - what :class:`StateMachineMetaclass` does for top-level declarations. + Unlike :class:`statemachine.factory._StateChartBody`, this reader has no class to register + on. Everything that is not a substate is handed back as a callback for + ``StateMachineMetaclass._unpack_builders_callbacks`` to place on the owning statechart. """ - event_id = declared.id if declared._has_real_id else _expand_event_id(key) - event = Event(id=event_id, name=declared.name) - if declared._transitions is not None: - declared._transitions.add_event(event) - return event + def __init__(self) -> None: + self.states: list[State] = [] + self.history: list[HistoryState] = [] + self.callbacks: dict = {} -def _bind_decorated_event(key: str, func: Any) -> Any: - """Bind a callback declared with the ``@.to()`` decorator syntax. + def on_states(self, states: Any) -> None: + for state_id, state in states.items(): + state._set_id(state_id) + self.states.append(state) - Mirrors ``StateMachineMetaclass._add_unbounded_callback``: the attribute name becomes the - event, so the callback itself is kept under the mangled name the callback machinery expects. - """ - if func.is_event: - func._transitions.add_event(_expand_event_id(key)) - return func - - -def _collect_nested_members(attrs: dict) -> "tuple[list[State], list[HistoryState], dict]": - """Split a nested state class body into states, history states and callbacks.""" - # Lazy import to avoid circular dependency (states.py imports state.py) - from .states import States - - states: list[State] = [] - history: list[HistoryState] = [] - callbacks: dict = {} - for key, value in attrs.items(): - if isinstance(value, States): - for state_id, state in value.items(): - state._set_id(state_id) - states.append(state) - elif isinstance(value, HistoryState): - value._set_id(key) - history.append(value) - elif isinstance(value, State): - value._set_id(key) - states.append(value) - elif isinstance(value, TransitionList): - value.add_event(_expand_event_id(key)) - elif isinstance(value, Event): - # `Event` is callable, so it must be handled before the `callable` branch, - # otherwise its transitions would be left eventless. - callbacks[key] = _bind_declared_event(key, value) - elif getattr(value, "attr_name", None): - callbacks[value.attr_name] = _bind_decorated_event(key, value) - elif callable(value): - callbacks[key] = value - - return states, history, callbacks + def on_history(self, key: str, state: "HistoryState") -> None: + state._set_id(key) + self.history.append(state) + + def on_state(self, key: str, state: "State") -> None: + state._set_id(key) + self.states.append(state) + + def on_transitions(self, key: str, transitions: "Transition | TransitionList") -> None: + transitions.add_event(_expand_event_id(key)) + + def on_event(self, key: str, declared: "Event") -> None: + event_id = declared.id if declared._has_real_id else _expand_event_id(key) + event = Event(id=event_id, name=declared.name) + if declared._transitions is not None: + declared._transitions.add_event(event) + self.callbacks[key] = event + + def on_decorated(self, key: str, func: Any) -> None: + if func.is_event: + func._transitions.add_event(key) + self.callbacks[func.attr_name] = func + + def on_other(self, key: str, value: Any) -> None: + if callable(value): + self.callbacks[key] = value class NestedStateFactory(type): @@ -129,10 +117,18 @@ def __new__( # type: ignore [misc] inherited_kwargs.update(getattr(base, "_factory_kwargs", {})) inherited_kwargs.update(kwargs) - states, history, callbacks = _collect_nested_members(attrs) + # Lazy import to avoid circular dependency (class_body.py imports state.py) + from .class_body import read + + body = _NestedBody() + read(attrs, body) return State( - name=name, states=states, history=history, _callbacks=callbacks, **inherited_kwargs + name=name, + states=body.states, + history=body.history, + _callbacks=body.callbacks, + **inherited_kwargs, ) @classmethod diff --git a/tests/test_state.py b/tests/test_state.py index 2e2d7f1c..e87cfc04 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -1,5 +1,6 @@ import pytest from statemachine.orderedset import OrderedSet +from statemachine.states import States from statemachine import State from statemachine import StateChart @@ -83,3 +84,11 @@ def test_ordered_set_union(): s1 = OrderedSet([1, 2]) result = s1.union([3, 4], [5, 6]) assert list(result) == [1, 2, 3, 4, 5, 6] + + +def test_states_getattr_unknown_name(): + """States exposes its members as attributes and rejects anything else.""" + states = States({"draft": State("Draft")}) + assert states.draft.name == "Draft" + with pytest.raises(AttributeError, match="published not found in States"): + _ = states.published diff --git a/tests/test_statechart_compound.py b/tests/test_statechart_compound.py index 6b4a386a..2c362fbf 100644 --- a/tests/test_statechart_compound.py +++ b/tests/test_statechart_compound.py @@ -236,7 +236,10 @@ class wrapper(State.Compound): await sm_runner.processing_loop(sm) assert {"done"} == set(sm.configuration_values) - async def test_error_execution_inside_compound(self, sm_runner): + @pytest.mark.parametrize( + "declare", [lambda transitions: transitions, Event], ids=["bare", "Event"] + ) + async def test_error_execution_inside_compound(self, sm_runner, declare): """error_execution inside a compound body registers error.execution event.""" def raise_error(): @@ -247,15 +250,19 @@ class active(State.Compound): ok = State(initial=True) failing = State() - trigger = ok.to(failing, on=raise_error) + trigger = declare(ok.to(failing, on=raise_error)) errored = State() - error_execution = failing.to(errored) + error_execution = declare(failing.to(errored)) done = State(final=True) finish = active.to(done) + assert "error.execution" in [event.id for event in ErrorInCompound.events] + sm = await sm_runner.start(ErrorInCompound) + assert "ok" in sm.configuration_values + await sm_runner.send(sm, "trigger") assert "errored" in sm.configuration_values @@ -376,29 +383,6 @@ class shire(State.Compound): await sm_runner.send(sm, "wander") assert "bag_end" in sm.configuration_values - async def test_event_id_expansion_conventions(self, sm_runner): - """The ``error_`` prefix expands to its dotted form.""" - - def raise_error(): - raise RuntimeError("boom") - - class ErrorInCompound(StateChart): - class active(State.Compound): - ok = State(initial=True) - failing = State() - errored = State(final=True) - - trigger = Event(ok.to(failing, on=raise_error)) - error_execution = Event(failing.to(errored)) - - assert "error.execution" in [event.id for event in ErrorInCompound.events] - - sm = await sm_runner.start(ErrorInCompound) - assert "ok" in sm.configuration_values - - await sm_runner.send(sm, "trigger") - assert "errored" in sm.configuration_values - async def test_event_inside_parallel_region(self, sm_runner): class WarOfTheRing(StateChart): class war(State.Parallel): From b8cd61e5399f0b7e4cc7482b496b47823d74a801 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Mon, 17 Aug 2026 14:40:30 -0300 Subject: [PATCH 3/7] docs: drop the versionchanged note for the compound Event fix The docs describe the current behavior. The previous behavior was a bug, not a documented contract, and the release notes already carry the history. Signed-off-by: Fernando Macedo --- docs/events.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/events.md b/docs/events.md index 18a1155f..c1e352a3 100644 --- a/docs/events.md +++ b/docs/events.md @@ -91,11 +91,6 @@ True ``` -```{versionchanged} 3.2.2 -Before this release, an `Event` declared inside a `State.Compound` body was -ignored and its transition became {ref}`eventless `. -``` - (event-identity)= From e5fe8810d1401e0e385bfe26da4e7cab8f964616 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sun, 13 Sep 2026 17:23:09 -0300 Subject: [PATCH 4/7] refactor: declare nested events inline instead of through a shared reader The reader added a module, a seven-method Protocol under TYPE_CHECKING and two implementations, to share class body recognition between the statechart metaclass and NestedStateFactory. The two consumers need different amounts of it, which showed up as an aliased on_history on one side and an empty on_other on the other, and all three defects found in review lived in the nested implementation. Declare the Event and decorator forms with two branches in the loop that was already there. The event reaches the machine through the path that already exists: add_state walks the tree and collects state.transitions.unique_events. Nothing is placed in the callbacks dict, so the expanded id of an error_ prefix no longer overwrites the class attribute that add_event had bound correctly. Two differences from the top level remain, both because a nested body is evaluated before the owning class exists: an explicit id that differs from the attribute name does not also bind the attribute name, and a transition-less Event is dropped. Signed-off-by: Fernando Macedo --- docs/events.md | 8 ++- docs/releases/3.2.2.md | 19 +++--- statemachine/class_body.py | 59 ------------------- statemachine/factory.py | 79 +++++++++++++------------ statemachine/state.py | 97 +++++++++++++------------------ tests/test_statechart_compound.py | 45 +++++++++----- 6 files changed, 124 insertions(+), 183 deletions(-) delete mode 100644 statemachine/class_body.py diff --git a/docs/events.md b/docs/events.md index c1e352a3..45c104fd 100644 --- a/docs/events.md +++ b/docs/events.md @@ -79,7 +79,7 @@ The same declaration works inside a {ref}`compound state ` body ... bag_end = State(initial=True) ... green_dragon = State() ... -... visit_pub = Event(bag_end.to(green_dragon), name="Visit the pub") +... visit_pub = Event(bag_end.to(green_dragon)) ... ... road = State(final=True) ... depart = Event(shire.to(road)) @@ -91,6 +91,12 @@ True ``` +```{note} +Inside a nested body there is no owning class yet, so an explicit `id` that differs from the +attribute name does not also bind the attribute name, and an `Event` with no transitions is +dropped instead of becoming a class attribute. Both work at the top level. +``` + (event-identity)= diff --git a/docs/releases/3.2.2.md b/docs/releases/3.2.2.md index a361b2e2..6e9e354a 100644 --- a/docs/releases/3.2.2.md +++ b/docs/releases/3.2.2.md @@ -11,7 +11,7 @@ event declaration (`visit_pub = bag_end.to(green_dragon)`). The two other docume silently dropped: the name was bound to a detached object and the transition it wrapped stayed {ref}`eventless `, firing as soon as its source state became active. -The `Event` class now works inside a nested state body: +The `Event` class now declares an event inside a nested state body: ```py >>> from statemachine import Event, State, StateChart @@ -21,7 +21,7 @@ The `Event` class now works inside a nested state body: ... bag_end = State(initial=True) ... green_dragon = State() ... -... visit_pub = Event(bag_end.to(green_dragon), name="Visit the pub") +... visit_pub = Event(bag_end.to(green_dragon)) ... ... road = State(final=True) ... depart = Event(shire.to(road)) @@ -34,15 +34,11 @@ True >>> set(sm.configuration_values) == {"shire", "green_dragon"} True ->>> Journey.visit_pub.name -'Visit the pub' - ``` Before this fix, `Journey` started already in `green_dragon` and `visit_pub` was not among its -events. The attribute name now becomes the event `id`, an explicit `id` takes precedence (with -the attribute name still resolving to the event), `name` is kept as the display name, and the -`error_` / `done_state_` / `done_invoke_` prefixes expand to their dotted form. +events. The attribute name now becomes the event `id`, an explicit `id` takes precedence, and +the `error_` / `done_state_` / `done_invoke_` prefixes expand to their dotted form. The same applies to the `@.to()` decorator, which declares an event and its inline action at once. Inside a compound body it registered no event and never ran its body: @@ -71,9 +67,10 @@ True ``` -An `Event` declared inside a nested body with no transitions at all (`knock = Event()`) still -differs from the top-level form: it is reachable as a class attribute but is not added to the -machine's event list. +Two differences from the top-level form remain, both because a nested body is evaluated before +the owning class exists: an explicit `id` that differs from the attribute name does not also +bind the attribute name, and an `Event` with no transitions is dropped instead of becoming a +class attribute. Reported by [@Dolecor](https://github.com/Dolecor). diff --git a/statemachine/class_body.py b/statemachine/class_body.py deleted file mode 100644 index 0d8d185d..00000000 --- a/statemachine/class_body.py +++ /dev/null @@ -1,59 +0,0 @@ -from typing import TYPE_CHECKING -from typing import Any - -from .event import Event -from .state import HistoryState -from .state import State -from .states import States -from .transition import Transition -from .transition_list import TransitionList - -if TYPE_CHECKING: - from typing import Protocol - - class ClassBodyReader(Protocol): - """One method per declaration form that :func:`read` recognizes. - - Adding a form here is what forces every reader to answer for it. - """ - - def on_states(self, states: States) -> None: ... - - def on_history(self, key: str, state: HistoryState) -> None: ... - - def on_state(self, key: str, state: State) -> None: ... - - def on_transitions(self, key: str, transitions: "Transition | TransitionList") -> None: ... - - def on_event(self, key: str, declared: Event) -> None: ... - - def on_decorated(self, key: str, func: Any) -> None: ... - - def on_other(self, key: str, value: Any) -> None: ... - - -def read(attrs: "dict[str, Any]", reader: "ClassBodyReader") -> None: - """Dispatch each entry of a statechart class body to ``reader``. - - A statechart is declared in two kinds of class body: a :ref:`StateChart` subclass and a - nested ``State.Compound`` / ``State.Parallel``. Both accept the same forms, so recognition - lives here once and each reader supplies only what it does with a form. - - Order is significant: a ``HistoryState`` is a ``State``, and an ``Event`` is a callable - ``str``, so both would be captured by a later branch. - """ - for key, value in attrs.items(): - if isinstance(value, States): - reader.on_states(value) - elif isinstance(value, HistoryState): - reader.on_history(key, value) - elif isinstance(value, State): - reader.on_state(key, value) - elif isinstance(value, (Transition, TransitionList)): - reader.on_transitions(key, value) - elif isinstance(value, Event): - reader.on_event(key, value) - elif getattr(value, "attr_name", None): - reader.on_decorated(key, value) - else: - reader.on_other(key, value) diff --git a/statemachine/factory.py b/statemachine/factory.py index 811f10ce..3bced620 100644 --- a/statemachine/factory.py +++ b/statemachine/factory.py @@ -5,7 +5,6 @@ from .callbacks import CallbackGroup from .callbacks import CallbackPriority from .callbacks import CallbackSpecList -from .class_body import read from .event import Event from .event import _expand_event_id from .exceptions import InvalidDefinition @@ -16,6 +15,8 @@ from .i18n import _ from .state import State from .states import States +from .transition import Transition +from .transition_list import TransitionList class StateMachineMetaclass(type): @@ -297,8 +298,43 @@ def add_inherited(cls, bases): for event in events: cls.add_event(event=Event(id=event.id, name=event.name)) - def add_from_attributes(cls, attrs): - read(attrs, _StateChartBody(cls)) + def add_from_attributes(cls, attrs): # noqa: C901 + for key, value in attrs.items(): + if isinstance(value, States): + cls._add_states_from_dict(value) + if isinstance(value, State): + cls.add_state(key, value) + elif isinstance(value, (Transition, TransitionList)): + event_id = _expand_event_id(key) + cls.add_event(event=Event(transitions=value, id=event_id)) + elif isinstance(value, (Event,)): + if value._has_real_id: + event_id = value.id + else: + event_id = _expand_event_id(key) + new_event = Event( + transitions=value._transitions, + id=event_id, + name=value.name, + ) + cls.add_event(event=new_event, old_event=value) + # Ensure the event is accessible by the Python attribute name + if event_id != key: + setattr(cls, key, new_event) + elif getattr(value, "attr_name", None): + cls._add_unbounded_callback(key, value) + + def _add_states_from_dict(cls, states): + for state_id, state in states.items(): + cls.add_state(state_id, state) + + def _add_unbounded_callback(cls, attr_name, func): + # if func is an event, the `attr_name` will be replaced by an event trigger, + # so we'll also give the ``func`` a new unique name to be used by the callback + # machinery that is stored at ``func.attr_name`` + setattr(cls, func.attr_name, func) + if func.is_event: + cls.add_event(event=Event(func._transitions, id=attr_name)) def add_state(cls, id, state: State): state._set_id(id) @@ -354,40 +390,3 @@ def _update_event_references(cls): @property def events(self): return list(self._events) - - -class _StateChartBody: - """Registers a statechart class body on the class under construction.""" - - def __init__(self, cls: "StateMachineMetaclass") -> None: - self.cls = cls - - def on_states(self, states: States) -> None: - for state_id, state in states.items(): - self.cls.add_state(state_id, state) - - def on_state(self, key: str, state: State) -> None: - self.cls.add_state(key, state) - - on_history = on_state - """A top-level history state is registered as an ordinary state.""" - - def on_transitions(self, key: str, transitions: Any) -> None: - self.cls.add_event(event=Event(transitions=transitions, id=_expand_event_id(key))) - - def on_event(self, key: str, declared: Event) -> None: - event_id = declared.id if declared._has_real_id else _expand_event_id(key) - new_event = Event(transitions=declared._transitions, id=event_id, name=declared.name) - self.cls.add_event(event=new_event, old_event=declared) - if event_id != key: - setattr(self.cls, key, new_event) - - def on_decorated(self, key: str, func: Any) -> None: - # The attribute name is taken over by the event trigger, so the callback machinery - # reaches the function through the unique name it stored at ``func.attr_name``. - setattr(self.cls, func.attr_name, func) - if func.is_event: - self.cls.add_event(event=Event(func._transitions, id=key)) - - def on_other(self, key: str, value: Any) -> None: - """Anything else is already a plain class attribute.""" diff --git a/statemachine/state.py b/statemachine/state.py index 422c52ae..02268083 100644 --- a/statemachine/state.py +++ b/statemachine/state.py @@ -56,54 +56,8 @@ def __call__(self, *states: "State | NestedStateFactory", **kwargs): return transitions -class _NestedBody: - """Collects a nested state class body into the arguments of the :ref:`State` it becomes. - - Unlike :class:`statemachine.factory._StateChartBody`, this reader has no class to register - on. Everything that is not a substate is handed back as a callback for - ``StateMachineMetaclass._unpack_builders_callbacks`` to place on the owning statechart. - """ - - def __init__(self) -> None: - self.states: list[State] = [] - self.history: list[HistoryState] = [] - self.callbacks: dict = {} - - def on_states(self, states: Any) -> None: - for state_id, state in states.items(): - state._set_id(state_id) - self.states.append(state) - - def on_history(self, key: str, state: "HistoryState") -> None: - state._set_id(key) - self.history.append(state) - - def on_state(self, key: str, state: "State") -> None: - state._set_id(key) - self.states.append(state) - - def on_transitions(self, key: str, transitions: "Transition | TransitionList") -> None: - transitions.add_event(_expand_event_id(key)) - - def on_event(self, key: str, declared: "Event") -> None: - event_id = declared.id if declared._has_real_id else _expand_event_id(key) - event = Event(id=event_id, name=declared.name) - if declared._transitions is not None: - declared._transitions.add_event(event) - self.callbacks[key] = event - - def on_decorated(self, key: str, func: Any) -> None: - if func.is_event: - func._transitions.add_event(key) - self.callbacks[func.attr_name] = func - - def on_other(self, key: str, value: Any) -> None: - if callable(value): - self.callbacks[key] = value - - class NestedStateFactory(type): - def __new__( # type: ignore [misc] + def __new__( # type: ignore [misc] # noqa: C901 cls, classname, bases, attrs, name="", **kwargs ) -> "State": if not bases: @@ -117,18 +71,47 @@ def __new__( # type: ignore [misc] inherited_kwargs.update(getattr(base, "_factory_kwargs", {})) inherited_kwargs.update(kwargs) - # Lazy import to avoid circular dependency (class_body.py imports state.py) - from .class_body import read - - body = _NestedBody() - read(attrs, body) + # Lazy import to avoid circular dependency (states.py imports state.py) + from .states import States + + states = [] + history = [] + callbacks = {} + # Order is significant: a ``HistoryState`` is a ``State``, and an ``Event`` is a + # callable ``str``, so both would be captured by a later branch. + for key, value in attrs.items(): + if isinstance(value, States): + for state_id, state in value.items(): + state._set_id(state_id) + states.append(state) + elif isinstance(value, HistoryState): + value._set_id(key) + history.append(value) + elif isinstance(value, State): + value._set_id(key) + states.append(value) + elif isinstance(value, TransitionList): + value.add_event(_expand_event_id(key)) + elif isinstance(value, Event): + if value._transitions is not None: + event_id = value.id if value._has_real_id else _expand_event_id(key) + value._transitions.add_event( + Event( + id=event_id, + name=value.name, + delay=value.delay, + internal=value.internal, + ) + ) + elif getattr(value, "attr_name", None): + if value.is_event: + value._transitions.add_event(key) + callbacks[value.attr_name] = value + elif callable(value): + callbacks[key] = value return State( - name=name, - states=body.states, - history=body.history, - _callbacks=body.callbacks, - **inherited_kwargs, + name=name, states=states, history=history, _callbacks=callbacks, **inherited_kwargs ) @classmethod diff --git a/tests/test_statechart_compound.py b/tests/test_statechart_compound.py index 2c362fbf..de1c52f3 100644 --- a/tests/test_statechart_compound.py +++ b/tests/test_statechart_compound.py @@ -337,20 +337,41 @@ class shire(State.Compound): await sm_runner.send(sm, "visit_pub") assert {"shire", "green_dragon"} == set(sm.configuration_values) - def test_display_name_is_preserved(self): + def test_name_delay_and_internal_are_preserved(self): class NamedEvent(StateChart): class shire(State.Compound): bag_end = State(initial=True) green_dragon = State(final=True) - visit_pub = Event(bag_end.to(green_dragon), name="Visit the pub") + visit_pub = Event( + bag_end.to(green_dragon), name="Visit the pub", delay=50, internal=True + ) - assert NamedEvent.visit_pub.id == "visit_pub" + (registered,) = NamedEvent.events + assert (registered.id, registered.name) == ("visit_pub", "Visit the pub") + assert (registered.delay, registered.internal) == (50, True) assert NamedEvent.visit_pub.name == "Visit the pub" - async def test_explicit_id_is_reachable_by_both_names(self, sm_runner): - """An explicit ``id`` wins over the attribute name, which still resolves.""" + def test_expanded_id_drops_the_declared_arguments(self): + """A space-separated id declares distinct events, so each one names itself. + + The ``error_`` prefix expands to ``"error_foo error.foo"``, which reuses that format + to mean two spellings of a single event. See + ``test_multiple_ids_from_the_same_event_will_be_converted_to_multiple_events``. + """ + class ErrorEvent(StateChart): + class shire(State.Compound): + bag_end = State(initial=True) + green_dragon = State(final=True) + + error_foo = Event(bag_end.to(green_dragon), name="Boom", delay=50) + + assert [event.id for event in ErrorEvent.events] == ["error_foo", "error.foo"] + assert [event.name for event in ErrorEvent.events] == ["Error foo", "Error foo"] + assert [event.delay for event in ErrorEvent.events] == [0, 0] + + async def test_explicit_id_wins_over_the_attribute_name(self, sm_runner): class ExplicitId(StateChart): class shire(State.Compound): bag_end = State(initial=True) @@ -359,7 +380,6 @@ class shire(State.Compound): visit_pub = Event(bag_end.to(green_dragon), id="pub.visit") assert [event.id for event in ExplicitId.events] == ["pub.visit"] - assert ExplicitId.visit_pub.id == "pub.visit" sm = await sm_runner.start(ExplicitId) await sm_runner.send(sm, "pub.visit") @@ -405,12 +425,8 @@ class battle(State.Compound): await sm_runner.send(sm, "victory") assert {"war", "quest", "end", "battle", "won"} == set(sm.configuration_values) - def test_event_without_transitions_is_reachable_but_unregistered(self): - """A transition-less ``Event`` gets its id from the attribute name. - - Having no transitions, it never reaches the machine's event list -- unlike the - top-level form, which registers it. - """ + def test_transition_less_event_declares_nothing(self): + """A nested ``Event`` carries only its id, so with no transitions it is dropped.""" class Placeholder(StateChart): class shire(State.Compound): @@ -420,9 +436,8 @@ class shire(State.Compound): visit_pub = bag_end.to(green_dragon) knock = Event(name="Knock on the door") - assert Placeholder.knock.id == "knock" - assert Placeholder.knock.name == "Knock on the door" - assert "knock" not in [event.id for event in Placeholder.events] + assert [event.id for event in Placeholder.events] == ["visit_pub"] + assert not hasattr(Placeholder, "knock") @pytest.mark.timeout(5) From 405857033658db0b98c5432415c0e9145128ae1f Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sun, 13 Sep 2026 17:23:36 -0300 Subject: [PATCH 5/7] fix: keep delay and internal when declaring an explicit Event Event(dark.to(lit), delay=50) rebuilt the event without its delay, so BeaconsOfGondor.light.delay was 0 and the event fired immediately instead of being queued. internal was dropped the same way. test_delayed_event_on_event_definition built its own BoundEvent(delay=50) instead of triggering the declared one, so it never exercised the bug. internal is preserved on the declaration but still has no effect at trigger time: Event.__get__ does not pass it to BoundEvent and send() does not read it. Signed-off-by: Fernando Macedo --- docs/releases/3.2.2.md | 18 ++++++++++++++++++ statemachine/factory.py | 2 ++ tests/test_events.py | 12 ++++++++++++ tests/test_statechart_delayed.py | 3 +-- 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/docs/releases/3.2.2.md b/docs/releases/3.2.2.md index 6e9e354a..6d4c2556 100644 --- a/docs/releases/3.2.2.md +++ b/docs/releases/3.2.2.md @@ -75,3 +75,21 @@ class attribute. Reported by [@Dolecor](https://github.com/Dolecor). [#643](https://github.com/fgmacedo/python-statemachine/issues/643). + +### `delay` and `internal` dropped from an explicit `Event` + +`Event(dark.to(lit), delay=50)` rebuilt the event without its `delay`, so +`BeaconsOfGondor.light.delay` was `0` and the event fired immediately instead of being queued. +The same happened to `internal`. Both are now preserved: + +```py +>>> class BeaconsOfGondor(StateChart): +... dark = State(initial=True) +... lit = State(final=True) +... +... light = Event(dark.to(lit), delay=50) + +>>> BeaconsOfGondor.light.delay +50 + +``` diff --git a/statemachine/factory.py b/statemachine/factory.py index 3bced620..33f66e28 100644 --- a/statemachine/factory.py +++ b/statemachine/factory.py @@ -316,6 +316,8 @@ def add_from_attributes(cls, attrs): # noqa: C901 transitions=value._transitions, id=event_id, name=value.name, + delay=value.delay, + internal=value.internal, ) cls.add_event(event=new_event, old_event=value) # Ensure the event is accessible by the Python attribute name diff --git a/tests/test_events.py b/tests/test_events.py index b4ce48ab..fc00d675 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -59,6 +59,18 @@ class StartMachine(StateChart): assert [e.name for e in StartMachine.events] == ["Start the machine"] assert StartMachine.start.name == "Start the machine" + def test_accept_delay_and_internal(self): + class BeaconsOfGondor(StateChart): + dark = State(initial=True) + lit = State(final=True) + + light = Event(dark.to(lit), delay=50, internal=True) + + (registered,) = BeaconsOfGondor.events + assert (registered.delay, registered.internal) == (50, True) + assert BeaconsOfGondor.light.delay == 50 + assert BeaconsOfGondor().light.delay == 50 + def test_derive_name_from_id(self): class StartMachine(StateChart): created = State(initial=True) diff --git a/tests/test_statechart_delayed.py b/tests/test_statechart_delayed.py index 5451895c..f1f7c21b 100644 --- a/tests/test_statechart_delayed.py +++ b/tests/test_statechart_delayed.py @@ -89,8 +89,7 @@ class BeaconsOfGondor(StateChart): sm = await sm_runner.start(BeaconsOfGondor) # Queue via BoundEvent.put() to avoid blocking in processing_loop - event = BoundEvent(id="light", name="Light", delay=50, _sm=sm) - event.put() + sm.light.put() # Not yet processed assert "dark" in sm.configuration_values From 5ac8c5875fa875a26596a173235f75a68564eeb4 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sun, 13 Sep 2026 17:25:20 -0300 Subject: [PATCH 6/7] docs: link the nested Event limitations to their issues The notes stated what does not work without pointing anywhere. #656 covers the missing attribute binding in a nested body, #655 the internal flag that is preserved on the declaration but ignored at trigger time. Signed-off-by: Fernando Macedo --- docs/events.md | 3 ++- docs/releases/3.2.2.md | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/events.md b/docs/events.md index 45c104fd..bfb1e9be 100644 --- a/docs/events.md +++ b/docs/events.md @@ -94,7 +94,8 @@ True ```{note} Inside a nested body there is no owning class yet, so an explicit `id` that differs from the attribute name does not also bind the attribute name, and an `Event` with no transitions is -dropped instead of becoming a class attribute. Both work at the top level. +dropped instead of becoming a class attribute. Both work at the top level, see +[#656](https://github.com/fgmacedo/python-statemachine/issues/656). ``` diff --git a/docs/releases/3.2.2.md b/docs/releases/3.2.2.md index 6d4c2556..1e8fa89a 100644 --- a/docs/releases/3.2.2.md +++ b/docs/releases/3.2.2.md @@ -70,7 +70,7 @@ True Two differences from the top-level form remain, both because a nested body is evaluated before the owning class exists: an explicit `id` that differs from the attribute name does not also bind the attribute name, and an `Event` with no transitions is dropped instead of becoming a -class attribute. +class attribute. See [#656](https://github.com/fgmacedo/python-statemachine/issues/656). Reported by [@Dolecor](https://github.com/Dolecor). @@ -80,7 +80,9 @@ Reported by [@Dolecor](https://github.com/Dolecor). `Event(dark.to(lit), delay=50)` rebuilt the event without its `delay`, so `BeaconsOfGondor.light.delay` was `0` and the event fired immediately instead of being queued. -The same happened to `internal`. Both are now preserved: +The same happened to `internal`. Both are now preserved, although `internal` still has no +effect at trigger time, see +[#655](https://github.com/fgmacedo/python-statemachine/issues/655): ```py >>> class BeaconsOfGondor(StateChart): From 5ecb8eaba912763e642646b6927a36de0ac348c1 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sun, 13 Sep 2026 17:29:51 -0300 Subject: [PATCH 7/7] docs: drop the compound Event section and the issue links Declaring an Event inside a nested body was always expected to work, so an example for it repeats what the section above already shows. Open limitations are tracked on GitHub, not in the docs. Signed-off-by: Fernando Macedo --- docs/events.md | 27 --------------------------- docs/releases/3.2.2.md | 5 ++--- 2 files changed, 2 insertions(+), 30 deletions(-) diff --git a/docs/events.md b/docs/events.md index bfb1e9be..d3414d98 100644 --- a/docs/events.md +++ b/docs/events.md @@ -71,33 +71,6 @@ human-readable display name, use the `Event` class explicitly: ``` -The same declaration works inside a {ref}`compound state ` body: - -```py ->>> class Journey(StateChart): -... class shire(State.Compound): -... bag_end = State(initial=True) -... green_dragon = State() -... -... visit_pub = Event(bag_end.to(green_dragon)) -... -... road = State(final=True) -... depart = Event(shire.to(road)) - ->>> sm = Journey() ->>> sm.send("visit_pub") ->>> set(sm.configuration_values) == {"shire", "green_dragon"} -True - -``` - -```{note} -Inside a nested body there is no owning class yet, so an explicit `id` that differs from the -attribute name does not also bind the attribute name, and an `Event` with no transitions is -dropped instead of becoming a class attribute. Both work at the top level, see -[#656](https://github.com/fgmacedo/python-statemachine/issues/656). -``` - (event-identity)= diff --git a/docs/releases/3.2.2.md b/docs/releases/3.2.2.md index 1e8fa89a..b0a6405e 100644 --- a/docs/releases/3.2.2.md +++ b/docs/releases/3.2.2.md @@ -70,7 +70,7 @@ True Two differences from the top-level form remain, both because a nested body is evaluated before the owning class exists: an explicit `id` that differs from the attribute name does not also bind the attribute name, and an `Event` with no transitions is dropped instead of becoming a -class attribute. See [#656](https://github.com/fgmacedo/python-statemachine/issues/656). +class attribute. Reported by [@Dolecor](https://github.com/Dolecor). @@ -81,8 +81,7 @@ Reported by [@Dolecor](https://github.com/Dolecor). `Event(dark.to(lit), delay=50)` rebuilt the event without its `delay`, so `BeaconsOfGondor.light.delay` was `0` and the event fired immediately instead of being queued. The same happened to `internal`. Both are now preserved, although `internal` still has no -effect at trigger time, see -[#655](https://github.com/fgmacedo/python-statemachine/issues/655): +effect at trigger time: ```py >>> class BeaconsOfGondor(StateChart):