Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions rendercanvas/_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__}"
Expand All @@ -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 = []
Expand Down Expand Up @@ -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)

Expand Down
8 changes: 8 additions & 0 deletions rendercanvas/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
54 changes: 49 additions & 5 deletions rendercanvas/utils/asyncadapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
132 changes: 132 additions & 0 deletions tests/test_sniffio.py
Original file line number Diff line number Diff line change
@@ -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())