Browse Source

Provide tests for run.io_bound and run.cpu_bound (#2234)

* provide tests for run.io_bound and run.cpu_bound

* fix import

* change test fixture from screen to fixture

* add test to ensure handling of unpickable exceptions in run.cpu_bound

* ensure we do not break the process pool with problematic exceptions

* code review

---------

Co-authored-by: Falko Schindler <falko@zauberzeug.com>
Rodja Trappe 9 tháng trước cách đây
mục cha
commit
d979476d87
2 tập tin đã thay đổi với 102 bổ sung1 xóa
  1. 30 1
      nicegui/run.py
  2. 72 0
      tests/test_run.py

+ 30 - 1
nicegui/run.py

@@ -1,5 +1,6 @@
 import asyncio
 import asyncio
 import sys
 import sys
+import traceback
 from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
 from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
 from functools import partial
 from functools import partial
 from typing import Any, Callable, TypeVar
 from typing import Any, Callable, TypeVar
@@ -15,6 +16,34 @@ P = ParamSpec('P')
 R = TypeVar('R')
 R = TypeVar('R')
 
 
 
 
+class SubprocessException(Exception):
+    """A picklable exception to represent exceptions raised in subprocesses."""
+
+    def __init__(self, original_type, original_message, original_traceback) -> None:
+        self.original_type = original_type
+        self.original_message = original_message
+        self.original_traceback = original_traceback
+        super().__init__(f'{original_type}: {original_message}')
+
+    def __reduce__(self):
+        return (SubprocessException, (self.original_type, self.original_message, self.original_traceback))
+
+    def __str__(self):
+        return (f'Exception in subprocess:\n'
+                f'  Type: {self.original_type}\n'
+                f'  Message: {self.original_message}\n'
+                f'  {self.original_traceback}')
+
+
+def safe_callback(callback: Callable, *args, **kwargs) -> Any:
+    """Run a callback; catch and wrap any exceptions that might occur."""
+    try:
+        return callback(*args, **kwargs)
+    except Exception as e:
+        # NOTE: we do not want to pass the original exception because it might be unpicklable
+        raise SubprocessException(type(e).__name__, str(e), traceback.format_exc()) from None
+
+
 async def _run(executor: Any, callback: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
 async def _run(executor: Any, callback: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
     if core.app.is_stopping:
     if core.app.is_stopping:
         return  # type: ignore  # the assumption is that the user's code no longer cares about this value
         return  # type: ignore  # the assumption is that the user's code no longer cares about this value
@@ -37,7 +66,7 @@ async def cpu_bound(callback: Callable[P, R], *args: P.args, **kwargs: P.kwargs)
     It is encouraged to create static methods (or free functions) which get all the data as simple parameters (eg. no class/ui logic)
     It is encouraged to create static methods (or free functions) which get all the data as simple parameters (eg. no class/ui logic)
     and return the result (instead of writing it in class properties or global variables).
     and return the result (instead of writing it in class properties or global variables).
     """
     """
-    return await _run(process_pool, callback, *args, **kwargs)
+    return await _run(process_pool, safe_callback, callback, *args, **kwargs)
 
 
 
 
 async def io_bound(callback: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
 async def io_bound(callback: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:

+ 72 - 0
tests/test_run.py

@@ -0,0 +1,72 @@
+import asyncio
+import time
+from typing import Awaitable, Generator
+
+import pytest
+
+from nicegui import app, run, ui
+from nicegui.testing import User
+
+
+@pytest.fixture(scope='module', autouse=True)
+def check_blocking_ui() -> Generator[None, None, None]:
+    """This fixture ensures that we see a warning if the UI is blocked for too long.
+
+    The warning would then automatically fail the test.
+    """
+    def configure() -> None:
+        loop = asyncio.get_running_loop()
+        loop.set_debug(True)
+        loop.slow_callback_duration = 0.02
+    app.on_startup(configure)
+    yield
+
+
+def delayed_hello() -> str:
+    """Test function that blocks for 1 second."""
+    time.sleep(1)
+    return 'hello'
+
+
+@pytest.mark.parametrize('func', [run.cpu_bound, run.io_bound])
+async def test_delayed_hello(user: User, func: Awaitable):
+    @ui.page('/')
+    async def index():
+        ui.label(await func(delayed_hello))
+
+    await user.open('/')
+    await user.should_see('hello')
+
+
+async def test_run_unpickable_exception_in_cpu_bound_callback(user: User):
+    class UnpicklableException(Exception):
+        def __reduce__(self):
+            raise NotImplementedError('This local object cannot be pickled')
+
+    def raise_unpicklable_exception():
+        raise UnpicklableException('test')
+
+    @ui.page('/')
+    async def index():
+        with pytest.raises(AttributeError, match="Can't pickle local object"):
+            ui.label(await run.cpu_bound(raise_unpicklable_exception))
+
+    await user.open('/')
+
+
+class ExceptionWithSuperParameter(Exception):
+    def __init__(self) -> None:
+        super().__init__('some parameter which does not appear in the custom exceptions init')
+
+
+def raise_exception_with_super_parameter():
+    raise ExceptionWithSuperParameter()
+
+
+async def test_run_cpu_bound_function_which_raises_problematic_exception(user: User):
+    @ui.page('/')
+    async def index():
+        with pytest.raises(run.SubprocessException, match='some parameter which does not appear in the custom exceptions init'):
+            ui.label(await run.cpu_bound(raise_exception_with_super_parameter))
+
+    await user.open('/')