Visitar URL original
fix: support event declarations inside State.Compound bodies (#645) · fgmacedo/python-statemachine@45d6ab1 · GitHub
Skip to content

Commit 45d6ab1

Browse files
authored
fix: support event declarations inside State.Compound bodies (#645)
* 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 `@<source>.to(<target>)` 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 <fgmacedo@gmail.com> * 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 <fgmacedo@gmail.com> * 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 <fgmacedo@gmail.com> * 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 <fgmacedo@gmail.com> * 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 <fgmacedo@gmail.com> * 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 <fgmacedo@gmail.com> * 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 <fgmacedo@gmail.com> --------- Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
1 parent 78e2b4c commit 45d6ab1

8 files changed

Lines changed: 331 additions & 6 deletions

File tree

‎docs/releases/3.2.2.md‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# StateChart 3.2.2
2+
3+
*Not released yet*
4+
5+
## Bug fixes in 3.2.2
6+
7+
### Event declarations inside `State.Compound`
8+
9+
A `State.Compound` (or `State.Parallel`) class body only understood the assignment form of an
10+
event declaration (`visit_pub = bag_end.to(green_dragon)`). The two other documented forms were
11+
silently dropped: the name was bound to a detached object and the transition it wrapped stayed
12+
{ref}`eventless <eventless>`, firing as soon as its source state became active.
13+
14+
The `Event` class now declares an event inside a nested state body:
15+
16+
```py
17+
>>> from statemachine import Event, State, StateChart
18+
19+
>>> class Journey(StateChart):
20+
... class shire(State.Compound):
21+
... bag_end = State(initial=True)
22+
... green_dragon = State()
23+
...
24+
... visit_pub = Event(bag_end.to(green_dragon))
25+
...
26+
... road = State(final=True)
27+
... depart = Event(shire.to(road))
28+
29+
>>> sm = Journey()
30+
>>> set(sm.configuration_values) == {"shire", "bag_end"}
31+
True
32+
33+
>>> sm.send("visit_pub")
34+
>>> set(sm.configuration_values) == {"shire", "green_dragon"}
35+
True
36+
37+
```
38+
39+
Before this fix, `Journey` started already in `green_dragon` and `visit_pub` was not among its
40+
events. The attribute name now becomes the event `id`, an explicit `id` takes precedence, and
41+
the `error_` / `done_state_` / `done_invoke_` prefixes expand to their dotted form.
42+
43+
The same applies to the `@<source>.to(<target>)` decorator, which declares an event and its
44+
inline action at once. Inside a compound body it registered no event and never ran its body:
45+
46+
```py
47+
>>> class Gate(StateChart):
48+
... class gate(State.Compound):
49+
... locked = State(initial=True)
50+
... unlocked = State()
51+
...
52+
... push = unlocked.to(locked)
53+
...
54+
... @locked.to(unlocked)
55+
... def coin(self):
56+
... return "accepted"
57+
...
58+
... broken = State(final=True)
59+
... smash = gate.to(broken)
60+
61+
>>> sm = Gate()
62+
>>> sm.send("coin")
63+
'accepted'
64+
65+
>>> set(sm.configuration_values) == {"gate", "unlocked"}
66+
True
67+
68+
```
69+
70+
Two differences from the top-level form remain, both because a nested body is evaluated before
71+
the owning class exists: an explicit `id` that differs from the attribute name does not also
72+
bind the attribute name, and an `Event` with no transitions is dropped instead of becoming a
73+
class attribute.
74+
75+
Reported by [@Dolecor](https://github.com/Dolecor).
76+
77+
[#643](https://github.com/fgmacedo/python-statemachine/issues/643).
78+
79+
### `delay` and `internal` dropped from an explicit `Event`
80+
81+
`Event(dark.to(lit), delay=50)` rebuilt the event without its `delay`, so
82+
`BeaconsOfGondor.light.delay` was `0` and the event fired immediately instead of being queued.
83+
The same happened to `internal`. Both are now preserved, although `internal` still has no
84+
effect at trigger time:
85+
86+
```py
87+
>>> class BeaconsOfGondor(StateChart):
88+
... dark = State(initial=True)
89+
... lit = State(final=True)
90+
...
91+
... light = Event(dark.to(lit), delay=50)
92+
93+
>>> BeaconsOfGondor.light.delay
94+
50
95+
96+
```

‎docs/releases/index.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Requires Python 3.10+.
1616
```{toctree}
1717
:maxdepth: 2
1818
19+
3.2.2
1920
3.2.1
2021
3.2.0
2122
3.1.2

‎statemachine/factory.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,8 @@ def add_from_attributes(cls, attrs): # noqa: C901
316316
transitions=value._transitions,
317317
id=event_id,
318318
name=value.name,
319+
delay=value.delay,
320+
internal=value.internal,
319321
)
320322
cls.add_event(event=new_event, old_event=value)
321323
# Ensure the event is accessible by the Python attribute name

‎statemachine/state.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from .callbacks import CallbackGroup
99
from .callbacks import CallbackPriority
1010
from .callbacks import CallbackSpecList
11+
from .event import Event
1112
from .event import _expand_event_id
1213
from .exceptions import InvalidDefinition
1314
from .i18n import _
@@ -56,7 +57,7 @@ def __call__(self, *states: "State | NestedStateFactory", **kwargs):
5657

5758

5859
class NestedStateFactory(type):
59-
def __new__( # type: ignore [misc]
60+
def __new__( # type: ignore [misc] # noqa: C901
6061
cls, classname, bases, attrs, name="", **kwargs
6162
) -> "State":
6263
if not bases:
@@ -76,6 +77,8 @@ def __new__( # type: ignore [misc]
7677
states = []
7778
history = []
7879
callbacks = {}
80+
# Order is significant: a ``HistoryState`` is a ``State``, and an ``Event`` is a
81+
# callable ``str``, so both would be captured by a later branch.
7982
for key, value in attrs.items():
8083
if isinstance(value, States):
8184
for state_id, state in value.items():
@@ -89,6 +92,21 @@ def __new__( # type: ignore [misc]
8992
states.append(value)
9093
elif isinstance(value, TransitionList):
9194
value.add_event(_expand_event_id(key))
95+
elif isinstance(value, Event):
96+
if value._transitions is not None:
97+
event_id = value.id if value._has_real_id else _expand_event_id(key)
98+
value._transitions.add_event(
99+
Event(
100+
id=event_id,
101+
name=value.name,
102+
delay=value.delay,
103+
internal=value.internal,
104+
)
105+
)
106+
elif getattr(value, "attr_name", None):
107+
if value.is_event:
108+
value._transitions.add_event(key)
109+
callbacks[value.attr_name] = value
92110
elif callable(value):
93111
callbacks[key] = value
94112

‎tests/test_events.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,18 @@ class StartMachine(StateChart):
5959
assert [e.name for e in StartMachine.events] == ["Start the machine"]
6060
assert StartMachine.start.name == "Start the machine"
6161

62+
def test_accept_delay_and_internal(self):
63+
class BeaconsOfGondor(StateChart):
64+
dark = State(initial=True)
65+
lit = State(final=True)
66+
67+
light = Event(dark.to(lit), delay=50, internal=True)
68+
69+
(registered,) = BeaconsOfGondor.events
70+
assert (registered.delay, registered.internal) == (50, True)
71+
assert BeaconsOfGondor.light.delay == 50
72+
assert BeaconsOfGondor().light.delay == 50
73+
6274
def test_derive_name_from_id(self):
6375
class StartMachine(StateChart):
6476
created = State(initial=True)

‎tests/test_state.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22
from statemachine.orderedset import OrderedSet
3+
from statemachine.states import States
34

45
from statemachine import State
56
from statemachine import StateChart
@@ -83,3 +84,11 @@ def test_ordered_set_union():
8384
s1 = OrderedSet([1, 2])
8485
result = s1.union([3, 4], [5, 6])
8586
assert list(result) == [1, 2, 3, 4, 5, 6]
87+
88+
89+
def test_states_getattr_unknown_name():
90+
"""States exposes its members as attributes and rejects anything else."""
91+
states = States({"draft": State("Draft")})
92+
assert states.draft.name == "Draft"
93+
with pytest.raises(AttributeError, match="published not found in States"):
94+
_ = states.published

0 commit comments

Comments
 (0)