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