Skip to content

Commit 68ba609

Browse files
committed
Merge tag 'v2.5.0' into develop
*November 5, 2024* This release introduces powerful new features for the `StateMachine` library: {ref}`Condition expressions` and explicit definition of {ref}`Events`. These updates make it easier to define complex transition conditions and enhance performance, especially in workflows with nested or recursive event structures. StateMachine 2.4.0 supports Python 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, and 3.13. This release introduces support for conditionals with Boolean algebra. You can now use expressions like `or`, `and`, and `not` directly within transition conditions, simplifying the definition of complex state transitions. This allows for more flexible and readable condition setups in your state machine configurations. Example (with a spoiler of the next highlight): ```py >>> from statemachine import StateMachine, State, Event >>> class AnyConditionSM(StateMachine): ... start = State(initial=True) ... end = State(final=True) ... ... submit = Event( ... start.to(end, cond="used_money or used_credit"), ... name="finish order", ... ) ... ... used_money: bool = False ... used_credit: bool = False >>> sm = AnyConditionSM() >>> sm.submit() Traceback (most recent call last): TransitionNotAllowed: Can't finish order when in Start. >>> sm.used_credit = True >>> sm.submit() >>> sm.current_state.id 'end' ``` ```{seealso} See {ref}`Condition expressions` for more details or take a look at the {ref}`sphx_glr_auto_examples_lor_machine.py` example. ``` Now you can explicit declare {ref}`Events` using the {ref}`event` class. This allows custom naming, translations, and also helps your IDE to know that events are callable. ```py >>> from statemachine import StateMachine, State, Event >>> class StartMachine(StateMachine): ... created = State(initial=True) ... started = State(final=True) ... ... start = Event(created.to(started), name="Launch the machine") ... >>> [e.id for e in StartMachine.events] ['start'] >>> [e.name for e in StartMachine.events] ['Launch the machine'] >>> StartMachine.start.name 'Launch the machine' ``` ```{seealso} See {ref}`Events` for more details. ``` We removed a note from the docs saying to avoid recursion loops. Since the {ref}`StateMachine 2.0.0` release we've turned the RTC model enabled by default, allowing nested events to occour as all events are put on an internal queue before being executed. ```{seealso} See {ref}`sphx_glr_auto_examples_recursive_event_machine.py` for an example of an infinite loop state machine declaration using `after` action callback to call the same event over and over again. ``` - Fixes [#484](#484) issue where nested events inside loops could leak memory by incorrectly referencing previous `event_data` when queuing the next event. This fix improves performance and stability in event-heavy workflows.
2 parents b1ef079 + d5006b5 commit 68ba609

File tree

7 files changed

+75
-46
lines changed

7 files changed

+75
-46
lines changed

.pre-commit-config.yaml

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
repos:
22
- repo: https://github.com/pre-commit/pre-commit-hooks
3-
rev: v2.3.0
3+
rev: v4.6.0
44
hooks:
55
- id: check-yaml
66
- id: end-of-file-fixer
@@ -9,7 +9,7 @@ repos:
99
exclude: docs/auto_examples
1010
- repo: https://github.com/charliermarsh/ruff-pre-commit
1111
# Ruff version.
12-
rev: v0.3.7
12+
rev: v0.8.1
1313
hooks:
1414
# Run the linter.
1515
- id: ruff

docs/guards.md

