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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ timeout 120 uv run pytest -n 4

Testes normally run under 60s (~40s on average), so take a closer look if they take longer, it can be a regression.

When analyzing warnings or extensive output, run the tests **once** saving the output to a file
(`> /tmp/pytest-output.txt 2>&1`), then analyze the file — instead of running the suite
repeatedly with different greps.

Coverage is enabled by default (`--cov` is in `pyproject.toml`'s `addopts`). To generate a
coverage report to a file, pass `--cov-report` **in addition to** `--cov`:

Expand Down
4 changes: 2 additions & 2 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ def __init__(self):
doctest_namespace["asyncio"] = ContribAsyncio()


def pytest_ignore_collect(collection_path, path, config):
def pytest_ignore_collect(collection_path, config):
if sys.version_info >= (3, 10): # noqa: UP036
return None

if "django_project" in str(path):
if "django_project" in str(collection_path):
return True


Expand Down
28 changes: 13 additions & 15 deletions docs/releases/2.3.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,22 @@ async code with a state machine.
```


```py
>>> class AsyncStateMachine(StateMachine):
... initial = State('Initial', initial=True)
... final = State('Final', final=True)
...
... advance = initial.to(final)
...
... async def on_advance(self):
... return 42
```python
class AsyncStateMachine(StateMachine):
initial = State('Initial', initial=True)
final = State('Final', final=True)

advance = initial.to(final)

async def on_advance(self):
return 42

>>> async def run_sm():
... sm = AsyncStateMachine()
... res = await sm.advance()
... return (42, sm.current_state.name)

>>> asyncio.run(run_sm())
(42, 'Final')
async def run_sm():
sm = AsyncStateMachine()
res = await sm.advance()
return (42, sm.current_state.name)

asyncio.run(run_sm())
# (42, 'Final')
```
40 changes: 19 additions & 21 deletions docs/releases/2.4.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,31 +16,29 @@ This release introduces support for conditionals with Boolean algebra. You can n

Example (with a spoiler of the next highlight):

```py
>>> from statemachine import StateMachine, State, Event
```python
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
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",
)

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

>>> sm.used_credit = True
>>> sm.submit()
>>> sm.current_state.id
'end'
sm = AnyConditionSM()
sm.submit()
# TransitionNotAllowed: Can't finish order when in Start.

sm.used_credit = True
sm.submit()
sm.current_state.id
# 'end'
```

```{seealso}
Expand Down
91 changes: 35 additions & 56 deletions docs/releases/2.5.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,46 +64,27 @@ You can think of the event as an implementation of the **command** design patter
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")
```python
import random
random.seed("15")

>>> sm = AccountStateMachine()
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.
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}")

>>> print(f"SM is in {sm.current_state.name} state.")
SM is in Closed state.
event = allowed_events[user_input]
print(f"Running the option {user_input} - {event.name}")
event()

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

### Conditions expressions in 2.5.0
Expand All @@ -120,30 +101,28 @@ The following comparison operators are supported:

Example:

```py
>>> from statemachine import StateMachine, State, Event
```python
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
class AnyConditionSM(StateMachine):
start = State(initial=True)
end = State(final=True)

submit = Event(
start.to(end, cond="order_value > 100"),
name="finish order",
)

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

>>> sm.order_value = 135.0
>>> sm.submit()
>>> sm.current_state.id
'end'
sm = AnyConditionSM()
sm.submit()
# TransitionNotAllowed: Can't finish order when in Start.

sm.order_value = 135.0
sm.submit()
sm.current_state.id
# 'end'
```

