Skip to content

Commit

Permalink
chore: Microwave example with parallel state working
Browse files Browse the repository at this point in the history
  • Loading branch information
fgmacedo committed Dec 12, 2024
1 parent f9bea85 commit 35f4bed
Show file tree
Hide file tree
Showing 19 changed files with 406 additions and 200 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ Or get a complete state representation for debugging purposes:

```py
>>> sm.current_state
State('Yellow', id='yellow', value='yellow', initial=False, final=False)
State('Yellow', id='yellow', value='yellow', initial=False, final=False, parallel=False)

```

Expand Down
63 changes: 63 additions & 0 deletions docs/releases/3.0.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# StateMachine 3.05.0

*Not released yet*

## What's new in 3.0.0

Statecharts are there! Now the library has support for Compound and Parallel states.

### Python compatibility in 3.0.0

StateMachine 3.0.0 supports Python 3.9, 3.10, 3.11, 3.12, and 3.13.

### In(state) checks in condition expressions

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>')"`.


## Bugfixes in 3.0.0

- Fixes [#XXX](https://github.com/fgmacedo/python-statemachine/issues/XXX).

## Misc in 3.0.0

TODO.

## Backward incompatible changes in 3.0

- Dropped support for Python `3.7` and `3.8`. If you need support for these versios use the 2.* series.


## Non-RTC model removed

This option was deprecated on version 2.3.2. Now all new events are put on a queue before being processed.


## Multiple current states

Due to the support of compound and parallel states, it's now possible to have multiple active states at the same time.

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.

```{note}
To allow a smooth migration, these properties still work as before if there's no compound states in the state machine definition.
```

Old

```py
def current_state(self) -> "State":
```

New

```py
def current_state(self) -> "State | MutableSet[State]":
```

We recomend using the new `sm.configuration` that has a stable API returning an `OrderedSet` on all cases:

```py
@property
def configuration(self) -> OrderedSet["State"]:
```
13 changes: 11 additions & 2 deletions docs/releases/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@ with advance notice in the **Deprecations** section of releases.

Below are release notes through StateMachine and its patch releases.

### 2.0 releases
### 3.* releases

```{toctree}
:maxdepth: 2
3.0.0
```

### 2.* releases

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


### 1.0 releases
### 1.* releases

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

Expand Down
2 changes: 1 addition & 1 deletion docs/states.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ You can query a list of all final states from your statemachine.
>>> machine = CampaignMachine()

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

>>> machine.current_state in machine.final_states
False
Expand Down
34 changes: 17 additions & 17 deletions statemachine/engines/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,22 @@ def add_descendant_states_to_enter(
state = info.target
assert state

if state.is_compound:
if state.parallel:
# Handle parallel states
for child_state in state.states:
if not any(s.target.is_descendant(child_state) for s in states_to_enter):
info_to_add = StateTransition(
transition=info.transition,
target=child_state,
source=info.transition.source,
)
self.add_descendant_states_to_enter(
info_to_add,
states_to_enter,
states_for_default_entry,
default_history_content,
)
elif state.is_compound:
# Handle compound states
states_for_default_entry.add(info)
initial_state = next(s for s in state.states if s.initial)
Expand All @@ -546,21 +561,6 @@ def add_descendant_states_to_enter(
states_for_default_entry,
default_history_content,
)
elif state.parallel:
# Handle parallel states
for child_state in state.states:
if not any(s.target.is_descendant(child_state) for s in states_to_enter):
info_to_add = StateTransition(
transition=info.transition,
target=child_state,
source=info.transition.source,
)
self.add_descendant_states_to_enter(
info_to_add,
states_to_enter,
states_for_default_entry,
default_history_content,
)

def add_ancestor_states_to_enter(
self,
Expand Down Expand Up @@ -602,7 +602,7 @@ def add_ancestor_states_to_enter(
source=info.transition.source,
)
self.add_descendant_states_to_enter(
child,
info_to_add,
states_to_enter,
states_for_default_entry,
default_history_content,
Expand Down
1 change: 0 additions & 1 deletion statemachine/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ def _initials_by_document_order(cls, states: List[State], parent: "State | None"
cls._initials_by_document_order(s.states, s)
if s.initial:
initial = s
break
if not initial and states:
initial = states[0]
initial._initial = True
Expand Down
2 changes: 2 additions & 0 deletions statemachine/io/scxml/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,11 @@ def parse_state(

for child_state_elem in state_elem.findall("state"):
child_state = parse_state(child_state_elem, initial_states=initial_states)
child_state.initial = child_state.initial
state.states[child_state.id] = child_state
for child_state_elem in state_elem.findall("parallel"):
state = parse_state(child_state_elem, initial_states=initial_states, is_parallel=True)
child_state.initial = child_state.initial
state.states[child_state.id] = child_state

return state
Expand Down
50 changes: 41 additions & 9 deletions statemachine/spec_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import re
from functools import reduce
from typing import Callable
from typing import Dict

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

Expand Down Expand Up @@ -69,6 +70,38 @@ def decorated(*args, **kwargs):
return decorated


class Functions:
registry: Dict[str, Callable] = {}

@classmethod
def register(cls, id) -> Callable:
def register(func):
cls.registry[id] = func
return func

return register

@classmethod
def get(cls, id):
id = id.lower()
if id not in cls.registry:
raise ValueError(f"Unsupported function: {id}")

Check warning on line 88 in statemachine/spec_parser.py

View check run for this annotation

Codecov / codecov/patch

statemachine/spec_parser.py#L88

Added line #L88 was not covered by tests
return cls.registry[id]


@Functions.register("in")
def build_in_call(*state_ids: str) -> Callable:
state_ids_set = set(state_ids)

def decorated(*args, **kwargs):
machine = kwargs["machine"]
return state_ids_set.issubset({s.id for s in machine.configuration})

decorated.__name__ = f"in({state_ids_set})"
decorated.unique_key = f"in({state_ids_set})" # type: ignore[attr-defined]
return decorated


def build_custom_operator(operator) -> Callable:
operator_repr = comparison_repr[operator]

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

return reduce(custom_and, expressions)
elif isinstance(node, ast.Call):
# Handle function calls
constructor = Functions.get(node.func.id)
params = [arg.value for arg in node.args if isinstance(arg, ast.Constant)]
return constructor(*params)
elif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
# Handle `not` operation
operand_expr = build_expression(node.operand, variable_hook, operator_mapping)
Expand All @@ -114,14 +152,6 @@ def build_expression(node, variable_hook, operator_mapping): # noqa: C901
elif isinstance(node, ast.Constant):
# Handle constants by returning the value
return build_constant(node.value)
elif hasattr(ast, "NameConstant") and isinstance(
node, ast.NameConstant
): # pragma: no cover | python3.7
return build_constant(node.value)
elif hasattr(ast, "Str") and isinstance(node, ast.Str): # pragma: no cover | python3.7
return build_constant(node.s)
elif hasattr(ast, "Num") and isinstance(node, ast.Num): # pragma: no cover | python3.7
return build_constant(node.n)
else:
raise ValueError(f"Unsupported expression structure: {node.__class__.__name__}")

Expand All @@ -130,7 +160,9 @@ def parse_boolean_expr(expr, variable_hook, operator_mapping):
"""Parses the expression into an AST and build a custom expression tree"""
if expr.strip() == "":
raise SyntaxError("Empty expression")
if "!" not in expr and " " not in expr:

# Optimization trying to avoid parsing the expression if not needed
if "!" not in expr and " " not in expr and "In(" not in expr:
return variable_hook(expr)
expr = replace_operators(expr)
tree = ast.parse(expr, mode="eval")
Expand Down
45 changes: 26 additions & 19 deletions statemachine/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,22 @@ def __new__( # type: ignore [misc]

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

@classmethod
def to(cls, *args: "State", **kwargs) -> "_ToState": # pragma: no cover
"""Create transitions to the given target states.
.. note: This method is only a type hint for mypy.
The actual implementation belongs to the :ref:`State` class.
"""
return _ToState(State())

@classmethod
def from_(cls, *args: "State", **kwargs) -> "_FromState": # pragma: no cover
"""Create transitions from the given target states (reversed).
.. note: This method is only a type hint for mypy.
The actual implementation belongs to the :ref:`State` class.
"""
return _FromState(State())


class State:
"""
Expand Down Expand Up @@ -157,25 +173,10 @@ class State:
"""

class Builder(metaclass=NestedStateFactory):
class Compound(metaclass=NestedStateFactory):
# Mimic the :ref:`State` public API to help linters discover the result of the Builder
# class.

@classmethod
def to(cls, *args: "State", **kwargs) -> "_ToState": # pragma: no cover
"""Create transitions to the given target states.
.. note: This method is only a type hint for mypy.
The actual implementation belongs to the :ref:`State` class.
"""
return _ToState(State())

@classmethod
def from_(cls, *args: "State", **kwargs) -> "_FromState": # pragma: no cover
"""Create transitions from the given target states (reversed).
.. note: This method is only a type hint for mypy.
The actual implementation belongs to the :ref:`State` class.
"""
return _FromState(State())
pass

def __init__(
self,
Expand Down Expand Up @@ -212,10 +213,16 @@ def __init__(
def _init_states(self):
for state in self.states:
state.parent = self
state._initial = state.initial or self.parallel
setattr(self, state.id, state)

def __eq__(self, other):
return isinstance(other, State) and self.name == other.name and self.id == other.id
return (
isinstance(other, State)
and self.name == other.name
and self.id == other.id
or (self.value == other)
)

def __hash__(self):
return hash(repr(self))
Expand All @@ -235,7 +242,7 @@ def _on_event_defined(self, event: str, transition: Transition, states: List["St
def __repr__(self):
return (
f"{type(self).__name__}({self.name!r}, id={self.id!r}, value={self.value!r}, "
f"initial={self.initial!r}, final={self.final!r})"
f"initial={self.initial!r}, final={self.final!r}, parallel={self.parallel!r})"
)

def __str__(self):
Expand Down
5 changes: 5 additions & 0 deletions statemachine/statemachine.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,11 @@ def current_state(self) -> "State | MutableSet[State]":
This is a low level API, that can be to assign any valid state
completely bypassing all the hooks and validations.
"""
warnings.warn(
"""Property `current_state` is deprecated in favor of `configuration`.""",
DeprecationWarning,
stacklevel=2,
)
current_value = self.current_state_value

try:
Expand Down
1 change: 0 additions & 1 deletion tests/scxml/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

CURRENT_DIR = Path(__file__).parent
TESTCASES_DIR = CURRENT_DIR
SUPPORTED_EXTENSIONS = "scxml"


@pytest.fixture()
Expand Down
Loading

0 comments on commit 35f4bed

Please sign in to comment.