Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit b1ef079

Browse files
feat: Comparison in condition expressions (#507)
1 parent 1f91cc4 commit b1ef079

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)

AltStyle によって変換されたページ (->オリジナル) /