```{seealso}
Expand Down
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ dev = [
"pytest-cov >=6.0.0; python_version >='3.9'",
"pytest-cov; python_version <'3.9'",
"pytest-sugar >=1.0.0",
"pytest-mock >=3.10.0",
"pytest-mock >=3.14.0",
"pytest-benchmark >=4.0.0",
"pytest-asyncio",
"pytest-asyncio >=0.25.0",
"pydot",
"django >=5.2.11; python_version >='3.10'",
"pytest-django >=4.8.0; python_version >'3.8'",
Expand Down Expand Up @@ -93,6 +93,9 @@ log_cli_level = "DEBUG"
log_cli_format = "%(relativeCreated)6.0fms %(threadName)-18s %(name)-35s %(message)s"
log_cli_date_format = "%H:%M:%S"
asyncio_default_fixture_loop_scope = "module"
filterwarnings = [
"ignore::pytest_benchmark.logger.PytestBenchmarkWarning",
]

[tool.coverage.run]
branch = true
Expand Down
4 changes: 2 additions & 2 deletions statemachine/engines/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ def handler(error: Exception) -> None:
# new error.execution is a separate event that may trigger a different
# transition (see W3C test 152). The infinite-loop guard lives at the
# *microstep* level (in ``_send_error_execution``), not here.
self.sm.send(_ERROR_EXECUTION, error=error, internal=True)
BoundEvent(_ERROR_EXECUTION, internal=True, _sm=self.sm).put(error=error)

return handler

Expand Down Expand Up @@ -188,7 +188,7 @@ def _send_error_execution(self, error: Exception, trigger_data: TriggerData):
if trigger_data.event and str(trigger_data.event) == _ERROR_EXECUTION:
logger.warning("Error while processing error.execution, ignoring: %s", error)
return
self.sm.send(_ERROR_EXECUTION, error=error, internal=True)
BoundEvent(_ERROR_EXECUTION, internal=True, _sm=self.sm).put(error=error)

def start(self, **kwargs):
if self.sm.current_state_value is not None:
Expand Down
4 changes: 2 additions & 2 deletions statemachine/invoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,7 @@ async def _run_async_handler(
None, lambda: callback.call(ctx=ctx, machine=ctx.machine, **ctx.kwargs)
)
if not ctx.cancelled.is_set():
self.sm.send(
await self.sm.send(
f"done.invoke.{ctx.invokeid}",
data=result,
)
Expand All @@ -466,7 +466,7 @@ async def _run_async_handler(
except Exception as e:
if not ctx.cancelled.is_set():
# External queue — see comment in _run_sync_handler.
self.sm.send("error.execution", error=e)
await self.sm.send("error.execution", error=e)
finally:
invocation.terminated = True
logger.debug(
Expand Down
5 changes: 3 additions & 2 deletions statemachine/io/scxml/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Callable
from uuid import uuid4

from ...event import BoundEvent
from ...event import Event
from ...event import _event_data_kwargs
from ...spec_parser import InState
Expand Down Expand Up @@ -426,7 +427,7 @@ def _send_to_invoke(action: SendAction, invokeid: str, **kwargs):
params_values[param.name] = _eval(param.expr, **kwargs)
if not machine._engine._invoke_manager.send_to_child(invokeid, event, **params_values):
# Per SCXML spec: if target is not reachable → error.communication
machine.send("error.communication", internal=True)
BoundEvent("error.communication", internal=True, _sm=machine).put()


def create_send_action_callable(action: SendAction) -> Callable: # noqa: C901
Expand All @@ -452,7 +453,7 @@ def send_action(*args, **kwargs): # noqa: C901
if target not in _valid_targets:
if target and target.startswith("#_scxml_"):
# Valid SCXML session reference but undispatchable → error.communication
machine.send("error.communication", internal=True)
BoundEvent("error.communication", internal=True, _sm=machine).put()
elif target and target.startswith("#_"):
# #_<invokeid> → route to invoked child session
_send_to_invoke(action, target[2:], **kwargs)
Expand Down
6 changes: 5 additions & 1 deletion statemachine/io/scxml/invoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
finalize.
"""

import asyncio
import logging
from inspect import isawaitable
from pathlib import Path
from typing import Any
from typing import Callable
Expand Down Expand Up @@ -222,7 +224,9 @@ def __init__(self, parent, invokeid: str):

def send_to_parent(self, event: str, **data):
"""Send an event to the parent machine's external queue."""
self.parent.send(event, _invokeid=self.invokeid, **data)
result = self.parent.send(event, _invokeid=self.invokeid, **data)
if isawaitable(result):
asyncio.ensure_future(result)


# Verify protocol compliance at import time
Expand Down
9 changes: 7 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,13 @@ def _check_leaked_threads():
if not new_threads:
return

# Filter out asyncio event loop threads (managed by pytest-asyncio, not by us).
new_threads = {t for t in new_threads if not t.name.startswith("asyncio_")}
# Filter out asyncio event loop threads (managed by pytest-asyncio, not by us)
# and DummyThreads (created by Python for foreign threads — cannot be joined).
new_threads = {
t
for t in new_threads
if not t.name.startswith("asyncio_") and not isinstance(t, threading._DummyThread)
}
if not new_threads:
return

Expand Down
2 changes: 1 addition & 1 deletion tests/test_invoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ def task_b():
return "b"

class SM(StateChart):
loading = State(initial=True, invoke=[task_a, task_b])
loading = State(initial=True, invoke=invoke_group(task_a, task_b))
ready = State(final=True)
done_invoke_loading = loading.to(ready)

Expand Down
8 changes: 6 additions & 2 deletions tests/test_scxml_units.py
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,9 @@ def test_sends_error_communication_when_child_not_found(self):

_send_to_invoke(action, "unknown", machine=machine)

machine.send.assert_called_once_with("error.communication", internal=True)
machine._put_nonblocking.assert_called_once()
trigger_data = machine._put_nonblocking.call_args[0][0]
assert str(trigger_data.event) == "error.communication"

def test_evaluates_eventexpr(self):
"""_send_to_invoke evaluates eventexpr when event is None."""
Expand Down Expand Up @@ -610,7 +612,9 @@ def test_send_action_callable_scxml_session_target(self):

send_callable(machine=machine)

machine.send.assert_called_once_with("error.communication", internal=True)
machine._put_nonblocking.assert_called_once()
trigger_data = machine._put_nonblocking.call_args[0][0]
assert str(trigger_data.event) == "error.communication"
machine._engine._invoke_manager.send_to_child.assert_not_called()


Expand Down
Loading
Loading