+1-14
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ The mini-language is based on Python's built-in language and the [`ast`](https:/
7777
4. `!=` — Not equal.
7878
5. `<` — Lower than.
7979
6. `<=` — Lower than or equal.
80-
- See the [comparisons](https://docs.python.org/3/reference/expressions.html#comparisons) from Python's.
80+
- All comparison operations in Python have the same priority.
8181

8282
3. **Parentheses for precedence**:
8383
- When operators with the same precedence appear in the expression, evaluation proceeds from left to right, unless parentheses specify a different order.
@@ -100,19 +100,6 @@ Being used on a transition definition:
100100
start.to(end, cond="frodo_has_ring and gandalf_present or !sauron_alive")
101101
```
102102

103-
#### Summary of grammar rules
104-
105-
The mini-language is formally specified as follows:
106-
107-
```
108-
Name: [A-Za-z_][A-Za-z0-9_]*
109-
Boolean Expression:
110-
111-
<boolean_expr> ::= <term> | <boolean_expr> 'or' <term> | <boolean_expr> 'v' <term>
112-
<term> ::= <factor> | <term> 'and' <factor> | <term> '^' <factor>
113-
<factor> ::= 'not' <factor> | '!' <factor> | '(' <boolean_expr> ')' | <name>
114-
115-
```
116103

117104
```{seealso}
118105
See {ref}`sphx_glr_auto_examples_lor_machine.py` for an example of

pyproject.toml

+6-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "python-statemachine"
3-
version = "2.4.0"
3+
version = "2.5.0"
44
description = "Python Finite State Machines made easy."
55
authors = [{ name = "Fernando Macedo", email = "fgmacedo@gmail" }]
66
maintainers = [{ name = "Fernando Macedo", email = "fgmacedo@gmail" }]
@@ -33,7 +33,7 @@ diagrams = ["pydot >= 2.0.0"]
3333

3434
[dependency-groups]
3535
dev = [
36-
"ruff >=0.4.8",
36+
"ruff >=0.8.1",
3737
"pre-commit",
3838
"mypy",
3939
"pytest",
@@ -82,6 +82,7 @@ doctest_optionflags = "ELLIPSIS IGNORE_EXCEPTION_DETAIL NORMALIZE_WHITESPACE IGN
8282
asyncio_mode = "auto"
8383
markers = ["""slow: marks tests as slow (deselect with '-m "not slow"')"""]
8484
python_files = ["tests.py", "test_*.py", "*_tests.py"]
85+
xfail_strict = true
8586

8687
[tool.coverage.run]
8788
branch = true
@@ -112,7 +113,7 @@ directory = "tmp/htmlcov"
112113
show_contexts = true
113114

114115
[tool.mypy]
115-
python_version = "3.12"
116+
python_version = "3.13"
116117
warn_return_any = true
117118
warn_unused_configs = true
118119
disable_error_code = "annotation-unchecked"
@@ -126,7 +127,7 @@ ignore_missing_imports = true
126127
src = ["statemachine"]
127128

128129
line-length = 99
129-
target-version = "py312"
130+
target-version = "py313"
130131

131132
# Exclude a variety of commonly ignored directories.
132133
exclude = [
@@ -178,7 +179,7 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
178179
"tests/examples/**.py" = ["B018"]
179180

180181
[tool.ruff.lint.mccabe]
181-
max-complexity = 6
182+
max-complexity = 10
182183

183184
[tool.ruff.lint.isort]
184185
force-single-line = true

statemachine/__init__.py

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

55
__author__ = """Fernando Macedo"""
66
__email__ = "[email protected]"
7-
__version__ = "2.4.0"
7+
__version__ = "2.5.0"
88

99
__all__ = ["StateMachine", "State", "Event"]

statemachine/state.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +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)."""
39+
"""Create transitions from all non-final states (reversed)."""
4040
return self.__call__(AnyState(), **kwargs)
4141

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

tests/test_spec_parser.py

+41
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,44 @@ def variable_hook(var_name):
218218
parsed_expr = parse_boolean_expr(expr, variable_hook, operator_mapping)
219219

220220
assert parsed_expr is original_callback
221+
222+
223+
@pytest.mark.parametrize(
224+
("expression", "expected", "hooks_called"),
225+
[
226+
("49 < frodo_age < 51", True, ["frodo_age"]),
227+
("49 < frodo_age > 50", False, ["frodo_age"]),
228+
(
229+
"aragorn_age < legolas_age < gimli_age",
230+
False,
231+
["aragorn_age", "legolas_age", "gimli_age"],
232+
), # 87 < 2931 and 2931 < 139
233+
(
234+
"gimli_age > aragorn_age < legolas_age",
235+
True,
236+
["gimli_age", "aragorn_age", "legolas_age"],
237+
), # 139 > 87 and 87 < 2931
238+
(
239+
"sword_power < ring_power > bow_power",
240+
True,
241+
["sword_power", "ring_power", "bow_power"],
242+
), # 80 < 100 and 100 > 75
243+
(
244+
"axe_power > sword_power == bow_power",
245+
False,
246+
["axe_power", "sword_power", "bow_power"],
247+
), # 85 > 80 and 80 == 75
248+
("height > 1 and height < 2", True, ["height"]),
249+
],
250+
)
251+
@pytest.mark.xfail(reason="TODO: Optimize so that expressios are evaluated only once")
252+
def test_should_evaluate_values_only_once(expression, expected, caplog, hooks_called):
253+
caplog.set_level(logging.DEBUG, logger="tests")
254+
255+
parsed_expr = parse_boolean_expr(expression, variable_hook, operator_mapping)
256+
assert parsed_expr() is expected, expression
257+
258+
if hooks_called:
259+
assert caplog.record_tuples == [
260+
("tests.test_spec_parser", DEBUG, f"variable_hook({hook})") for hook in hooks_called
261+
]

uv.lock

+23-23
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)