|
1 | 1 | """Tests for the invoke callback group.""" |
2 | 2 |
|
| 3 | +import asyncio |
3 | 4 | import threading |
4 | 5 | import time |
5 | 6 |
|
| 7 | +import pytest |
6 | 8 | from statemachine.invoke import IInvoke |
7 | 9 | from statemachine.invoke import InvokeContext |
8 | 10 | from statemachine.invoke import invoke_group |
9 | 11 |
|
10 | 12 | from statemachine import Event |
11 | 13 | from statemachine import State |
12 | 14 | from statemachine import StateChart |
| 15 | +from statemachine import StateMachine |
13 | 16 |
|
14 | 17 |
|
15 | 18 | class TestInvokeSimpleCallable: |
@@ -295,6 +298,152 @@ def on_enter_error_state(self, **kwargs): |
295 | 298 | assert "error_state" in sm.configuration_values |
296 | 299 |
|
297 | 300 |
|
| 301 | +@pytest.fixture() |
| 302 | +def thread_errors(monkeypatch) -> "list[BaseException]": |
| 303 | + errors: "list[BaseException]" = [] |
| 304 | + monkeypatch.setattr(threading, "excepthook", lambda args: errors.append(args.exc_value)) |
| 305 | + return errors |
| 306 | + |
| 307 | + |
| 308 | +async def finish_invocation( |
| 309 | + sm, release: threading.Event, thread_errors: "list[BaseException]" |
| 310 | +) -> "list[BaseException]": |
| 311 | + """Let the only invocation finish and return the errors its thread or task reported.""" |
| 312 | + (invocation,) = sm._engine._invoke_manager._active.values() |
| 313 | + if invocation.task is None: |
| 314 | + release.set() |
| 315 | + invocation.thread.join(timeout=5) |
| 316 | + return thread_errors |
| 317 | + loop_errors: "list[BaseException]" = [] |
| 318 | + loop = asyncio.get_running_loop() |
| 319 | + previous_handler = loop.get_exception_handler() |
| 320 | + loop.set_exception_handler(lambda _loop, context: loop_errors.append(context["exception"])) |
| 321 | + try: |
| 322 | + release.set() |
| 323 | + await asyncio.wait([invocation.task], timeout=5) |
| 324 | + finally: |
| 325 | + loop.set_exception_handler(previous_handler) |
| 326 | + assert invocation.task.exception() is None |
| 327 | + return loop_errors |
| 328 | + |
| 329 | + |
| 330 | +class TestInvokeCompletionProcessingErrors: |
| 331 | + """An error raised while the invocation itself processes its ``done.invoke`` is not lost. |
| 332 | +
|
| 333 | + The invocation finishes after the machine settled, so its own thread or task processes |
| 334 | + ``done.invoke`` and the error has no other caller to reach. |
| 335 | + """ |
| 336 | + |
| 337 | + async def test_error_leaving_the_state_propagates(self, sm_runner, thread_errors): |
| 338 | + release = threading.Event() |
| 339 | + |
| 340 | + class SM(StateMachine): |
| 341 | + s1 = State(initial=True) |
| 342 | + s2 = State(final=True) |
| 343 | + done_invoke_s1 = s1.to(s2, on="boom") |
| 344 | + |
| 345 | + def on_invoke_s1(self): |
| 346 | + release.wait(timeout=5) |
| 347 | + |
| 348 | + def boom(self): |
| 349 | + raise ValueError("boom") |
| 350 | + |
| 351 | + sm = await sm_runner.start(SM) |
| 352 | + errors = await finish_invocation(sm, release, thread_errors) |
| 353 | + |
| 354 | + assert [repr(e) for e in errors] == [repr(ValueError("boom"))] |
| 355 | + assert sm.configuration_values == {"s1"} |
| 356 | + |
| 357 | + async def test_error_staying_in_the_state_propagates(self, sm_runner, thread_errors): |
| 358 | + release = threading.Event() |
| 359 | + |
| 360 | + class SM(StateMachine): |
| 361 | + s1 = State(initial=True) |
| 362 | + s2 = State(final=True) |
| 363 | + done_invoke_s1 = s1.to.itself(internal=True, on="boom") |
| 364 | + error_execution = s1.to(s2) |
| 365 | + |
| 366 | + def on_invoke_s1(self): |
| 367 | + release.wait(timeout=5) |
| 368 | + |
| 369 | + def boom(self): |
| 370 | + raise ValueError("boom") |
| 371 | + |
| 372 | + sm = await sm_runner.start(SM) |
| 373 | + errors = await finish_invocation(sm, release, thread_errors) |
| 374 | + |
| 375 | + assert [repr(e) for e in errors] == [repr(ValueError("boom"))] |
| 376 | + assert sm.configuration_values == {"s1"} |
| 377 | + |
| 378 | + async def test_error_leaving_the_state_becomes_error_execution(self, sm_runner, thread_errors): |
| 379 | + release = threading.Event() |
| 380 | + |
| 381 | + class SM(StateChart): |
| 382 | + s1 = State(initial=True) |
| 383 | + s2 = State() |
| 384 | + handled = State(final=True) |
| 385 | + done_invoke_s1 = s1.to(s2, on="boom") |
| 386 | + error_execution = s2.to(handled) |
| 387 | + |
| 388 | + def on_invoke_s1(self): |
| 389 | + release.wait(timeout=5) |
| 390 | + |
| 391 | + def boom(self): |
| 392 | + raise ValueError("boom") |
| 393 | + |
| 394 | + sm = await sm_runner.start(SM) |
| 395 | + errors = await finish_invocation(sm, release, thread_errors) |
| 396 | + |
| 397 | + assert errors == [] |
| 398 | + assert sm.configuration_values == {"handled"} |
| 399 | + |
| 400 | + |
| 401 | +class FailingFinalize: |
| 402 | + def __init__(self, release: threading.Event): |
| 403 | + self.release = release |
| 404 | + |
| 405 | + def run(self, ctx): |
| 406 | + self.release.wait(timeout=5) |
| 407 | + |
| 408 | + def on_finalize(self, trigger_data): |
| 409 | + raise ValueError("finalize") |
| 410 | + |
| 411 | + |
| 412 | +class TestInvokeFinalizeErrors: |
| 413 | + """An error raised by ``on_finalize`` is handled like an error in any other step.""" |
| 414 | + |
| 415 | + async def test_finalize_error_becomes_error_execution(self, sm_runner, thread_errors): |
| 416 | + release = threading.Event() |
| 417 | + |
| 418 | + class SM(StateChart): |
| 419 | + s1 = State(initial=True, invoke=FailingFinalize(release)) |
| 420 | + s2 = State() |
| 421 | + handled = State(final=True) |
| 422 | + done_invoke_s1 = s1.to(s2) |
| 423 | + error_execution = s2.to(handled) |
| 424 | + |
| 425 | + sm = await sm_runner.start(SM) |
| 426 | + errors = await finish_invocation(sm, release, thread_errors) |
| 427 | + |
| 428 | + assert errors == [] |
| 429 | + assert sm.configuration_values == {"handled"} |
| 430 | + |
| 431 | + async def test_finalize_error_propagates(self, sm_runner, thread_errors): |
| 432 | + release = threading.Event() |
| 433 | + |
| 434 | + class SM(StateMachine): |
| 435 | + s1 = State(initial=True, invoke=FailingFinalize(release)) |
| 436 | + s2 = State(final=True) |
| 437 | + done_invoke_s1 = s1.to(s2) |
| 438 | + error_execution = s1.to(s2) |
| 439 | + |
| 440 | + sm = await sm_runner.start(SM) |
| 441 | + errors = await finish_invocation(sm, release, thread_errors) |
| 442 | + |
| 443 | + assert [repr(e) for e in errors] == [repr(ValueError("finalize"))] |
| 444 | + assert sm.configuration_values == {"s1"} |
| 445 | + |
| 446 | + |
298 | 447 | class TestInvokeMultiple: |
299 | 448 | """Multiple invokes per state — all run concurrently.""" |
300 | 449 |
|
|
0 commit comments