Skip to content

Read every statechart class body through one reader with an owning state #657

Description

@fgmacedo

The failure mode

Two places must agree about what a statechart class body may contain:
StateMachineMetaclass.add_from_attributes for a StateChart subclass, and
NestedStateFactory.__new__ for a State.Compound / State.Parallel. They diverge silently.

#643 is exactly that: the Event class and the @<source>.to(<target>) decorator worked at the
top level and were dropped inside a nested body for as long as compound states have existed. The
next declaration form added will do it again.

#645 first tried to close this with a shared reader: a module, a seven-method Protocol under
TYPE_CHECKING, and two implementations. It was reverted on the branch. The mechanism aimed at
the right target but the two consumers need different amounts of it (the top level needs seven
forms, the nested body three), which showed as an aliased on_history on one side and an empty
on_other on the other. All three defects found in review lived in the nested implementation.

Step 1: one reader, owned by the metaclass

The nested body stops translating. NestedStateFactory resolves only the structural forms it
needs to build the State (States, HistoryState, State) and stashes the rest raw. The
metaclass reads that stash from add_state, which already walks the tree and already holds
cls:

# state.py
return State(name=name, states=states, history=history, _body=body, **inherited_kwargs)

# factory.py
def add_state(cls, id, state):
    state._set_id(id)
    ...
    cls._read_body(state._body, owner=state)
    for event in state.transitions.unique_events:
        cls.add_event(event)
    for substate in state.states:
        cls.add_state(substate.id, substate)

What it buys:

The split that remains is "structure now, behaviour later", and #643 lived entirely in the
behaviour half.

Watch out: _unpack_builders_callbacks currently runs after _collect_class_listeners.
Moving the callback setattr earlier may change what the listener collector sees. The 2161-test
suite plus the W3C SCXML suite is the net.

Step 2: a root state

factory.py already carries the TODO: Experiment with the IDEA of a root state. With the
StateChart class body being the body of a root compound state, the top level stops being a
special case at all.

It is smaller than it looks. Of the .states uses across the package, almost all are
state.states (substates) and do not change. cls.states can stay the very same States object,
now hanging off the root: States already provides append, __iter__, __len__ and
__getitem__, which is everything State.__init__ and the engine use. The engine, the diagram
renderers, io/ and graph.py stay untouched.

Sketch:

# factory.py __init__
cls.states: States = States()
cls.root = State(id=cls.id, name=cls.name)
cls.root.states = cls.states
...
cls._initials_by_document_order(list(cls.states), parent=cls.root)
cls.root.parallel = root_only_has_parallels
# engines/base.py _initial_transitions
empty_state = State()
empty_state.parent = self.sm.root

That last line is what keeps the root out of the configuration:
find_lcca([empty_state, shire]) then returns the root, the transition domain becomes the root,
and shire.ancestors(parent=root) stops before it. Today empty_state is an orphan, the LCCA is
None and the entry set climbs to the top. The root state replaces a pseudo-state the engine
already has, just unparented.

The initial-state validation (len(initials) != 1 and not root_only_has_parallels) is the generic
"a compound has one initial, a parallel does not need one" rule applied to the root, so it stops
being a separate branch.

Three traps found while sizing it, none of them verified by running code:

  1. A parallel root leaks into the configuration. find_lcca filters ancestors by
    anc.is_compound, and is_compound is bool(states) and not parallel. For a machine whose
    top level is all parallel regions the LCCA returns None and the root gets entered. The
    docstring of find_lcca already hints at the fix, it says "filtering for compound or SCXML
    elements
    " while the code only checks is_compound.
  2. states_map and setattr. The root must not go through add_state, or it pollutes
    states_map[root.value] and shadows the lowercased class name as a class attribute.
  3. Final states. With a root there is the real SCXML termination condition ("the root is in a
    final state"). Keep the current final_states list in the first pass or the diff doubles.

Step 2 touches the construction path of every machine and the initial entry of every engine, so
it wants its own PR with the W3C SCXML suite as the acceptance criterion.

Related

Fixes #656 and #653. Removes the cause of #643.

When fixing: docs/statecharts.md and docs/processing_model.md describe the current
top-level/nested split; the notes in docs/events.md and docs/releases/3.2.2.md about nested
limitations come out, and the open release notes need an entry.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions