Skip to content

Commit 35f4bed

Browse files
committed
chore: Microwave example with parallel state working
1 parent f9bea85 commit 35f4bed

19 files changed

+406
-200
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ Or get a complete state representation for debugging purposes:
135135

136136
```py
137137
>>> sm.current_state
138-
State('Yellow', id='yellow', value='yellow', initial=False, final=False)
138+
State('Yellow', id='yellow', value='yellow', initial=False, final=False, parallel=False)
139139

140140
```
141141

docs/releases/3.0.0.md

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# StateMachine 3.05.0
2+
3+
*Not released yet*
4+
5+
## What's new in 3.0.0
6+
7+
Statecharts are there! Now the library has support for Compound and Parallel states.
8+
9+
### Python compatibility in 3.0.0
10+
11+
StateMachine 3.0.0 supports Python 3.9, 3.10, 3.11, 3.12, and 3.13.
12+
13+
### In(state) checks in condition expressions
14+
15+
Now a condition can check if the state machine current set of active states (a.k.a `configuration`) contains a state using the syntax `cond="In('<state-id>')"`.
16+
17+
18+
## Bugfixes in 3.0.0
19+
20+
- Fixes [#XXX](https://github.com/fgmacedo/python-statemachine/issues/XXX).
21+
22+
## Misc in 3.0.0
23+
24+
TODO.
25+
26+
## Backward incompatible changes in 3.0
27+
28+
- Dropped support for Python `3.7` and `3.8`. If you need support for these versios use the 2.* series.
29+
30+
31+
## Non-RTC model removed
32+
33+
This option was deprecated on version 2.3.2. Now all new events are put on a queue before being processed.
34+
35+
36+
## Multiple current states
37+
38+
Due to the support of compound and parallel states, it's now possible to have multiple active states at the same time.
39+
40+
This introduces an impedance mismatch into the old public API, specifically, `sm.current_state` is deprecated and `sm.current_state_value` can returns a flat value if no compound state or a `list` instead.
41+
42+
```{note}
43+
To allow a smooth migration, these properties still work as before if there's no compound states in the state machine definition.
44+
```
45+
46+
Old
47+
48+
```py
49+
def current_state(self) -> "State":
50+
```
51+
52+
New
53+
54+
```py
55+
def current_state(self) -> "State | MutableSet[State]":
56+
```
57+
58+
We recomend using the new `sm.configuration` that has a stable API returning an `OrderedSet` on all cases:
59+
60+
```py
61+
@property
62+
def configuration(self) -> OrderedSet["State"]:
63+
```

docs/releases/index.md

+11-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,16 @@ with advance notice in the **Deprecations** section of releases.
1010

1111
Below are release notes through StateMachine and its patch releases.
1212

13-
### 2.0 releases
13+
### 3.* releases
14+
15+
```{toctree}
16+
:maxdepth: 2
17+
18+
3.0.0
19+
20+
```
21+
22+
### 2.* releases
1423

1524
```{toctree}
1625
:maxdepth: 2
@@ -33,7 +42,7 @@ Below are release notes through StateMachine and its patch releases.
3342
```
3443

3544

36-
### 1.0 releases
45+
### 1.* releases
3746

3847
This is the last release series to support Python 2.X series.
3948

docs/states.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ You can query a list of all final states from your statemachine.
142142
>>> machine = CampaignMachine()
143143

144144
>>> machine.final_states
145-
[State('Closed', id='closed', value=3, initial=False, final=True)]
145+
[State('Closed', id='closed', value=3, initial=False, final=True, parallel=False)]
146146

147147
>>> machine.current_state in machine.final_states
148148
False

statemachine/engines/base.py

+17-17
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,22 @@ def add_descendant_states_to_enter(
520520
state = info.target
521521
assert state
522522

523-
if state.is_compound:
523+
if state.parallel:
524+
# Handle parallel states
525+
for child_state in state.states:
526+
if not any(s.target.is_descendant(child_state) for s in states_to_enter):
527+
info_to_add = StateTransition(
528+
transition=info.transition,
529+
target=child_state,
530+
source=info.transition.source,
531+
)
532+
self.add_descendant_states_to_enter(
533+
info_to_add,
534+
states_to_enter,
535+
states_for_default_entry,
536+
default_history_content,
537+
)
538+
elif state.is_compound:
524539
# Handle compound states
525540
states_for_default_entry.add(info)
526541
initial_state = next(s for s in state.states if s.initial)
@@ -546,21 +561,6 @@ def add_descendant_states_to_enter(
546561
states_for_default_entry,
547562
default_history_content,
548563
)
549-
elif state.parallel:
550-
# Handle parallel states
551-
for child_state in state.states:
552-
if not any(s.target.is_descendant(child_state) for s in states_to_enter):
553-
info_to_add = StateTransition(
554-
transition=info.transition,
555-
target=child_state,
556-
source=info.transition.source,
557-
)
558-
self.add_descendant_states_to_enter(
559-
info_to_add,
560-
states_to_enter,
561-
states_for_default_entry,
562-
default_history_content,
563-
)
564564

565565
def add_ancestor_states_to_enter(
566566
self,
@@ -602,7 +602,7 @@ def add_ancestor_states_to_enter(
602602
source=info.transition.source,
603603
)
604604
self.add_descendant_states_to_enter(
605-
child,
605+
info_to_add,
606606
states_to_enter,
607607
states_for_default_entry,
608608
default_history_content,

statemachine/factory.py

-1
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,6 @@ def _initials_by_document_order(cls, states: List[State], parent: "State | None"
8888
cls._initials_by_document_order(s.states, s)
8989
if s.initial:
9090
initial = s
91-
break
9291
if not initial and states:
9392
initial = states[0]
9493
initial._initial = True

statemachine/io/scxml/parser.py

+2
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,11 @@ def parse_state(
140140

141141
for child_state_elem in state_elem.findall("state"):
142142
child_state = parse_state(child_state_elem, initial_states=initial_states)
143+
child_state.initial = child_state.initial
143144
state.states[child_state.id] = child_state
144145
for child_state_elem in state_elem.findall("parallel"):
145146
state = parse_state(child_state_elem, initial_states=initial_states, is_parallel=True)
147+
child_state.initial = child_state.initial
146148
state.states[child_state.id] = child_state
147149

148150
return state

statemachine/spec_parser.py

+41-9
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import re
44
from functools import reduce
55
from typing import Callable
6+
from typing import Dict
67

78
replacements = {"!": "not ", "^": " and ", "v": " or "}
89

@@ -69,6 +70,38 @@ def decorated(*args, **kwargs):
6970
return decorated
7071

7172

73+
class Functions:
74+
registry: Dict[str, Callable] = {}
75+
76+
@classmethod
77+
def register(cls, id) -> Callable:
78+
def register(func):
79+
cls.registry[id] = func
80+
return func
81+
82+
return register
83+
84+
@classmethod
85+
def get(cls, id):
86+
id = id.lower()
87+
if id not in cls.registry:
88+
raise ValueError(f"Unsupported function: {id}")
89+
return cls.registry[id]
90+
91+
92+
@Functions.register("in")
93+
def build_in_call(*state_ids: str) -> Callable:
94+
state_ids_set = set(state_ids)
95+
96+
def decorated(*args, **kwargs):
97+
machine = kwargs["machine"]
98+
return state_ids_set.issubset({s.id for s in machine.configuration})
99+
100+
decorated.__name__ = f"in({state_ids_set})"
101+
decorated.unique_key = f"in({state_ids_set})" # type: ignore[attr-defined]
102+
return decorated
103+
104+
72105
def build_custom_operator(operator) -> Callable:
73106
operator_repr = comparison_repr[operator]
74107

@@ -104,6 +137,11 @@ def build_expression(node, variable_hook, operator_mapping): # noqa: C901
104137
expressions.append(expression)
105138

106139
return reduce(custom_and, expressions)
140+
elif isinstance(node, ast.Call):
141+
# Handle function calls
142+
constructor = Functions.get(node.func.id)
143+
params = [arg.value for arg in node.args if isinstance(arg, ast.Constant)]
144+
return constructor(*params)
107145
elif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
108146
# Handle `not` operation
109147
operand_expr = build_expression(node.operand, variable_hook, operator_mapping)
@@ -114,14 +152,6 @@ def build_expression(node, variable_hook, operator_mapping): # noqa: C901
114152
elif isinstance(node, ast.Constant):
115153
# Handle constants by returning the value
116154
return build_constant(node.value)
117-
elif hasattr(ast, "NameConstant") and isinstance(
118-
node, ast.NameConstant
119-
): # pragma: no cover | python3.7
120-
return build_constant(node.value)
121-
elif hasattr(ast, "Str") and isinstance(node, ast.Str): # pragma: no cover | python3.7
122-
return build_constant(node.s)
123-
elif hasattr(ast, "Num") and isinstance(node, ast.Num): # pragma: no cover | python3.7
124-
return build_constant(node.n)
125155
else:
126156
raise ValueError(f"Unsupported expression structure: {node.__class__.__name__}")
127157

@@ -130,7 +160,9 @@ def parse_boolean_expr(expr, variable_hook, operator_mapping):
130160
"""Parses the expression into an AST and build a custom expression tree"""
131161
if expr.strip() == "":
132162
raise SyntaxError("Empty expression")
133-
if "!" not in expr and " " not in expr:
163+
164+
# Optimization trying to avoid parsing the expression if not needed
165+
if "!" not in expr and " " not in expr and "In(" not in expr:
134166
return variable_hook(expr)
135167
expr = replace_operators(expr)
136168
tree = ast.parse(expr, mode="eval")

statemachine/state.py

+26-19
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,22 @@ def __new__( # type: ignore [misc]
6969

7070
return State(name=name, states=states, _callbacks=callbacks, **kwargs)
7171

72+
@classmethod
73+
def to(cls, *args: "State", **kwargs) -> "_ToState": # pragma: no cover
74+
"""Create transitions to the given target states.
75+
.. note: This method is only a type hint for mypy.
76+
The actual implementation belongs to the :ref:`State` class.
77+
"""
78+
return _ToState(State())
79+
80+
@classmethod
81+
def from_(cls, *args: "State", **kwargs) -> "_FromState": # pragma: no cover
82+
"""Create transitions from the given target states (reversed).
83+
.. note: This method is only a type hint for mypy.
84+
The actual implementation belongs to the :ref:`State` class.
85+
"""
86+
return _FromState(State())
87+
7288

7389
class State:
7490
"""
@@ -157,25 +173,10 @@ class State:
157173
158174
"""
159175

160-
class Builder(metaclass=NestedStateFactory):
176+
class Compound(metaclass=NestedStateFactory):
161177
# Mimic the :ref:`State` public API to help linters discover the result of the Builder
162178
# class.
163-
164-
@classmethod
165-
def to(cls, *args: "State", **kwargs) -> "_ToState": # pragma: no cover
166-
"""Create transitions to the given target states.
167-
.. note: This method is only a type hint for mypy.
168-
The actual implementation belongs to the :ref:`State` class.
169-
"""
170-
return _ToState(State())
171-
172-
@classmethod
173-
def from_(cls, *args: "State", **kwargs) -> "_FromState": # pragma: no cover
174-
"""Create transitions from the given target states (reversed).
175-
.. note: This method is only a type hint for mypy.
176-
The actual implementation belongs to the :ref:`State` class.
177-
"""
178-
return _FromState(State())
179+
pass
179180

180181
def __init__(
181182
self,
@@ -212,10 +213,16 @@ def __init__(
212213
def _init_states(self):
213214
for state in self.states:
214215
state.parent = self
216+
state._initial = state.initial or self.parallel
215217
setattr(self, state.id, state)
216218

217219
def __eq__(self, other):
218-
return isinstance(other, State) and self.name == other.name and self.id == other.id
220+
return (
221+
isinstance(other, State)
222+
and self.name == other.name
223+
and self.id == other.id
224+
or (self.value == other)
225+
)
219226

220227
def __hash__(self):
221228
return hash(repr(self))
@@ -235,7 +242,7 @@ def _on_event_defined(self, event: str, transition: Transition, states: List["St
235242
def __repr__(self):
236243
return (
237244
f"{type(self).__name__}({self.name!r}, id={self.id!r}, value={self.value!r}, "
238-
f"initial={self.initial!r}, final={self.final!r})"
245+
f"initial={self.initial!r}, final={self.final!r}, parallel={self.parallel!r})"
239246
)
240247

241248
def __str__(self):

statemachine/statemachine.py

+5
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,11 @@ def current_state(self) -> "State | MutableSet[State]":
301301
This is a low level API, that can be to assign any valid state
302302
completely bypassing all the hooks and validations.
303303
"""
304+
warnings.warn(
305+
"""Property `current_state` is deprecated in favor of `configuration`.""",
306+
DeprecationWarning,
307+
stacklevel=2,
308+
)
304309
current_value = self.current_state_value
305310

306311
try:

tests/scxml/conftest.py

-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
CURRENT_DIR = Path(__file__).parent
66
TESTCASES_DIR = CURRENT_DIR
7-
SUPPORTED_EXTENSIONS = "scxml"
87

98

109
@pytest.fixture()

0 commit comments

Comments
 (0)