Skip to content
Open
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
20 changes: 20 additions & 0 deletions docs/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,26 @@ human-readable display name, use the `Event` class explicitly:

```

The same declaration works inside a {ref}`compound state <compound-states>` 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

```


(event-identity)=

Expand Down
80 changes: 80 additions & 0 deletions docs/releases/3.2.2.md
Original file line number Diff line number Diff line change
@@ -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 <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 `@<source>.to(<target>)` 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).
1 change: 1 addition & 0 deletions docs/releases/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Requires Python 3.10+.
```{toctree}
:maxdepth: 2

3.2.2
3.2.1
3.2.0
3.1.2
Expand Down
59 changes: 59 additions & 0 deletions statemachine/class_body.py
Original file line number Diff line number Diff line change
@@ -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)
79 changes: 40 additions & 39 deletions statemachine/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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."""
79 changes: 57 additions & 22 deletions statemachine/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 _
Expand Down Expand Up @@ -55,6 +56,52 @@ 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]
cls, classname, bases, attrs, name="", **kwargs
Expand All @@ -70,30 +117,18 @@ 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
# 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
Expand Down
Loading
Loading