Skip to content

Commit c4eace0

Browse files
committed
feat: Comparison in condition expressions
1 parent 1f91cc4 commit c4eace0

File tree

9 files changed

+458
-44
lines changed

9 files changed

+458
-44
lines changed

docs/guards.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,20 @@ The mini-language is based on Python's built-in language and the [`ast`](https:/
6565
1. `not` / `!` — Logical negation
6666
2. `and` / `^` — Logical conjunction
6767
3. `or` / `v` — Logical disjunction
68+
4. `or` / `v` — Logical disjunction
6869
- These operators are case-sensitive (e.g., `NOT` and `Not` are not equivalent to `not` and will raise syntax errors).
6970
- Both formats can be used interchangeably, so `!sauron_alive` and `not sauron_alive` are equivalent.
7071

72+
2. **Comparisson operators**:
73+
- The following comparison operators are supported:
74+
1. `>` — Greather than.
75+
2. `>=` — Greather than or equal.
76+
3. `==` — Equal.
77+
4. `!=` — Not equal.
78+
5. `<` — Lower than.
79+
6. `<=` — Lower than or equal.
80+
- See the [comparisons](https://docs.python.org/3/reference/expressions.html#comparisons) from Python's.
81+
7182
3. **Parentheses for precedence**:
7283
- When operators with the same precedence appear in the expression, evaluation proceeds from left to right, unless parentheses specify a different order.
7384
- Parentheses `(` and `)` are supported to control the order of evaluation in expressions.

docs/releases/2.5.0.md

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# StateMachine 2.5.0
2+
3+
*December 3, 2024*
4+
5+
## What's new in 2.5.0
6+
7+
This release improves {ref}`Condition expressions` and explicit definition of {ref}`Events` and introduces the helper `State.from_.any()`.
8+
9+
### Python compatibility in 2.5.0
10+
11+
StateMachine 2.5.0 supports Python 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, and 3.13.
12+
13+
### Helper to declare transition from any state
14+
15+
You can now declare that a state is accessible from any other state with a simple constructor. Using `State.from_.any()`, the state machine meta class automatically creates transitions from all non-final states to the target state.
16+
17+
Furthermore, both `State.from_.itself()` and `State.to.itself()` have been refactored to support type hints and are now fully visible for code completion in your preferred editor.
18+
19+
``` py
20+
>>> from statemachine import Event
21+
22+
>>> class AccountStateMachine(StateMachine):
23+
... active = State("Active", initial=True)
24+
... suspended = State("Suspended")
25+
... overdrawn = State("Overdrawn")
26+
... closed = State("Closed", final=True)
27+
...
28+
... suspend = Event(active.to(suspended))
29+
... activate = Event(suspended.to(active))
30+
... overdraft = Event(active.to(overdrawn))
31+
... resolve_overdraft = Event(overdrawn.to(active))
32+
...
33+
... close_account = Event(closed.from_.any(cond="can_close_account"))
34+
...
35+
... can_close_account: bool = True
36+
...
37+
... def on_close_account(self):
38+
... print("Account has been closed.")
39+
40+
>>> sm = AccountStateMachine()
41+
>>> sm.close_account()
42+
Account has been closed.
43+
>>> sm.closed.is_active
44+
True
45+
46+
```
47+
48+
49+
### Allowed events are now bounded to the state machine instance
50+
51+
Since 2.0, the state machine can return a list of allowed events given the current state:
52+
53+
```
54+
>>> sm = AccountStateMachine()
55+
>>> [str(e) for e in sm.allowed_events]
56+
['suspend', 'overdraft', 'close_account']
57+
58+
```
59+
60+
`Event` instances are now bound to the state machine instance, allowing you to pass the event by reference and call it like a method, which triggers the event in the state machine.
61+
62+
You can think of the event as an implementation of the **command** design pattern.
63+
64+
On this example, we iterate until the state machine reaches a final state,
65+
listing the current state allowed events and executing the simulated user choice:
66+
67+
```
68+
>>> import random
69+
>>> random.seed("15")
70+
71+
>>> sm = AccountStateMachine()
72+
73+
>>> while not sm.current_state.final:
74+
... allowed_events = sm.allowed_events
75+
... print("Choose an action: ")
76+
... for idx, event in enumerate(allowed_events):
77+
... print(f"{idx} - {event.name}")
78+
...
79+
... user_input = random.randint(0, len(allowed_events)-1)
80+
... print(f"User input: {user_input}")
81+
...
82+
... event = allowed_events[user_input]
83+
... print(f"Running the option {user_input} - {event.name}")
84+
... event()
85+
Choose an action:
86+
0 - Suspend
87+
1 - Overdraft
88+
2 - Close account
89+
User input: 0
90+
Running the option 0 - Suspend
91+
Choose an action:
92+
0 - Activate
93+
1 - Close account
94+
User input: 0
95+
Running the option 0 - Activate
96+
Choose an action:
97+
0 - Suspend
98+
1 - Overdraft
99+
2 - Close account
100+
User input: 2
101+
Running the option 2 - Close account
102+
Account has been closed.
103+
104+
>>> print(f"SM is in {sm.current_state.name} state.")
105+
SM is in Closed state.
106+
107+
```
108+
109+
### Conditions expressions in 2.5.0
110+
111+
This release adds support for comparison operators into {ref}`Condition expressions`.
112+
113+
The following comparison operators are supported:
114+
1. `>` — Greather than.
115+
2. `>=` — Greather than or equal.
116+
3. `==` — Equal.
117+
4. `!=` — Not equal.
118+
5. `<` — Lower than.
119+
6. `<=` — Lower than or equal.
120+
121+
Example:
122+
123+
```py
124+
>>> from statemachine import StateMachine, State, Event
125+
126+
>>> class AnyConditionSM(StateMachine):
127+
... start = State(initial=True)
128+
... end = State(final=True)
129+
...
130+
... submit = Event(
131+
... start.to(end, cond="order_value > 100"),
132+
... name="finish order",
133+
... )
134+
...
135+
... order_value: float = 0
136+
137+
>>> sm = AnyConditionSM()
138+
>>> sm.submit()
139+
Traceback (most recent call last):
140+
TransitionNotAllowed: Can't finish order when in Start.
141+
142+
>>> sm.order_value = 135.0
143+
>>> sm.submit()
144+
>>> sm.current_state.id
145+
'end'
146+
147+
```
148+
149+
```{seealso}
150+
See {ref}`Condition expressions` for more details or take a look at the {ref}`sphx_glr_auto_examples_lor_machine.py` example.
151+
```
152+
153+
### Decorator callbacks with explicit event creation in 2.5.0
154+
155+
Now you can add callbacks using the decorator syntax using {ref}`Events`. Note that this syntax is also available without the explicit `Event`.
156+
157+
```py
158+
>>> from statemachine import StateMachine, State, Event
159+
160+
>>> class StartMachine(StateMachine):
161+
... created = State(initial=True)
162+
... started = State(final=True)
163+
...
164+
... start = Event(created.to(started), name="Launch the machine")
165+
...
166+
... @start.on
167+
... def call_service(self):
168+
... return "calling..."
169+
...
170+
171+
>>> sm = StartMachine()
172+
>>> sm.start()
173+
'calling...'
174+
175+
176+
```
177+
178+
179+
## Bugfixes in 2.5.0
180+
181+
- Fixes [#500](https://github.com/fgmacedo/python-statemachine/issues/500) issue adding support for Pickle.
182+
183+
184+
## Misc in 2.5.0
185+
186+
- We're now using `uv` [#491](https://github.com/fgmacedo/python-statemachine/issues/491).
187+
- Simplification of the engines code [#498](https://github.com/fgmacedo/python-statemachine/pull/498).
188+
- The dispatcher and callback modules where refactored with improved separation of concerns [#490](https://github.com/fgmacedo/python-statemachine/pull/490).

docs/releases/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Below are release notes through StateMachine and its patch releases.
1515
```{toctree}
1616
:maxdepth: 2
1717
18+
2.5.0
1819
2.4.0
1920
2.3.6
2021
2.3.5

pyproject.toml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ dev = [
4242
"pytest-mock >=3.10.0",
4343
"pytest-benchmark >=4.0.0",
4444
"pytest-asyncio",
45+
"pydot",
4546
"django >=5.0.8; python_version >='3.10'",
4647
"pytest-django >=4.8.0; python_version >'3.8'",
4748
"Sphinx; python_version >'3.8'",
@@ -51,6 +52,7 @@ dev = [
5152
"sphinx-autobuild; python_version >'3.8'",
5253
"furo >=2024.5.6; python_version >'3.8'",
5354
"sphinx-copybutton >=0.5.2; python_version >'3.8'",
55+
"pdbr>=0.8.9; python_version >'3.8'",
5456
]
5557

5658
[build-system]
@@ -61,7 +63,21 @@ build-backend = "hatchling.build"
6163
packages = ["statemachine/"]
6264

6365
[tool.pytest.ini_options]
64-
addopts = "--ignore=docs/conf.py --ignore=docs/auto_examples/ --ignore=docs/_build/ --ignore=tests/examples/ --cov --cov-config .coveragerc --doctest-glob='*.md' --doctest-modules --doctest-continue-on-failure --benchmark-autosave --benchmark-group-by=name"
66+
addopts = [
67+
"--ignore=docs/conf.py",
68+
"--ignore=docs/auto_examples/",
69+
"--ignore=docs/_build/",
70+
"--ignore=tests/examples/",
71+
"--cov",
72+
"--cov-config",
73+
".coveragerc",
74+
"--doctest-glob=*.md",
75+
"--doctest-modules",
76+
"--doctest-continue-on-failure",
77+
"--benchmark-autosave",
78+
"--benchmark-group-by=name",
79+
"--pdbcls=pdbr:RichPdb",
80+
]
6581
doctest_optionflags = "ELLIPSIS IGNORE_EXCEPTION_DETAIL NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL"
6682
asyncio_mode = "auto"
6783
markers = ["""slow: marks tests as slow (deselect with '-m "not slow"')"""]

statemachine/spec_parser.py

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
11
import ast
2+
import operator
23
import re
4+
from functools import reduce
35
from typing import Callable
46

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

7-
pattern = re.compile(r"\!|\^|\bv\b")
9+
pattern = re.compile(r"\!(?!=)|\^|\bv\b")
10+
11+
comparison_repr = {
12+
operator.eq: "==",
13+
operator.ne: "!=",
14+
operator.gt: ">",
15+
operator.ge: ">=",
16+
operator.lt: "<",
17+
operator.le: "<=",
18+
}
19+
20+
21+
def _unique_key(left, right, operator) -> str:
22+
left_key = getattr(left, "unique_key", "")
23+
right_key = getattr(right, "unique_key", "")
24+
return f"{left_key} {operator} {right_key}"
825

926

1027
def replace_operators(expr: str) -> str:
@@ -25,12 +42,6 @@ def decorated(*args, **kwargs) -> bool:
2542
return decorated
2643

2744

28-
def _unique_key(left, right, operator) -> str:
29-
left_key = getattr(left, "unique_key", "")
30-
right_key = getattr(right, "unique_key", "")
31-
return f"{left_key} {operator} {right_key}"
32-
33-
3445
def custom_and(left: Callable, right: Callable) -> Callable:
3546
def decorated(*args, **kwargs) -> bool:
3647
return left(*args, **kwargs) and right(*args, **kwargs) # type: ignore[no-any-return]
@@ -49,7 +60,30 @@ def decorated(*args, **kwargs) -> bool:
4960
return decorated
5061

5162

52-
def build_expression(node, variable_hook, operator_mapping):
63+
def build_constant(constant) -> Callable:
64+
def decorated(*args, **kwargs):
65+
return constant
66+
67+
decorated.__name__ = str(constant)
68+
decorated.unique_key = str(constant) # type: ignore[attr-defined]
69+
return decorated
70+
71+
72+
def build_custom_operator(operator) -> Callable:
73+
operator_repr = comparison_repr[operator]
74+
75+
def custom_comparator(left: Callable, right: Callable) -> Callable:
76+
def decorated(*args, **kwargs) -> bool:
77+
return bool(operator(left(*args, **kwargs), right(*args, **kwargs)))
78+
79+
decorated.__name__ = f"({left.__name__} {operator_repr} {right.__name__})"
80+
decorated.unique_key = _unique_key(left, right, operator_repr) # type: ignore[attr-defined]
81+
return decorated
82+
83+
return custom_comparator
84+
85+
86+
def build_expression(node, variable_hook, operator_mapping): # noqa: C901
5387
if isinstance(node, ast.BoolOp):
5488
# Handle `and` / `or` operations
5589
operator_fn = operator_mapping[type(node.op)]
@@ -58,13 +92,36 @@ def build_expression(node, variable_hook, operator_mapping):
5892
right_expr = build_expression(right, variable_hook, operator_mapping)
5993
left_expr = operator_fn(left_expr, right_expr)
6094
return left_expr
95+
elif isinstance(node, ast.Compare):
96+
# Handle `==` / `!=` / `>` / `<` / `>=` / `<=` operations
97+
expressions = []
98+
left_expr = build_expression(node.left, variable_hook, operator_mapping)
99+
for right_op, right in zip(node.ops, node.comparators): # noqa: B905 # strict=True requires 3.10+
100+
right_expr = build_expression(right, variable_hook, operator_mapping)
101+
operator_fn = operator_mapping[type(right_op)]
102+
expression = operator_fn(left_expr, right_expr)
103+
left_expr = right_expr
104+
expressions.append(expression)
105+
106+
return reduce(custom_and, expressions)
61107
elif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
62108
# Handle `not` operation
63109
operand_expr = build_expression(node.operand, variable_hook, operator_mapping)
64110
return operator_mapping[type(node.op)](operand_expr)
65111
elif isinstance(node, ast.Name):
66112
# Handle variables by calling the variable_hook
67113
return variable_hook(node.id)
114+
elif isinstance(node, ast.Constant):
115+
# Handle constants by returning the value
116+
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)
68125
else:
69126
raise ValueError(f"Unsupported expression structure: {node.__class__.__name__}")
70127

@@ -80,4 +137,14 @@ def parse_boolean_expr(expr, variable_hook, operator_mapping):
80137
return build_expression(tree.body, variable_hook, operator_mapping)
81138

82139

83-
operator_mapping = {ast.Or: custom_or, ast.And: custom_and, ast.Not: custom_not}
140+
operator_mapping = {
141+
ast.Or: custom_or,
142+
ast.And: custom_and,
143+
ast.Not: custom_not,
144+
ast.GtE: build_custom_operator(operator.ge),
145+
ast.Gt: build_custom_operator(operator.gt),
146+
ast.LtE: build_custom_operator(operator.le),
147+
ast.Lt: build_custom_operator(operator.lt),
148+
ast.Eq: build_custom_operator(operator.eq),
149+
ast.NotEq: build_custom_operator(operator.ne),
150+
}

statemachine/state.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ def __call__(self, *states: "State", **kwargs):
3636

3737
class _FromState(_TransitionBuilder):
3838
def any(self, **kwargs):
39+
"""Create transitions from all non-finalstates (reversed)."""
3940
return self.__call__(AnyState(), **kwargs)
4041

4142
def __call__(self, *states: "State", **kwargs):

0 commit comments

Comments
 (0)