diff --git a/docs/releases/3.2.2.md b/docs/releases/3.2.2.md index 970d292a..47b25b89 100644 --- a/docs/releases/3.2.2.md +++ b/docs/releases/3.2.2.md @@ -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). diff --git a/statemachine/state.py b/statemachine/state.py index 02268083..f5e9b549 100644 --- a/statemachine/state.py +++ b/statemachine/state.py @@ -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 @@ -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 diff --git a/statemachine/statemachine.py b/statemachine/statemachine.py index 2f88ab1e..ba7398b4 100644 --- a/statemachine/statemachine.py +++ b/statemachine/statemachine.py @@ -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 diff --git a/tests/test_statechart_parallel.py b/tests/test_statechart_parallel.py index 297589e3..4fd97e78 100644 --- a/tests/test_statechart_parallel.py +++ b/tests/test_statechart_parallel.py @@ -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.""" @@ -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.""" @@ -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): @@ -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) @@ -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.""" @@ -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 + 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