fix: support event declarations inside State.Compound bodies - #645
fix: support event declarations inside State.Compound bodies#645fgmacedo wants to merge 3 commits into
Conversation
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>
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #645 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 52 53 +1
Lines 5572 5608 +36
Branches 879 876 -3
=========================================
+ Hits 5572 5608 +36
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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>
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>
|
Two commits added after a design review of the fix.
The review's central objection: Recognition now lives once in Also in this commit: both Straightening the recognition chain exposed something worth noting. The new
The docs describe current behavior; the release notes carry the history. Follow-ups filed The two pre-existing bugs found while reviewing are now #646 (subclassing a |
|



Closes #643. Closes MAC-21 (personal board)
The bug
Inside a
State.Compound/State.Parallelclass body, only the assignment form of an eventdeclaration was understood:
The nested class body scanner in
NestedStateFactory.__new__matchedStates,HistoryState,StateandTransitionList, then fell through to a genericcallable(value)branch. AnEventis callable, so
visit_pub = Event(bag_end.to(green_dragon))landed there: the name was bound toa detached placeholder
Event(still carrying its generated__event__<uuid>id) and thetransition it wrapped never received an event, becoming eventless. It then fired as soon as its
source state became active, which is why the reporter's machine started already in
green_dragon.While fixing it I found the
@<source>.to(<target>)decorator fails the same way in the sameplace, for the same reason:
add_from_attributeshas anattr_namebranch that the nestedscanner lacks. Inside a compound body it registered no event and never ran the decorated body
(
sm.send("coin")raisedAttributeError: 'function' object has no attribute 'name'). Both arethe same defect, so both are fixed here.
The fix
statemachine/state.py: the scanner is extracted fromNestedStateFactory.__new__into_collect_nested_membersand gains two branches, both placed beforecallable(value):Event→_bind_declared_eventrebuilds the event with the id derived from the attributename (or the explicit
id=, when given), keepsnameas the display name, and attaches it tothe declared transitions. The rebuilt event is returned through the existing
_callbacksunpacking so the Python attribute name keeps resolving to it even when
id=differs._bind_decorated_eventmirrorsStateMachineMetaclass._add_unbounded_callback: the callback is stored under its mangledattr_nameand, when it is an event, the attribute name is added to its transitions.Nested declarations now follow the top-level rules: the attribute name becomes the event
id,an explicit
idtakes precedence,nameis kept, and theerror_/done_state_/done_invoke_prefixes expand to their dotted form.Tests
TestEventClassInsideCompoundandTestDecoratorEventInsideCompoundintests/test_statechart_compound.py(14 of the 16 new test items fail ondevelop; the 2 thatpass are the
@<event>.onnon-regression guard for the newattr_namebranch). They cover thereporter's exact machine, display name, explicit
id, combined transitions via|, theerror_prefix, a parallel region, a transition-lessEvent, and the decorator form.Each test asserts the configuration before sending, so an eventless regression cannot pass by
letting the machine auto-advance to the expected end state.
Full suite green with 100% branch coverage; ruff, mypy and pyright clean.
Known remaining divergence
An
Eventdeclared inside a nested body with no transitions at all (knock = Event()) isreachable as a class attribute but is not added to the machine's event list, unlike the
top-level form which registers it. The nested factory has no channel to the metaclass's event
registry for a transition-less event. This is asserted by a test and stated in the release
notes rather than left implicit.
Adversarial review
An independent sub-agent review attacked the fix and surfaced two unrelated pre-existing bugs
that are not addressed here (both reproduce on
develop, neither is a regression):StateChartwhose compound body has callbacks crashes:AttributeError: 'State' object has no attribute '_callbacks'.factory.py_unpack_builders_callbacksdoesdel state._callbacks, and the nestedStateobject isshared with the subclass via
add_inherited, so the subclass re-reads a deleted attribute.delay=/internal=passed toEvent(...)are silently dropped at both levels:Event(dark.to(lit), delay=500)yieldsSM.light.delay == 0and fires immediately.tests/test_statechart_delayed.py::test_delayed_event_on_event_definitiondoes not catch itbecause it builds its own
BoundEvent(id="light", delay=50)to trigger instead of using thedeclared event.