Skip to content

Commit f1e07d0

Browse files
committed
refactor: extract Configuration class to encapsulate state representation
Move the dual scalar/OrderedSet representation, caching, validation, and incremental mutation logic from StateChart and BaseEngine into a dedicated Configuration class (Information Expert pattern). - StateChart properties (configuration, current_state_value, etc.) now delegate to self._config - Engine add/remove methods reduced to one-line _config.add/discard calls - Remove vestigial list handling in current_state (was a v3 dev artifact) - Cache uses identity check against raw value to detect external bypasses
1 parent 246037f commit f1e07d0

6 files changed

Lines changed: 263 additions & 91 deletions

File tree

statemachine/configuration.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
from typing import TYPE_CHECKING
2+
from typing import Any
3+
from typing import Dict
4+
from typing import MutableSet
5+
from weakref import ref
6+
7+
from .exceptions import InvalidStateValue
8+
from .i18n import _
9+
from .orderedset import OrderedSet
10+
11+
_SENTINEL = object()
12+
13+
if TYPE_CHECKING:
14+
from .state import State
15+
from .statemachine import StateChart
16+
17+
18+
class Configuration:
19+
"""Encapsulates the dual representation of the active state configuration.
20+
21+
Internally, ``current_state_value`` is either a scalar (single active state)
22+
or an ``OrderedSet`` (parallel regions). This class hides that detail behind
23+
a uniform interface for reading, mutating, and caching the resolved
24+
``OrderedSet[State]``.
25+
"""
26+
27+
__slots__ = (
28+
"_machine_ref",
29+
"_model",
30+
"_state_field",
31+
"_states_map",
32+
"_for_instance",
33+
"_cached",
34+
"_cached_value",
35+
)
36+
37+
def __init__(
38+
self,
39+
machine: "StateChart",
40+
model: Any,
41+
state_field: str,
42+
states_map: "Dict[Any, State]",
43+
for_instance_cache: "Dict[State, State]",
44+
):
45+
self._machine_ref: "ref[StateChart]" = ref(machine)
46+
self._model = model
47+
self._state_field = state_field
48+
self._states_map = states_map
49+
self._for_instance = for_instance_cache
50+
self._cached: "OrderedSet[State] | None" = None
51+
self._cached_value: Any = _SENTINEL
52+
53+
# -- Raw value (persisted on the model) ------------------------------------
54+
55+
@property
56+
def value(self) -> Any:
57+
"""The raw state value stored on the model (scalar or ``OrderedSet``)."""
58+
return getattr(self._model, self._state_field, None)
59+
60+
@value.setter
61+
def value(self, val: Any):
62+
self._invalidate()
63+
if val is not None and not isinstance(val, MutableSet) and val not in self._states_map:
64+
raise InvalidStateValue(val)
65+
setattr(self._model, self._state_field, val)
66+
67+
@property
68+
def values(self) -> OrderedSet[Any]:
69+
"""The set of raw state values currently active."""
70+
v = self.value
71+
if isinstance(v, OrderedSet):
72+
return v
73+
return OrderedSet([v])
74+
75+
# -- Resolved states -------------------------------------------------------
76+
77+
@property
78+
def states(self) -> "OrderedSet[State]":
79+
"""The set of currently active :class:`State` instances (cached)."""
80+
csv = self.value
81+
if self._cached is not None and self._cached_value is csv:
82+
return self._cached
83+
if csv is None:
84+
return OrderedSet()
85+
86+
machine = self._machine_ref()
87+
assert machine is not None
88+
89+
if not isinstance(csv, MutableSet):
90+
result = OrderedSet(
91+
[self._states_map[csv].for_instance(machine=machine, cache=self._for_instance)]
92+
)
93+
else:
94+
result = OrderedSet(
95+
[
96+
self._states_map[v].for_instance(machine=machine, cache=self._for_instance)
97+
for v in csv
98+
]
99+
)
100+
101+
self._cached = result
102+
self._cached_value = csv
103+
return result
104+
105+
@states.setter
106+
def states(self, new_configuration: "OrderedSet[State]"):
107+
if len(new_configuration) == 0:
108+
self.value = None
109+
elif len(new_configuration) == 1:
110+
self.value = next(iter(new_configuration)).value
111+
else:
112+
self.value = OrderedSet(s.value for s in new_configuration)
113+
114+
# -- Incremental mutation (used by the engine) -----------------------------
115+
116+
def add(self, state: "State"):
117+
"""Add *state* to the configuration, maintaining the dual representation."""
118+
csv = self.value
119+
if csv is None:
120+
self.value = state.value
121+
elif isinstance(csv, MutableSet):
122+
csv.add(state.value)
123+
self._invalidate()
124+
else:
125+
self.value = OrderedSet([csv, state.value])
126+
127+
def discard(self, state: "State"):
128+
"""Remove *state* from the configuration, normalizing back to scalar."""
129+
csv = self.value
130+
if isinstance(csv, MutableSet):
131+
csv.discard(state.value)
132+
self._invalidate()
133+
if len(csv) == 1:
134+
self.value = next(iter(csv))
135+
elif len(csv) == 0:
136+
self.value = None
137+
elif csv == state.value:
138+
self.value = None
139+
140+
# -- Deprecated v2 compat --------------------------------------------------
141+
142+
@property
143+
def current_state(self) -> "State | OrderedSet[State]":
144+
"""Resolve the current state with validation.
145+
146+
Unlike ``states`` (which returns an empty set for ``None``), this
147+
raises ``InvalidStateValue`` when the value is ``None`` or not
148+
found in ``states_map`` — matching the v2 ``current_state`` contract.
149+
"""
150+
csv = self.value
151+
if csv is None:
152+
raise InvalidStateValue(
153+
csv,
154+
_(
155+
"There's no current state set. In async code, "
156+
"did you activate the initial state? "
157+
"(e.g., `await sm.activate_initial_state()`)"
158+
),
159+
)
160+
try:
161+
config = self.states
162+
if len(config) == 1:
163+
return next(iter(config))
164+
return config
165+
except KeyError as err:
166+
raise InvalidStateValue(csv) from err
167+
168+
# -- Internal --------------------------------------------------------------
169+
170+
def _invalidate(self):
171+
self._cached = None
172+
self._cached_value = _SENTINEL

