From 95eb4603a038b7b3c63e43ee6630ab27036b918e Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sat, 21 Feb 2026 01:36:08 -0300 Subject: [PATCH 1/7] chore: eliminate ~1900 test warnings by upgrading deps and fixing deprecated usage - Bump pytest-asyncio >=0.25.0 and pytest-mock >=3.14.0 to fix ~1800 asyncio DeprecationWarnings on Python 3.14 - Remove deprecated `path` parameter from pytest_ignore_collect (PytestRemovedIn9Warning) - Add filterwarnings for PytestBenchmarkWarning (expected with xdist) - Migrate test_weighted_transitions.py from deprecated `current_state` to `configuration` - Convert release notes 2.3/2.4/2.5 doctests using `current_state` to plain python blocks --- AGENTS.md | 4 + conftest.py | 4 +- docs/releases/2.3.0.md | 28 +++-- docs/releases/2.4.0.md | 40 ++++---- docs/releases/2.5.0.md | 91 +++++++---------- pyproject.toml | 7 +- tests/test_weighted_transitions.py | 28 ++--- uv.lock | 158 +++++++++++------------------ 8 files changed, 154 insertions(+), 206 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e3321431..ad23b2d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`: diff --git a/conftest.py b/conftest.py index 5b0a38fa..2ae7e7bd 100644 --- a/conftest.py +++ b/conftest.py @@ -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 diff --git a/docs/releases/2.3.0.md b/docs/releases/2.3.0.md index 57cb9278..83bb0901 100644 --- a/docs/releases/2.3.0.md +++ b/docs/releases/2.3.0.md @@ -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') ``` diff --git a/docs/releases/2.4.0.md b/docs/releases/2.4.0.md index d2f776bb..8054af36 100644 --- a/docs/releases/2.4.0.md +++ b/docs/releases/2.4.0.md @@ -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} diff --git a/docs/releases/2.5.0.md b/docs/releases/2.5.0.md index fbdb8b01..8cfe5840 100644 --- a/docs/releases/2.5.0.md +++ b/docs/releases/2.5.0.md @@ -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 @@ -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} diff --git a/pyproject.toml b/pyproject.toml index 39b24f15..8e126525 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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'", @@ -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 diff --git a/tests/test_weighted_transitions.py b/tests/test_weighted_transitions.py index f3cd55f1..d2a612cb 100644 --- a/tests/test_weighted_transitions.py +++ b/tests/test_weighted_transitions.py @@ -22,20 +22,20 @@ class TestWeightedTransitionsBasic: def test_deterministic_with_seed(self, WeightedIdleSC): sm = WeightedIdleSC() sm.send("idle") - first_state = sm.current_state + first_config = sm.configuration sm.send("finish") sm.send("idle") - second_state = sm.current_state + second_config = sm.configuration # With seed=42, results are deterministic # Create a fresh instance to verify same seed produces same sequence sm2 = WeightedIdleSC() sm2.send("idle") - assert sm2.current_state == first_state + assert sm2.configuration == first_config sm2.send("finish") sm2.send("idle") - assert sm2.current_state == second_state + assert sm2.configuration == second_config def test_statistical_distribution(self, WeightedIdleSC): """Over many iterations, the distribution should approximate the weights.""" @@ -45,7 +45,7 @@ def test_statistical_distribution(self, WeightedIdleSC): for _ in range(iterations): sm.send("idle") - counts[sm.current_state.id] += 1 + counts[next(iter(sm.configuration)).id] += 1 sm.send("finish") # With 70/20/10 weights, check roughly correct distribution (within 5%) @@ -63,7 +63,7 @@ class SingleWeighted(StateChart): sm = SingleWeighted() sm.send("go") - assert sm.current_state == SingleWeighted.s2 + assert sm.configuration == {SingleWeighted.s2} def test_equal_weights(self): class EqualWeights(StateChart): @@ -80,7 +80,7 @@ class EqualWeights(StateChart): for _ in range(iterations): sm.send("go") - counts[sm.current_state.id] += 1 + counts[next(iter(sm.configuration)).id] += 1 sm.send("back") # Should be roughly 50/50 within 5% @@ -102,7 +102,7 @@ class FloatWeights(StateChart): for _ in range(iterations): sm.send("go") - counts[sm.current_state.id] += 1 + counts[next(iter(sm.configuration)).id] += 1 sm.send("back") assert abs(counts["s2"] / iterations - 0.70) < 0.05 @@ -119,7 +119,7 @@ class MixedWeights(StateChart): sm = MixedWeights() sm.send("go") - assert sm.current_state in (MixedWeights.s2, MixedWeights.s3) + assert sm.configuration & {MixedWeights.s2, MixedWeights.s3} class TestWeightedTransitionsWithGuards: @@ -147,7 +147,7 @@ def is_allowed(self): counts = Counter() for _ in range(1000): sm.send("go") - counts[sm.current_state.id] += 1 + counts[next(iter(sm.configuration)).id] += 1 sm.send("back") assert counts["s2"] > 0 @@ -175,7 +175,7 @@ def is_blocked(self): # When not blocked, s2 can fire sm.send("go") - first_state = sm.current_state + first_state = next(iter(sm.configuration)) sm.send("back") # When blocked, s2 cond fails even if weight selects it @@ -184,7 +184,7 @@ def is_blocked(self): for _ in range(100): try: sm.send("go") - results[sm.current_state.id] += 1 + results[next(iter(sm.configuration)).id] += 1 sm.send("back") except Exception: results["failed"] += 1 @@ -427,12 +427,12 @@ class MultiGroup(StateChart): sm = MultiGroup() sm.send("go_a") - state_a = sm.current_state + state_a = next(iter(sm.configuration)) assert state_a in (MultiGroup.s2, MultiGroup.s3) sm.send("back") sm.send("go_b") - state_b = sm.current_state + state_b = next(iter(sm.configuration)) assert state_b in (MultiGroup.s4, MultiGroup.s5) diff --git a/uv.lock b/uv.lock index c72653e5..496ec66d 100644 --- a/uv.lock +++ b/uv.lock @@ -22,8 +22,7 @@ dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "sniffio" }, - { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422 } wheels = [ @@ -35,7 +34,7 @@ name = "asgiref" version = "3.8.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186 } wheels = [ @@ -51,6 +50,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/20/bc79bc575ba2e2a7f70e8a1155618bb1301eaa5132a8271373a6903f73f8/babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", size = 9587599 }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313 }, +] + [[package]] name = "beautifulsoup4" version = "4.12.3" @@ -571,7 +579,7 @@ resolution-markers = [ dependencies = [ { name = "mypy-extensions", marker = "python_full_version < '3.10'" }, { name = "tomli", marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/28/d8a8233ff167d06108e53b7aefb4a8d7350adbbf9d7abd980f17fdb7a3a6/mypy-1.4.1.tar.gz", hash = "sha256:9bbcd9ab8ea1f2e1c8031c21445b511442cc45c89951e49bbf852cbb70755b1b", size = 2855162 } wheels = [ @@ -603,7 +611,7 @@ resolution-markers = [ dependencies = [ { name = "mypy-extensions", marker = "python_full_version >= '3.10'" }, { name = "tomli", marker = "python_full_version == '3.10.*'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b9/eb/2c92d8ea1e684440f54fa49ac5d9a5f19967b7b472a281f419e69a8d228e/mypy-1.14.1.tar.gz", hash = "sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6", size = 3216051 } wheels = [ @@ -819,25 +827,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/16/70be3b725073035aa5fc3229321d06e22e73e3e09f6af78dcfdf16c7636c/platformdirs-4.0.0-py3-none-any.whl", hash = "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b", size = 17562 }, ] -[[package]] -name = "pluggy" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/8a/42/8f2833655a29c4e9cb52ee8a2be04ceac61bcff4a680fb338cbd3d1e322d/pluggy-1.2.0.tar.gz", hash = "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3", size = 61613 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/32/4a79112b8b87b21450b066e102d6608907f4c885ed7b04c3fdb085d4d6ae/pluggy-1.2.0-py3-none-any.whl", hash = "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849", size = 17695 }, -] - [[package]] name = "pluggy" version = "1.5.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, @@ -914,8 +907,7 @@ version = "1.1.408" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, - { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578 } wheels = [ @@ -924,55 +916,53 @@ wheels = [ [[package]] name = "pytest" -version = "7.4.4" +version = "8.3.3" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, - { name = "iniconfig", marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pluggy", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/1f/9d8e98e4133ffb16c90f3b405c43e38d3abb715bb5d7a63a5a684f7e46a3/pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280", size = 1357116 } +sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8", size = 325287 }, + { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, ] [[package]] -name = "pytest" -version = "8.3.3" +name = "pytest-asyncio" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.10'", + "python_full_version < '3.10'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "iniconfig", marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "backports-asyncio-runner", marker = "python_full_version < '3.10'" }, + { name = "pytest", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119 } wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095 }, ] [[package]] name = "pytest-asyncio" -version = "0.21.2" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] dependencies = [ - { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "8.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" }, + { name = "pytest", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/53/57663d99acaac2fcdafdc697e52a9b1b7d6fcf36616281ff9768a44e7ff3/pytest_asyncio-0.21.2.tar.gz", hash = "sha256:d67738fc232b94b326b9d060750beb16e0074210b98dd8b58a5239fa2a154f45", size = 30656 } +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087 } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/ce/1e4b53c213dce25d6e8b163697fbce2d43799d76fa08eea6ad270451c370/pytest_asyncio-0.21.2-py3-none-any.whl", hash = "sha256:ab664c88bb7998f711d8039cacd4884da6430886ae8bbd4eded552ed2004f16b", size = 13368 }, + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 }, ] [[package]] @@ -984,7 +974,7 @@ resolution-markers = [ ] dependencies = [ { name = "py-cpuinfo", marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/28/08/e6b0067efa9a1f2a1eb3043ecd8a0c48bfeb60d3255006dcc829d72d5da2/pytest-benchmark-4.0.0.tar.gz", hash = "sha256:fb0785b83efe599a6a956361c0691ae1dbb5318018561af10f3e915caa0048d1", size = 334641 } wheels = [ @@ -1000,7 +990,7 @@ resolution-markers = [ ] dependencies = [ { name = "py-cpuinfo", marker = "python_full_version >= '3.10'" }, - { name = "pytest", version = "8.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest", marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/39/d0/a8bd08d641b393db3be3819b03e2d9bb8760ca8479080a26a5f6e540e99c/pytest-benchmark-5.1.0.tar.gz", hash = "sha256:9ea661cdc292e8231f7cd4c10b0319e56a2118e2c09d9f50e1b3d150d2aca105", size = 337810 } wheels = [ @@ -1013,10 +1003,8 @@ version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "coverage", extra = ["toml"] }, - { name = "pluggy", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "8.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pluggy" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328 } wheels = [ @@ -1028,8 +1016,7 @@ name = "pytest-django" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "8.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/c0/43c8b2528c24d7f1a48a47e3f7381f5ab2ae8c64634b0c3f4bd843063955/pytest_django-4.9.0.tar.gz", hash = "sha256:8bf7bc358c9ae6f6fc51b6cebb190fe20212196e6807121f11bd6a3b03428314", size = 84067 } wheels = [ @@ -1038,15 +1025,14 @@ wheels = [ [[package]] name = "pytest-mock" -version = "3.11.1" +version = "3.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "8.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/2d/b3a811ec4fa24190a9ec5013e23c89421a7916167c6240c31fdc445f850c/pytest-mock-3.11.1.tar.gz", hash = "sha256:7f6b125602ac6d743e523ae0bfa71e1a697a2f5534064528c6ff84c2f7c2fc7f", size = 31251 } +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036 } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/85/80ae98e019a429445bfb74e153d4cb47c3695e3e908515e95e95c18237e5/pytest_mock-3.11.1-py3-none-any.whl", hash = "sha256:21c279fff83d70763b05f8874cc9cfb3fcacd6d354247a976f9529d19f9acf39", size = 9590 }, + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095 }, ] [[package]] @@ -1055,8 +1041,7 @@ version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, - { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "8.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest" }, { name = "termcolor" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/ac/5754f5edd6d508bc6493bc37d74b928f102a5fff82d9a80347e180998f08/pytest-sugar-1.0.0.tar.gz", hash = "sha256:6422e83258f5b0c04ce7c632176c7732cab5fdb909cb39cca5c9139f81276c0a", size = 14992 } @@ -1069,8 +1054,7 @@ name = "pytest-timeout" version = "2.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "8.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973 } wheels = [ @@ -1083,8 +1067,7 @@ version = "3.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "execnet" }, - { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "8.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069 } wheels = [ @@ -1114,9 +1097,9 @@ dev = [ { name = "pre-commit" }, { name = "pydot" }, { name = "pyright" }, - { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "pytest", version = "8.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytest-asyncio" }, + { name = "pytest" }, + { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest-benchmark", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest-benchmark", version = "5.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest-cov" }, @@ -1148,12 +1131,12 @@ dev = [ { name = "pydot" }, { name = "pyright", specifier = ">=1.1.400" }, { name = "pytest" }, - { name = "pytest-asyncio" }, + { name = "pytest-asyncio", specifier = ">=0.25.0" }, { name = "pytest-benchmark", specifier = ">=4.0.0" }, { name = "pytest-cov", marker = "python_full_version < '3.9'" }, { name = "pytest-cov", marker = "python_full_version >= '3.9'", specifier = ">=6.0.0" }, { name = "pytest-django", marker = "python_full_version >= '3.9'", specifier = ">=4.8.0" }, - { name = "pytest-mock", specifier = ">=3.10.0" }, + { name = "pytest-mock", specifier = ">=3.14.0" }, { name = "pytest-sugar", specifier = ">=1.0.0" }, { name = "pytest-timeout", specifier = ">=2.3.1" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, @@ -1225,8 +1208,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, - { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149 } wheels = [ @@ -1441,7 +1423,7 @@ resolution-markers = [ ] dependencies = [ { name = "anyio", marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/8b/d0/0332bd8a25779a0e2082b0e179805ad39afad642938b371ae0882e7f880d/starlette-0.47.0.tar.gz", hash = "sha256:1f64887e94a447fed5f23309fb6890ef23349b7e478faa7b24a851cd4eb844af", size = 2582856 } wheels = [ @@ -1457,7 +1439,7 @@ resolution-markers = [ ] dependencies = [ { name = "anyio", marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031 } wheels = [ @@ -1482,25 +1464,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 }, ] -[[package]] -name = "typing-extensions" -version = "4.7.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/3c/8b/0111dd7d6c1478bf83baa1cab85c686426c7a6274119aceb2bd9d35395ad/typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2", size = 72876 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/6b/63cc3df74987c36fe26157ee12e09e8f9db4de771e0f3404263117e75b95/typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36", size = 33232 }, -] - [[package]] name = "typing-extensions" version = "4.13.2" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } wheels = [ { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, @@ -1531,8 +1498,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, - { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/fc/1d785078eefd6945f3e5bab5c076e4230698046231eb0f3747bc5c8fa992/uvicorn-0.32.0.tar.gz", hash = "sha256:f78b36b143c16f54ccdb8190d0a26b5f1901fe5a3c777e1ab29f26391af8551e", size = 77564 } wheels = [ @@ -1567,7 +1533,7 @@ dependencies = [ { name = "distlib", marker = "python_full_version >= '3.10'" }, { name = "filelock", version = "3.20.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "platformdirs", marker = "python_full_version >= '3.10'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, + { name = "typing-extensions", marker = "python_full_version == '3.10.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239 } wheels = [ From 2347a325234910bd1e78e7c40e2c1257420aea29 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sat, 21 Feb 2026 07:24:57 -0300 Subject: [PATCH 2/7] fix: use BoundEvent.put() in engine error handlers to avoid unawaited coroutine warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The error handlers _on_error_handler() and _send_error_execution() used sm.send() which triggers _processing_loop() — creating an unawaited coroutine in AsyncEngine. Since these are always called within an active macrostep, BoundEvent.put() is sufficient to enqueue the error.execution event on the internal queue without the redundant processing loop call. --- statemachine/engines/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/statemachine/engines/base.py b/statemachine/engines/base.py index 0186926b..2a199a01 100644 --- a/statemachine/engines/base.py +++ b/statemachine/engines/base.py @@ -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 @@ -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: From a820fef33d50a9b207f2ced70ff1bf25aacd4142 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sat, 21 Feb 2026 07:32:03 -0300 Subject: [PATCH 3/7] fix: await async send() in invoke handlers to avoid unawaited coroutine warnings The async invoke handler (_run_async_handler) called sm.send() without await for done.invoke and error.execution events, creating unawaited coroutines from AsyncEngine.processing_loop(). --- statemachine/invoke.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/statemachine/invoke.py b/statemachine/invoke.py index 68eae851..9c775563 100644 --- a/statemachine/invoke.py +++ b/statemachine/invoke.py @@ -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, ) @@ -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( From 326863df6c733bc6f0f38392f8aa69691b3d86d6 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sat, 21 Feb 2026 07:48:16 -0300 Subject: [PATCH 4/7] fix: use BoundEvent.put() for error.communication in SCXML send actions Replace machine.send() with BoundEvent.put() for error.communication events in _send_to_invoke and create_send_action_callable. These are always called within the processing loop, so put() is sufficient and avoids unawaited coroutine warnings in AsyncEngine. --- statemachine/io/scxml/actions.py | 5 +++-- tests/test_scxml_units.py | 8 ++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/statemachine/io/scxml/actions.py b/statemachine/io/scxml/actions.py index 5b7cb5ca..9830f268 100644 --- a/statemachine/io/scxml/actions.py +++ b/statemachine/io/scxml/actions.py @@ -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 @@ -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 @@ -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("#_"): # #_ → route to invoked child session _send_to_invoke(action, target[2:], **kwargs) diff --git a/tests/test_scxml_units.py b/tests/test_scxml_units.py index 31287403..c3a467d1 100644 --- a/tests/test_scxml_units.py +++ b/tests/test_scxml_units.py @@ -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.""" @@ -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() From fc3846a1f4b20f730470141e5065cf36a8b2e096 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sat, 21 Feb 2026 07:59:08 -0300 Subject: [PATCH 5/7] fix: schedule unawaited coroutine in send_to_parent with ensure_future When the child machine runs inside the parent's async event loop, send_to_parent returns an unawaited coroutine from AsyncEngine's processing_loop. Use asyncio.ensure_future to schedule it as a task on the running loop instead of dropping it. --- statemachine/io/scxml/invoke.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/statemachine/io/scxml/invoke.py b/statemachine/io/scxml/invoke.py index 1b4b355b..436c1bf2 100644 --- a/statemachine/io/scxml/invoke.py +++ b/statemachine/io/scxml/invoke.py @@ -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 @@ -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 From dce46428f355b7abee9562900049e78b7b9f15c4 Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sat, 21 Feb 2026 08:13:02 -0300 Subject: [PATCH 6/7] fix: skip DummyThread in leaked-thread check (Python 3.9-3.11 compat) DummyThread instances (created by Python for foreign threads) raise AssertionError on join() in Python <3.12. Filter them out alongside asyncio threads since they are not leaked by test code. --- tests/conftest.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 9c5e83cd..052e4df1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 From e7812d99e4add99770e962c0d7dd14f884cc96db Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Sat, 21 Feb 2026 08:40:05 -0300 Subject: [PATCH 7/7] fix(test): use invoke_group in test_multiple_invokes to avoid race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With invoke=[task_a, task_b], each task is a separate invocation that sends its own done.invoke — the first one triggers the transition and cancels the second. Using invoke_group ensures both complete before a single done.invoke is sent. --- tests/test_invoke.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_invoke.py b/tests/test_invoke.py index 87d6fa20..54d35249 100644 --- a/tests/test_invoke.py +++ b/tests/test_invoke.py @@ -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)