Skip to content
Closed
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
7 changes: 7 additions & 0 deletions docs/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,13 @@ True

```

The same event declaration rules apply inside each compound or parallel body.
An explicit `id` does not replace the Python attribute: both names can be used
to refer to the event. A transition-less `Event` is also retained in the
machine event catalog, including its `name`, `delay`, and `internal` metadata.
Callback methods declared in sibling regions are resolved in their owning
state's scope, so equal method names do not use last-writer-wins lookup.

(donedata)=

#### DoneData
Expand Down
15 changes: 15 additions & 0 deletions docs/processing_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,21 @@ Within a single macrostep, the engine repeats:
After the macrostep completes, the engine picks the next event from the
**external queue** (placed by `send()`) and starts a new macrostep.

## Callback lookup in nested state bodies

Callbacks declared inside `State.Compound` and `State.Parallel` bodies are
bound when the owning `StateChart` is assembled, but resolved against the real
machine instance when an event is processed. This preserves dynamic model and
property values and keeps synchronous and asynchronous callbacks on their
normal engine paths.

When more than one listener supplies the same callback name, lookup follows
the existing order: the machine method, model, class listeners, then runtime
listeners. A callback declared in a nested body is scoped to its owning state,
so sibling regions can use the same name without replacing each other's
handler. Inherited state-chart classes reuse the declaration without consuming
it from the base or another subclass.


### Event queues

Expand Down
30 changes: 26 additions & 4 deletions docs/releases/3.2.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,37 @@ 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.
Nested declarations now use the same supported event forms as top-level declarations. An
explicit `id` is preserved while the Python attribute remains available as an alias, and an
`Event` without transitions is still registered in the machine event catalog with its `name`,
`delay`, and `internal` metadata. This also applies inside `State.Parallel` regions.

Reported by [@Dolecor](https://github.com/Dolecor).

[#643](https://github.com/fgmacedo/python-statemachine/issues/643).

### Nested callback ownership and inheritance

Nested compound and parallel bodies keep callback declarations associated with
the owning state. Two sibling regions may use the same callback name without
one region's method replacing the other. Reusing a state-chart class through
inheritance also keeps the base declaration available to child and sibling
classes; constructing one class does not consume callback metadata needed by
another.

Callback lookup remains instance-time. Machine methods, model callbacks, class
listeners, and runtime listeners continue to run in that order. This covers
[#646](https://github.com/fgmacedo/python-statemachine/issues/646) and
[#653](https://github.com/fgmacedo/python-statemachine/issues/653).

### Explicit IDs and transition-less nested events

An event declared inside a nested state body can use an explicit dotted ID that
differs from its Python attribute name. Both identities remain usable, and a
transition-less `Event` remains in `StateChart.events`. These declarations
preserve display names and scheduling metadata. See
[#656](https://github.com/fgmacedo/python-statemachine/issues/656).

### `delay` and `internal` dropped from an explicit `Event`

`Event(dark.to(lit), delay=50)` rebuilt the event without its `delay`, so
Expand Down
12 changes: 12 additions & 0 deletions docs/statechart.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,18 @@ True
Use `is_terminated` instead of checking individual states — it handles
arbitrarily nested structures for you.

Nested state bodies support the same declaration forms as a top-level
`StateChart`: `State`, `States.from_enum()`, transitions and transition lists,
`Event`, decorated event callbacks, and ordinary callback methods. The nested
body may be a `State.Compound` or a `State.Parallel`; each parallel region
keeps its callbacks associated with its own owning state, so equal method names
in sibling regions do not overwrite one another.

An explicit event ID and the Python attribute name are separate identities. Both
remain available when they differ, and an event with no transitions is still
listed in `StateChart.events` with its display name, delay, and `internal`
metadata.

**`final_states`** lists all top-level states marked as `final`:

```py
Expand Down
8 changes: 8 additions & 0 deletions statemachine/contrib/diagram/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ def getter(grouper): # pyright: ignore[reportRedeclaration]

def getter(grouper):
all_names = set(dir(machine))
scope = machine._callback_scopes.get(id(grouper.list))
if scope is not None:
body = scope[1]
all_names.update(body)
for value in body.values():
attr_name = getattr(value, "attr_name", None)
if attr_name:
all_names.add(attr_name)
return ", ".join(str(c) for c in grouper if not c.is_convention or c.func in all_names)

