Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/guards.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,20 @@ The mini-language is based on Python's built-in language and the [`ast`](https:/
1. `not` / `!` — Logical negation
2. `and` / `^` — Logical conjunction
3. `or` / `v` — Logical disjunction
4. `or` / `v` — Logical disjunction
- These operators are case-sensitive (e.g., `NOT` and `Not` are not equivalent to `not` and will raise syntax errors).
- Both formats can be used interchangeably, so `!sauron_alive` and `not sauron_alive` are equivalent.

2. **Comparisson operators**:
- The following comparison operators are supported:
1. `>` — Greather than.
2. `>=` — Greather than or equal.
3. `==` — Equal.
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.

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.
- Parentheses `(` and `)` are supported to control the order of evaluation in expressions.
Expand Down
188 changes: 188 additions & 0 deletions docs/releases/2.5.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# StateMachine 2.5.0

*December 3, 2024*

## What's new in 2.5.0

This release improves {ref}`Condition expressions` and explicit definition of {ref}`Events` and introduces the helper `State.from_.any()`.

### Python compatibility in 2.5.0

StateMachine 2.5.0 supports Python 3.7, 3.8, 3.9, 3.10, 3.11, 3.12, and 3.13.

### Helper to declare transition from any state

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.

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.

``` py
>>> from statemachine import Event

>>> class AccountStateMachine(StateMachine):
... active = State("Active", initial=True)
... suspended = State("Suspended")
... overdrawn = State("Overdrawn")
... closed = State("Closed", final=True)
...
... suspend = Event(active.to(suspended))
... activate = Event(suspended.to(active))
... overdraft = Event(active.to(overdrawn))
... resolve_overdraft = Event(overdrawn.to(active))
...
... close_account = Event(closed.from_.any(cond="can_close_account"))
...
... can_close_account: bool = True
...
... def on_close_account(self):
... print("Account has been closed.")

>>> sm = AccountStateMachine()
>>> sm.close_account()
Account has been closed.
>>> sm.closed.is_active
True

```


### Allowed events are now bounded to the state machine instance

Since 2.0, the state machine can return a list of allowed events given the current state:

```
>>> sm = AccountStateMachine()
>>> [str(e) for e in sm.allowed_events]
['suspend', 'overdraft', 'close_account']

```

`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.

You can think of the event as an implementation of the **command** design pattern.

On this example, we iterate until the state machine reaches a final state,
listing the current state allowed events and executing the simulated user choice:

```
>>> import random
>>> random.seed("15")

>>> sm = AccountStateMachine()

>>> while not sm.current_state.final:
... allowed_events = sm.allowed_events
... print("Choose an action: ")
... for idx, event in enumerate(allowed_events):
... print(f"{idx} - {event.name}")
...
... user_input = random.randint(0, len(allowed_events)-1)
... print(f"User input: {user_input}")
...
... event = allowed_events[user_input]
... print(f"Running the option {user_input} - {event.name}")
... event()
Choose an action:
0 - Suspend
1 - Overdraft
2 - Close account
User input: 0
Running the option 0 - Suspend
Choose an action:
0 - Activate
1 - Close account
User input: 0
Running the option 0 - Activate
Choose an action:
0 - Suspend
1 - Overdraft
2 - Close account
User input: 2
Running the option 2 - Close account
Account has been closed.

>>> print(f"SM is in {sm.current_state.name} state.")
SM is in Closed state.

```

### Conditions expressions in 2.5.0

This release adds support for comparison operators into {ref}`Condition expressions`.

The following comparison operators are supported:
1. `>` — Greather than.
2. `>=` — Greather than or equal.
3. `==` — Equal.
4. `!=` — Not equal.
5. `<` — Lower than.
6. `<=` — Lower than or equal.

Example:

```py
>>> from statemachine import StateMachine, State, Event

>>> class AnyConditionSM(StateMachine):
... start = State(initial=True)
... end = State(final=True)
...
... submit = Event(
... start.to(end, cond="order_value > 100"),
... name="finish order",
... )
...
... order_value: float = 0

>>> sm = AnyConditionSM()
>>> sm.submit()
Traceback (most recent call last):
TransitionNotAllowed: Can't finish order when in Start.

>>> sm.order_value = 135.0
>>> 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.
```

### Decorator callbacks with explicit event creation in 2.5.0

Now you can add callbacks using the decorator syntax using {ref}`Events`. Note that this syntax is also available without the explicit `Event`.

```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")
...
... @start.on
... def call_service(self):
... return "calling..."
...

>>> sm = StartMachine()
>>> sm.start()
'calling...'


```


## Bugfixes in 2.5.0

- Fixes [#500](https://github.com/fgmacedo/python-statemachine/issues/500) issue adding support for Pickle.


## Misc in 2.5.0

- We're now using `uv` [#491](https://github.com/fgmacedo/python-statemachine/issues/491).
- Simplification of the engines code [#498](https://github.com/fgmacedo/python-statemachine/pull/498).
- The dispatcher and callback modules where refactored with improved separation of concerns [#490](https://github.com/fgmacedo/python-statemachine/pull/490).
1 change: 1 addition & 0 deletions docs/releases/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Below are release notes through StateMachine and its patch releases.
```{toctree}
:maxdepth: 2

