From dbc38b8b02770f46fec0a756ef4eaf09a4170b65 Mon Sep 17 00:00:00 2001 From: Martin Bernstorff Date: Sun, 13 Sep 2026 21:39:05 +0200 Subject: [PATCH] fix: resolve nested child states to per-instance proxies for is_active A compound state nested inside a parallel region was reported as inactive when reached through an attribute chain (e.g. `sm.door.shut`), even though it was present in the configuration. `sm.shut` (accessed directly) worked because it resolves to an `InstanceState` proxy, but `sm.door.shut` returned the raw class-level `State`, whose `is_active` is a stale attribute fixed to `False`. Root cause: `InstanceState.__init__` ran `_init_states()`, which set the raw child `State` objects directly on the proxy's `__dict__` (bypassing the delegating `__getattr__`) and mutated the shared raw children's `parent`. Now children are resolved lazily: `__getattr__` maps a declared child (or history) state to the machine's per-instance proxy via the new `Configuration.instance_state()`, so `is_active` is consistent regardless of access path. Fixes #651 Signed-off-by: Martin Bernstorff --- statemachine/configuration.py | 10 ++++++++++ statemachine/state.py | 14 +++++++++++++- tests/test_statechart_parallel.py | 26 ++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/statemachine/configuration.py b/statemachine/configuration.py index 0a293658..3b02ddfc 100644 --- a/statemachine/configuration.py +++ b/statemachine/configuration.py @@ -93,6 +93,16 @@ def states(self) -> "OrderedSet[State]": def states(self, new_configuration: "OrderedSet[State]"): self._write_to_model(OrderedSet(s.value for s in new_configuration)) + def instance_state(self, value: Any) -> "State | None": + """Return the per-instance proxy registered for *value*, if any. + + Used to resolve a nested child :class:`State` (reached through an + attribute chain like ``sm.door.shut``) to the same proxy the machine + exposes directly, so instance-scoped attributes (e.g. ``is_active``) + stay consistent regardless of access path. + """ + return self._instance_states.get(value) + # -- Incremental mutation (used by the engine) ----------------------------- def add(self, state: "State"): diff --git a/statemachine/state.py b/statemachine/state.py index 065cc52a..cb87d233 100644 --- a/statemachine/state.py +++ b/statemachine/state.py @@ -370,10 +370,22 @@ def __init__( self._state = state self._machine = ref(machine) self._hash = hash(state) - self._init_states() + # Children are resolved lazily by __getattr__ to their per-instance + # proxies. Running _init_states() here would instead cache the raw + # child States and mutate their shared ``parent`` to point at this + # proxy — so it is deliberately skipped. def __getattr__(self, name: str): value = getattr(self._state, name) + if isinstance(value, State) and ( + value in self._state.states or value in self._state.history + ): + # A nested child state resolves to its per-instance proxy, so + # instance-scoped attributes (e.g. is_active) stay consistent + # whether accessed directly or through an attribute chain. + machine = self._machine() + assert machine is not None + value = machine._config.instance_state(value.value) or value self.__dict__[name] = value return value diff --git a/tests/test_statechart_parallel.py b/tests/test_statechart_parallel.py index 297589e3..3a237e29 100644 --- a/tests/test_statechart_parallel.py +++ b/tests/test_statechart_parallel.py @@ -199,3 +199,29 @@ 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: + async def test_is_active_on_nested_child_via_attribute_chain(self, sm_runner): + """``is_active`` is correct for a leaf reached through an attribute chain. + + A compound child nested inside a parallel region must report ``is_active`` + consistently whether accessed directly (``sm.shut``) or through its parent + proxy (``sm.door.shut``). Both resolve to the same per-instance proxy. + """ + + class Microwave(StateChart): + class microwave(State.Parallel): + my_state = State(initial=True) + + class door(State.Compound): + shut = State(initial=True) + + sm = await sm_runner.start(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