diff --git a/docs/backends.rst b/docs/backends.rst index 7738a63e..b01222df 100644 --- a/docs/backends.rst +++ b/docs/backends.rst @@ -297,7 +297,7 @@ An example using PyScript (which uses Pyodide):
- @@ -335,7 +335,6 @@ An example using Pyodide directly: await pyodide.loadPackage("micropip"); const micropip = pyodide.pyimport("micropip"); await micropip.install("numpy"); - await micropip.install("sniffio"); await micropip.install("rendercanvas"); // have to call as runPythonAsync pyodide.runPythonAsync(pythonCode); diff --git a/docs/start.rst b/docs/start.rst index 85676ecf..04cf0f0e 100644 --- a/docs/start.rst +++ b/docs/start.rst @@ -132,7 +132,7 @@ If you like callbacks, ``loop.call_later()`` always works. If you like async, us If you make use of async functions (co-routines), and want to keep your code portable across different canvas backends, restrict your use of async features to ``sleep`` and ``Event``; these are the only features currently implemented in our async adapter utility. -We recommend importing these from :doc:`rendercanvas.utils.asyncs ` or use ``sniffio`` to detect the library that they can be imported from. +We recommend importing these from :doc:`rendercanvas.utils.asyncs ` if you want your code to be portable across different event loop backends. On the other hand, if you know your code always runs on the asyncio loop, you can fully make use of ``asyncio``. Ditto for Trio. diff --git a/docs/static/_pyodide_iframe.html b/docs/static/_pyodide_iframe.html index 6e9d8d09..34da006d 100644 --- a/docs/static/_pyodide_iframe.html +++ b/docs/static/_pyodide_iframe.html @@ -22,7 +22,6 @@

Loading...

let pyodide = await loadPyodide(); await pyodide.loadPackage("micropip"); const micropip = pyodide.pyimport("micropip"); - await micropip.install('sniffio'); await micropip.install('numpy'); // The below loads rendercanvas from pypi. But we will replace it with the name of the wheel, // so that it's loaded from the docs (in _static). diff --git a/examples/pyscript.html b/examples/pyscript.html index e99fbb1e..d888dd57 100644 --- a/examples/pyscript.html +++ b/examples/pyscript.html @@ -31,7 +31,7 @@

Loading...


