Skip to content
5 changes: 3 additions & 2 deletions examples/tests/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ def test_examples_screenshots(
def unload_module():
del sys.modules[module_name]

request.addfinalizer(unload_module)
if request:
request.addfinalizer(unload_module)

if not hasattr(example, "canvas"):
# some examples we screenshot test don't have a canvas as a global variable when imported,
Expand Down Expand Up @@ -188,4 +189,4 @@ def test_examples_run(module, force_offscreen):
os.environ["RENDERCANVAS_FORCE_OFFSCREEN"] = "true"
pytest.getoption = lambda x: False
is_lavapipe = True
test_examples_screenshots("validate_volume", pytest, None, None)
test_examples_screenshots("cube", pytest, mock_time, None, None)
2 changes: 1 addition & 1 deletion tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def test_enums_and_flags_and_structs():

def test_base_wgpu_api():
# Fake a device and an adapter
adapter = wgpu.GPUAdapter(None, set(), {}, wgpu.GPUAdapterInfo({}), None)
adapter = wgpu.GPUAdapter(None, set(), {}, wgpu.GPUAdapterInfo({}))
queue = wgpu.GPUQueue("", None, None)
device = wgpu.GPUDevice("device08", -1, adapter, {42, 43}, {}, queue)

Expand Down
32 changes: 16 additions & 16 deletions tests/test_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ def poller():
async def test_promise_async_loop_simple():
loop = SillyLoop()

promise = GPUPromise("test", None, loop=loop)
promise = GPUPromise("test", None, _loop=loop)

loop.process_events()
result = await promise
Expand All @@ -226,7 +226,7 @@ async def test_promise_async_loop_normal():
def handler(input):
return input * 2

promise = GPUPromise("test", handler, loop=loop)
promise = GPUPromise("test", handler, _loop=loop)

loop.process_events()
result = await promise
Expand All @@ -240,7 +240,7 @@ async def test_promise_async_loop_fail2():
def handler(input):
return input / 0

promise = GPUPromise("test", handler, loop=loop)
promise = GPUPromise("test", handler, _loop=loop)

loop.process_events()
with raises(ZeroDivisionError):
Expand Down Expand Up @@ -272,7 +272,7 @@ def callback(r):
nonlocal result
result = r

promise = GPUPromise("test", None, loop=loop)
promise = GPUPromise("test", None, _loop=loop)

promise.then(callback)
loop.process_events()
Expand All @@ -291,7 +291,7 @@ def callback(r):
def handler(input):
return input * 2

promise = GPUPromise("test", handler, loop=loop)
promise = GPUPromise("test", handler, _loop=loop)

promise.then(callback)
loop.process_events()
Expand All @@ -315,7 +315,7 @@ def err_callback(e):
def handler(input):
return input / 0

promise = GPUPromise("test", handler, loop=loop)
promise = GPUPromise("test", handler, _loop=loop)

promise.then(callback, err_callback)
loop.process_events()
Expand All @@ -338,7 +338,7 @@ def callback1(r):
nonlocal result
result = r

promise = MyPromise("test", None, loop=loop)
promise = MyPromise("test", None, _loop=loop)

p = promise.then(callback1)
loop.process_events()
Expand Down Expand Up @@ -371,7 +371,7 @@ def callback3(r):
nonlocal result
result = r

promise = GPUPromise("test", None, loop=loop)
promise = GPUPromise("test", None, _loop=loop)

p = promise.then(callback1).then(callback2).then(callback3)
assert isinstance(p, GPUPromise)
Expand Down Expand Up @@ -400,7 +400,7 @@ def err_callback(e):
nonlocal error
error = e

promise = GPUPromise("test", None, loop=loop)
promise = GPUPromise("test", None, _loop=loop)

p = promise.then(callback1).then(callback2).then(callback3, err_callback)
assert isinstance(p, GPUPromise)
Expand Down Expand Up @@ -430,7 +430,7 @@ def err_callback(e):
nonlocal error
error = e

promise = GPUPromise("test", None, loop=loop)
promise = GPUPromise("test", None, _loop=loop)

p = promise.then(callback1).then(callback2).then(callback3, err_callback)
assert isinstance(p, GPUPromise)
Expand All @@ -454,7 +454,7 @@ def callback2(r):
def callback3(r):
results.append(r * 3)

promise = GPUPromise("test", None, loop=loop)
promise = GPUPromise("test", None, _loop=loop)

promise.then(callback1)
promise.then(callback2)
Expand All @@ -473,7 +473,7 @@ def test_promise_chaining_after_resolve():
def callback1(r):
results.append(r)

promise = GPUPromise("test", None, loop=loop)
promise = GPUPromise("test", None, _loop=loop)

# Adding handler has no result, because promise is not yet resolved.
promise.then(callback1)
Expand Down Expand Up @@ -503,16 +503,16 @@ def test_promise_chaining_with_promises():
result = None

def callback1(r):
return GPUPromise("test", lambda _: r * 3, loop=loop)
return GPUPromise("test", lambda _: r * 3, _loop=loop)

def callback2(r):
return GPUPromise("test", lambda _: r + 2, loop=loop)
return GPUPromise("test", lambda _: r + 2, _loop=loop)

def callback3(r):
nonlocal result
result = r

promise = GPUPromise("test", None, loop=loop)
promise = GPUPromise("test", None, _loop=loop)

p = promise.then(callback1).then(callback2).then(callback3)
assert isinstance(p, GPUPromise)
Expand All @@ -535,7 +535,7 @@ def test_promise_decorator():
def handler(input):
return input * 2

promise = GPUPromise("test", handler, loop=loop)
promise = GPUPromise("test", handler, _loop=loop)

@promise
def decorated(r):
Expand Down
84 changes: 60 additions & 24 deletions wgpu/_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,43 @@
logger = logging.getLogger("wgpu")


class StubLoop:
def __init__(self, name, call_soon_threadsafe):
self.name = name
self.call_soon_threadsafe = call_soon_threadsafe

def __repr__(self):
return f"<StubLoop for {self.name} at {hex(id(self))}>"


def get_running_loop():
"""Get an object with a call_soon_threadsafe() method.

Sniffio is used for this, and it supports asyncio, trio, and rendercanvas.utils.asyncadapter.
If this function returns None, it means that the GPUPromise will not support ``await`` and ``.then()``.

It's relatively easy to register a custom loop to sniffio so that this code works on it.
"""

try:
name = sniffio.current_async_library()
except sniffio.AsyncLibraryNotFoundError:
return None

if name == "trio":
trio = sys.modules[name]
token = trio.lowlevel.current_trio_token()
return StubLoop("trio", token.run_sync_soon)
else: # asyncio, rendercanvas.utils.asyncadapter, and easy to mimic for custom loops
try:
mod = sys.modules[name]
loop = mod.get_running_loop()
loop.call_soon_threadsafe # noqa: B018 - access to make sure it exists
return loop
except Exception:
return None


# The async_sleep and AsyncEvent are a copy of the implementation in rendercanvas.asyncs


Expand All @@ -35,16 +72,6 @@ def __new__(cls):
AwaitedType = TypeVar("AwaitedType")


class LoopInterface:
"""A loop object must have (at least) this API.

Rendercanvas loop objects do, asyncio.loop does too.
"""

def call_soon(self, callback: Callable, *args: object):
raise NotImplementedError()


def get_backoff_time_generator() -> Generator[float, None, None]:
"""Generates sleep-times, start at 0 then increasing to 100Hz and sticking there."""
for _ in range(5):
Expand Down Expand Up @@ -88,24 +115,20 @@ def __init__(
title: str,
handler: Callable | None,
*,
loop: LoopInterface | None = None,
keepalive: object = None,
_loop: object = None, # for testing and chaining
):
"""
Arguments:
title (str): The title of this promise, mostly for debugging purposes.
handler (callable, optional): The function to turn promise input into the result. If None,
the result will simply be the input.
loop (LoopInterface, optional): A loop object that at least has a ``call_soon()`` method.
If not given, this promise does not support .then() or promise-chaining.
keepalive (object, optional): Pass any data via this arg who's lifetime must be bound to the
resolving of this promise.

"""
self._title = str(title) # title for debugging
self._handler = handler # function to turn input into the result

self._loop = loop # Event loop instance, can be None
self._keepalive = keepalive # just to keep something alive

self._state = "pending" # "pending", "pending-rejected", "pending-fulfilled", "rejected", "fulfilled"
Expand All @@ -117,6 +140,9 @@ def __init__(
self._error_callbacks = []
self._UNRESOLVED.add(self)

# we only care about call_soon_threadsafe, but clearer to just have a loop object
self._loop = _loop or get_running_loop()

def __repr__(self):
return f"<GPUPromise '{self._title}' {self._state} at {hex(id(self))}>"

Expand All @@ -140,7 +166,9 @@ def _set_input(self, result: object, *, resolve_now=True) -> None:
# If the input is a promise, we need to wait for it, i.e. chain to self.
if isinstance(result, GPUPromise):
if self._loop is None:
self._set_error("Cannot chain GPUPromise if the loop is not set.")
self._set_error(
"Cannot chain GPUPromise because no running loop could be detected."
)
else:
result._chain(self)
return
Expand Down Expand Up @@ -197,9 +225,12 @@ def _resolve_callback(self):
# Allow tasks that await this promise to continue.
if self._async_event is not None:
self._async_event.set()
# The callback may already be resolved
# If the value is set, let's resolve it so the handlers get called. But swallow the promise's value/failure.
if self._state.startswith("pending-"):
self._resolve()
try:
self._resolve()
except Exception:
pass

def _resolve(self):
"""Finalize the promise, by calling the handler to get the result, and then invoking callbacks."""
Expand Down Expand Up @@ -253,7 +284,7 @@ def sync_wait(self) -> AwaitedType:

def _sync_wait(self):
# Each subclass may implement this in its own way. E.g. it may wait for
# the _thread_event, it may poll the device in a loop while checking the
# the _thread_event, it may poll the device in a while-loop while checking the
# status, and Pyodide may use its special logic to sync wait the JS
# promise.
raise NotImplementedError()
Expand All @@ -276,7 +307,9 @@ def then(
The callback will receive one argument: the result of the promise.
"""
if self._loop is None:
raise RuntimeError("Cannot use GPUPromise.then() if the loop is not set.")
raise RuntimeError(
"Cannot use GPUPromise.then() because no running loop could be detected."
)
if not callable(callback):
raise TypeError(
f"GPUPromise.then() got a callback that is not callable: {callback!r}"
Expand All @@ -293,7 +326,7 @@ def then(
title = self._title + " -> " + callback_name

# Create new promise
new_promise = self.__class__(title, callback, loop=self._loop)
new_promise = self.__class__(title, callback, _loop=self._loop)
self._chain(new_promise)

if error_callback is not None:
Expand All @@ -307,7 +340,9 @@ def catch(self, callback: Callable[[Exception], None] | None):
The callback will receive one argument: the error object.
"""
if self._loop is None:
raise RuntimeError("Cannot use GPUPromise.catch() if the loop is not set.")
raise RuntimeError(
"Cannot use GPUPromise.catch() because not running loop could be detected."
)
if not callable(callback):
raise TypeError(
f"GPUPromise.catch() got a callback that is not callable: {callback!r}"
Expand All @@ -317,7 +352,7 @@ def catch(self, callback: Callable[[Exception], None] | None):
title = "Catcher for " + self._title

# Create new promise
new_promise = self.__class__(title, callback, loop=self._loop)
new_promise = self.__class__(title, callback, _loop=self._loop)

# Custom chain
with self._lock:
Expand All @@ -329,7 +364,8 @@ def catch(self, callback: Callable[[Exception], None] | None):

def __await__(self):
if self._loop is None:
# An async busy loop
# An async busy loop. In theory we should be able to remove this code, but it helps make the transition
# simpler, since then we depend less on https://github.com/pygfx/rendercanvas/pull/151
async def awaiter():
if self._state == "pending":
# Do small incremental async naps. Other tasks and threads can run.
Expand Down
Loading
Loading