From 5e91e188f66a54775f5cb1bf8b3eeded5ad0c14d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 15 Jan 2025 16:51:05 +0100 Subject: [PATCH 1/6] Add a fully functioning pure-Python event loop --- examples/cube_trio.py | 2 +- examples/demo.py | 2 +- rendercanvas/_loop.py | 6 +++--- tests/test_backends.py | 6 +++--- tests/test_events.py | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/examples/cube_trio.py b/examples/cube_trio.py index 4ac486c..d93d14c 100644 --- a/examples/cube_trio.py +++ b/examples/cube_trio.py @@ -2,7 +2,7 @@ Cube trio --------- -Run a wgpu example on the glfw backend, and the trio loop +Run a wgpu example on the glfw backend, and the trio loop. """ import trio diff --git a/examples/demo.py b/examples/demo.py index 07fe305..0692c69 100644 --- a/examples/demo.py +++ b/examples/demo.py @@ -22,7 +22,7 @@ canvas = RenderCanvas( size=(640, 480), - title="Canvas events with $backend - $fps fps", + title="Canvas events with $backend on $loop - $fps fps", max_fps=10, update_mode="continuous", present_method="", diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index ddb7755..ccf9f76 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -137,7 +137,7 @@ async def _loop_task(self): del canvas finally: - self._stop() + self.__stop() def add_task(self, async_func, *args, name="unnamed"): """Run an async function in the event-loop. @@ -260,9 +260,9 @@ def stop(self): self.__should_stop += 1 if self.__should_stop >= 4: # If for some reason the tick method is no longer being called, but the loop is still running, we can still stop it by spamming stop() :) - self._stop() + self.__stop() - def _stop(self): + def __stop(self): """Move to the off-state.""" # If we used the async adapter, cancel any tasks while self.__tasks: diff --git a/tests/test_backends.py b/tests/test_backends.py index 9d923c9..00c43e7 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -146,9 +146,9 @@ def test_meta(): continue module_name = fname.split(".")[0] test_func_name = f"test_{module_name}_module" - assert ( - test_func_name in all_test_names - ), f"Test missing for {module_name} module" + assert test_func_name in all_test_names, ( + f"Test missing for {module_name} module" + ) def test_ref_rc_methods(): diff --git a/tests/test_events.py b/tests/test_events.py index a400e0f..677c6cf 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -352,7 +352,7 @@ def handler(event): ee.sync_flush() t2 = time.perf_counter() - t0 - print(f"add_handler: {1000*t1:0.0f} ms, emit: {1000*t2:0.0f} ms") + print(f"add_handler: {1000 * t1:0.0f} ms, emit: {1000 * t2:0.0f} ms") if __name__ == "__main__": From bf2daa24f4cbaa5d10c8cf5058518ae294fd6f07 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 15 Jan 2025 16:51:50 +0100 Subject: [PATCH 2/6] add files --- examples/cube_rawloop.py | 23 ++++++++++ rendercanvas/raw.py | 94 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 examples/cube_rawloop.py create mode 100644 rendercanvas/raw.py diff --git a/examples/cube_rawloop.py b/examples/cube_rawloop.py new file mode 100644 index 0000000..42d3a58 --- /dev/null +++ b/examples/cube_rawloop.py @@ -0,0 +1,23 @@ +""" +Cube raw loop +------------- + +Run a wgpu example on the glfw backend, and the raw loop. +""" + +from rendercanvas.glfw import RenderCanvas +from rendercanvas.raw import loop +from rendercanvas.utils.cube import setup_drawing_sync + + +RenderCanvas.select_loop(loop) + +canvas = RenderCanvas( + title="The wgpu cube on $backend with $loop", update_mode="continuous" +) +draw_frame = setup_drawing_sync(canvas) +canvas.request_draw(draw_frame) + + +if __name__ == "__main__": + loop.run() diff --git a/rendercanvas/raw.py b/rendercanvas/raw.py new file mode 100644 index 0000000..74d9012 --- /dev/null +++ b/rendercanvas/raw.py @@ -0,0 +1,94 @@ +""" +Implements a pure Python raw event-loop for backends that don't have an event-loop by themselves, like glfw. + +There is not really an advantage over say the asyncio loop, except perhaps that it does not use +asyncio, so you can start an asyncio loop from a callback. Other than that, this is more +for educational purposes: look how simple a loop can be! +""" + +__all__ = ["RawLoop", "loop"] + +import time +import heapq +import logging +import threading +from itertools import count + +from .base import BaseLoop + + +logger = logging.getLogger("rendercanvas") +counter = count() + + +class CallAtWrapper: + def __init__(self, time, callback): + self.index = next(counter) + self.time = time + self.callback = callback + + def __lt__(self, other): + return (self.time, self.index) < (other.time, other.index) + + def cancel(self): + self.callback = None + + +class RawLoop(BaseLoop): + def _rc_init(self): + self._queue = [] # prioriry queue + self._should_stop = False + self._event = threading.Event() + + def _rc_run(self): + while True: + # todo: how to stop + # todo: log error context + # todo: how to make sure all pending tasks are done without forgetting other tasks? + + self._event.clear() + + # Get wrapper for callback that is first to be called + try: + wrapper = heapq.heappop(self._queue) + except IndexError: + wrapper = None + + if wrapper is None: + # Empty queue, wait a little + self._event.wait(0.1) + else: + # Wait until its time for it to be called + wait_time = wrapper.time - time.perf_counter() + self._event.wait(max(wait_time, 0)) + + # Put it back or call it? + if time.perf_counter() < wrapper.time: + heapq.heappush(self._queue, wrapper) + elif wrapper.callback is not None: + try: + wrapper.callback() + except Exception as err: + logger.error(f"Error in callback: {err}") + + async def _rc_run_async(self): + raise NotImplementedError() + + def _rc_stop(self): + # Note: is only called when we're inside _rc_run + self._should_stop = True + self._event.set() + + def _rc_add_task(self, async_func, name): + # we use the async adapter with call_later + return super()._rc_add_task(async_func, name) + + def _rc_call_later(self, delay, callback): + now = time.perf_counter() + time_at = now + max(0, delay) + wrapper = CallAtWrapper(time_at, callback) + heapq.heappush(self._queue, wrapper) + self._event.set() + + +loop = RawLoop() From ffda5449f3e782608b6c32b463dc51381dfbe841 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 15 Jan 2025 16:54:06 +0100 Subject: [PATCH 3/6] clean --- rendercanvas/raw.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/rendercanvas/raw.py b/rendercanvas/raw.py index 74d9012..411261a 100644 --- a/rendercanvas/raw.py +++ b/rendercanvas/raw.py @@ -42,10 +42,6 @@ def _rc_init(self): def _rc_run(self): while True: - # todo: how to stop - # todo: log error context - # todo: how to make sure all pending tasks are done without forgetting other tasks? - self._event.clear() # Get wrapper for callback that is first to be called From 0a7017e2e73dbcb47ffae959726132c143701838 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 16 Jan 2025 13:03:00 +0100 Subject: [PATCH 4/6] Add test --- tests/test_backends.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_backends.py b/tests/test_backends.py index 00c43e7..3892ab8 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -192,6 +192,16 @@ def test_auto_module(): # %% Test modules that only provide a loop +def test_raw_module(): + m = Module("raw") + + assert "loop" in m.names + assert m.names["loop"] + loop_class = m.names["RawLoop"] + m.check_loop(loop_class) + assert loop_class.name == "RawLoop" + + def test_asyncio_module(): m = Module("asyncio") From 4cde649c09cbe14675fdd2b969d8de9b186f4949 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 16 Jan 2025 15:41:28 +0100 Subject: [PATCH 5/6] Tests and fixes --- rendercanvas/raw.py | 13 +++++++---- tests/test_loop.py | 53 ++++++++++++++++++++++++++++----------------- tests/testutils.py | 9 ++++++++ 3 files changed, 51 insertions(+), 24 deletions(-) diff --git a/rendercanvas/raw.py b/rendercanvas/raw.py index 411261a..4a4ec27 100644 --- a/rendercanvas/raw.py +++ b/rendercanvas/raw.py @@ -35,13 +35,18 @@ def cancel(self): class RawLoop(BaseLoop): - def _rc_init(self): + def __init__(self): + super().__init__() self._queue = [] # prioriry queue self._should_stop = False self._event = threading.Event() + def _rc_init(self): + # This gets called when the first canvas is created (possibly after having run and stopped before). + pass + def _rc_run(self): - while True: + while not self._should_stop: self._event.clear() # Get wrapper for callback that is first to be called @@ -51,8 +56,8 @@ def _rc_run(self): wrapper = None if wrapper is None: - # Empty queue, wait a little - self._event.wait(0.1) + # Empty queue, exit + break else: # Wait until its time for it to be called wait_time = wrapper.time - time.perf_counter() diff --git a/tests/test_loop.py b/tests/test_loop.py index 0cd6b0b..f62e422 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -2,6 +2,8 @@ Some tests for the base loop and asyncio loop. """ +# ruff: noqa: N803 + import time import signal import asyncio @@ -10,6 +12,7 @@ from rendercanvas.base import BaseCanvasGroup, BaseRenderCanvas from rendercanvas.asyncio import AsyncioLoop from rendercanvas.trio import TrioLoop +from rendercanvas.raw import RawLoop from rendercanvas.utils.asyncs import sleep as async_sleep from testutils import run_tests import trio @@ -71,17 +74,19 @@ def _rc_request_draw(self): loop.call_soon(self._draw_frame_and_present) -def test_run_loop_and_close_bc_no_canvases(): +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_run_loop_and_close_bc_no_canvases(SomeLoop): # Run the loop without canvas; closes immediately - loop = AsyncioLoop() + loop = SomeLoop() loop.call_later(0.1, print, "hi from loop!") loop.run() -def test_loop_detects_canvases(): +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_loop_detects_canvases(SomeLoop): # After all canvases are closed, it can take one tick before its detected. - loop = AsyncioLoop() + loop = SomeLoop() group1 = CanvasGroup(loop) group2 = CanvasGroup(loop) @@ -104,10 +109,11 @@ def test_loop_detects_canvases(): assert len(loop.get_canvases()) == 3 -def test_run_loop_without_canvases(): +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_run_loop_without_canvases(SomeLoop): # After all canvases are closed, it can take one tick before its detected. - loop = AsyncioLoop() + loop = SomeLoop() group = CanvasGroup(loop) # The loop is in its stopped state, but it fires up briefly to do one tick @@ -147,10 +153,11 @@ def test_run_loop_without_canvases(): assert 0.0 <= et < 0.15 -def test_run_loop_and_close_canvases(): +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_run_loop_and_close_canvases(SomeLoop): # After all canvases are closed, it can take one tick before its detected. - loop = AsyncioLoop() + loop = SomeLoop() group = CanvasGroup(loop) canvas1 = FakeCanvas() @@ -173,9 +180,10 @@ def test_run_loop_and_close_canvases(): assert canvas2._events.is_closed -def test_run_loop_and_close_by_loop_stop(): +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_run_loop_and_close_by_loop_stop(SomeLoop): # Close, then wait at most one tick to close canvases, and another to conform close. - loop = AsyncioLoop() + loop = SomeLoop() group = CanvasGroup(loop) canvas1 = FakeCanvas() @@ -197,9 +205,10 @@ def test_run_loop_and_close_by_loop_stop(): assert canvas2._events.is_closed -def test_run_loop_and_close_by_loop_stop_via_async(): +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_run_loop_and_close_by_loop_stop_via_async(SomeLoop): # Close using a coro - loop = AsyncioLoop() + loop = SomeLoop() group = CanvasGroup(loop) canvas1 = FakeCanvas() @@ -252,10 +261,11 @@ async def stopper(): assert 0.35 < et < 0.65 -def test_run_loop_and_close_by_deletion(): +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_run_loop_and_close_by_deletion(SomeLoop): # Make the canvases be deleted by the gc. - loop = AsyncioLoop() + loop = SomeLoop() group = CanvasGroup(loop) canvases = [FakeCanvas() for _ in range(2)] @@ -297,10 +307,11 @@ def test_run_loop_and_close_by_deletion_real(): assert 0.25 < et < 0.55 -def test_run_loop_and_interrupt(): +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_run_loop_and_interrupt(SomeLoop): # Interrupt, calls close, can take one tick to close canvases, and anoter to conform close. - loop = AsyncioLoop() + loop = SomeLoop() group = CanvasGroup(loop) canvas1 = FakeCanvas() @@ -329,10 +340,11 @@ def interrupt_soon(): assert canvas2._events.is_closed -def test_run_loop_and_interrupt_harder(): +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_run_loop_and_interrupt_harder(SomeLoop): # In the next tick after the second interupt, it stops the loop without closing the canvases - loop = AsyncioLoop() + loop = SomeLoop() group = CanvasGroup(loop) canvas1 = FakeCanvas(refuse_close=True) @@ -364,8 +376,9 @@ def interrupt_soon(): assert not canvas2._events.is_closed -def test_loop_threaded(): - t = threading.Thread(target=test_run_loop_and_close_by_loop_stop) +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_loop_threaded(SomeLoop): + t = threading.Thread(target=test_run_loop_and_close_by_loop_stop, args=(SomeLoop,)) t.start() t.join() diff --git a/tests/testutils.py b/tests/testutils.py index a211ddb..79027b2 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -35,11 +35,20 @@ def run_tests(scope): caplog = LogCaptureHandler() for func in list(scope.values()): if callable(func) and func.__name__.startswith("test_"): + params = [ + mark.args + for mark in getattr(func, "pytestmark", []) + if mark.name == "parametrize" + ] nargs = func.__code__.co_argcount argnames = [func.__code__.co_varnames[i] for i in range(nargs)] if not argnames: print(f"Running {func.__name__} ...") func() + elif nargs == 1 and len(params) == 1: + for arg in params[0][1]: + print(f"Running {func.__name__} with {arg}...") + func(arg) elif argnames == ["caplog"]: print(f"Running {func.__name__} ...") logging.root.addHandler(caplog) From a06039bc091559153f8c17519df3d7a73e9fb0d2 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 16 Jan 2025 15:46:44 +0100 Subject: [PATCH 6/6] docs --- docs/backends.rst | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/backends.rst b/docs/backends.rst index ae48e2a..0702d6e 100644 --- a/docs/backends.rst +++ b/docs/backends.rst @@ -55,10 +55,14 @@ There are also two loop-backends. These are mainly intended for use with the glf * - **backend module** - **names** - **purpose** + * - ``raw`` + - | ``RawLoop`` + | ``loop`` + - | Provide a pure Python event loop. * - ``asyncio`` - | ``AsyncoLoop`` | ``loop`` - - | Provide a generic loop based on Asyncio. + - | Provide a generic loop based on Asyncio. Recommended. * - ``trio`` - | ``TrioLoop`` | ``loop``