- diff --git a/examples/serve_browser_examples.py b/examples/serve_browser_examples.py index aaf1ac6d..5fc81da7 100644 --- a/examples/serve_browser_examples.py +++ b/examples/serve_browser_examples.py @@ -104,7 +104,7 @@ def get_html_index(): diff --git a/pyproject.toml b/pyproject.toml index dc3a7e90..1162a476 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ keywords = [ "jupyter", ] requires-python = ">= 3.10" -dependencies = ["sniffio"] +dependencies = [] # Zero hard dependencies! [project.optional-dependencies] # For users jupyter = ["jupyter_rfb>=0.4.2"] diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 523d6815..6221ed5a 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -31,44 +31,66 @@ class BaseLoop: """The base class for an event-loop object. + The rendercanvas ``loop`` object is a proxy to a real event-loop. It abstracts away methods like ``run()``, + ``call_later``, ``call_soon_threadsafe()``, and more. + Canvas backends can implement their own loop subclass (like qt and wx do), but a canvas backend can also rely on one of multiple loop implementations (like glfw running on asyncio or trio). - The lifecycle states of a loop are: - - * off: the initial state, the subclass should probably not even import dependencies yet. - * ready: the first canvas is created, ``_rc_init()`` is called to get the loop ready for running. - * active: the loop is active (we detect it because our task is running), but we don't know how. - * interactive: the loop is inter-active in e.g. an IDE, reported by the backend. - * running: the loop is running via ``_rc_run()`` or ``_rc_run_async()``. - - Notes: + In the majority of use-cases, users don't need to know much about the loop. It will typically + run once. In more complex scenario's the section below explains the working of the loop in more detail. + + **Details about loop lifetime** + + The rendercanvas loop object is a proxy, which has to support a variety of backends. + To realize this, it has the following lifetime model: + + * off: + * Entered when the loop is instantiated, and when the loop has stopped. + * This is the 'idle' state. + * The backend probably has not even imported dependencies yet. + * ready: + * Entered when the first canvas is created that is associated with this loop, or when a task is added. + * It is assumed that the loop will become active soon. + * This is when ``_rc_init()`` is called to get the backend ready for running. + * A special 'loop-task' is created (a coroutine, which is not yet running). + * running: + * Entered when ``loop.run()`` is called. + * The loop is now running. + * Signal handlers and asyncgen hooks are installed if applicable. + * interactive: + * Entered in ``_rc_init()`` when the backend detects that the loop is interactive. + * Example use-cases are a notebook or interactive IDE, usually via asyncio. + * This means there is a persistent native loop already running, which rendercanvas makes use of. + * active: + * Entered when the backend-loop starts running, but not via the loop's ``run()`` method. + * This is detected via the loop-task. + * Signal handlers and asyncgen hooks are installed if applicable. + * Detecting loop stopping occurs by the loop-task being cancelled. + + Notes related to starting and stopping: * The loop goes back to the "off" state once all canvases are closed. * Stopping the loop (via ``.stop()``) closes the canvases, which will then stop the loop. * From there it can go back to the ready state (which would call ``_rc_init()`` again). * In backends like Qt, the native loop can be started without us knowing: state "active". * In interactive settings like an IDE that runs an asyncio or Qt loop, the - loop can become "active" as soon as the first canvas is created. - - The lifecycle of this loop does not necessarily co-inside with the native loop's cycle: - - * The rendercanvas loop can be in the 'off' state while the native loop is running. - * When we stop the loop, the native loop likely runs slightly longer. - * When the loop is interactive (asyncio or Qt) the native loop keeps running when rendercanvas' loop stops. - * For async loops (asyncio or trio), the native loop may run before and after this loop. - * On Qt, we detect the app's aboutToQuit to stop this loop. - * On wx, we detect all windows closed to stop this loop. + loop becomes "interactive" as soon as the first canvas is created. + * The rendercanvas loop can be in the 'off' state while the native loop is running (especially for the 'interactive' case). + * On Qt, the app's 'aboutToQuit' signal is used to stop this loop. + * On wx, the loop is stopped when all windows are closed. """ def __init__(self): - self.__tasks = set() + self.__tasks = set() # only used by the async adapter self.__canvas_groups = set() self.__should_stop = 0 self.__state = LoopState.off self.__is_initialized = False + self.__hook_data = None + self.__using_adapter = False # set to True if using our asyncadapter self._asyncgens = weakref.WeakSet() # self._setup_debug_thread() @@ -143,6 +165,7 @@ async def wrapper(): self.__is_initialized = True self._rc_init() self._rc_add_task(wrapper, "loop-task") + self.__using_adapter = len(self.__tasks) > 0 async def _loop_task(self): # This task has multiple purposes: @@ -162,6 +185,10 @@ async def _loop_task(self): # because its minimized (applies to backends that implement # _rc_gui_poll). + # In some cases the task may run after the loop was closed + if self.__state == LoopState.off: + return + # The loop has started! self.__start() @@ -295,8 +322,7 @@ def run(self) -> None: self._ensure_initialized() - # Register interrupt handler - prev_sig_handlers = self.__setup_interrupt() + need_unregister = self.__setup_hooks() # Run. We could be in this loop for a long time. Or we can exit immediately if # the backend already has an (interactive) event loop and did not call _mark_as_interactive(). @@ -307,14 +333,15 @@ def run(self) -> None: # Mark state as not 'running', but also not to 'off', that happens elsewhere. if self.__state == LoopState.running: self.__state = LoopState.active - for sig, cb in prev_sig_handlers.items(): - signal.signal(sig, cb) + if need_unregister: + self.__restore_hooks() async def run_async(self) -> None: """ "Alternative to ``run()``, to enter the mainloop from a running async framework. Only supported by the asyncio and trio loops. """ + # Can we enter the loop? if self.__state in (LoopState.off, LoopState.ready): pass @@ -337,7 +364,7 @@ async def run_async(self) -> None: def stop(self, *, force=False) -> None: """Close all windows and stop the currently running event-loop. - If the loop is active but not running via our ``run()`` method, the loop + If the loop is active but not running via the ``run()`` method, the loop moves back to its off-state, but the underlying loop is not stopped. Normally, the windows are closed and the underlying event loop is given @@ -368,17 +395,47 @@ def stop(self, *, force=False) -> None: if len(canvases) == 0 or self.__should_stop >= 2: self.__stop() + def __setup_hooks(self): + """Setup asycgen hooks and interrupt hooks.""" + if self.__hook_data is not None: + return False + + # Setup asyncgen hooks + prev_asyncgen_hooks = self.__setup_asyncgen_hooks() + + # Set interrupts + prev_interrupt_hooks = self.__setup_interrupt_hooks() + + self.__hook_data = prev_asyncgen_hooks, prev_interrupt_hooks + return True + + def __restore_hooks(self): + """Unregister hooks.""" + + # This is separated from stop(), so that a loop can be 'active' by repeated calls to ``run()``, but will only + # actually have registered hooks while inside ``run()``. The StubLoop has this behavior, and it may be a bit silly + # to organize for this special use-case, but it does make it more clean/elegant, and maybe someday we will want another + # loop class that runs for short periods. This now works, even when another loop is running. + + if self.__hook_data is None: + return + + prev_asyncgen_hooks, prev_interrupt_hooks = self.__hook_data + self.__hook_data = None + + if prev_asyncgen_hooks is not None: + sys.set_asyncgen_hooks(*prev_asyncgen_hooks) + + for sig, cb in prev_interrupt_hooks.items(): + signal.signal(sig, cb) + def __start(self): """Move to running state.""" - # Update state, but leave 'interactive' and 'running' if self.__state in (LoopState.off, LoopState.ready): self.__state = LoopState.active - # Setup asyncgen hooks. This is done when we detect the loop starting, - # not in run(), because most event-loops will handle interrupts, while - # e.g. qt won't care about async generators. - self.__setup_asyncgen_hooks() + self.__setup_hooks() def __stop(self): """Move to the off-state.""" @@ -390,8 +447,6 @@ def __stop(self): self.__state = LoopState.off self.__should_stop = 0 - self.__finish_asyncgen_hooks() - # If we used the async adapter, cancel any tasks. If we could assume # that the backend processes pending events before actually shutting # down, we could only call .cancel(), and leave the event-loop to do the @@ -407,11 +462,20 @@ def __stop(self): # Note that backends that do not use the asyncadapter are responsible # for cancelling pending tasks. + self.__restore_hooks() + + # Cancel async gens + if len(self._asyncgens): + closing_agens = list(self._asyncgens) + self._asyncgens.clear() + for agen in closing_agens: + close_agen(agen) + # Tell the backend to stop the loop. This usually means it will stop # soon, but not *now*; remember that we're currently in a task as well. self._rc_stop() - def __setup_interrupt(self): + def __setup_interrupt_hooks(self): """Setup the interrupt handlers.""" def on_interrupt(sig, _frame): @@ -445,29 +509,20 @@ def __setup_asyncgen_hooks(self): # the generator in the user's code. Note that when a proper async # framework (asyncio or trio) is used, all of this does not apply; only # for the qt/wx/raw loop do we do this, an in these cases we don't - # expect fancy async stuff. - - current_asyncgen_hooks = sys.get_asyncgen_hooks() - if ( - current_asyncgen_hooks.firstiter is None - and current_asyncgen_hooks.finalizer is None - ): - sys.set_asyncgen_hooks( - firstiter=self._asyncgen_firstiter_hook, - finalizer=self._asyncgen_finalizer_hook, - ) - else: - # Assume that the hooks are from asyncio/trio on which this loop is running. - pass + # expect fancy async stuff. Oh, and the sleep and Event actually become no-ops when the + # asyncgen hooks are restored, so that error message should in theory never happen anyway. - def __finish_asyncgen_hooks(self): - sys.set_asyncgen_hooks(None, None) + # Only register hooks if we use the asyncadapter; async frameworks install their own hooks. + if not self.__using_adapter: + return None - if len(self._asyncgens): - closing_agens = list(self._asyncgens) - self._asyncgens.clear() - for agen in closing_agens: - close_agen(agen) + prev_asyncgen_hooks = sys.get_asyncgen_hooks() + sys.set_asyncgen_hooks( + firstiter=self._asyncgen_firstiter_hook, + finalizer=self._asyncgen_finalizer_hook, + ) + + return prev_asyncgen_hooks def _asyncgen_firstiter_hook(self, agen): self._asyncgens.add(agen) @@ -518,7 +573,7 @@ def _rc_stop(self): raise NotImplementedError() def _rc_add_task(self, async_func, name): - """Add an async task to the running loop. + """Add an async task to this loop. True async loop-backends (like asyncio and trio) should implement this. When they do, ``_rc_call_later`` is not used. diff --git a/rendercanvas/asyncio.py b/rendercanvas/asyncio.py index e54cb4e8..98117a62 100644 --- a/rendercanvas/asyncio.py +++ b/rendercanvas/asyncio.py @@ -6,8 +6,7 @@ __all__ = ["AsyncioLoop", "loop"] from .base import BaseLoop - -import sniffio +from .utils.asyncs import detect_current_async_lib class AsyncioLoop(BaseLoop): @@ -43,7 +42,7 @@ async def _rc_run_async(self): import asyncio # Protect against usage of wrong loop object - libname = sniffio.current_async_library() + libname = detect_current_async_lib() if libname != "asyncio": raise TypeError(f"Attempt to run AsyncioLoop with {libname}.") @@ -74,7 +73,8 @@ def _rc_stop(self): task = self.__tasks.pop() task.cancel() # is a no-op if the task is no longer running # Signal that we stopped - self._stop_event.set() + if self._stop_event is not None: + self._stop_event.set() self._stop_event = None self._run_loop = None # Note how we don't explicitly stop a loop, not the interactive loop, nor the running loop diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index 846ba376..3a40d312 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -119,7 +119,6 @@ def draw(self): This object can be converted to a numpy array (without copying data) using ``np.asarray(arr)``. """ - loop.process_tasks() # Little trick to keep the event loop going self._draw_frame_and_present() return self._last_image @@ -151,7 +150,7 @@ def _rc_init(self): # This gets called when the first canvas is created (possibly after having run and stopped before). pass - def process_tasks(self): + def _process_tasks(self): callbacks_to_run = [] new_callbacks = [] for etime, callback in self._callbacks: @@ -165,10 +164,15 @@ def process_tasks(self): callback() def _rc_run(self): - self.process_tasks() + # Only process tasks inside the run method. While inside ``run()``, the + # loop state is 'running' and its the current loop. If we'd process + # tasks outside the run method, the loop-task triggers, putting the loop + # in the 'active' mode, making it the current loop (via asyncgen hooks), + # and it will stay active until it's explicitly stopped. + self._process_tasks() def _rc_stop(self): - pass + self._callbacks.clear() def _rc_add_task(self, async_func, name): super()._rc_add_task(async_func, name) diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 6ce37237..a9a38c12 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -7,6 +7,7 @@ import sys import ctypes +import weakref import importlib @@ -234,7 +235,10 @@ def _rc_init(self): # when the app closes. So we explicitly detect the app-closing instead. # Note that we should not use app.setQuitOnLastWindowClosed(False), because we (may) rely on the # application's closing mechanic. - self._app.aboutToQuit.connect(lambda: self.stop(force=True)) + loop_ref = weakref.ref(self) + self._app.aboutToQuit.connect( + lambda: (loop := loop_ref()) and loop.stop(force=True) + ) if already_had_app_on_import: self._mark_as_interactive() self._callback_pool = set() diff --git a/rendercanvas/trio.py b/rendercanvas/trio.py index ee498023..d7e3e767 100644 --- a/rendercanvas/trio.py +++ b/rendercanvas/trio.py @@ -8,7 +8,7 @@ from .base import BaseLoop import trio -import sniffio +from .utils.asyncs import detect_current_async_lib class TrioLoop(BaseLoop): @@ -24,7 +24,7 @@ def _rc_run(self): async def _rc_run_async(self): # Protect against usage of wrong loop object - libname = sniffio.current_async_library() + libname = detect_current_async_lib() if libname != "trio": raise TypeError(f"Attempt to run TrioLoop with {libname}.") diff --git a/rendercanvas/utils/asyncadapter.py b/rendercanvas/utils/asyncadapter.py index be6d35a4..c68c3986 100644 --- a/rendercanvas/utils/asyncadapter.py +++ b/rendercanvas/utils/asyncadapter.py @@ -3,9 +3,15 @@ Intended for internal use, but is fully standalone. """ +import weakref import logging +import threading -from sniffio import thread_local as sniffio_thread_local +# Support sniffio for older wgpu releases, and for code that relies on sniffio. +try: + from sniffio import thread_local as sniffio_thread_local +except ImportError: + sniffio_thread_local = threading.local() logger = logging.getLogger("asyncadapter") @@ -68,13 +74,17 @@ def __init__(self, call_later_func, coro, name): self.name = name self.running = False self.cancelled = False + + # Trick to get a callback function that does not hold a ref to the task, and therefore not the loop (via the loop-task coro). + task_ref = weakref.ref(self) + self._step_cb = lambda: (task := task_ref()) and task.step() + self.call_step_later(0) def add_done_callback(self, callback): self._done_callbacks.append(callback) def _close(self): - self.loop = None self.coro = None for callback in self._done_callbacks: try: @@ -84,7 +94,7 @@ def _close(self): self._done_callbacks.clear() def call_step_later(self, delay): - self._call_later(delay, self.step) + self._call_later(delay, self._step_cb) def cancel(self): self.cancelled = True @@ -97,7 +107,9 @@ def step(self): result = None stop = False - old_name, sniffio_thread_local.name = sniffio_thread_local.name, __name__ + old_name = getattr(sniffio_thread_local, "name", None) + sniffio_thread_local.name = __name__ + self.running = True try: if self.cancelled: diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 38cf9a77..59b0506e 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -1,63 +1,121 @@ """ This module implements all async functionality that one can use in any ``rendercanvas`` backend. -This uses ``sniffio`` to detect the async framework in use. -To give an idea how to use ``sniffio`` to get a generic async sleep function: +To give an idea how to implement a generic async sleep function: .. code-block:: py - libname = sniffio.current_async_library() + libname = detect_current_async_lib() sleep = sys.modules[libname].sleep """ import sys -import sniffio - from .._coreutils import IS_WIN, call_later_from_thread USE_THREADED_TIMER = IS_WIN +# Below detection methods use ``sys.get_asyncgen_hooks()`` for fast and robust detection. +# Compared to sniffio, this is faster and also works when not inside a task. + + +def detect_current_async_lib(): + """Get the lib name of the currently active async lib, or None.""" + ob = sys.get_asyncgen_hooks()[0] + if ob is not None: + try: + libname = ob.__module__.partition(".")[0] + except AttributeError: + return None + if libname == "rendercanvas": + libname = "rendercanvas.utils.asyncadapter" + elif libname == "pyodide": + libname = "asyncio" + return libname + + +def detect_current_call_soon_threadsafe(): + """Get the current applicable call_soon_threadsafe function, or None""" + + # Get asyncgen hook func, return fast when no async loop active + ob = sys.get_asyncgen_hooks()[0] + if ob is None: + return None + + # Super-fast path that works for loop objects that have call_soon_threadsafe() + # and use sys.set_asyncgen_hooks() on a method of the same loop object. + # Works with asyncio, rendercanvas' asyncadapter, and also custom (direct) loops. + try: + return ob.__self__.call_soon_threadsafe + except AttributeError: + pass + + # Otherwise, checkout the module name + try: + libname = ob.__module__.partition(".")[0] + except AttributeError: + return None + + if libname == "trio": + # Still pretty fast for trio + trio = sys.modules[libname] + token = trio.lowlevel.current_trio_token() + return token.run_sync_soon + else: + # Ok, it looks like there is an async loop, try to get the func. + # This is also a fallback for asyncio (in case the ob.__self__ stops working) + # Note: we have a unit test for the asyncio fast-path, so we will know when we need to update, + # but the code below makes sure that it keeps working regardless (just a tiiiny bit slower). + if libname == "pyodide": + libname = "asyncio" + mod = sys.modules.get(libname, None) + if mod is None: + return None + try: + return mod.call_soon_threadsafe + except AttributeError: + pass + try: + return mod.get_running_loop().call_soon_threadsafe + except Exception: # (RuntimeError, AttributeError) but accept any error + pass + + async def sleep(delay): """Generic async sleep. Works with trio, asyncio and rendercanvas-native. - On Windows, with asyncio or trio, this uses a special sleep routine that is more accurate than the standard ``sleep()``. + On Windows, with asyncio or trio, this uses a special sleep routine that is more accurate than the ``sleep()`` of asyncio/trio. """ - # The commented code below would be quite elegant, but we don't have get_running_rendercanvas_loop(), - # so instead we replicate the call_soon_threadsafe logic for asyncio and trio here. - # - # if delay > 0 and USE_THREADED_TIMER: - # event = Event() - # rc_loop = get_running_rendercanvas_loop() - # call_later_from_thread(delay, rc_loop.call_soon_threadsafe, event.set) - # await event.wait() - - libname = sniffio.current_async_library() - if libname == "asyncio" and delay > 0 and USE_THREADED_TIMER: - asyncio = sys.modules[libname] - loop = asyncio.get_running_loop() - event = asyncio.Event() - call_later_from_thread(delay, loop.call_soon_threadsafe, event.set) - await event.wait() - elif libname == "trio" and delay > 0 and USE_THREADED_TIMER: - trio = sys.modules[libname] - event = trio.Event() - token = trio.lowlevel.current_trio_token() - call_later_from_thread(delay, token.run_sync_soon, event.set) - await event.wait() + if delay > 0 and USE_THREADED_TIMER: + call_soon_threadsafe = detect_current_call_soon_threadsafe() + if call_soon_threadsafe: + event = Event() + call_later_from_thread(delay, call_soon_threadsafe, event.set) + await event.wait() else: - sleep = sys.modules[libname].sleep - await sleep(delay) + libname = detect_current_async_lib() + if libname is not None: + sleep = sys.modules[libname].sleep + await sleep(delay) class Event: """Generic async event object. Works with trio, asyncio and rendercanvas-native.""" def __new__(cls): - libname = sniffio.current_async_library() - Event = sys.modules[libname].Event # noqa - return Event() + libname = detect_current_async_lib() + if libname is None: + return object.__new__(cls) + else: + Event = sys.modules[libname].Event # noqa + return Event() + + async def wait(self): + return + + def set(self): + pass diff --git a/tests/test_loop.py b/tests/test_loop.py index 6ba98c3b..e4413bf4 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -8,6 +8,10 @@ * Run "pytest -k PySide6Loop" etc. * Run "python tests/test_loop.py WxLoop" etc. + +Note that in here we create *a lot* of different kind of loop objects. +In practice though, an application will use (and even import) a single +loop object and run it one time for the duration of the application. """ # ruff: noqa: N803 @@ -16,6 +20,7 @@ import sys import time import signal +import weakref import asyncio import threading @@ -158,6 +163,111 @@ def _rc_request_draw(self): loop.call_soon(self._draw_frame_and_present) +# %%%%% deleting loops + + +@pytest.mark.parametrize("SomeLoop", loop_classes) +def test_loop_deletion1(SomeLoop): + # Loops get gc'd when instantiated but not used. + + loop = SomeLoop() + + loop_ref = weakref.ref(loop) + del loop + gc.collect() + gc.collect() + + assert loop_ref() is None + + +@pytest.mark.parametrize("SomeLoop", loop_classes) +def test_loop_deletion2(SomeLoop): + # Loops get gc'd when in ready state + + async def foo(): + pass + + loop = SomeLoop() + loop.add_task(foo) + assert "ready" in repr(loop) + + loop_ref = weakref.ref(loop) + del loop + for _ in range(4): + time.sleep(0.01) + gc.collect() + + assert loop_ref() is None + + +@pytest.mark.parametrize("SomeLoop", loop_classes) +def test_loop_deletion3(SomeLoop): + # Loops get gc'd when closed after use + + flag = [] + + async def foo(): + flag.append(True) + + loop = SomeLoop() + loop.add_task(foo) + assert "ready" in repr(loop) + loop.run() + assert flag == [True] + + loop_ref = weakref.ref(loop) + del loop + for _ in range(4): + time.sleep(0.01) + gc.collect() + + assert loop_ref() is None + + +# %%%%% loop detection + + +@pytest.mark.parametrize("SomeLoop", loop_classes) +def test_loop_detection(SomeLoop): + from rendercanvas.utils.asyncs import ( + detect_current_async_lib, + detect_current_call_soon_threadsafe, + ) + + loop = SomeLoop() + + flag = [] + + async def task(): + # Our methods + flag.append(detect_current_async_lib()) + flag.append(detect_current_call_soon_threadsafe()) + # Test that the fast-path works + if SomeLoop is not TrioLoop: + flag.append(sys.get_asyncgen_hooks()[0].__self__.call_soon_threadsafe) + loop.stop() + + loop.add_task(task) + loop.run() + + if SomeLoop is AsyncioLoop: + assert flag[0] == "asyncio" + assert callable(flag[1]) + assert flag[1].__name__ == "call_soon_threadsafe" + assert flag[1].__func__ is flag[2].__func__ + # !! here we double-check that the fast-path for loop detection works for asyncio + elif SomeLoop is TrioLoop: + assert flag[0] == "trio" + assert callable(flag[1]) + assert flag[1].__name__ == "run_sync_soon" + else: + # RawLoop or QtLoop + assert flag[0] == "rendercanvas.utils.asyncadapter" + assert callable(flag[1]) + assert flag[1].__name__ == "call_soon_threadsafe" + assert flag[1].__func__ is flag[2].__func__ + + # %%%%% running and closing @@ -166,12 +276,12 @@ def test_run_loop_and_close_bc_no_canvases(SomeLoop): # Run the loop without canvas; closes immediately loop = SomeLoop() - loop.call_later(0.1, print, "hi from loop!") loop.call_later(1.0, loop.stop) # failsafe t0 = time.perf_counter() loop.run() t1 = time.perf_counter() + assert (t1 - t0) < 0.2 @@ -201,6 +311,17 @@ def test_loop_detects_canvases(SomeLoop): assert len(loop._BaseLoop__canvas_groups) == 2 assert len(loop.get_canvases()) == 3 + # Call stop explicitly. Because we created some canvases, but never ran the + # loops, they are in a 'ready' state, ready to move to the running state + # when the loop-task starts running. For raw/asyncio/trio this is fine, + # because cleanup will cancel all tasks. But for the QtLoop, the QTimer has + # a reference to the callback, which refs asyncadapter.Task, which refs the + # coroutine which refs the loop object. So there will not be any cleanup and + # *this* loop will start running at the next test func. + loop.stop() + loop.stop() + assert loop._BaseLoop__state == "off" + @pytest.mark.parametrize("SomeLoop", loop_classes) def test_run_loop_without_canvases(SomeLoop): @@ -263,7 +384,6 @@ def test_run_loop_and_close_canvases(SomeLoop): group._register_canvas(canvas1, fake_task) group._register_canvas(canvas2, fake_task) - loop.call_later(0.1, print, "hi from loop!") loop.call_later(0.1, canvas1.manually_close) loop.call_later(0.3, canvas2.manually_close) @@ -855,7 +975,6 @@ async def tester_coroutine(): @pytest.mark.parametrize("SomeLoop", loop_classes) def test_async_gens_cleanup_bad_agen(SomeLoop): # Same as last but not with a bad-behaving finalizer. - # This will log an error. g = None @@ -873,13 +992,18 @@ async def tester_coroutine(): loop.call_later(0.2, loop.stop) loop.run() - if SomeLoop in (AsyncioLoop,): + if SomeLoop is AsyncioLoop: # Handled properly ref_flag = ["started", "except GeneratorExit", "closed"] - else: - # We accept to fail here with our async adapter. - # It looks like trio does too??? + elif SomeLoop is TrioLoop: + # Not handled correctly? It did at some point. + # Anyway, rather adversarial use-case, so I don't care too much. ref_flag = ["started", "except GeneratorExit"] + else: + # Actually, our adapter also works, because the sleep and Event + # become no-ops once the loop is gone, and since there are no other things + # one can wait on with our asyncadapter, we're good! + ref_flag = ["started", "except GeneratorExit", "closed"] assert flag == ref_flag, flag diff --git a/tests/test_meta.py b/tests/test_meta.py index 2ac9063b..468bb831 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -76,7 +76,8 @@ def test_namespace(): @pytest.mark.skipif(sys.version_info < (3, 10), reason="Need py310+") def test_deps_plain_import(): modules = get_loaded_modules("rendercanvas", 1) - assert modules == {"rendercanvas", "sniffio"} + modules.discard("sniffio") # sniffio is imported if available + assert modules == {"rendercanvas"} # Note, no wgpu diff --git a/tests/test_scheduling.py b/tests/test_scheduling.py index 01153982..79a9dfca 100644 --- a/tests/test_scheduling.py +++ b/tests/test_scheduling.py @@ -1,49 +1,23 @@ """ -Test scheduling mechanics, by implememting a minimal canvas class to +Test scheduling mechanics, by implementing a minimal canvas class to implement drawing. This tests the basic scheduling mechanics, as well -as the behabior of the different update modes. +as the behavior of the different update modes. """ import time from testutils import run_tests -from rendercanvas.base import BaseCanvasGroup, BaseRenderCanvas, BaseLoop +from rendercanvas.base import BaseCanvasGroup, BaseRenderCanvas +from rendercanvas.offscreen import StubLoop class MyCanvasGroup(BaseCanvasGroup): pass -class MyLoop(BaseLoop): - def __init__(self): - super().__init__() - self.__stopped = False - self._callbacks = [] - - def process_tasks(self): - callbacks_to_run = [] - new_callbacks = [] - for etime, callback in self._callbacks: - if time.perf_counter() >= etime: - callbacks_to_run.append(callback) - else: - new_callbacks.append((etime, callback)) - if callbacks_to_run: - self._callbacks = new_callbacks - for callback in callbacks_to_run: - callback() - - def _rc_run(self): - self.__stopped = False - - def _rc_stop(self): - self.__stopped = True - - def _rc_add_task(self, async_func, name): - # Run tasks via call_later - super()._rc_add_task(async_func, name) +class MyLoop(StubLoop): + pass - def _rc_call_later(self, delay, callback): - self._callbacks.append((time.perf_counter() + delay, callback)) + # Note: run() is non-blocking and simply does one iteration to process pending tasks. class MyCanvas(BaseRenderCanvas): @@ -83,7 +57,7 @@ def active_sleep(self, delay): etime = time.perf_counter() + delay while time.perf_counter() < etime: time.sleep(0.001) - loop.process_tasks() + loop.run() self.draw_if_necessary() diff --git a/tests/test_sniffio.py b/tests/test_sniffio.py new file mode 100644 index 00000000..c3cb6814 --- /dev/null +++ b/tests/test_sniffio.py @@ -0,0 +1,120 @@ +""" +Test the behaviour of our asyncadapter w.r.t. sniffio. +""" + + +# ruff: noqa: N803 + +import asyncio + +from testutils import run_tests +import rendercanvas +from rendercanvas.base import BaseCanvasGroup, BaseRenderCanvas +from rendercanvas.asyncio import loop as asyncio_loop + +from rendercanvas.asyncio import AsyncioLoop +from rendercanvas.trio import TrioLoop +from rendercanvas.raw import RawLoop + +import sniffio +import pytest + + +class CanvasGroup(BaseCanvasGroup): + pass + + +class RealRenderCanvas(BaseRenderCanvas): + _rc_canvas_group = CanvasGroup(asyncio_loop) + _is_closed = False + + def _rc_close(self): + self._is_closed = True + self.submit_event({"event_type": "close"}) + + def _rc_get_closed(self): + return self._is_closed + + def _rc_request_draw(self): + loop = self._rc_canvas_group.get_loop() + loop.call_soon(self._draw_frame_and_present) + + +def get_sniffio_name(): + try: + return sniffio.current_async_library() + except sniffio.AsyncLibraryNotFoundError: + return None + + +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_sniffio_on_loop(SomeLoop): + loop = SomeLoop() + + RealRenderCanvas.select_loop(loop) + + c = RealRenderCanvas() + + names = [] + funcs = [] + + @c.request_draw + def draw(): + name = get_sniffio_name() + names.append(("draw", name)) + + funcs.append(rendercanvas.utils.asyncs.detect_current_call_soon_threadsafe()) + + @c.add_event_handler("*") + def on_event(event): + names.append((event["event_type"], get_sniffio_name())) + + async def task(): + names.append(("task", get_sniffio_name())) + + loop.add_task(task) + loop.call_later(0.3, c.close) + # loop.call_later(1.3, loop.stop) # failsafe + + loop.run() + + refname = "nope" + if SomeLoop is RawLoop: + refname = "rendercanvas.utils.asyncadapter" + elif SomeLoop is AsyncioLoop: + refname = "asyncio" + elif SomeLoop is TrioLoop: + refname = "trio" + + for key, val in names: + assert val == refname + + assert len(funcs) == 1 + for func in funcs: + assert callable(func) + + +def test_asyncio(): + # Just make sure that in a call_soon/call_later the get_running_loop stil works + + loop = asyncio.new_event_loop() + + running_loops = [] + + def set_current_loop(name): + running_loops.append((name, asyncio.get_running_loop())) + + loop.call_soon(set_current_loop, "call_soon") + loop.call_later(0.1, set_current_loop, "call_soon") + loop.call_soon(loop.call_soon_threadsafe, set_current_loop, "call_soon_threadsafe") + loop.call_later(0.2, loop.stop) + loop.run_forever() + + print(running_loops) + assert len(running_loops) == 3 + for name, running_loop in running_loops: + assert running_loop is loop + + +if __name__ == "__main__": + run_tests(globals())