return getter
Expand Down
62 changes: 54 additions & 8 deletions statemachine/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,39 @@ class Listener:
obj: object
all_attrs: set[str]
resolver_id: str
local_scope: dict[str, Any] | None = None
scope_id: str | None = None

@classmethod
def from_obj(cls, obj, skip_attrs=None) -> "Listener":
def from_obj(cls, obj, skip_attrs=None, local_scope=None, scope_id=None) -> "Listener":
if isinstance(obj, Listener):
return obj
else:
if skip_attrs is None:
skip_attrs = set()
all_attrs = set(dir(obj)) - skip_attrs
return cls(obj, all_attrs, str(id(obj)))
if local_scope is not None:
all_attrs = set(local_scope)
return cls(obj, all_attrs, str(id(obj)), local_scope, scope_id)

def build_key(self, attr_name) -> str:
return f"{attr_name}@{self.resolver_id}"
suffix = self.resolver_id
if self.scope_id is not None:
suffix = f"{suffix}:{self.scope_id}"
return f"{attr_name}@{suffix}"

def get(self, name):
if self.local_scope is None:
return getattr(self.obj, name)
value = self.local_scope[name]
if isinstance(value, property):
return value.__get__(self.obj, type(self.obj))
descriptor = getattr(type(self.obj), name, None)
if descriptor is value and hasattr(value, "__get__"):
return value.__get__(self.obj, type(self.obj))
if hasattr(value, "__get__") and callable(value):
return value.__get__(self.obj, type(self.obj))
return value


@dataclass
Expand Down Expand Up @@ -149,11 +169,19 @@ def _search_property(self, spec):
if attr_name not in self.all_attrs:
return
for listener in self.items:
func = getattr(type(listener.obj), attr_name, None)
if listener.local_scope is not None:
func = listener.local_scope.get(attr_name)
else:
func = getattr(type(listener.obj), attr_name, None)
if func is not None and func is spec.func:
builder = (
partial(listener_attr_method, listener, attr_name)
if listener.local_scope is not None
else partial(attr_method, attr_name, listener.obj)
)
yield (
listener.build_key(attr_name),
partial(attr_method, attr_name, listener.obj),
builder,
)
return

Expand All @@ -162,7 +190,12 @@ def _search_callable(self, spec):
# on the self
if not spec.is_bounded:
for listener in self.items:
func = getattr(listener.obj, spec.attr_name, None)
if listener.local_scope is not None:
func = listener.local_scope.get(spec.attr_name)
if func is not None and hasattr(func, "__get__"):
func = func.__get__(listener.obj, type(listener.obj))
else:
func = getattr(listener.obj, spec.attr_name, None)
# ``getattr`` may return a non-method that happens to share the name
# (e.g. a model attribute named like a compiled guard); it is not the
# unbounded method we are rebinding, so skip it instead of accessing
Expand All @@ -179,9 +212,12 @@ def search_name(self, name):
continue

key = listener.build_key(name)
func = getattr(listener.obj, name)
func = listener.get(name)
if not callable(func):
yield key, partial(attr_method, name, listener.obj)
if listener.local_scope is not None:
yield key, partial(listener_attr_method, listener, name)
else:
yield key, partial(attr_method, name, listener.obj)
continue

if isinstance(func, Event):
Expand Down Expand Up @@ -225,6 +261,16 @@ def method(*args, **kwargs):
return method


def listener_attr_method(listener: Listener, attribute: str) -> Callable:
"""Read a listener member at invocation time, preserving scoped descriptors."""

def method(*args, **kwargs):
return listener.get(attribute)

method.__name__ = attribute
return method


def event_method(func) -> Callable:
def method(*args, **kwargs):
kwargs.pop("machine", None)
Expand Down
100 changes: 72 additions & 28 deletions statemachine/factory.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import builtins
import re
from typing import Any