statemachine/engines/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,7 @@ def _prepare_exit_states(
494494
def _remove_state_from_configuration(self, state: State):
495495
"""Remove a state from the configuration if not using atomic updates."""
496496
if not self.sm.atomic_configuration_update:
497-
self.sm.configuration -= {state}
497+
self.sm._config.discard(state)
498498

499499
def _exit_states(
500500
self, enabled_transitions: List[Transition], trigger_data: TriggerData
@@ -576,7 +576,7 @@ def _prepare_entry_states(
576576
def _add_state_to_configuration(self, target: State):
577577
"""Add a state to the configuration if not using atomic updates."""
578578
if not self.sm.atomic_configuration_update:
579-
self.sm.configuration |= {target}
579+
self.sm._config.add(target)
580580

581581
def __del__(self):
582582
try:

statemachine/state.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,7 @@ def __str__(self):
298298
def __get__(self, machine, owner):
299299
if machine is None:
300300
return self
301-
return self.for_instance(machine=machine, cache=machine._states_for_instance)
301+
return self.for_instance(machine=machine, cache=machine._config._for_instance)
302302

303303
def __set__(self, instance, value):
304304
raise StateMachineError(

statemachine/statemachine.py

Lines changed: 22 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from .callbacks import CallbacksRegistry
1717
from .callbacks import SpecListGrouper
1818
from .callbacks import SpecReference
19+
from .configuration import Configuration
1920
from .dispatcher import Listener
2021
from .dispatcher import Listeners
2122
from .engines.async_ import AsyncEngine
@@ -150,7 +151,13 @@ def __init__(
150151
[start_value] if start_value is not None else list(self.start_configuration_values)
151152
)
152153
self._callbacks = CallbacksRegistry()
153-
self._states_for_instance: Dict[State, State] = {}
154+
self._config = Configuration(
155+
machine=self,
156+
model=self.model,
157+
state_field=self.state_field,
158+
states_map=self.states_map,
159+
for_instance_cache={},
160+
)
154161
self._listeners: Dict[int, Any] = {}
155162
"""Listeners that provides attributes to be used as callbacks."""
156163

@@ -215,15 +222,21 @@ def __repr__(self):
215222
def __getstate__(self):
216223
state = self.__dict__.copy()
217224
del state["_callbacks"]
218-
del state["_states_for_instance"]
225+
del state["_config"]
219226
del state["_engine"]
220227
return state
221228

222229
def __setstate__(self, state: Dict[str, Any]) -> None:
223230
listeners = state.pop("_listeners")
224231
self.__dict__.update(state) # type: ignore[attr-defined]
225232
self._callbacks = CallbacksRegistry()
226-
self._states_for_instance = {}
233+
self._config = Configuration(
234+
machine=self,
235+
model=self.model,
236+
state_field=self.state_field,
237+
states_map=self.states_map,
238+
for_instance_cache={},
239+
)
227240
self._listeners = {}
228241

229242
# _listeners already contained both class-level and runtime listeners
@@ -335,44 +348,16 @@ def _graph(self):
335348
def configuration_values(self) -> OrderedSet[Any]:
336349
"""The state configuration values is the set of currently active states's values
337350
(or ids if no custom value is defined)."""
338-
if isinstance(self.current_state_value, OrderedSet):
339-
return self.current_state_value
340-
return OrderedSet([self.current_state_value])
351+
return self._config.values
341352

342353
@property
343354
def configuration(self) -> OrderedSet["State"]:
344355
"""The set of currently active states."""
345-
if self.current_state_value is None:
346-
return OrderedSet()
347-
348-
if not isinstance(self.current_state_value, MutableSet):
349-
return OrderedSet(
350-
[
351-
self.states_map[self.current_state_value].for_instance(
352-
machine=self,
353-
cache=self._states_for_instance,
354-
)
355-
]
356-
)
357-
358-
return OrderedSet(
359-
[
360-
self.states_map[value].for_instance(
361-
machine=self,
362-
cache=self._states_for_instance,
363-
)
364-
for value in self.current_state_value
365-
]
366-
)
356+
return self._config.states
367357

368358
@configuration.setter
369359
def configuration(self, new_configuration: OrderedSet["State"]):
370-
if len(new_configuration) == 0:
371-
self.current_state_value = None
372-
elif len(new_configuration) == 1:
373-
self.current_state_value = new_configuration.pop().value
374-
else:
375-
self.current_state_value = OrderedSet(s.value for s in new_configuration)
360+
self._config.states = new_configuration
376361

377362
@property
378363
def current_state_value(self):
@@ -381,17 +366,11 @@ def current_state_value(self):
381366
This is a low level API, that can be used to assign any valid state value
382367
completely bypassing all the hooks and validations.
383368
"""
384-
return getattr(self.model, self.state_field, None)
369+
return self._config.value
385370

386371
@current_state_value.setter
387372
def current_state_value(self, value):
388-
if (
389-
value is not None
390-
and not isinstance(value, MutableSet)
391-
and value not in self.states_map
392-
):
393-
raise InvalidStateValue(value)
394-
setattr(self.model, self.state_field, value)
373+
self._config.value = value
395374

396375
@property
397376
def current_state(self) -> "State | MutableSet[State]":
@@ -405,36 +384,7 @@ def current_state(self) -> "State | MutableSet[State]":
405384
DeprecationWarning,
406385
stacklevel=2,
407386
)
408-
current_value = self.current_state_value
409-
410-
try:
411-
if isinstance(current_value, list):
412-
return OrderedSet(
413-
[
414-
self.states_map[value].for_instance(
415-
machine=self,
416-
cache=self._states_for_instance,
417-
)
418-
for value in current_value
419-
]
420-
)
421-
422-
state: State = self.states_map[current_value].for_instance(
423-
machine=self,
424-
cache=self._states_for_instance,
425-
)
426-
return state
427-
except KeyError as err:
428-
if self.current_state_value is None:
429-
raise InvalidStateValue(
430-
self.current_state_value,
431-
_(
432-
"There's no current state set. In async code, "
433-
"did you activate the initial state? "
434-
"(e.g., `await sm.activate_initial_state()`)"
435-
),
436-
) from err
437-
raise InvalidStateValue(self.current_state_value) from err
387+
return self._config.current_state
438388

439389
@current_state.setter
440390
def current_state(self, value): # pragma: no cover

0 commit comments

Comments
 (0)