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
36 changes: 36 additions & 0 deletions docs/releases/3.2.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,39 @@ effect at trigger time:
50

```

### `is_active` on a state nested in a parallel region

Reaching a child state through its parent (`sm.door.shut`) returned the class-level `State`,
whose `is_active` is always `False`, while `sm.shut.is_active` on the same machine was `True`.
The same happened to `parent`. A state reached through any attribute path now resolves to the
machine's per-instance proxy:

```py
>>> class Microwave(StateChart):
... class microwave(State.Parallel):
... my_state = State(initial=True)
...
... class door(State.Compound):
... shut = State(initial=True)

>>> sm = Microwave()
>>> sm.door.shut.is_active
True

>>> sm.door.shut is sm.shut
True

>>> sm.shut.parent.is_active
True

```

Creating a machine also rebound the shared `parent` of every nested state to that machine's
proxy, so with two instances alive, `parent` on the first answered for the second. Each
instance now keeps its own proxies and the class-level states are never mutated.

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

[#651](https://github.com/fgmacedo/python-statemachine/issues/651),
[#652](https://github.com/fgmacedo/python-statemachine/pull/652).
7 changes: 6 additions & 1 deletion statemachine/state.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from collections.abc import Generator
from collections.abc import Mapping
from enum import Enum
from typing import TYPE_CHECKING
from typing import Any
Expand Down Expand Up @@ -384,14 +385,18 @@ def __init__(
self,
state: State,
machine: "StateChart",
instance_states: "Mapping[Any, State]",
):
self._state = state
self._machine = ref(machine)
self._instance_states = instance_states
self._hash = hash(state)
self._init_states()
# No _init_states(): it would rebind the shared children's ``parent`` to this proxy.

def __getattr__(self, name: str):
value = getattr(self._state, name)
if isinstance(value, State):
value = self._instance_states.get(value.value, value)
self.__dict__[name] = value
return value

Expand Down
2 changes: 1 addition & 1 deletion statemachine/statemachine.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ def _build_configuration(self) -> Configuration:
instance_states: dict[Any, Any] = {}
events = self.__class__._events
for state in self.states_map.values():
ist = InstanceState(state, self)
ist = InstanceState(state, self, instance_states)
instance_states[state.value] = ist
if state.id not in events:
vars(self)[state.id] = ist
Expand Down
54 changes: 47 additions & 7 deletions tests/test_statechart_parallel.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ async def test_exit_parallel_exits_all_regions(self, sm_runner):
sm = await sm_runner.start(WarWithExit)
assert "war" in sm.configuration_values
await sm_runner.send(sm, "truce")
assert {"peace"} == set(sm.configuration_values)
assert set(sm.configuration_values) == {"peace"}

async def test_event_in_one_region_no_effect_on_others(self, sm_runner):
"""Region isolation: events affect only the targeted region."""
Expand Down Expand Up @@ -106,7 +106,7 @@ async def test_parallel_done_when_all_regions_final(self, sm_runner):

await sm_runner.send(sm, "flood")
# Both regions are final -> done.state.battle fires
assert {"aftermath"} == set(sm.configuration_values)
assert set(sm.configuration_values) == {"aftermath"}

async def test_parallel_not_done_when_one_region_final(self, sm_runner):
"""Parallel not done when only one region reaches final."""
Expand Down Expand Up @@ -142,7 +142,7 @@ async def test_top_level_parallel_done_state_fires_before_termination(self, sm_r
await sm_runner.send(sm, "close_ui")
await sm_runner.send(sm, "stop_backend")
# done.state.session fires, transitions to finished, then terminates
assert {"finished"} == set(sm.configuration_values)
assert set(sm.configuration_values) == {"finished"}
assert sm.is_terminated is True

async def test_from_enum_inside_parallel(self, sm_runner):
Expand Down Expand Up @@ -173,7 +173,7 @@ class region_b(State.Compound):
finish = work.to(done)

sm = await sm_runner.start(SC)
assert {"start"} == set(sm.configuration_values)
assert set(sm.configuration_values) == {"start"}

await sm_runner.send(sm, "begin")
vals = set(sm.configuration_values)
Expand All @@ -188,9 +188,8 @@ class region_b(State.Compound):

await sm_runner.send(sm, "go_b")
# Both regions final -> done.state.work fires
assert {RegionA.ACTIVE, RegionB.ON} <= set(sm.configuration_values) or {"done"} == set(
sm.configuration_values
)
vals = set(sm.configuration_values)
assert {RegionA.ACTIVE, RegionB.ON} <= vals or vals == {"done"}

async def test_top_level_parallel_not_terminated_when_one_region_pending(self, sm_runner):
"""Machine keeps running when only one region reaches final."""
Expand All @@ -199,3 +198,44 @@ async def test_top_level_parallel_not_terminated_when_one_region_pending(self, s
assert sm.is_terminated is False
assert "closed" in sm.configuration_values
assert "running" in sm.configuration_values


@pytest.mark.timeout(5)
class TestNestedIsActive:
class Microwave(StateChart):
class microwave(State.Parallel):
my_state = State(initial=True)

class door(State.Compound):
shut = State(initial=True)
opened = State()

toggle = shut.to(opened) | opened.to(shut)

async def test_nested_child_resolves_to_the_same_proxy_on_every_path(self, sm_runner):
"""A compound child nested inside a parallel region reports ``is_active``
consistently whether reached directly (``sm.shut``), through its parent
(``sm.door.shut``) or back through ``parent``."""
sm = await sm_runner.start(self.Microwave)

assert sm.my_state.is_active
assert sm.door.is_active
assert "shut" in sm.configuration_values
assert sm.shut.is_active
assert sm.door.shut.is_active
Comment thread
fgmacedo marked this conversation as resolved.
assert sm.door.shut is sm.shut
assert sm.door.shut.parent.is_active
assert sm.shut.parent is sm.door

async def test_nested_child_is_isolated_between_instances(self, sm_runner):
"""Each machine resolves nested children to its own proxies, so one
instance toggling a region does not leak into another."""
sm = await sm_runner.start(self.Microwave)
other = await sm_runner.start(self.Microwave)

await sm_runner.send(other, "toggle")

assert other.door.opened.is_active
assert not other.door.shut.is_active
assert sm.door.shut.is_active
assert not sm.door.opened.is_active
Loading