Expand All @@ -9,7 +10,6 @@
from .event import _expand_event_id
from .exceptions import InvalidDefinition
from .graph import disconnected_states
from .graph import iterate_states
from .graph import iterate_states_and_transitions
from .graph import states_without_path_to_final_states
from .i18n import _
Expand Down Expand Up @@ -52,6 +52,7 @@
cls._events: dict[Event, None] = {} # used Dict to preserve order and avoid duplicates
cls._protected_attrs: set = set()
cls._events_to_update: dict[Event, Event | None] = {}
cls._callback_scopes: dict[int, tuple[State, dict[str, Any]]] = {}
cls._specs = CallbackSpecList()
cls.prepare = cls._specs.grouper(CallbackGroup.PREPARE).add(
"prepare_event", priority=CallbackPriority.GENERIC, is_convention=True
Expand Down Expand Up @@ -163,14 +164,10 @@
if not any(t for t in parent.transitions if t.initial and t.target == state):
parent.to(state, initial=True) # pragma: no cover

def _unpack_builders_callbacks(cls):

Check warning on line 167 in statemachine/factory.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused function parameter "cls".

See more on https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&issues=AaEKErefTbMxDqICHafH&open=AaEKErefTbMxDqICHafH&pullRequest=666
callbacks = {}
for state in iterate_states(cls.states):
if state._callbacks:
callbacks.update(state._callbacks)
del state._callbacks
for key, value in callbacks.items():
setattr(cls, key, value)
# Kept as a compatibility no-op. Nested callback bodies are retained on
# their State and resolved through the scoped machine listener.
return None

def _check(cls):
has_states = bool(cls.states)
Expand Down Expand Up @@ -304,27 +301,10 @@
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,
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
if event_id != key:
setattr(cls, key, new_event)
elif isinstance(value, (Transition, TransitionList, Event)):
cls._read_body({key: value})
elif getattr(value, "attr_name", None):
cls._add_unbounded_callback(key, value)
cls._read_body({key: value})

def _add_states_from_dict(cls, states):
for state_id, state in states.items():
Expand All @@ -346,13 +326,77 @@
if not hasattr(cls, id):
setattr(cls, id, state)

cls._read_body(state._body, owner=state)
body_owner = state
while not body_owner._body and body_owner.parent is not None:
body_owner = body_owner.parent
cls._callback_scopes[builtins.id(state._specs)] = (body_owner, body_owner._body)

# also register all events associated directly with transitions
for event in state.transitions.unique_events:
cls.add_event(event)

for transition in state.transitions:
cls._callback_scopes[builtins.id(transition._specs)] = (body_owner, body_owner._body)

for substate in state.states:
cls.add_state(substate.id, substate)

def _read_body(cls, body, owner=None): # noqa: C901

Check failure on line 345 in statemachine/factory.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 43 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&issues=AaEKErefTbMxDqICHafI&open=AaEKErefTbMxDqICHafI&pullRequest=666
"""Read behavioural declarations with an optional owning State."""
if not body:
return
for key, value in body.items():
if key.startswith("__"):
continue
# Structural declarations were consumed by NestedStateFactory.
if isinstance(value, (States, State)):
continue
if isinstance(value, TransitionList):
event_id = _expand_event_id(key)
if owner is None:
cls.add_event(event=Event(transitions=value, id=event_id))
else:
value.add_event(event_id)
continue
if isinstance(value, Transition):
event_id = _expand_event_id(key)
if owner is None:
cls.add_event(event=Event(transitions=value, id=event_id))
else:
value.add_event(event_id)
continue
if isinstance(value, Event):
event_id = value.id if value._has_real_id else _expand_event_id(key)
new_event = Event(
transitions=value._transitions,
id=event_id,
name=value.name,
delay=value.delay,
internal=value.internal,
)
if owner is None or value._transitions is None:
cls.add_event(event=new_event, old_event=value)
else:
value._transitions._on_event_defined(
event=new_event,
states=list(cls.states),
)
if event_id != key:
setattr(cls, key, new_event)
continue
if getattr(value, "attr_name", None):
if value.is_event and value._transitions is not None:
value._transitions.add_event(key)
cls.add_event(event=Event(value._transitions, id=key))
# Event-decorator callbacks need their private callable name on
# the class; ordinary local callbacks stay in the owner scope.
if owner is None:
cls._add_unbounded_callback(key, value)
continue
if callable(value) and owner is None:
cls._add_unbounded_callback(key, value)

def add_event(
cls,
event: Event,
Expand Down
Loading