Skip to content

Commit

Permalink
Merge tag 'v2.5.0' into develop
Browse files Browse the repository at this point in the history
*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.
  • Loading branch information
fgmacedo committed Dec 3, 2024
2 parents b1ef079 + d5006b5 commit 68ba609
Show file tree
Hide file tree
Showing 7 changed files with 75 additions and 46 deletions.
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.3.0
rev: v4.6.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
Expand All @@ -9,7 +9,7 @@ repos:
exclude: docs/auto_examples
- repo: https://github.com/charliermarsh/ruff-pre-commit
# Ruff version.
rev: v0.3.7
rev: v0.8.1
hooks:
# Run the linter.
- id: ruff
Expand Down
15 changes: 1 addition & 14 deletions docs/guards.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ The mini-language is based on Python's built-in language and the [`ast`](https:/
4. `!=` — Not equal.
5. `<` — Lower than.
6. `<=` — Lower than or equal.
- See the [comparisons](https://docs.python.org/3/reference/expressions.html#comparisons) from Python's.
- All comparison operations in Python have the same priority.

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

#### Summary of grammar rules

The mini-language is formally specified as follows:

```
Name: [A-Za-z_][A-Za-z0-9_]*
Boolean Expression:
<boolean_expr> ::= <term> | <boolean_expr> 'or' <term> | <boolean_expr> 'v' <term>
<term> ::= <factor> | <term> 'and' <factor> | <term> '^' <factor>
<factor> ::= 'not' <factor> | '!' <factor> | '(' <boolean_expr> ')' | <name>
```

```{seealso}
See {ref}`sphx_glr_auto_examples_lor_machine.py` for an example of
Expand Down
11 changes: 6 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "python-statemachine"
version = "2.4.0"
version = "2.5.0"
description = "Python Finite State Machines made easy."
authors = [{ name = "Fernando Macedo", email = "fgmacedo@gmail" }]
maintainers = [{ name = "Fernando Macedo", email = "fgmacedo@gmail" }]
Expand Down Expand Up @@ -33,7 +33,7 @@ diagrams = ["pydot >= 2.0.0"]

[dependency-groups]
dev = [
"ruff >=0.4.8",
"ruff >=0.8.1",
"pre-commit",
"mypy",
"pytest",
Expand Down Expand Up @@ -82,6 +82,7 @@ doctest_optionflags = "ELLIPSIS IGNORE_EXCEPTION_DETAIL NORMALIZE_WHITESPACE IGN
asyncio_mode = "auto"
markers = ["""slow: marks tests as slow (deselect with '-m "not slow"')"""]
python_files = ["tests.py", "test_*.py", "*_tests.py"]
xfail_strict = true

[tool.coverage.run]
branch = true
Expand Down Expand Up @@ -112,7 +113,7 @@ directory = "tmp/htmlcov"
show_contexts = true

[tool.mypy]
python_version = "3.12"
python_version = "3.13"
warn_return_any = true
warn_unused_configs = true
disable_error_code = "annotation-unchecked"
Expand All @@ -126,7 +127,7 @@ ignore_missing_imports = true
src = ["statemachine"]

line-length = 99
target-version = "py312"
target-version = "py313"

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

[tool.ruff.lint.mccabe]
max-complexity = 6
max-complexity = 10

[tool.ruff.lint.isort]
force-single-line = true
Expand Down
2 changes: 1 addition & 1 deletion statemachine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@

__author__ = """Fernando Macedo"""
__email__ = "[email protected]"
__version__ = "2.4.0"
__version__ = "2.5.0"

__all__ = ["StateMachine", "State", "Event"]
2 changes: 1 addition & 1 deletion statemachine/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def __call__(self, *states: "State", **kwargs):

class _FromState(_TransitionBuilder):
def any(self, **kwargs):
"""Create transitions from all non-finalstates (reversed)."""
"""Create transitions from all non-final states (reversed)."""
return self.__call__(AnyState(), **kwargs)

def __call__(self, *states: "State", **kwargs):
Expand Down
41 changes: 41 additions & 0 deletions tests/test_spec_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,44 @@ def variable_hook(var_name):
parsed_expr = parse_boolean_expr(expr, variable_hook, operator_mapping)

assert parsed_expr is original_callback


@pytest.mark.parametrize(
("expression", "expected", "hooks_called"),
[
("49 < frodo_age < 51", True, ["frodo_age"]),
("49 < frodo_age > 50", False, ["frodo_age"]),
(
"aragorn_age < legolas_age < gimli_age",
False,
["aragorn_age", "legolas_age", "gimli_age"],
), # 87 < 2931 and 2931 < 139
(
"gimli_age > aragorn_age < legolas_age",
True,
["gimli_age", "aragorn_age", "legolas_age"],
), # 139 > 87 and 87 < 2931
(
"sword_power < ring_power > bow_power",
True,
["sword_power", "ring_power", "bow_power"],
), # 80 < 100 and 100 > 75
(
"axe_power > sword_power == bow_power",
False,
["axe_power", "sword_power", "bow_power"],
), # 85 > 80 and 80 == 75
("height > 1 and height < 2", True, ["height"]),
],
)
@pytest.mark.xfail(reason="TODO: Optimize so that expressios are evaluated only once")
def test_should_evaluate_values_only_once(expression, expected, caplog, hooks_called):
caplog.set_level(logging.DEBUG, logger="tests")

parsed_expr = parse_boolean_expr(expression, variable_hook, operator_mapping)
assert parsed_expr() is expected, expression

if hooks_called:
assert caplog.record_tuples == [
("tests.test_spec_parser", DEBUG, f"variable_hook({hook})") for hook in hooks_called
]
46 changes: 23 additions & 23 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 68ba609

Please sign in to comment.