From 072ba9785c0ca54ff50b3881dfcc2926cde3a520 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sat, 21 Feb 2026 11:25:38 -0300 Subject: [PATCH] refactor: replace `deep: bool` with `type: HistoryType` on HistoryState Replace the boolean `deep` parameter with a `HistoryType(str, Enum)` that mirrors the SCXML spec (`type="deep"` / `type="shallow"`). The `(str, Enum)` hybrid allows passing either `"deep"` or `HistoryType.DEEP`. V3 hasn't been released yet, so no backward-compatibility shims are needed. --- README.md | 2 +- docs/releases/3.0.0.md | 2 +- docs/statecharts.md | 4 ++-- docs/states.md | 2 +- statemachine/__init__.py | 11 +++++++++- statemachine/contrib/diagram.py | 2 +- statemachine/engines/base.py | 6 ++--- statemachine/io/__init__.py | 2 +- statemachine/io/scxml/parser.py | 5 ++++- statemachine/io/scxml/processor.py | 2 +- statemachine/io/scxml/schema.py | 3 ++- statemachine/state.py | 23 ++++++++++++++++++-- tests/examples/statechart_history_machine.py | 4 ++-- tests/test_contrib_diagram.py | 6 ++--- tests/test_io.py | 6 ++--- tests/test_statechart_history.py | 4 ++-- 16 files changed, 58 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index fa09d5cb..c8dd58ea 100644 --- a/README.md +++ b/README.md @@ -239,7 +239,7 @@ True ``` -Use `HistoryState(deep=True)` for deep history that remembers the exact leaf +Use `HistoryState(type="deep")` for deep history that remembers the exact leaf state across nested compounds. diff --git a/docs/releases/3.0.0.md b/docs/releases/3.0.0.md index a88f0ed8..c90c9b73 100644 --- a/docs/releases/3.0.0.md +++ b/docs/releases/3.0.0.md @@ -159,7 +159,7 @@ Events in one region don't affect others. See {ref}`statecharts` for full detail The **History pseudo-state** records the configuration of a compound state when it is exited. Re-entering via the history state restores the previously active child. -Supports both shallow (`HistoryState()`) and deep (`HistoryState(deep=True)`) history: +Supports both shallow (`HistoryState()`) and deep (`HistoryState(type="deep")`) history: ```py >>> from statemachine import HistoryState, State, StateChart diff --git a/docs/statecharts.md b/docs/statecharts.md index e254d345..30961e36 100644 --- a/docs/statecharts.md +++ b/docs/statecharts.md @@ -462,7 +462,7 @@ By default, `HistoryState()` uses **shallow** history: it remembers only the dir child of the compound. If the remembered child is itself a compound, it re-enters from its initial state. -Use `HistoryState(deep=True)` for **deep** history, which remembers the exact leaf +Use `HistoryState(type="deep")` for **deep** history, which remembers the exact leaf state and restores the full hierarchy: ```py @@ -475,7 +475,7 @@ state and restores the full hierarchy: ... chamber = State() ... explore = entrance.to(chamber) ... assert isinstance(halls, State) -... h = HistoryState(deep=True) +... h = HistoryState(type="deep") ... bridge = State(final=True) ... flee = halls.to(bridge) ... outside = State() diff --git a/docs/states.md b/docs/states.md index bcc77a78..9fe6e519 100644 --- a/docs/states.md +++ b/docs/states.md @@ -284,7 +284,7 @@ True ``` -Use `HistoryState(deep=True)` for deep history that remembers the exact leaf state +Use `HistoryState(type="deep")` for deep history that remembers the exact leaf state in nested compounds. ```{seealso} diff --git a/statemachine/__init__.py b/statemachine/__init__.py index 5b219f7a..6993e7cf 100644 --- a/statemachine/__init__.py +++ b/statemachine/__init__.py @@ -1,5 +1,6 @@ from .event import Event from .state import HistoryState +from .state import HistoryType from .state import State from .statemachine import StateChart from .statemachine import StateMachine @@ -9,4 +10,12 @@ __email__ = "fgmacedo@gmail.com" __version__ = "3.0.0" -__all__ = ["StateChart", "StateMachine", "State", "HistoryState", "Event", "TModel"] +__all__ = [ + "StateChart", + "StateMachine", + "State", + "HistoryState", + "HistoryType", + "Event", + "TModel", +] diff --git a/statemachine/contrib/diagram.py b/statemachine/contrib/diagram.py index 0014fb9f..1ec59804 100644 --- a/statemachine/contrib/diagram.py +++ b/statemachine/contrib/diagram.py @@ -132,7 +132,7 @@ def _state_id(state): return state.id def _history_node(self, state): - label = "H*" if state.deep else "H" + label = "H*" if state.type.is_deep else "H" return pydot.Node( self._state_id(state), label=label, diff --git a/statemachine/engines/base.py b/statemachine/engines/base.py index 2a199a01..990d530a 100644 --- a/statemachine/engines/base.py +++ b/statemachine/engines/base.py @@ -474,7 +474,7 @@ def _prepare_exit_states( for info in ordered_states: state = info.state for history in state.history: - if history.deep: + if history.type.is_deep: history_value = [s for s in self.sm.configuration if s.is_descendant(state)] # noqa: E501 else: # shallow history history_value = [s for s in self.sm.configuration if s.parent == state] @@ -767,12 +767,12 @@ def add_descendant_states_to_enter( # noqa: C901 self._log_id, state.parent, state, - "deep" if state.deep else "shallow", + state.type.value, [s.id for s in self.sm.history_values[state.id]], ) for history_state in self.sm.history_values[state.id]: info_to_add = StateTransition(transition=info.transition, state=history_state) - if state.deep: + if state.type.is_deep: states_to_enter.add(info_to_add) else: self.add_descendant_states_to_enter( diff --git a/statemachine/io/__init__.py b/statemachine/io/__init__.py index 5105842f..41d947e7 100644 --- a/statemachine/io/__init__.py +++ b/statemachine/io/__init__.py @@ -56,7 +56,7 @@ class StateKwargs(BaseStateKwargs, total=False): class HistoryKwargs(TypedDict, total=False): name: str value: Any - deep: bool + type: str class HistoryDefinition(HistoryKwargs, total=False): diff --git a/statemachine/io/scxml/parser.py b/statemachine/io/scxml/parser.py index 229914aa..227955ef 100644 --- a/statemachine/io/scxml/parser.py +++ b/statemachine/io/scxml/parser.py @@ -1,7 +1,9 @@ import re import xml.etree.ElementTree as ET from typing import List +from typing import Literal from typing import Set +from typing import cast from urllib.parse import urlparse from .schema import Action @@ -141,9 +143,10 @@ def parse_history(state_elem: ET.Element) -> HistoryState: if not state_id: raise ValueError("History must have an 'id' attribute") + history_type = cast("Literal['shallow', 'deep']", state_elem.get("type", "shallow")) state = HistoryState( id=state_id, - deep=state_elem.get("type") == "deep", + type=history_type, ) for trans_elem in state_elem.findall("transition"): transition = parse_transition(trans_elem) diff --git a/statemachine/io/scxml/processor.py b/statemachine/io/scxml/processor.py index 844eb591..52ed83f6 100644 --- a/statemachine/io/scxml/processor.py +++ b/statemachine/io/scxml/processor.py @@ -153,7 +153,7 @@ def _process_history(self, history: Dict[str, HistoryState]) -> Dict[str, Histor for state_id, state in history.items(): state_dict = HistoryDefinition() - state_dict["deep"] = state.deep + state_dict["type"] = state.type # Process transitions if state.transitions: diff --git a/statemachine/io/scxml/schema.py b/statemachine/io/scxml/schema.py index 0b1ec9ca..0b25a773 100644 --- a/statemachine/io/scxml/schema.py +++ b/statemachine/io/scxml/schema.py @@ -2,6 +2,7 @@ from dataclasses import field from typing import Dict from typing import List +from typing import Literal from urllib.parse import ParseResult @@ -148,7 +149,7 @@ class State: @dataclass class HistoryState: id: str - deep: bool = False # Must be 'deep' or 'shallow' + type: "Literal['shallow', 'deep']" = "shallow" transitions: List[Transition] = field(default_factory=list) diff --git a/statemachine/state.py b/statemachine/state.py index b7eaa20d..3a85a893 100644 --- a/statemachine/state.py +++ b/statemachine/state.py @@ -1,3 +1,4 @@ +from enum import Enum from typing import TYPE_CHECKING from typing import Any from typing import Dict @@ -471,8 +472,26 @@ def _on_event_defined(self, event: str, transition: Transition, states: List[Sta state.transitions.add_transitions(new_transition) +class HistoryType(str, Enum): + """Type of history recorded by a :class:`HistoryState`.""" + + SHALLOW = "shallow" + """Remembers only the direct children of the compound state. + If the remembered child is itself a compound, it re-enters from its initial state.""" + + DEEP = "deep" + """Remembers the exact leaf (atomic) state across the entire nested hierarchy. + Re-entering restores the full ancestor chain down to that leaf.""" + + @property + def is_deep(self) -> bool: + return self == HistoryType.DEEP + + class HistoryState(State): - def __init__(self, name: str = "", value: Any = None, deep: bool = False): + def __init__( + self, name: str = "", value: Any = None, type: "str | HistoryType" = HistoryType.SHALLOW + ): super().__init__(name=name, value=value) - self.deep = deep + self.type = HistoryType(type) self.is_active = False diff --git a/tests/examples/statechart_history_machine.py b/tests/examples/statechart_history_machine.py index d8a4ac6e..caaad6bc 100644 --- a/tests/examples/statechart_history_machine.py +++ b/tests/examples/statechart_history_machine.py @@ -8,7 +8,7 @@ child instead of starting from the initial child. Both shallow history (``HistoryState()``) and deep history -(``HistoryState(deep=True)``) are shown. +(``HistoryState(type="deep")``) are shown. """ @@ -95,7 +95,7 @@ class inner(State.Compound): explore = entrance.to(chamber) assert isinstance(inner, State) - h = HistoryState(deep=True) # type: ignore[has-type] + h = HistoryState(type="deep") # type: ignore[has-type] bridge = State("Bridge", final=True) flee = inner.to(bridge) diff --git a/tests/test_contrib_diagram.py b/tests/test_contrib_diagram.py index 9b87cf1d..54c94cbd 100644 --- a/tests/test_contrib_diagram.py +++ b/tests/test_contrib_diagram.py @@ -237,7 +237,7 @@ class parent(State.Compound, name="Parent"): def test_history_state_shallow_diagram(): """DOT output contains an 'H' circle node for shallow history state.""" - h = HistoryState(name="H", deep=False) + h = HistoryState(name="H") h._set_id("h_shallow") graph_maker = DotGraphMachine.__new__(DotGraphMachine) @@ -250,7 +250,7 @@ def test_history_state_shallow_diagram(): def test_history_state_deep_diagram(): """DOT output contains an 'H*' circle node for deep history state.""" - h = HistoryState(name="H*", deep=True) + h = HistoryState(name="H*", type="deep") h._set_id("h_deep") graph_maker = DotGraphMachine.__new__(DotGraphMachine) @@ -269,7 +269,7 @@ def test_history_state_default_transition(): child2 = State("child2") child2._set_id("child2") - h = HistoryState(name="H", deep=False) + h = HistoryState(name="H") h._set_id("hist") # Add a default transition from history to child1 t = Transition(source=h, target=child1, initial=True) diff --git a/tests/test_io.py b/tests/test_io.py index 884e9de4..8171ac39 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -7,15 +7,15 @@ class TestParseHistory: def test_history_without_transitions(self): """History state with no 'on' or 'transitions' keys.""" - states_instances, events_definitions = _parse_history({"h1": {"deep": False}}) + states_instances, events_definitions = _parse_history({"h1": {"type": "shallow"}}) assert "h1" in states_instances - assert states_instances["h1"].deep is False + assert states_instances["h1"].type.value == "shallow" assert events_definitions == {} def test_history_with_on_only(self): """History state with 'on' events but no 'transitions' key.""" states_instances, events_definitions = _parse_history( - {"h1": {"deep": True, "on": {"restore": [{"target": "s1"}]}}} + {"h1": {"type": "deep", "on": {"restore": [{"target": "s1"}]}}} ) assert "h1" in states_instances assert "h1" in events_definitions diff --git a/tests/test_statechart_history.py b/tests/test_statechart_history.py index 520590ca..3ed35fe0 100644 --- a/tests/test_statechart_history.py +++ b/tests/test_statechart_history.py @@ -77,7 +77,7 @@ class halls(State.Compound): explore = entrance.to(chamber) assert isinstance(halls, State) - h = HistoryState(deep=True) + h = HistoryState(type="deep") bridge = State(final=True) flee = halls.to(bridge) @@ -161,7 +161,7 @@ class halls(State.Compound): explore = entrance.to(chamber) assert isinstance(halls, State) - h = HistoryState(deep=False) + h = HistoryState() bridge = State(final=True) flee = halls.to(bridge)