diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index cba3023..b005baf 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -52,12 +52,13 @@ class BaseLoop: """ 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 = ( 0 # 0: off, 1: ready, 2: detected-active, 3: inter-active, 4: running ) + self._using_adapter = False def __repr__(self): full_class_name = f"{self.__class__.__module__}.{self.__class__.__name__}" @@ -77,12 +78,20 @@ def _register_canvas_group(self, canvas_group): self.__state = 1 self._rc_init() self.add_task(self._loop_task, name="loop-task") + self._using_adapter = len(self.__tasks) > 0 self.__canvas_groups.add(canvas_group) def _unregister_canvas_group(self, canvas_group): # A CanvasGroup will call this when it selects a different loop. self.__canvas_groups.discard(canvas_group) + def _get_sniffio_activator(self): + # A CanvasGroup will call this to activate the loop + if self._using_adapter: + return asyncadapter.SniffioActivator(self) + else: + return None + def get_canvases(self) -> list[BaseRenderCanvas]: """Get a list of currently active (not-closed) canvases.""" canvases = [] @@ -391,7 +400,7 @@ def _rc_add_task(self, async_func, name): * The subclass is responsible for cancelling remaining tasks in _rc_stop. * Return None. """ - task = asyncadapter.Task(self._rc_call_later, async_func(), name) + task = asyncadapter.Task(self._rc_call_later, async_func(), name, self) self.__tasks.add(task) task.add_done_callback(self.__tasks.discard) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 1bd5225..27f8528 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -478,6 +478,12 @@ def _draw_frame_and_present(self): return self.__is_drawing = True + try: + # sniffio_activator = self._rc_canvas_group?._loop?._get_sniffio_activator() + sniffio_activator = self._rc_canvas_group._loop._get_sniffio_activator() + except AttributeError: # _rc_canvas_group or _loop can be None + sniffio_activator = None + try: # This method is called from the GUI layer. It can be called from a # "draw event" that we requested, or as part of a forced draw. @@ -531,6 +537,8 @@ def _draw_frame_and_present(self): finally: self.__is_drawing = False + if sniffio_activator: + sniffio_activator.restore() # %% Primary canvas management methods diff --git a/rendercanvas/utils/asyncadapter.py b/rendercanvas/utils/asyncadapter.py index e1f44ab..01be77e 100644 --- a/rendercanvas/utils/asyncadapter.py +++ b/rendercanvas/utils/asyncadapter.py @@ -4,8 +4,9 @@ """ import logging +import threading -from sniffio import thread_local as sniffio_thread_local +from sniffio import thread_local as _sniffio_thread_local logger = logging.getLogger("asyncadapter") @@ -58,14 +59,56 @@ class CancelledError(BaseException): pass +class _ThreadLocalWithLoop(threading.local): + loop = None # set default value as a class attr, like sniffio does + + +_ourloop_thread_local = _ThreadLocalWithLoop() + + +def get_running_loop() -> object: + """Return the running event loop. Raise a RuntimeError if there is none. + + This function is thread-specific. + """ + # This is inspired by asyncio, and together with sniffio, allows the same + # code to handle asyncio and our adapter for some cases. + loop = _ourloop_thread_local.loop + if loop is None: + raise RuntimeError(f"no running {__name__} loop") + return loop + + +class SniffioActivator: + def __init__(self, loop): + self.active = True + self.old_loop = _ourloop_thread_local.loop + self.old_name = _sniffio_thread_local.name + _sniffio_thread_local.name = __name__ + _ourloop_thread_local.loop = loop + + def restore(self): + if self.active: + self.active = False + _sniffio_thread_local.name = self.old_name + _ourloop_thread_local.loop = self.old_loop + + def __del__(self): + if self.active: + logger.warning( + "asyncadapter's SniffioActivator.restore() was never called." + ) + + class Task: - """Representation of task, exectuting a co-routine.""" + """Representation of a task, executing a co-routine.""" - def __init__(self, call_later_func, coro, name): + def __init__(self, call_later_func, coro, name, loop): self._call_later = call_later_func self._done_callbacks = [] self.coro = coro self.name = name + self.loop = loop self.cancelled = False self.call_step_later(0) @@ -95,7 +138,8 @@ def step(self): result = None stop = False - old_name, sniffio_thread_local.name = sniffio_thread_local.name, __name__ + sniffio_activator = SniffioActivator(self.loop) + try: if self.cancelled: stop = True @@ -112,7 +156,7 @@ def step(self): logger.error(f"Error in task: {err}") stop = True finally: - sniffio_thread_local.name = old_name + sniffio_activator.restore() # Clean up to help gc if stop: diff --git a/tests/test_sniffio.py b/tests/test_sniffio.py new file mode 100644 index 0000000..50e7364 --- /dev/null +++ b/tests/test_sniffio.py @@ -0,0 +1,132 @@ +""" +Test the behaviour of our asyncadapter w.r.t. sniffio. + +We want to make sure that it reports the running lib and loop correctly, +so that other code can use sniffio to get our loop and e.g. +call_soon_threadsafe, without actually knowing about rendercanvas, other +than that it's API is very similar to asyncio. +""" + + +# ruff: noqa: N803 + +import sys +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 + + +def test_no_loop_running(): + assert get_sniffio_name() is None + + with pytest.raises(RuntimeError): + rendercanvas.utils.asyncadapter.get_running_loop() + + +@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)) + + # Downstream code like wgpu-py can use this with sniffio + mod = sys.modules[name] + running_loop = mod.get_running_loop() + funcs.append(running_loop.call_soon_threadsafe) + + @c.add_event_handler("*") + def on_event(event): + names.append((event["event_type"], get_sniffio_name())) + + 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())