Skip to content
Open
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
10 changes: 10 additions & 0 deletions statemachine/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
14 changes: 13 additions & 1 deletion statemachine/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 26 additions & 0 deletions tests/test_statechart_parallel.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,15 @@
"""Configuration set includes all active states across regions."""
sm = await sm_runner.start(WarOfTheRing)
config_ids = {s.id for s in sm.configuration}
assert config_ids == {
"war",
"frodos_quest",
"shire",
"aragorns_path",
"ranger",
"gandalfs_defense",
"rohan",
}

Check warning on line 60 in tests/test_statechart_parallel.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unify assertion argument order in this file; both "actual first" and "expected first" conventions are used.

See more on https://sonarcloud.io/project/issues?id=fgmacedo_python-statemachine&issues=AaCcSMKtkcuVuZeQ1-wb&open=AaCcSMKtkcuVuZeQ1-wb&pullRequest=652

async def test_exit_parallel_exits_all_regions(self, sm_runner):
"""Transition out of a parallel clears everything."""
Expand Down Expand Up @@ -199,3 +199,29 @@
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