2.5.0
2.4.0
2.3.6
2.3.5
Expand Down
18 changes: 17 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ dev = [
"pytest-mock >=3.10.0",
"pytest-benchmark >=4.0.0",
"pytest-asyncio",
"pydot",
"django >=5.0.8; python_version >='3.10'",
"pytest-django >=4.8.0; python_version >'3.8'",
"Sphinx; python_version >'3.8'",
Expand All @@ -51,6 +52,7 @@ dev = [
"sphinx-autobuild; python_version >'3.8'",
"furo >=2024.5.6; python_version >'3.8'",
"sphinx-copybutton >=0.5.2; python_version >'3.8'",
"pdbr>=0.8.9; python_version >'3.8'",
]

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

[tool.pytest.ini_options]
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"
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",
"--pdbcls=pdbr:RichPdb",
]
doctest_optionflags = "ELLIPSIS IGNORE_EXCEPTION_DETAIL NORMALIZE_WHITESPACE IGNORE_EXCEPTION_DETAIL"
asyncio_mode = "auto"
markers = ["""slow: marks tests as slow (deselect with '-m "not slow"')"""]
Expand Down
85 changes: 76 additions & 9 deletions statemachine/spec_parser.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
import ast
import operator
import re
from functools import reduce
from typing import Callable

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

pattern = re.compile(r"\!|\^|\bv\b")
pattern = re.compile(r"\!(?!=)|\^|\bv\b")

comparison_repr = {
operator.eq: "==",
operator.ne: "!=",
operator.gt: ">",
operator.ge: ">=",
operator.lt: "<",
operator.le: "<=",
}


def _unique_key(left, right, operator) -> str:
left_key = getattr(left, "unique_key", "")
right_key = getattr(right, "unique_key", "")
return f"{left_key} {operator} {right_key}"


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


def _unique_key(left, right, operator) -> str:
left_key = getattr(left, "unique_key", "")
right_key = getattr(right, "unique_key", "")
return f"{left_key} {operator} {right_key}"


def custom_and(left: Callable, right: Callable) -> Callable:
def decorated(*args, **kwargs) -> bool:
return left(*args, **kwargs) and right(*args, **kwargs) # type: ignore[no-any-return]
Expand All @@ -49,7 +60,30 @@ def decorated(*args, **kwargs) -> bool:
return decorated


def build_expression(node, variable_hook, operator_mapping):
def build_constant(constant) -> Callable:
def decorated(*args, **kwargs):
return constant

decorated.__name__ = str(constant)
decorated.unique_key = str(constant) # type: ignore[attr-defined]
return decorated


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

def custom_comparator(left: Callable, right: Callable) -> Callable:
def decorated(*args, **kwargs) -> bool:
return bool(operator(left(*args, **kwargs), right(*args, **kwargs)))

decorated.__name__ = f"({left.__name__} {operator_repr} {right.__name__})"
decorated.unique_key = _unique_key(left, right, operator_repr) # type: ignore[attr-defined]
return decorated

return custom_comparator


def build_expression(node, variable_hook, operator_mapping): # noqa: C901
if isinstance(node, ast.BoolOp):
# Handle `and` / `or` operations
operator_fn = operator_mapping[type(node.op)]
Expand All @@ -58,13 +92,36 @@ def build_expression(node, variable_hook, operator_mapping):
right_expr = build_expression(right, variable_hook, operator_mapping)
left_expr = operator_fn(left_expr, right_expr)
return left_expr
elif isinstance(node, ast.Compare):
# Handle `==` / `!=` / `>` / `<` / `>=` / `<=` operations
expressions = []
left_expr = build_expression(node.left, variable_hook, operator_mapping)
for right_op, right in zip(node.ops, node.comparators): # noqa: B905 # strict=True requires 3.10+
right_expr = build_expression(right, variable_hook, operator_mapping)
operator_fn = operator_mapping[type(right_op)]
expression = operator_fn(left_expr, right_expr)
left_expr = right_expr
expressions.append(expression)

return reduce(custom_and, expressions)
elif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
# Handle `not` operation
operand_expr = build_expression(node.operand, variable_hook, operator_mapping)
return operator_mapping[type(node.op)](operand_expr)
elif isinstance(node, ast.Name):
# Handle variables by calling the variable_hook
return variable_hook(node.id)
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 @@ -80,4 +137,14 @@ def parse_boolean_expr(expr, variable_hook, operator_mapping):
return build_expression(tree.body, variable_hook, operator_mapping)


operator_mapping = {ast.Or: custom_or, ast.And: custom_and, ast.Not: custom_not}
operator_mapping = {
ast.Or: custom_or,
ast.And: custom_and,
ast.Not: custom_not,
ast.GtE: build_custom_operator(operator.ge),
ast.Gt: build_custom_operator(operator.gt),
ast.LtE: build_custom_operator(operator.le),
ast.Lt: build_custom_operator(operator.lt),
ast.Eq: build_custom_operator(operator.eq),
ast.NotEq: build_custom_operator(operator.ne),
}
1 change: 1 addition & 0 deletions statemachine/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def __call__(self, *states: "State", **kwargs):

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

def __call__(self, *states: "State", **kwargs):
Expand Down
Loading
Loading