Skip to content
Merged
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
96 changes: 96 additions & 0 deletions docs/releases/3.2.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# 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 declares an event 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))
...
... 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

```

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, 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

```

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.

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

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

### `delay` and `internal` dropped from 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.
The same happened to `internal`. Both are now preserved, although `internal` still has no
effect at trigger time:

```py
>>> class BeaconsOfGondor(StateChart):
... dark = State(initial=True)
... lit = State(final=True)
...
... light = Event(dark.to(lit), delay=50)

>>> BeaconsOfGondor.light.delay
50

```
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
2 changes: 2 additions & 0 deletions statemachine/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,8 @@ def add_from_attributes(cls, attrs): # noqa: C901
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
Expand Down
20 changes: 19 additions & 1 deletion 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 @@ -56,7 +57,7 @@


class NestedStateFactory(type):
def __new__( # type: ignore [misc]
def __new__( # type: ignore [misc] # noqa: C901

Check failure on line 60 in statemachine/state.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

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

See more on https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&issues=AaCccce3uOjzGCLqZgpW&open=AaCccce3uOjzGCLqZgpW&pullRequest=645
cls, classname, bases, attrs, name="", **kwargs
) -> "State":
if not bases:
Expand All @@ -76,6 +77,8 @@
states = []
history = []
callbacks = {}
# 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):
for state_id, state in value.items():
Expand All @@ -89,6 +92,21 @@
states.append(value)
elif isinstance(value, TransitionList):
value.add_event(_expand_event_id(key))
elif isinstance(value, Event):
if value._transitions is not None:
event_id = value.id if value._has_real_id else _expand_event_id(key)
value._transitions.add_event(
Event(
id=event_id,
name=value.name,
delay=value.delay,
internal=value.internal,
)
)
elif getattr(value, "attr_name", None):
if value.is_event:
value._transitions.add_event(key)
callbacks[value.attr_name] = value
elif callable(value):
callbacks[key] = value

Expand Down
12 changes: 12 additions & 0 deletions tests/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ class StartMachine(StateChart):
assert [e.name for e in StartMachine.events] == ["Start the machine"]
assert StartMachine.start.name == "Start the machine"

def test_accept_delay_and_internal(self):
class BeaconsOfGondor(StateChart):
dark = State(initial=True)
lit = State(final=True)

light = Event(dark.to(lit), delay=50, internal=True)

(registered,) = BeaconsOfGondor.events
assert (registered.delay, registered.internal) == (50, True)
assert BeaconsOfGondor.light.delay == 50
assert BeaconsOfGondor().light.delay == 50

def test_derive_name_from_id(self):
class StartMachine(StateChart):
created = State(initial=True)
Expand Down
9 changes: 9 additions & 0 deletions tests/test_state.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import pytest
from statemachine.orderedset import OrderedSet
from statemachine.states import States

from statemachine import State
from statemachine import StateChart
Expand Down Expand Up @@ -83,3 +84,11 @@ def test_ordered_set_union():
s1 = OrderedSet([1, 2])
result = s1.union([3, 4], [5, 6])
assert list(result) == [1, 2, 3, 4, 5, 6]


def test_states_getattr_unknown_name():
"""States exposes its members as attributes and rejects anything else."""
states = States({"draft": State("Draft")})
assert states.draft.name == "Draft"
with pytest.raises(AttributeError, match="published not found in States"):
_ = states.published
Loading
Loading