From 779c7405884ee2d889646f5c8198cd657fa3f63a Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 6 Jul 2026 15:34:59 +0200 Subject: [PATCH 01/18] More fix wx closing --- rendercanvas/wx.py | 14 ++++++++++---- tests/test_backend_glfw.py | 8 ++++++-- tests/test_backend_qt.py | 6 +++++- tests/test_backend_wx.py | 6 +++++- tests/testutils_backends.py | 35 ++++++++++++++++++++++++++++++----- 5 files changed, 56 insertions(+), 13 deletions(-) diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index c2c6b3d..eb52733 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -162,21 +162,27 @@ def _rc_run(self): # but we cannot do that when the wx app is started from the outside (which is likely), so we need # to make it work without it anyway. # self._app.SetExitOnFrameDelete(False) - self._app.MainLoop() + self._app = None async def _rc_run_async(self): raise NotImplementedError() def _rc_stop(self): - self._app.ExitMainLoop() + if self._app is not None: + try: + self._app.ExitMainLoop() + except RuntimeError: + pass # wrapped C/C++ object of type App has been deleted 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): - if delay <= 0: + if self._app is None: + pass + elif delay <= 0: wx.CallAfter(callback) else: wx.CallLater(int(max(delay * 1000, 1)), callback) @@ -581,7 +587,7 @@ def __init__(self, parent=None, **kwargs): self._subwidget = WxRenderWidget(parent=self, **kwargs) - self.Bind(wx.EVT_CLOSE, lambda e: self.Destroy()) + self.Bind(wx.EVT_CLOSE, lambda e: self.close()) self.Show() self._final_canvas_init() diff --git a/tests/test_backend_glfw.py b/tests/test_backend_glfw.py index 176d93d..43c1086 100644 --- a/tests/test_backend_glfw.py +++ b/tests/test_backend_glfw.py @@ -19,7 +19,7 @@ if not can_use_glfw: pytest.skip("Skipping tests that needs glfw", allow_module_level=True) - +import glfw # def setup_module(): # import glfw # glfw.init() @@ -40,10 +40,14 @@ def test_is_canvas_classes(): assert issubclass(RenderCanvas, BaseRenderCanvas) +def glfw_close(canvas): + glfw.set_window_should_close(canvas._window, 1) + + @pytest.mark.parametrize("backend", ["glfw"]) @pytest.mark.parametrize("func", BACKEND_TEST_FUNCS) def test_backend_generic(func, backend): - func(backend) + func(backend, close_func=glfw_close) def test_glfw_canvas_del(): diff --git a/tests/test_backend_qt.py b/tests/test_backend_qt.py index dbed962..88074b9 100644 --- a/tests/test_backend_qt.py +++ b/tests/test_backend_qt.py @@ -56,10 +56,14 @@ def test_is_canvas_classes(): assert issubclass(RenderCanvas, QtWidgets.QWidget) # toplevel +def qt_close(canvas): + QtWidgets.QWidget.close(canvas) + + @pytest.mark.parametrize("backend", [backend_name]) @pytest.mark.parametrize("func", BACKEND_TEST_FUNCS) def test_backend_generic(func, backend): - func(backend) + func(backend, close_func=qt_close) if __name__ == "__main__": diff --git a/tests/test_backend_wx.py b/tests/test_backend_wx.py index 39b3fe0..4be9e1c 100644 --- a/tests/test_backend_wx.py +++ b/tests/test_backend_wx.py @@ -35,10 +35,14 @@ def test_is_canvas_classes(): assert issubclass(RenderCanvas, wx.Frame) +def wx_close(canvas): + canvas.Close() + + @pytest.mark.parametrize("backend", ["wx"]) @pytest.mark.parametrize("func", BACKEND_TEST_FUNCS) def test_backend_generic(func, backend): - func(backend) + func(backend, close_func=wx_close) if __name__ == "__main__": diff --git a/tests/testutils_backends.py b/tests/testutils_backends.py index b84343f..05e702b 100644 --- a/tests/testutils_backends.py +++ b/tests/testutils_backends.py @@ -22,7 +22,7 @@ def import_canvas_class_and_loop(backend) -> tuple[type, object]: @BACKEND_TEST_FUNCS.append -def canvas_close_by_canvas(backend): +def canvas_close_by_canvas(backend, *, close_func): RenderCanvas, loop = import_canvas_class_and_loop(backend) # noqa: N806 canvas1 = RenderCanvas() @@ -47,7 +47,32 @@ def canvas_close_by_canvas(backend): @BACKEND_TEST_FUNCS.append -def canvas_close_by_loop(backend): +def canvas_close_by_pressing_cross(backend, *, close_func): + RenderCanvas, loop = import_canvas_class_and_loop(backend) # noqa: N806 + + canvas1 = RenderCanvas() + canvas2 = RenderCanvas() + + loop.call_later(0.5, lambda: close_func(canvas1)) + loop.call_later(0.6, lambda: close_func(canvas2)) + loop.run() + + assert canvas1.get_closed() + assert canvas2.get_closed() + + canvas_ref1 = weakref.ref(canvas1) + canvas_ref2 = weakref.ref(canvas2) + del canvas1, canvas2 + gc.collect() + time.sleep(0.02) + gc.collect() + + assert canvas_ref1() is None + assert canvas_ref2() is None + + +@BACKEND_TEST_FUNCS.append +def canvas_close_by_loop(backend, close_func): RenderCanvas, loop = import_canvas_class_and_loop(backend) # noqa: N806 canvas1 = RenderCanvas() @@ -61,7 +86,7 @@ def canvas_close_by_loop(backend): @BACKEND_TEST_FUNCS.append -def canvas_sizing(backend): +def canvas_sizing(backend, close_func): RenderCanvas, _ = import_canvas_class_and_loop(backend) # noqa: N806 canvas = RenderCanvas(size=(640, 480)) @@ -91,7 +116,7 @@ def canvas_sizing(backend): @BACKEND_TEST_FUNCS.append -def canvas_render_bitmap(backend): +def canvas_render_bitmap(backend, close_func): RenderCanvas, loop = import_canvas_class_and_loop(backend) # noqa: N806 canvas = RenderCanvas(size=(640, 480)) @@ -124,7 +149,7 @@ def draw(): @BACKEND_TEST_FUNCS.append -def canvas_render_wgpu(backend): +def canvas_render_wgpu(backend, close_func): if not can_use_wgpu_lib: pytest.skip("Skipping tests that needs the wgpu lib") From 975f05658d4ce3f8678b9f2c6effba70cb833f3e Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 8 Jul 2026 11:07:28 +0200 Subject: [PATCH 02/18] wx shows bitmaps more consistently --- rendercanvas/contexts/bitmapcontext.py | 5 +++-- rendercanvas/wx.py | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/rendercanvas/contexts/bitmapcontext.py b/rendercanvas/contexts/bitmapcontext.py index f01ef2f..af17eb7 100644 --- a/rendercanvas/contexts/bitmapcontext.py +++ b/rendercanvas/contexts/bitmapcontext.py @@ -43,8 +43,9 @@ def set_bitmap(self, bitmap): grayscale or rgba format, with uint8 values. The bitmap does not have to match the physical size of the canvas; - backends will stretch the bitmap to match, though it's not specified - what interpolation method is used. + backends will stretch the bitmap to match the window. The interpolation + method is currently not specified, but we recommend backends to use + nearest-neighbor interpolation. """ diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index eb52733..e33101d 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -253,7 +253,10 @@ def on_paint(self, event): if not self._draw_lock: self._time_to_paint() if self._last_image is not None: - dc.DrawBitmap(self._last_image, 0, 0, False) + gc = wx.GraphicsContext.Create(dc) + gc.SetInterpolationQuality(wx.INTERPOLATION_NONE) + lw, lh = self._size_info["logical_size"] + gc.DrawBitmap(self._last_image, 0, 0, lw, lh) else: event.Skip() del dc @@ -347,7 +350,6 @@ def _rc_present_bitmap(self, *, data, format, **kwargs): assert format == "rgba-u8" width, height = data.shape[1], data.shape[0] self._last_image = wx.Bitmap.FromBufferRGBA(width, height, data) - self._last_image.SetScaleFactor(self.get_pixel_ratio()) def _rc_set_logical_size(self, width, height): width, height = int(width), int(height) From 71063bae173fffd57f753e6b5b126c3e366b513d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 8 Jul 2026 11:43:30 +0200 Subject: [PATCH 03/18] fix moment of closing event emitter --- rendercanvas/base.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 2f0d761..2dd73eb 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -683,10 +683,11 @@ def close(self) -> None: except Exception: pass self._canvas_context = None - # Clean events. Should already have happened in loop, but the loop may not be running. - self._events.close() - # Let the subclass clean up. - self._rc_close() + # Close the canvas and then the event emitter. In that order, so that the canvas is actually closed when the close event is emitted. + try: + self._rc_close() + finally: + self._events.close() def get_closed(self) -> bool: """Get whether the window is closed.""" From 923105d73f27b28e5e183da26411dc461d8f46a6 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 8 Jul 2026 12:12:03 +0200 Subject: [PATCH 04/18] fix process events and closing --- rendercanvas/wx.py | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index e33101d..42387f9 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -191,14 +191,10 @@ def _rc_call_soon_threadsafe(self, callback): wx.CallAfter(callback) def process_wx_events(self): - old_loop = wx.GUIEventLoop.GetActive() - event_loop = wx.GUIEventLoop() - wx.EventLoop.SetActive(event_loop) - count = 0 - while event_loop.Pending() and count < 3: - count += 1 - event_loop.Dispatch() - wx.EventLoop.SetActive(old_loop) + if self._app: + # Not SafeYield, becaus then events would not get processed + # We have to be careful not to re-enter! + self._app.Yield() loop = WxLoop() @@ -567,12 +563,8 @@ def _on_focus(self, event: wx.FocusEvent): self.submit_event(ev) def _on_close(self, _event): - if self._is_closed: - return - self._is_closed = True - loop = self._rc_canvas_group.get_loop() - if not loop.get_canvases(): - loop.stop(force=True) + if not self._is_closed: + self.close() class WxRenderCanvas(WrapperRenderCanvas, wx.Frame): From d912b114e05e141d2f6ac41142376064a09f3190 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 8 Jul 2026 12:32:23 +0200 Subject: [PATCH 05/18] more tests --- .github/workflows/ci.yml | 7 +---- rendercanvas/wx.py | 1 + tests/test_loop.py | 58 ++++++++++++++++++++++++++-------------- tests/testutils.py | 45 +++++++++++++++++-------------- 4 files changed, 65 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67399d9..0c0d11e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,21 +158,17 @@ jobs: - backendname: 'pyside6' deps: 'PySide6' testname: 'qt' - loopname: 'PySide6Loop' os: windows-latest - backendname: 'pyqt6' deps: 'PyQt6' testname: 'qt' - loopname: 'PyQt6Loop' os: windows-latest - backendname: 'pyqt5' deps: 'PyQt5' testname: 'qt' - loopname: 'PyQt5Loop' os: windows-latest - backendname: 'wx' deps: 'wxPython' - # loopname: 'WxLoop' wx's event loop has limitations os: windows-latest steps: - uses: actions/checkout@v7 @@ -196,9 +192,8 @@ jobs: run: | python -c 'import rendercanvas.${{ matrix.backendname }}' - name: Test loop - if: matrix.loopname run: | - pytest -v tests/test_loop.py -k ${{ matrix.loopname }} + pytest -v tests/test_loop.py -k ${{ matrix.backendname }} - name: Test backend canvas run: | pytest -v tests/test_backend_${{ matrix.testname || matrix.backendname }}.py diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index 42387f9..eec9474 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -162,6 +162,7 @@ def _rc_run(self): # but we cannot do that when the wx app is started from the outside (which is likely), so we need # to make it work without it anyway. # self._app.SetExitOnFrameDelete(False) + self._rc_init() self._app.MainLoop() self._app = None diff --git a/tests/test_loop.py b/tests/test_loop.py index 716c109..9fceb5f 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -5,8 +5,8 @@ because importing more than one in the same process always causes problems. Therefore, tests for these GUI framework need to be explicitly run: -* Run "pytest -k PySide6Loop" etc. -* Run "python tests/test_loop.py WxLoop" etc. +* Run "pytest -k pyside6" etc. +* Run "python tests/test_loop.py wx" etc. Note that in here we create *a lot* of different kind of loop objects. @@ -44,60 +44,59 @@ # Determine what loops to test -if "RawLoop" in sys.argv: +if "raw" in sys.argv: loop_classes.append(RawLoop) -elif "AsyncioLoop" in sys.argv: +elif "asyncio" in sys.argv: loop_classes.append(AsyncioLoop) -elif "TrioLoop" in sys.argv: +elif "glfw" in sys.argv: + class GlfwLoop(AsyncioLoop): + pass + loop_classes.append(GlfwLoop) +elif "trio" in sys.argv: loop_classes.append(TrioLoop) -elif "QtLoop" in sys.argv: +elif "qt" in sys.argv: from rendercanvas.pyside6 import QtLoop loop_classes.append(QtLoop) -elif "PySide6Loop" in sys.argv: +elif "pyside6" in sys.argv: from rendercanvas.pyside6 import QtLoop class PySide6Loop(QtLoop): pass loop_classes.append(PySide6Loop) -elif "PyQt6Loop" in sys.argv: +elif "pyqt6" in sys.argv: from rendercanvas.pyqt6 import QtLoop class PyQt6Loop(QtLoop): pass loop_classes.append(PyQt6Loop) -elif "PyQt5Loop" in sys.argv: +elif "pyqt5" in sys.argv: from rendercanvas.pyqt5 import QtLoop class PyQt5Loop(QtLoop): pass loop_classes.append(PyQt5Loop) -elif "PySide2Loop" in sys.argv: +elif "pyside2" in sys.argv: from rendercanvas.pyside2 import QtLoop class PySide2Loop(QtLoop): pass loop_classes.append(PySide2Loop) -elif "WxLoop" in sys.argv: - # NOTE: because for wx we have to do a few things differently, the - # tests in this module do not pass for it. +elif "wx" in sys.argv: + # NOTE: because we cannot prevent wx from closing the loop + # once the last canvas closes, some tests are skipped. from rendercanvas.wx import WxLoop loop_classes.append(WxLoop) else: + loop_classes[:] = default_loop_classes - # When Pyside6 is installed, run the tests with a QtLoop. - try: - from rendercanvas.pyside6 import QtLoop - except Exception: - pass - else: - loop_classes.append(QtLoop) + async def fake_task(): @@ -204,6 +203,9 @@ async def foo(): def test_loop_deletion3(SomeLoop): # Loops get gc'd when closed after use + if SomeLoop.__name__.startswith("Wx"): + pytest.skip() + flag = [] async def foo(): @@ -229,6 +231,10 @@ async def foo(): @pytest.mark.parametrize("SomeLoop", loop_classes) def test_loop_detection(SomeLoop): + + if SomeLoop.__name__.startswith("Wx"): + pytest.skip() + from rendercanvas.utils.asyncs import ( detect_current_async_lib, detect_current_call_soon_threadsafe, @@ -375,6 +381,9 @@ def test_run_loop_without_canvases(SomeLoop): def test_run_loop_and_close_canvases(SomeLoop): # After all canvases are closed, it can take one tick before its detected. + if SomeLoop.__name__.startswith("Wx"): + pytest.skip() + leeway = 0.20 if os.getenv("CI") else 0 loop = SomeLoop() @@ -403,6 +412,9 @@ def test_run_loop_and_close_canvases(SomeLoop): def test_run_loop_and_close_by_loop_stop(SomeLoop): # Close, then wait at most one tick to close canvases, and another to confirm close. + if SomeLoop.__name__.startswith("Wx"): + pytest.skip() + leeway = 0.20 if os.getenv("CI") else 0 loop = SomeLoop() @@ -431,6 +443,9 @@ def test_run_loop_and_close_by_loop_stop(SomeLoop): def test_run_loop_and_close_by_loop_stop_via_async(SomeLoop): # Close using a coro + if SomeLoop.__name__.startswith("Wx"): + pytest.skip() + leeway = 0.20 if os.getenv("CI") else 0 loop = SomeLoop() @@ -462,6 +477,9 @@ async def stopper(): def test_run_loop_and_close_by_deletion(SomeLoop): # Make the canvases be deleted by the gc. + if SomeLoop.__name__.startswith("Wx"): + pytest.skip() + leeway = 0.20 if os.getenv("CI") else 0 loop = SomeLoop() diff --git a/tests/testutils.py b/tests/testutils.py index 4b531a2..b5fb177 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -5,6 +5,8 @@ import subprocess from io import StringIO +import pytest + class LogCaptureHandler(logging.StreamHandler): _ANSI_ESCAPE_SEQ = re.compile(r"\x1b\[[\d;]+m") @@ -40,26 +42,29 @@ def run_tests(scope): ] 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 nargs == 2 and len(params) == 2: - for arg1 in params[0][1]: - for arg2 in params[1][1]: - print(f"Running {func.__name__} with {arg1}-{arg2}...") - func(arg1, arg2) - elif argnames == ["caplog"]: - print(f"Running {func.__name__} ...") - logging.root.addHandler(caplog) - caplog.reset() - func(caplog) - logging.root.removeHandler(caplog) - else: - print(f"SKIPPING {func.__name__} because it needs args") + try: + 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 nargs == 2 and len(params) == 2: + for arg1 in params[0][1]: + for arg2 in params[1][1]: + print(f"Running {func.__name__} with {arg1}-{arg2}...") + func(arg1, arg2) + elif argnames == ["caplog"]: + print(f"Running {func.__name__} ...") + logging.root.addHandler(caplog) + caplog.reset() + func(caplog) + logging.root.removeHandler(caplog) + else: + print(f"SKIPPING {func.__name__} because it needs args") + except pytest.skip.Exception: + print(f"SKIPPING {func.__name__} by pytest skip") print("Done") From 2d441f3afed88db0e8bf27b06aff7cc67cc06761 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 8 Jul 2026 12:58:39 +0200 Subject: [PATCH 06/18] ruff --- tests/test_loop.py | 5 ++--- tests/testutils_backends.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/test_loop.py b/tests/test_loop.py index 9fceb5f..162bd4b 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -49,8 +49,10 @@ elif "asyncio" in sys.argv: loop_classes.append(AsyncioLoop) elif "glfw" in sys.argv: + class GlfwLoop(AsyncioLoop): pass + loop_classes.append(GlfwLoop) elif "trio" in sys.argv: loop_classes.append(TrioLoop) @@ -93,12 +95,9 @@ class PySide2Loop(QtLoop): loop_classes.append(WxLoop) else: - loop_classes[:] = default_loop_classes - - async def fake_task(): pass diff --git a/tests/testutils_backends.py b/tests/testutils_backends.py index 05e702b..784ecda 100644 --- a/tests/testutils_backends.py +++ b/tests/testutils_backends.py @@ -53,8 +53,8 @@ def canvas_close_by_pressing_cross(backend, *, close_func): canvas1 = RenderCanvas() canvas2 = RenderCanvas() - loop.call_later(0.5, lambda: close_func(canvas1)) - loop.call_later(0.6, lambda: close_func(canvas2)) + loop.call_later(0.5, lambda: close_func(canvas1)) # noqa: F821 + loop.call_later(0.6, lambda: close_func(canvas2)) # noqa: F821 loop.run() assert canvas1.get_closed() From b056c21667ec321888b77d76c25cb877cdfe108f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 17 Jul 2026 15:13:18 +0200 Subject: [PATCH 07/18] Major refactoring --- .github/workflows/ci.yml | 14 + rendercanvas/asyncio.py | 4 + rendercanvas/base.py | 66 ++-- rendercanvas/core/loop.py | 79 +--- rendercanvas/qt.py | 15 +- rendercanvas/raw.py | 13 +- rendercanvas/stub.py | 13 +- rendercanvas/trio.py | 4 + rendercanvas/utils/asyncs.py | 1 + rendercanvas/wx.py | 79 ++-- tests/test_backend_glfw.py | 19 +- tests/test_backend_qt.py | 14 +- tests/test_backend_wx.py | 16 +- tests/test_loop.py | 746 ++++++++--------------------------- tests/testutils.py | 47 ++- tests/testutils_backends.py | 373 +++++++++++++++--- 16 files changed, 682 insertions(+), 821 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c0d11e..970f081 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,9 +152,23 @@ jobs: fail-fast: false matrix: include: + # Loops + # - backendname: 'asyncio' -> via glfw + - backendname: 'raw' + deps: 'glfw' + os: windows-latest + - backendname: 'trio' + deps: 'glfw' + os: windows-latest + # Native - backendname: 'glfw' deps: 'glfw' os: windows-latest + - backendname: 'terminal' + os: windows-latest + - backendname: 'offscreen' + os: windows-latest + # GUI - backendname: 'pyside6' deps: 'PySide6' testname: 'qt' diff --git a/rendercanvas/asyncio.py b/rendercanvas/asyncio.py index 98117a6..d781a29 100644 --- a/rendercanvas/asyncio.py +++ b/rendercanvas/asyncio.py @@ -62,6 +62,10 @@ async def _rc_run_async(self): while self.__pending_tasks: self._rc_add_task(*self.__pending_tasks.pop(0)) + # Only do one cycle of processing tasks if there are no canvases. + if not self.get_canvases(): + self._run_loop.call_soon(self.stop) + # Wait for loop to finish if self._stop_event is None: self._stop_event = asyncio.Event() diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 0814dd7..8d99c0e 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -59,6 +59,10 @@ def _register_canvas(self, canvas, task): loop._register_canvas_group(self) loop.add_task(task, name="scheduler-task") + def _unregister_canvas(self, canvas): + """Used by the canvas to unregister itself when closed.""" + self._canvases.discard(canvas) + def select_loop(self, loop: BaseLoop) -> None: """Select the loop to use for this group of canvases.""" if not (loop is None or isinstance(loop, BaseLoop)): @@ -76,17 +80,8 @@ def get_loop(self) -> BaseLoop | None: """Get the currently associated loop (can be None for canvases that don't run a scheduler).""" return self._loop - def get_canvases(self, *, close_closed=False) -> list[BaseRenderCanvas]: - if close_closed: - closed_canvases = [ - canvas for canvas in self._canvases if canvas.get_closed() - ] - for canvas in closed_canvases: - canvas.close() - self._canvases.discard(canvas) - return self._canvases - else: - return [canvas for canvas in self._canvases if not canvas.get_closed()] + def get_canvases(self) -> list[BaseRenderCanvas]: + return [canvas for canvas in self._canvases] class BaseRenderCanvas: @@ -681,19 +676,34 @@ def get_pixel_ratio(self) -> float: def close(self) -> None: """Close the canvas.""" + errors = [] + # Close the canvas natively, the canvas may only be marked as closed once this is done + try: + self._rc_close() + except Exception as err: + errors.append(err) + # Unregister + self._rc_canvas_group._unregister_canvas(self) # Clear the draw-function, to avoid it holding onto e.g. wgpu objects. self._draw_frame = None # type: ignore # Clear the canvas context too. try: - self._canvas_context._rc_close() # type: ignore - except Exception: - pass + if self._canvas_context is not None: + self._canvas_context._rc_close() # type: ignore + except Exception as err: + errors.append(err) self._canvas_context = None - # Close the canvas and then the event emitter. In that order, so that the canvas is actually closed when the close event is emitted. - try: - self._rc_close() - finally: - self._events.close() + # Close the event at least after get_closed() would return True + self._events.close() + # Stop the loop if this was the last canvas. It's important to do now, + # because the native loop may stop right after, giving us no flow to properly close. + loop = self._rc_canvas_group.get_loop() + if loop is not None: + if not loop.get_canvases(): + loop.stop() + # Errors + if errors: + raise errors[0] def get_closed(self) -> bool: """Get whether the window is closed.""" @@ -854,21 +864,21 @@ def _rc_set_logical_size(self, width: float, height: float): def _rc_close(self): """Close the canvas. - Note that ``BaseRenderCanvas`` implements the ``close()`` method, which is a - rather common name; it may be necessary to re-implement that too. + This is the place to delete the native widget, and to maybe set a flag + that ``_rc_get_closed()`` uses. It is not necessary to emit a close event, + because the base class handles that. - Backends should probably not mark the canvas as closed yet, but wait until the - underlying system really closes the canvas. Otherwise the loop may end before a - canvas gets properly cleaned up. + In a backend, all flows that lead to a close must call ``.close()``, so + that ``BaseRenderCanvas`` has a clear place to handle closing. It calls + ``_rc_close()`` from there. - Backends can emit a closed event, either in this method, or when the real close - happens, but this is optional, since the loop detects canvases getting closed - and sends the close event if this has not happened yet. + Note that backends may also have a ``close()`` method, which is + overridden by the base class. """ pass def _rc_get_closed(self) -> bool: - """Get whether the canvas is closed.""" + """Get whether the canvas is closed. A typical implementation uses a flag that is set in ``_rc_close()``.""" return False def _rc_set_title(self, title: str): diff --git a/rendercanvas/core/loop.py b/rendercanvas/core/loop.py index 4bae96e..a0ec3f1 100644 --- a/rendercanvas/core/loop.py +++ b/rendercanvas/core/loop.py @@ -141,11 +141,11 @@ 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_canvases(self, *, close_closed=False) -> list[BaseRenderCanvas]: + def get_canvases(self) -> list[BaseRenderCanvas]: """Get a list of currently active (not-closed) canvases.""" canvases = [] for canvas_group in self.__canvas_groups: - canvases += canvas_group.get_canvases(close_closed=close_closed) + canvases += canvas_group.get_canvases() return canvases def _ensure_initialized(self): @@ -156,69 +156,16 @@ def _ensure_initialized(self): if self.__state == LoopState.off: self.__state = LoopState.ready - async def wrapper(): - try: - with log_exception("Error in loop-task:"): - await self._loop_task() - finally: - # We get here when the task is finished or cancelled. - self.__is_initialized = False - 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: - # - # * Detect when the the loop starts running. When this code runs, it - # means something is running the task. - # * Detect closed windows while the loop is running. This is nice, - # because it means backends only have to mark the canvas as closed, - # and the base canvas takes care that .close() is called and the close - # event is emitted. - # * Stop the loop when there are no more canvases. Note that the loop - # may also be stopped from the outside, in which case *this* task is - # cancelled along with the other tasks. - # * Detect when the loop stops running, in case the native loop stops in - # a friendly way, cancelling tasks, including *this* task. - # * Keep the GUI going even when the canvas loop is on pause e.g. - # 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() - - try: - while True: - await sleep(0.1) - - # Note that this triggers .close() on closed canvases, for proper cleanup and sending close event. - canvases = self.get_canvases(close_closed=True) - # Keep canvases alive - for canvas in canvases: - canvas._rc_gui_poll() - del canvas - - # Break? - canvas_count = len(canvases) - del canvases - if not canvas_count and self._stop_when_no_canvases: - break + # self._rc_add_task(wrapper, "loop-task") + self._rc_add_task(self._loop_start_detection_task, "loop-start-detection-task") + self.__using_adapter = len(self.__tasks) > 0 - finally: - # We get here when we break the while-loop, but also when the task - # is cancelled (e.g. because the asyncio loop stops). In both cases - # we call stop from the *end* of the task, which is important since - # __stop() cancels all tasks, but cannot cancel the task that it is - # currently in. - self.stop(force=True) + async def _loop_start_detection_task(self): + if self.__state != LoopState.off: + self.__start() def add_task( self, @@ -363,7 +310,7 @@ async def run_async(self) -> None: try: await self._rc_run_async() finally: - self.__state = LoopState.off + self.stop(force=True) def stop(self, *, force=False) -> None: """Close all windows and stop the currently running event-loop. @@ -384,7 +331,7 @@ def stop(self, *, force=False) -> None: self.__should_stop += 2 if force else 1 # Close all canvases - canvases = self.get_canvases(close_closed=True) + canvases = self.get_canvases() for canvas in canvases: try: closed_by_loop = canvas._rc_closed_by_loop # type: ignore @@ -396,7 +343,7 @@ def stop(self, *, force=False) -> None: del canvas # Do a real stop? - if len(canvases) == 0 or self.__should_stop >= 2: + if len(self.get_canvases()) == 0 or self.__should_stop >= 2: self.__stop() def __setup_hooks(self): @@ -426,7 +373,6 @@ def __restore_hooks(self): 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) @@ -444,9 +390,10 @@ def __start(self): def __stop(self): """Move to the off-state.""" + self.__is_initialized = False + # Note that in here, we must fully bring our loop to a stop. # We cannot rely on future loop cycles. - # Set flags to off state self.__state = LoopState.off self.__should_stop = 0 diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index fabe7cb..9ee85ab 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -7,7 +7,6 @@ import sys import ctypes -import weakref import importlib @@ -208,15 +207,7 @@ def _rc_init(self): self._app = QtWidgets.QApplication.instance() if self._app is None: self._app = QtWidgets.QApplication([]) - # We do detect when the canvas-widget is closed, and also when *our* toplevel wrapper is closed, - # but when embedded in an application, it seems hard/impossible to detect the canvas being closed - # 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. - 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._caller = CallerHelper() @@ -234,6 +225,10 @@ def _rc_run(self): if already_had_app_on_import: return + # If no canvases, we only do a flush + if not self.get_canvases(): + self._rc_call_later(0, self.stop) + self._we_run_the_loop = True try: app = self._app diff --git a/rendercanvas/raw.py b/rendercanvas/raw.py index cf43e69..c3fc3fa 100644 --- a/rendercanvas/raw.py +++ b/rendercanvas/raw.py @@ -25,14 +25,23 @@ def _rc_init(self): self._should_stop = False def _rc_run(self): + self._rc_init() + + # If no canvases, we flush the current queue and then stop + if not self.get_canvases(): + self._queue.put(self.stop) + while not self._should_stop: callback = self._queue.get(True, None) try: callback() except Exception as err: logger.error(f"Error in RawLoop callback: {err}") - # Note that the queue may still contain pending callbacks, but these will - # mostly be task.step() for finished tasks (coro already deleted), so its ok. + + # Clear queue + # NOTE: there could be call laters underway! + while not self._queue.empty(): + self._queue.get_nowait() async def _rc_run_async(self): raise NotImplementedError() diff --git a/rendercanvas/stub.py b/rendercanvas/stub.py index 331f3ef..276d8e7 100644 --- a/rendercanvas/stub.py +++ b/rendercanvas/stub.py @@ -75,14 +75,23 @@ class StubRenderCanvas(BaseRenderCanvas): resize event. Setting the size implicitly requests a new draw. Backends must also call ``self.submit_event()``, if applicable, to produce - events for mouse and keyboard. Backends must *not* submit a "resize" event; - the base class takes care of that. See the event spec for details. + events for mouse and keyboard. Backends must *not* submit "resize" or + "close" events; the base class takes care of that. See the event spec for + more details. + + Backends must make sure that all closing flows (e.g. a user pressing the cross) + lead to ``close()`` being called. """ # Note that the methods below don't have docstrings, but Sphinx recovers the docstrings from the base class. _rc_canvas_group = StubCanvasGroup(loop) + def __init__(self, **kwargs): + super().__init__(**kwargs) + # backend-specific init + self._final_canvas_init() # <-- Backends must call this near the end of their init + def _rc_gui_poll(self): raise NotImplementedError() diff --git a/rendercanvas/trio.py b/rendercanvas/trio.py index d7e3e76..90fd2e1 100644 --- a/rendercanvas/trio.py +++ b/rendercanvas/trio.py @@ -28,6 +28,10 @@ async def _rc_run_async(self): if libname != "trio": raise TypeError(f"Attempt to run TrioLoop with {libname}.") + # Only do one cycle of processing tasks if there are no canvases. + if not self.get_canvases(): + self.call_soon(self.stop) + self._token = trio.lowlevel.current_trio_token() with trio.CancelScope() as self._cancel_scope: diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index e3ec819..2723efb 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -29,6 +29,7 @@ def detect_current_async_lib(): return "asyncio" ob = sys.get_asyncgen_hooks()[0] + if ob is not None: try: libname = ob.__module__.partition(".")[0] diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index 77a83bd..ae9039f 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -14,6 +14,7 @@ from .core.coreutils import ( logger, + weakbind, get_alt_x11_display, get_alt_wayland_display, ) @@ -157,12 +158,15 @@ def _rc_init(self): wx.App.SetInstance(self._app) def _rc_run(self): - # In wx we can, it seems, reliably detect widget destruction, so we don't rely on detecting the - # app from quitting (which we cannot reliably detect in wx). We could prevent the app from exiting, - # but we cannot do that when the wx app is started from the outside (which is likely), so we need - # to make it work without it anyway. - # self._app.SetExitOnFrameDelete(False) self._rc_init() + + # If no canvases, we only do a flush + if not self.get_canvases(): + self.process_wx_events() + self.stop() + self._app = None + return + self._app.MainLoop() self._app = None @@ -220,23 +224,23 @@ def __init__(self, *args, **kwargs): # We keep a timer to prevent draws during a resize. This prevents # issues with mismatching present sizes during resizing (on Linux). - self._resize_timer = TimerWithCallback(self._on_resize_done) + self._resize_timer = TimerWithCallback(weakbind(self._on_resize_done)) self._draw_lock = False - self.Bind(wx.EVT_PAINT, self.on_paint) + self.Bind(wx.EVT_PAINT, weakbind(self.on_paint)) self.Bind(wx.EVT_ERASE_BACKGROUND, lambda x: None) - self.Bind(wx.EVT_SIZE, self._on_resize) + self.Bind(wx.EVT_SIZE, weakbind(self._on_resize)) - self.Bind(wx.EVT_KEY_DOWN, self._on_key_down) - self.Bind(wx.EVT_KEY_UP, self._on_key_up) + self.Bind(wx.EVT_KEY_DOWN, weakbind(self._on_key_down)) + self.Bind(wx.EVT_KEY_UP, weakbind(self._on_key_up)) - self.Bind(wx.EVT_MOUSE_EVENTS, self._on_mouse_events) - self.Bind(wx.EVT_MOTION, self._on_mouse_move) - self.Bind(wx.EVT_ENTER_WINDOW, self._on_window_enter) - self.Bind(wx.EVT_LEAVE_WINDOW, self._on_window_enter) - self.Bind(wx.EVT_SET_FOCUS, self._on_focus) - self.Bind(wx.EVT_KILL_FOCUS, self._on_focus) - self.Bind(wx.EVT_WINDOW_DESTROY, self._on_close) + self.Bind(wx.EVT_MOUSE_EVENTS, weakbind(self._on_mouse_events)) + self.Bind(wx.EVT_MOTION, weakbind(self._on_mouse_move)) + self.Bind(wx.EVT_ENTER_WINDOW, weakbind(self._on_window_enter)) + self.Bind(wx.EVT_LEAVE_WINDOW, weakbind(self._on_window_enter)) + self.Bind(wx.EVT_SET_FOCUS, weakbind(self._on_focus)) + self.Bind(wx.EVT_KILL_FOCUS, weakbind(self._on_focus)) + self.Bind(wx.EVT_WINDOW_DESTROY, weakbind(self._on_close)) self.Show() self._final_canvas_init() @@ -352,7 +356,7 @@ def _rc_present_bitmap(self, *, data, format, **kwargs): def _rc_set_logical_size(self, width, height): width, height = int(width), int(height) parent = self.Parent - if isinstance(parent, WxRenderCanvas): + if isinstance(parent, WxRenderFrame): parent.SetClientSize(width, height) elif parent is not None: self.SetSize(width, height) @@ -369,10 +373,14 @@ def _rc_close(self): except RuntimeError: return # native C++ object is already deleted self._resize_timer.Stop() - if isinstance(parent, WxRenderCanvas): + if isinstance(parent, WxRenderFrame): parent.Destroy() else: self.Destroy() + if not self._rc_canvas_group.get_canvases(): + end_time = time.perf_counter() + 0.1 + while time.perf_counter() < end_time: + wx.Yield() def _rc_get_closed(self): return self._is_closed @@ -380,7 +388,7 @@ def _rc_get_closed(self): def _rc_set_title(self, title): # Set title only on frame parent = self.Parent - if isinstance(parent, WxRenderCanvas): + if isinstance(parent, WxRenderFrame): parent.SetTitle(title) def _rc_set_cursor(self, cursor): @@ -569,28 +577,37 @@ def _on_close(self, _event): self.close() -class WxRenderCanvas(WrapperRenderCanvas, wx.Frame): - """A toplevel wx Frame providing a render canvas.""" +class WxRenderFrame(wx.Frame): + pass + + +class WxRenderCanvas(WrapperRenderCanvas): + """A toplevel canvas (wrapping a wx.Frame that contains a WxRenderWidget).""" # Most of this is proxying stuff to the inner widget. def __init__(self, parent=None, **kwargs): - # There needs to be an application before any widget is created. - loop._rc_init() # Any kwargs that we want to pass to *this* class, must be explicitly # specified in the signature. The rest goes to the subwidget. - super().__init__(parent) - self._subwidget = WxRenderWidget(parent=self, **kwargs) + # There needs to be an application before any widget is created. + loop._rc_init() - self.Bind(wx.EVT_CLOSE, lambda e: self.close()) + self._frame = WxRenderFrame(parent) + self._subwidget = WxRenderWidget(parent=self._frame, **kwargs) + + self._frame.Bind(wx.EVT_CLOSE, weakbind(self._on_close)) + self._frame.Show() - self.Show() self._final_canvas_init() - def Refresh(self): # noqa: N802 - self._subwidget.request_draw() - super().Refresh() + def _on_close(self, _event): + self.close() + + @property + def frame(self): + """The wx.Frame that this class wraps.""" + return self._frame # Make available under a name that is the same for all gui backends diff --git a/tests/test_backend_glfw.py b/tests/test_backend_glfw.py index 43c1086..883e5ce 100644 --- a/tests/test_backend_glfw.py +++ b/tests/test_backend_glfw.py @@ -13,7 +13,7 @@ import pytest from testutils import run_tests, can_use_glfw, can_use_wgpu_lib, is_pypy -from testutils_backends import BACKEND_TEST_FUNCS, _get_draw_function +from testutils_backends import BACKEND_TEST_FUNCS, NativeHelper, _get_draw_function if not can_use_glfw: @@ -32,22 +32,23 @@ # glfw.terminate() -def test_is_canvas_classes(): - from rendercanvas.base import BaseRenderCanvas - from rendercanvas.glfw import RenderCanvas, GlfwRenderCanvas +from rendercanvas.base import BaseRenderCanvas +from rendercanvas.glfw import RenderCanvas, GlfwRenderCanvas, loop + +def test_is_canvas_classes(): assert GlfwRenderCanvas is RenderCanvas assert issubclass(RenderCanvas, BaseRenderCanvas) -def glfw_close(canvas): - glfw.set_window_should_close(canvas._window, 1) +class GlfwHelper(NativeHelper): + def close_canvas(self, canvas): + glfw.set_window_should_close(canvas._window, 1) -@pytest.mark.parametrize("backend", ["glfw"]) @pytest.mark.parametrize("func", BACKEND_TEST_FUNCS) -def test_backend_generic(func, backend): - func(backend, close_func=glfw_close) +def test_backend_glfw(func): + func(RenderCanvas, loop, GlfwHelper()) def test_glfw_canvas_del(): diff --git a/tests/test_backend_qt.py b/tests/test_backend_qt.py index 88074b9..d4db8b6 100644 --- a/tests/test_backend_qt.py +++ b/tests/test_backend_qt.py @@ -12,7 +12,7 @@ import pytest from testutils import run_tests -from testutils_backends import BACKEND_TEST_FUNCS +from testutils_backends import BACKEND_TEST_FUNCS, NativeHelper # Only run when running directly (through Python or pytest) @@ -40,7 +40,7 @@ from rendercanvas.base import BaseRenderCanvas, WrapperRenderCanvas -from rendercanvas.qt import RenderCanvas, RenderWidget +from rendercanvas.qt import RenderCanvas, RenderWidget, loop from rendercanvas.qt import QRenderWidget, QRenderCanvas @@ -56,14 +56,14 @@ def test_is_canvas_classes(): assert issubclass(RenderCanvas, QtWidgets.QWidget) # toplevel -def qt_close(canvas): - QtWidgets.QWidget.close(canvas) +class QtHelper(NativeHelper): + def close_canvas(self, canvas): + QtWidgets.QWidget.close(canvas) -@pytest.mark.parametrize("backend", [backend_name]) @pytest.mark.parametrize("func", BACKEND_TEST_FUNCS) -def test_backend_generic(func, backend): - func(backend, close_func=qt_close) +def test_backend_qt(func): + func(RenderCanvas, loop, QtHelper()) if __name__ == "__main__": diff --git a/tests/test_backend_wx.py b/tests/test_backend_wx.py index 4be9e1c..bb93bf5 100644 --- a/tests/test_backend_wx.py +++ b/tests/test_backend_wx.py @@ -9,7 +9,7 @@ import pytest from testutils import run_tests -from testutils_backends import BACKEND_TEST_FUNCS +from testutils_backends import BACKEND_TEST_FUNCS, NativeHelper # Only run when running directly (through Python or pytest) @@ -19,7 +19,7 @@ import wx from rendercanvas.base import BaseRenderCanvas, WrapperRenderCanvas -from rendercanvas.wx import RenderCanvas, RenderWidget +from rendercanvas.wx import RenderCanvas, RenderWidget, loop from rendercanvas.wx import WxRenderWidget, WxRenderCanvas @@ -32,17 +32,17 @@ def test_is_canvas_classes(): assert issubclass(RenderCanvas, WrapperRenderCanvas) assert issubclass(RenderWidget, wx.Window) - assert issubclass(RenderCanvas, wx.Frame) + # assert issubclass(RenderCanvas, wx.Frame) # -> made gc really hard -def wx_close(canvas): - canvas.Close() +class WxHelper(NativeHelper): + def close_canvas(self, canvas): + canvas.frame.Close() -@pytest.mark.parametrize("backend", ["wx"]) @pytest.mark.parametrize("func", BACKEND_TEST_FUNCS) -def test_backend_generic(func, backend): - func(backend, close_func=wx_close) +def test_backend_wx(func): + func(RenderCanvas, loop, WxHelper()) if __name__ == "__main__": diff --git a/tests/test_loop.py b/tests/test_loop.py index 162bd4b..c81e6cf 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -1,13 +1,7 @@ """ -Some tests for the base loop and asyncio loop. - -Testing the loop of GUI frameworks like Qt and wx is a bit tricky, -because importing more than one in the same process always causes problems. - -Therefore, tests for these GUI framework need to be explicitly run: -* Run "pytest -k pyside6" etc. -* Run "python tests/test_loop.py wx" etc. - +Some tests for the base loop. This is tested with our generic loops (raw, asyncio, trio). +Of these tests we assume that they'd succeed for the GUI loops as well (qt, wx, ...). +When in doubt of this assumption, better add a test to testutils_backends.py. 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 @@ -16,14 +10,9 @@ # ruff: noqa: N803 -import os import gc -import sys import time -import signal -import weakref import asyncio -import threading from rendercanvas.base import BaseCanvasGroup, BaseRenderCanvas from rendercanvas.asyncio import AsyncioLoop @@ -38,256 +27,39 @@ import pytest -default_loop_classes = [RawLoop, AsyncioLoop, TrioLoop] +loop_classes = [RawLoop, AsyncioLoop, TrioLoop] async_loop_classes = [AsyncioLoop, TrioLoop] -loop_classes = [] - - -# Determine what loops to test -if "raw" in sys.argv: - loop_classes.append(RawLoop) -elif "asyncio" in sys.argv: - loop_classes.append(AsyncioLoop) -elif "glfw" in sys.argv: - class GlfwLoop(AsyncioLoop): - pass - - loop_classes.append(GlfwLoop) -elif "trio" in sys.argv: - loop_classes.append(TrioLoop) -elif "qt" in sys.argv: - from rendercanvas.pyside6 import QtLoop - - loop_classes.append(QtLoop) -elif "pyside6" in sys.argv: - from rendercanvas.pyside6 import QtLoop - - class PySide6Loop(QtLoop): - pass - - loop_classes.append(PySide6Loop) -elif "pyqt6" in sys.argv: - from rendercanvas.pyqt6 import QtLoop - - class PyQt6Loop(QtLoop): - pass - - loop_classes.append(PyQt6Loop) -elif "pyqt5" in sys.argv: - from rendercanvas.pyqt5 import QtLoop - - class PyQt5Loop(QtLoop): - pass - - loop_classes.append(PyQt5Loop) -elif "pyside2" in sys.argv: - from rendercanvas.pyside2 import QtLoop - - class PySide2Loop(QtLoop): - pass - loop_classes.append(PySide2Loop) -elif "wx" in sys.argv: - # NOTE: because we cannot prevent wx from closing the loop - # once the last canvas closes, some tests are skipped. - from rendercanvas.wx import WxLoop - - loop_classes.append(WxLoop) -else: - loop_classes[:] = default_loop_classes - - -async def fake_task(): +class FooCanvasGroup(BaseCanvasGroup): pass -class CanvasGroup(BaseCanvasGroup): - pass - +class FooCanvas(BaseRenderCanvas): + _rc_canvas_group = FooCanvasGroup(None) -class FakeEventEmitter: - is_closed = False - - def close(self): - self.is_closed = True - - -class FakeCanvas: - def __init__(self, refuse_close=False): - self.refuse_close = refuse_close - self.is_closed = False - self._events = FakeEventEmitter() + def __init__(self): + super().__init__() + self._is_closed = False + self._final_canvas_init() def _rc_gui_poll(self): pass - def close(self): - # Called by the loop to close a canvas - self._events.close() # Mimic BaseRenderCanvas - if not self.refuse_close: - self.is_closed = True - - def get_closed(self): - return self.is_closed - - def manually_close(self): - self.is_closed = True - - def __del__(self): - # Mimic BaseRenderCanvas - try: - self.close() - except Exception: - pass - - -real_loop = AsyncioLoop() - - -class RealRenderCanvas(BaseRenderCanvas): - _rc_canvas_group = CanvasGroup(real_loop) - _is_closed = False - def _rc_close(self): + # Note: in earlier rendercanvas versions, canvases could ignore + # the signal to close. Now, closing is not a request but a command, + # and the basse class and loop will consider the canvas as closed. self._is_closed = True def _rc_get_closed(self): return self._is_closed - def _rc_request_paint(self): - loop = self._rc_canvas_group.get_loop() - loop.call_soon(self._time_to_paint) - - -# %%%%% 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.filterwarnings("ignore:.*was never awaited") -@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 - - if SomeLoop.__name__.startswith("Wx"): - pytest.skip() - - 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): - - if SomeLoop.__name__.startswith("Wx"): - pytest.skip() - - 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 - - -@pytest.mark.parametrize("SomeLoop", loop_classes) -def test_run_loop_and_close_bc_no_canvases(SomeLoop): - # Run the loop without canvas; closes immediately - - loop = SomeLoop() - loop.call_later(1.0, loop.stop) # failsafe + def manually_close(self): + self.close() - t0 = time.perf_counter() - loop.run() - t1 = time.perf_counter() - assert (t1 - t0) < 0.3 +# ==================== Running and closing @pytest.mark.parametrize("SomeLoop", loop_classes) @@ -295,317 +67,67 @@ def test_loop_detects_canvases(SomeLoop): # After all canvases are closed, it can take one tick before its detected. loop = SomeLoop() - - group1 = CanvasGroup(loop) - group2 = CanvasGroup(loop) + FooCanvas.select_loop(loop) assert len(loop._BaseLoop__canvas_groups) == 0 - canvas1 = FakeCanvas() - group1._register_canvas(canvas1, fake_task) + _canvas1 = FooCanvas() assert len(loop._BaseLoop__canvas_groups) == 1 assert len(loop.get_canvases()) == 1 - canvas2 = FakeCanvas() - group1._register_canvas(canvas2, fake_task) - - canvas3 = FakeCanvas() - group2._register_canvas(canvas3, fake_task) + _canvas2 = FooCanvas() + _canvas3 = FooCanvas() - assert len(loop._BaseLoop__canvas_groups) == 2 + assert len(loop._BaseLoop__canvas_groups) == 1 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. + # Call stop explicitly. loop.stop() loop.stop() assert loop._BaseLoop__state == "off" -@pytest.mark.parametrize("SomeLoop", loop_classes) -def test_run_loop_without_canvases(SomeLoop): - # After all canvases are closed, it can take one tick before its detected. - - leeway = 0.20 if os.getenv("CI") else 0 - if "Qt" in SomeLoop.__name__ or "PySide" in SomeLoop.__name__: - leeway = 0.10 - - loop = SomeLoop() - group = CanvasGroup(loop) - - # The loop is in its stopped state, but it fires up briefly to do one tick - - t0 = time.time() - loop.run() - et = time.time() - t0 - - print(et) - assert 0.0 <= et < 0.15 + leeway - - # Create a canvas and close it right away - - canvas1 = FakeCanvas() - group._register_canvas(canvas1, fake_task) - assert len(loop.get_canvases()) == 1 - canvas1.manually_close() - assert len(loop.get_canvases()) == 0 - - # This time the loop is in its ready state, so it will actually - # run for one tick for it to notice that all canvases are gone. - - t0 = time.time() - loop.run() - et = time.time() - t0 - - print(et) - assert 0.0 <= et < 0.15 + leeway - - # Now its in its stopped state again - - t0 = time.time() - loop.run() - et = time.time() - t0 - - print(et) - assert 0.0 <= et < 0.15 + leeway - - -@pytest.mark.parametrize("SomeLoop", loop_classes) -def test_run_loop_and_close_canvases(SomeLoop): - # After all canvases are closed, it can take one tick before its detected. - - if SomeLoop.__name__.startswith("Wx"): - pytest.skip() - - leeway = 0.20 if os.getenv("CI") else 0 - - loop = SomeLoop() - group = CanvasGroup(loop) - - canvas1 = FakeCanvas() - canvas2 = FakeCanvas() - group._register_canvas(canvas1, fake_task) - group._register_canvas(canvas2, fake_task) - - loop.call_later(0.1, canvas1.manually_close) - loop.call_later(0.3, canvas2.manually_close) - - t0 = time.time() - loop.run() - et = time.time() - t0 - - print(et) - assert 0.25 < et < 0.50 + leeway - - assert canvas1._events.is_closed - assert canvas2._events.is_closed - - -@pytest.mark.parametrize("SomeLoop", loop_classes) -def test_run_loop_and_close_by_loop_stop(SomeLoop): - # Close, then wait at most one tick to close canvases, and another to confirm close. - - if SomeLoop.__name__.startswith("Wx"): - pytest.skip() - - leeway = 0.20 if os.getenv("CI") else 0 - - loop = SomeLoop() - group = CanvasGroup(loop) - - canvas1 = FakeCanvas() - canvas2 = FakeCanvas() - 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.3, loop.stop) - - t0 = time.time() - loop.run() - et = time.time() - t0 - - print(et) - assert 0.25 < et < 0.55 + leeway - - assert canvas1._events.is_closed - assert canvas2._events.is_closed +# ==================== Lifetime @pytest.mark.parametrize("SomeLoop", loop_classes) -def test_run_loop_and_close_by_loop_stop_via_async(SomeLoop): - # Close using a coro - - if SomeLoop.__name__.startswith("Wx"): - pytest.skip() - - leeway = 0.20 if os.getenv("CI") else 0 - - loop = SomeLoop() - group = CanvasGroup(loop) - - canvas1 = FakeCanvas() - canvas2 = FakeCanvas() - group._register_canvas(canvas1, fake_task) - group._register_canvas(canvas2, fake_task) - - async def stopper(): - await async_sleep(0.3) - loop.stop() - - loop.add_task(stopper) +def test_loop_lifetime_nocanvas(SomeLoop): + # No canvas; the loop flushes the queue and stops - t0 = time.time() - loop.run() - et = time.time() - t0 - - print(et) - assert 0.25 < et < 0.55 + leeway - - assert canvas1._events.is_closed - assert canvas2._events.is_closed - - -@pytest.mark.parametrize("SomeLoop", loop_classes) -def test_run_loop_and_close_by_deletion(SomeLoop): - # Make the canvases be deleted by the gc. - - if SomeLoop.__name__.startswith("Wx"): - pytest.skip() - - leeway = 0.20 if os.getenv("CI") else 0 + states = [] + log_state = lambda loop: states.append(loop._BaseLoop__state) loop = SomeLoop() - group = CanvasGroup(loop) - - canvases = [FakeCanvas() for _ in range(2)] - events1 = canvases[0]._events - events2 = canvases[1]._events - for canvas in canvases: - group._register_canvas(canvas, fake_task) - del canvas - - loop.call_later(0.3, canvases.clear) - loop.call_later(1.3, loop.stop) # failsafe - t0 = time.time() - loop.run() - et = time.time() - t0 - - print(et) - assert 0.25 < et < 0.55 + leeway - - assert events1.is_closed - assert events2.is_closed - - -def test_run_loop_and_close_by_deletion_real(): - # Stop by deleting canvases, with a real canvas. - # This tests that e.g. scheduler task does not hold onto the canvas. - - leeway = 0.20 if os.getenv("CI") else 0 - - loop = real_loop - - canvases = [RealRenderCanvas() for _ in range(2)] - loop.call_later(0.3, canvases.clear) - loop.call_later(1.3, loop.stop) # failsafe - - t0 = time.time() + log_state(loop) + loop.call_later(0, log_state, loop) loop.run() - et = time.time() - t0 - - print(et) - assert 0.25 < et < 0.55 + leeway - - -@pytest.mark.parametrize("SomeLoop", loop_classes) -def test_run_loop_and_interrupt(SomeLoop): - # Interrupt, calls close, can take one tick to close canvases, and anoter to conform close. - - leeway = 0.50 if os.getenv("CI") else 0 - - loop = SomeLoop() - group = CanvasGroup(loop) - - canvas1 = FakeCanvas() - canvas2 = FakeCanvas() - group._register_canvas(canvas1, fake_task) - group._register_canvas(canvas2, fake_task) - - loop.call_later(0.1, print, "hi from loop!") + log_state(loop) - def interrupt_soon(): - time.sleep(0.3) - signal.raise_signal(signal.SIGINT) + assert states == ["off", "running", "off"] - t = threading.Thread(target=interrupt_soon) - t.start() + states.clear() - t0 = time.time() + log_state(loop) + loop.call_later(0, log_state, loop) + loop.call_later(0, log_state, loop) + loop.call_later(0, log_state, loop) loop.run() - et = time.time() - t0 - t.join() - - print(et) - assert 0.25 < et < 0.55 + leeway - - assert canvas1._events.is_closed - assert canvas2._events.is_closed - - -@pytest.mark.parametrize("SomeLoop", loop_classes) -def test_run_loop_and_interrupt_harder(SomeLoop): - # In the first tick it attempts to close the canvas, clearing some - # stuff of the BaseRenderCanvase, like the events, but the native canvas - # won't close, so in the second try, the loop is closed regardless. - # after the second interupt, it stops the loop and closes the canvases - - leeway = 0.50 if os.getenv("CI") else 0 - - loop = SomeLoop() - group = CanvasGroup(loop) - - canvas1 = FakeCanvas(refuse_close=True) - canvas2 = FakeCanvas(refuse_close=True) - group._register_canvas(canvas1, fake_task) - group._register_canvas(canvas2, fake_task) - - loop.call_later(0.1, print, "hi from loop!") + log_state(loop) - def interrupt_soon(): - time.sleep(0.3) - signal.raise_signal(signal.SIGINT) - time.sleep(0.3) - signal.raise_signal(signal.SIGINT) + assert states == ["off", "running", "running", "running", "off"] - t = threading.Thread(target=interrupt_soon) - t.start() + states.clear() - t0 = time.time() + log_state(loop) + loop.call_later(0, log_state, loop) + loop.call_later(0.91, log_state, loop) + loop.call_later(0.92, log_state, loop) loop.run() - et = time.time() - t0 - t.join() - - print(et) - assert 0.6 < et < 0.75 + leeway - - # The events are closed - assert canvas1._events.is_closed - assert canvas2._events.is_closed - - # But the canvases themselves are still marked not-closed - assert not canvas1.is_closed - assert not canvas2.is_closed - + log_state(loop) -# %%%%% lifetime + assert states == ["off", "running", "off"] @pytest.mark.parametrize("SomeLoop", loop_classes) @@ -614,33 +136,40 @@ def test_loop_lifetime_normal(SomeLoop): log_state = lambda loop: states.append(loop._BaseLoop__state) loop = SomeLoop() + FooCanvas.select_loop(loop) + log_state(loop) + _canvas1 = FooCanvas() loop.call_later(0.01, log_state, loop) loop.call_later(0.1, loop.stop) + log_state(loop) loop.run() log_state(loop) - assert states == ["off", "running", "off"] + assert states == ["off", "ready", "running", "off"] # Again states.clear() + log_state(loop) + _canvas1 = FooCanvas() loop.call_later(0.01, log_state, loop) loop.call_later(0.1, loop.stop) + log_state(loop) loop.run() log_state(loop) - assert states == ["off", "running", "off"] + assert states == ["off", "ready", "running", "off"] @pytest.mark.parametrize("SomeLoop", loop_classes) def test_loop_lifetime_with_ready(SomeLoop): - # Creating a canvas, or addding a task puts the loop in its ready state + # Creating a canvas, or adding a task puts the loop in its ready state states = [] log_state = lambda loop: states.append(loop._BaseLoop__state) @@ -649,10 +178,12 @@ async def noop(): pass loop = SomeLoop() - log_state(loop) + FooCanvas.select_loop(loop) + log_state(loop) loop.add_task(noop) log_state(loop) + _canvas1 = FooCanvas() loop.call_later(0.01, log_state, loop) loop.call_later(0.1, loop.stop) @@ -662,34 +193,47 @@ async def noop(): assert states == ["off", "ready", "running", "off"] - # Again - states.clear() - log_state(loop) +@pytest.mark.parametrize("SomeLoop", async_loop_classes) +def test_loop_lifetime_async1(SomeLoop): + # Run using loop.run_async, without canvases + + states = [] + log_state = lambda loop: states.append(loop._BaseLoop__state) + + loop = SomeLoop() - loop.add_task(noop) log_state(loop) - loop.call_later(0.01, log_state, loop) - loop.call_later(0.1, loop.stop) + loop.call_later(0, log_state, loop) + + if SomeLoop is AsyncioLoop: + asyncio.run(loop.run_async()) + elif SomeLoop is TrioLoop: + trio.run(loop.run_async) + else: + raise NotImplementedError() - loop.run() log_state(loop) - assert states == ["off", "ready", "running", "off"] + assert states == ["off", "active", "off"] @pytest.mark.parametrize("SomeLoop", async_loop_classes) -def test_loop_lifetime_async(SomeLoop): - # Run using loop.run_async +def test_loop_lifetime_async2(SomeLoop): + # Run using loop.run_async, with a canvas states = [] log_state = lambda loop: states.append(loop._BaseLoop__state) loop = SomeLoop() + FooCanvas.select_loop(loop) + log_state(loop) + _canvas1 = FooCanvas() loop.call_later(0.01, log_state, loop) + loop.call_later(0.1, loop.stop) if SomeLoop is AsyncioLoop: asyncio.run(loop.run_async()) @@ -704,34 +248,31 @@ def test_loop_lifetime_async(SomeLoop): def test_loop_lifetime_running_outside(): - # Run using asyncio.run. - # Note how the rendercanvas loop is stopped earlier than the asyncio loop. - # Note that we use asyncio.run() here which has the logic to - # clean up tasks. When using asyncio.new_event_loop().run_xx() then - # it does *not* work, the user is expected to cancel tasks then. - # Or ... just exit Python when done *shrug*. + # Run using asyncio.run, make sure the rc loop detects the stop states = [] log_state = lambda loop: states.append(loop._BaseLoop__state) loop = AsyncioLoop() + FooCanvas.select_loop(loop) + log_state(loop) + _canvas1 = FooCanvas() loop.call_later(0.01, log_state, loop) - loop.call_later(0.1, loop.stop) async def main(): lop = asyncio.get_running_loop() task = lop.create_task(loop.run_async()) - lop.call_later(0.15, log_state, loop) # by this time rc has stopped - await asyncio.sleep(0.25) + lop.call_later(0.1, log_state, loop) + await asyncio.sleep(0.2) del task # for ruff and good practice, we kept a ref to task asyncio.run(main()) log_state(loop) - assert states == ["off", "active", "off", "off"] + assert states == ["off", "active", "active", "off"] def test_loop_lifetime_interactive(): @@ -764,7 +305,7 @@ async def main(): assert (times[2] - times[1]) > 0.20 -# %%%%% tasks +# ==================== Tasks @pytest.mark.parametrize("SomeLoop", loop_classes) @@ -775,9 +316,9 @@ def test_loop_task_order(SomeLoop): flag = [] class MyLoop(SomeLoop): - async def _loop_task(self): - flag.append("loop-task") - return await super()._loop_task() + async def _loop_start_detection_task(self): + flag.append("loop-start-detect-task") + return await super()._loop_start_detection_task() async def user_task(id): flag.append(f"user-task{id}") @@ -789,7 +330,7 @@ async def user_task(id): loop.call_later(0.2, loop.stop) loop.run() - assert flag == ["loop-task", "user-task1", "user-task2"], flag + assert flag == ["loop-start-detect-task", "user-task1", "user-task2"], flag # Again @@ -800,7 +341,7 @@ async def user_task(id): loop.call_later(0.2, loop.stop) loop.run() - assert flag == ["loop-task", "user-task1", "user-task2"], flag + assert flag == ["loop-start-detect-task", "user-task1", "user-task2"], flag @pytest.mark.parametrize("SomeLoop", loop_classes) @@ -833,7 +374,7 @@ async def user_task(): assert flag == ["start", "stop"], flag -# %%%%% Misc +# ==================== Misc def test_not_using_loop_debug_thread(): @@ -855,34 +396,13 @@ def test_not_using_loop_debug_thread(): assert not thread.is_alive() -@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop, TrioLoop]) -def test_loop_threaded(SomeLoop): - # Does not work for QtLoop - - error = None - - def wrapper(): - nonlocal error - try: - test_run_loop_and_close_by_loop_stop(SomeLoop) - except Exception as err: - error = err - - t = threading.Thread(target=wrapper) - t.start() - t.join() - - if error is not None: - raise error - - def test_async_loops_check_lib(): # Cannot run asyncio loop on trio asyncio_loop = AsyncioLoop() - group = CanvasGroup(asyncio_loop) - canvas1 = FakeCanvas() - group._register_canvas(canvas1, fake_task) + FooCanvas.select_loop(asyncio_loop) + + canvas1 = FooCanvas() canvas1.manually_close() with pytest.raises(TypeError): @@ -893,9 +413,9 @@ def test_async_loops_check_lib(): # Cannot run trio loop on asyncio trio_loop = TrioLoop() - group = CanvasGroup(trio_loop) - canvas1 = FakeCanvas() - group._register_canvas(canvas1, fake_task) + FooCanvas.select_loop(asyncio_loop) + + canvas1 = FooCanvas() canvas1.manually_close() with pytest.raises(TypeError): @@ -904,7 +424,7 @@ def test_async_loops_check_lib(): trio.run(trio_loop.run_async) -# %%%%% async generator cleanup +# ==================== async generator cleanup async def a_generator(flag, *, await_in_finalizer=False): @@ -934,15 +454,41 @@ async def tester_coroutine(): flag = [] loop = SomeLoop() + FooCanvas.select_loop(loop) + loop.add_task(tester_coroutine) - loop.call_later(0.2, loop.stop) + _canvas1 = FooCanvas() + loop.call_later(0.1, loop.stop) loop.run() assert flag == [], flag @pytest.mark.parametrize("SomeLoop", loop_classes) -def test_async_gens_cleanup1(SomeLoop): +def test_async_gens_cleanup1a(SomeLoop): + # Run the generator, but stop too soon + + async def tester_coroutine(): + g = a_generator(flag) + async for i in g: + pass + + flag = [] + + loop = SomeLoop() + + loop.add_task(tester_coroutine) + + loop.call_later(0.1, loop.stop) # just a failsafe + loop.run() + + flags1 = ["started", "except Cancelled", "closed"] + flags2 = ["started", "except CancelledError", "closed"] + assert flag == flags1 or flag == flags2, flag + + +@pytest.mark.parametrize("SomeLoop", loop_classes) +def test_async_gens_cleanup1b(SomeLoop): # Run the generator to completion. # Just works, because code of generator is done. @@ -952,9 +498,14 @@ async def tester_coroutine(): pass flag = [] + loop = SomeLoop() + FooCanvas.select_loop(loop) + loop.add_task(tester_coroutine) - loop.call_later(0.2, loop.stop) + _canvas1 = FooCanvas() + + loop.call_later(0.1, loop.stop) loop.run() assert flag == ["started", "finished", "closed"], flag @@ -974,9 +525,14 @@ async def tester_coroutine(): break flag = [] + loop = SomeLoop() + FooCanvas.select_loop(loop) + loop.add_task(tester_coroutine) - loop.call_later(0.2, loop.stop) + _canvas1 = FooCanvas() + + loop.call_later(0.1, loop.stop) loop.run() assert flag == ["started", "except GeneratorExit", "closed"], flag @@ -998,9 +554,14 @@ async def tester_coroutine(): break flag = [] + loop = SomeLoop() + FooCanvas.select_loop(loop) + loop.add_task(tester_coroutine) - loop.call_later(0.2, loop.stop) + _canvas1 = FooCanvas() + + loop.call_later(0.1, loop.stop) loop.run() assert flag == ["started", "except GeneratorExit", "closed"], flag @@ -1021,9 +582,14 @@ async def tester_coroutine(): break flag = [] + loop = SomeLoop() + FooCanvas.select_loop(loop) + loop.add_task(tester_coroutine) - loop.call_later(0.2, loop.stop) + _canvas1 = FooCanvas() + + loop.call_later(0.1, loop.stop) loop.run() if SomeLoop is AsyncioLoop: @@ -1043,6 +609,4 @@ async def tester_coroutine(): if __name__ == "__main__": - # from rendercanvas.wx import WxLoop - # loop_classes[:] = [WxLoop] run_tests(globals()) diff --git a/tests/testutils.py b/tests/testutils.py index b5fb177..7dd4125 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -42,29 +42,36 @@ def run_tests(scope): ] nargs = func.__code__.co_argcount argnames = [func.__code__.co_varnames[i] for i in range(nargs)] - try: - if not argnames: - print(f"Running {func.__name__} ...") + if not argnames: + print(f"Running {func.__name__} ...") + try: func() - elif nargs == 1 and len(params) == 1: - for arg in params[0][1]: - print(f"Running {func.__name__} with {arg}...") + except pytest.skip.Exception: + print(f"SKIPPING {func.__name__} by pytest skip") + elif nargs == 1 and len(params) == 1: + for arg in params[0][1]: + print(f"Running {func.__name__} with {arg}...") + try: func(arg) - elif nargs == 2 and len(params) == 2: - for arg1 in params[0][1]: - for arg2 in params[1][1]: - print(f"Running {func.__name__} with {arg1}-{arg2}...") + except pytest.skip.Exception: + print(f"SKIPPING {func.__name__} by pytest skip") + elif nargs == 2 and len(params) == 2: + for arg1 in params[0][1]: + for arg2 in params[1][1]: + print(f"Running {func.__name__} with {arg1}-{arg2}...") + try: func(arg1, arg2) - elif argnames == ["caplog"]: - print(f"Running {func.__name__} ...") - logging.root.addHandler(caplog) - caplog.reset() - func(caplog) - logging.root.removeHandler(caplog) - else: - print(f"SKIPPING {func.__name__} because it needs args") - except pytest.skip.Exception: - print(f"SKIPPING {func.__name__} by pytest skip") + except pytest.skip.Exception: + print(f"SKIPPING {func.__name__} by pytest skip") + elif argnames == ["caplog"]: + print(f"Running {func.__name__} ...") + logging.root.addHandler(caplog) + caplog.reset() + func(caplog) + logging.root.removeHandler(caplog) + else: + print(f"SKIPPING {func.__name__} because it needs args") + print("Done") diff --git a/tests/testutils_backends.py b/tests/testutils_backends.py index 784ecda..116a7f1 100644 --- a/tests/testutils_backends.py +++ b/tests/testutils_backends.py @@ -2,11 +2,17 @@ Some tests that can be run for different (GUI) backends. """ +# ruff: noqa: N803, N806 + import gc +import os import time import weakref -import importlib +import signal +import threading + import numpy as np +from rendercanvas.utils.asyncs import sleep as async_sleep from testutils import can_use_wgpu_lib import pytest @@ -16,45 +22,328 @@ BACKEND_TEST_FUNCS = [] -def import_canvas_class_and_loop(backend) -> tuple[type, object]: - mod = importlib.import_module(f"rendercanvas.{backend}") - return mod.RenderCanvas, mod.loop +def add_test_func(func): + BACKEND_TEST_FUNCS.append(func) + return func + + +class NativeHelper: + def close_canvas(self, canvas): + raise NotImplementedError() + + +def events_is_closed(canvas): + if hasattr(canvas, "_subwidget"): + return canvas._subwidget._events._closed + else: + return canvas._events._closed + + +# ==================== loop deletion + + +@add_test_func +def backend_loop_deletion1(RenderCanvas: type, loop, helper: NativeHelper): + # Loops get gc'd when instantiated but not used. + + SomeLoop = loop.__class__ + + loop = SomeLoop() + + assert loop._BaseLoop__state == "off" + + loop_ref = weakref.ref(loop) + del loop + gc.collect() + gc.collect() + + assert loop_ref() is None + + +@add_test_func +def backend_loop_deletion2(RenderCanvas: type, loop, helper: NativeHelper): + # Loops get gc'd when in ready state + SomeLoop = loop.__class__ + + 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 + + +@add_test_func +def backend_loop_deletion3(RenderCanvas: type, loop, helper: NativeHelper): + # Loops get gc'd when closed after use + SomeLoop = loop.__class__ + + flag = [] + + async def foo(): + flag.append(True) + + loop = SomeLoop() + loop.add_task(foo) + assert "ready" in repr(loop) + loop.run() + assert flag == [True] + assert loop._BaseLoop__state == "off" + + loop_ref = weakref.ref(loop) + del loop + for _ in range(4): + time.sleep(0.02) + gc.collect() + + assert loop_ref() is None + + +# TODO: a test that makes sure that pending call_later's are cancelled when the loop is closed +# TODO: -> may need to refactor to focus fully on call_soon_threadsafe. + + +# ==================== running and closing + + +@add_test_func +def backend_run_loop_and_close_bc_no_canvases( + RenderCanvas: type, loop, helper: NativeHelper +): + # Run the loop without canvas; closes immediately + + loop.call_later(1.0, loop.stop) # failsafe + + # TODO: also test pending call_soons etc. also see test_loop_lifetime_normal in test_loop.py + + t0 = time.perf_counter() + loop.run() + t1 = time.perf_counter() + + assert (t1 - t0) < 0.3 + assert loop._BaseLoop__state == "off" + + +@add_test_func +def backend_loop_detects_canvases(RenderCanvas: type, loop, helper: NativeHelper): + # After all canvases are closed, it can take one tick before its detected. + + _canvas1 = RenderCanvas() + assert len(loop.get_canvases()) == 1 + + _canvas2 = RenderCanvas() + _canvas3 = RenderCanvas() + 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" + + +@add_test_func +def backend_run_loop_without_canvases(RenderCanvas: type, loop, helper: NativeHelper): + # After all canvases are closed, it can take one tick before its detected. + + leeway = 0.20 if os.getenv("CI") else 0 + if "Qt" in loop.__class__.__name__ or "PySide" in loop.__class__.__name__: + leeway = 0.10 + + # The loop is in its stopped state, but it fires up briefly to do one tick + + t0 = time.time() + loop.run() + et = time.time() - t0 + + print(et) + assert 0.0 <= et < 0.15 + leeway + assert loop._BaseLoop__state == "off" + + # Create a canvas and close it right away + + canvas1 = RenderCanvas() + assert len(loop.get_canvases()) == 1 + canvas1.close() + assert len(loop.get_canvases()) == 0 + + # This time the loop is in its ready state, so it will actually + # run for one tick for it to notice that all canvases are gone. + + t0 = time.time() + loop.run() + et = time.time() - t0 + + print(et) + assert 0.0 <= et < 0.15 + leeway + assert loop._BaseLoop__state == "off" + + # Now its in its stopped state again + + t0 = time.time() + loop.run() + et = time.time() - t0 + + print(et) + assert 0.0 <= et < 0.15 + leeway + assert loop._BaseLoop__state == "off" + +@add_test_func +def backend_run_loop_and_close_canvases(RenderCanvas: type, loop, helper: NativeHelper): + # After all canvases are closed, it can take one tick before its detected. -@BACKEND_TEST_FUNCS.append -def canvas_close_by_canvas(backend, *, close_func): - RenderCanvas, loop = import_canvas_class_and_loop(backend) # noqa: N806 + leeway = 0.20 if os.getenv("CI") else 0 canvas1 = RenderCanvas() canvas2 = RenderCanvas() - loop.call_later(0.5, canvas1.close) - loop.call_later(0.6, canvas2.close) + loop.call_later(0.1, canvas1.close) + loop.call_later(0.3, canvas2.close) + + t0 = time.time() + print(loop) loop.run() + et = time.time() - t0 - assert canvas1.get_closed() - assert canvas2.get_closed() + print(et) + assert 0.25 < et < 0.50 + leeway - canvas_ref1 = weakref.ref(canvas1) - canvas_ref2 = weakref.ref(canvas2) - del canvas1, canvas2 - gc.collect() - time.sleep(0.02) - gc.collect() + assert events_is_closed(canvas1) + assert events_is_closed(canvas2) + assert loop._BaseLoop__state == "off" - assert canvas_ref1() is None - assert canvas_ref2() is None +@add_test_func +def backend_run_loop_and_close_by_loop_stop( + RenderCanvas: type, loop, helper: NativeHelper +): + # Close, then wait at most one tick to close canvases, and another to confirm close. -@BACKEND_TEST_FUNCS.append -def canvas_close_by_pressing_cross(backend, *, close_func): - RenderCanvas, loop = import_canvas_class_and_loop(backend) # noqa: N806 + leeway = 0.20 if os.getenv("CI") else 0 + + canvas1 = RenderCanvas() + + loop.call_later(0.1, print, "hi from loop!") + loop.call_later(0.3, loop.stop) + + t0 = time.time() + loop.run() + et = time.time() - t0 + + print(et) + assert 0.25 < et < 0.55 + leeway + + assert events_is_closed(canvas1) + assert loop._BaseLoop__state == "off" + + +@add_test_func +def backend_run_loop_and_close_by_loop_stop_via_async( + RenderCanvas: type, loop, helper: NativeHelper +): + # Close using a coro + + leeway = 0.20 if os.getenv("CI") else 0 + + canvas1 = RenderCanvas() + + async def stopper(): + await async_sleep(0.3) + loop.stop() + + loop.add_task(stopper) + + t0 = time.time() + loop.run() + et = time.time() - t0 + + print(et) + assert 0.25 < et < 0.55 + leeway + + assert events_is_closed(canvas1) + assert loop._BaseLoop__state == "off" + + +@add_test_func +def backend_run_loop_and_close_by_del(RenderCanvas: type, loop, helper: NativeHelper): + # Make the canvases be deleted by the gc. + + leeway = 0.20 if os.getenv("CI") else 0 + + canvases = [RenderCanvas() for _ in range(2)] + weak = [weakref.ref(c) for c in canvases] + + def print_refs(): + print([type(x) for x in gc.get_referrers(weak[0]())]) + print([x for x in gc.get_referrers(weak[0]())]) + + loop.call_later(0.2, canvases.clear) + loop.call_later(1.3, loop.stop) # failsafe + t0 = time.time() + loop.run() + et = time.time() - t0 + + print(et) + assert 0.15 < et < 0.55 + leeway, et + + assert loop._BaseLoop__state == "off" + + +@add_test_func +def backend_run_loop_and_interrupt(RenderCanvas: type, loop, helper: NativeHelper): + # Interrupt, calls close, can take one tick to close canvases, and another to conform close. + + if RenderCanvas.__name__.startswith("Wx"): + pytest.skip("wx seems to overload interrupt by a hard exit") + + leeway = 0.50 if os.getenv("CI") else 0 + + canvas1 = RenderCanvas() + + loop.call_later(0.1, print, "hi from loop!") + + def interrupt_soon(): + time.sleep(0.3) + signal.raise_signal(signal.SIGINT) + + t = threading.Thread(target=interrupt_soon) + t.start() + + t0 = time.time() + loop.run() + et = time.time() - t0 + t.join() + + print(et) + assert 0.25 < et < 0.55 + leeway + + assert events_is_closed(canvas1) + assert loop._BaseLoop__state == "off" + + +@add_test_func +def backend_close_by_pressing_cross(RenderCanvas: type, loop, helper: NativeHelper): + # Emulate the user pressing the cross canvas1 = RenderCanvas() canvas2 = RenderCanvas() - loop.call_later(0.5, lambda: close_func(canvas1)) # noqa: F821 - loop.call_later(0.6, lambda: close_func(canvas2)) # noqa: F821 + loop.call_later(0.5, lambda: helper.close_canvas(canvas1)) # noqa: F821 + loop.call_later(0.6, lambda: helper.close_canvas(canvas2)) # noqa: F821 loop.run() assert canvas1.get_closed() @@ -69,26 +358,14 @@ def canvas_close_by_pressing_cross(backend, *, close_func): assert canvas_ref1() is None assert canvas_ref2() is None + assert loop._BaseLoop__state == "off" -@BACKEND_TEST_FUNCS.append -def canvas_close_by_loop(backend, close_func): - RenderCanvas, loop = import_canvas_class_and_loop(backend) # noqa: N806 - - canvas1 = RenderCanvas() - canvas2 = RenderCanvas() - - loop.call_later(0.5, loop.stop) - loop.run() - - assert canvas1.get_closed() - assert canvas2.get_closed() - +# ==================== Resizing -@BACKEND_TEST_FUNCS.append -def canvas_sizing(backend, close_func): - RenderCanvas, _ = import_canvas_class_and_loop(backend) # noqa: N806 +@add_test_func +def backend_sizing(RenderCanvas: type, loop, helper: NativeHelper): canvas = RenderCanvas(size=(640, 480)) canvas._rc_gui_poll() @@ -113,12 +390,14 @@ def canvas_sizing(backend, close_func): canvas.close() canvas._rc_gui_poll() assert canvas.get_closed() + assert loop._BaseLoop__state == "off" -@BACKEND_TEST_FUNCS.append -def canvas_render_bitmap(backend, close_func): - RenderCanvas, loop = import_canvas_class_and_loop(backend) # noqa: N806 +# ==================== Rendering + +@add_test_func +def backend_render_bitmap(RenderCanvas: type, loop, helper: NativeHelper): canvas = RenderCanvas(size=(640, 480)) ctx = canvas.get_bitmap_context() @@ -146,17 +425,16 @@ def draw(): gc.collect() assert canvas_ref() is None + assert loop._BaseLoop__state == "off" -@BACKEND_TEST_FUNCS.append -def canvas_render_wgpu(backend, close_func): +@add_test_func +def backend_render_wgpu(RenderCanvas: type, loop, helper: NativeHelper): if not can_use_wgpu_lib: pytest.skip("Skipping tests that needs the wgpu lib") import wgpu - RenderCanvas, loop = import_canvas_class_and_loop(backend) # noqa: N806 - canvas = RenderCanvas(size=(640, 480)) device = wgpu.gpu.request_adapter_sync().request_device_sync() @@ -176,6 +454,7 @@ def canvas_render_wgpu(backend, close_func): gc.collect() assert canvas_ref() is None + assert loop._BaseLoop__state == "off" def _get_draw_function(device, canvas): From b28ff63fd865b97e770148cacd0ba079e264f98e Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 17 Jul 2026 15:18:31 +0200 Subject: [PATCH 08/18] add test files --- tests/test_backend_raw.py | 38 ++++++++++++++++++++++++++++++ tests/test_backend_terminal.py | 42 ++++++++++++++++++++++++++++++++++ tests/test_backend_trio.py | 38 ++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 tests/test_backend_raw.py create mode 100644 tests/test_backend_terminal.py create mode 100644 tests/test_backend_trio.py diff --git a/tests/test_backend_raw.py b/tests/test_backend_raw.py new file mode 100644 index 0000000..df90ba5 --- /dev/null +++ b/tests/test_backend_raw.py @@ -0,0 +1,38 @@ +""" +Tests for the raw loop. We use the glfw canvas to test it with. +""" + +from rendercanvas.raw import loop +from rendercanvas.glfw import GlfwRenderCanvas + +import pytest +from testutils import run_tests, can_use_glfw +from testutils_backends import BACKEND_TEST_FUNCS, NativeHelper + + +if not can_use_glfw: + pytest.skip("Skipping tests that needs glfw", allow_module_level=True) + + +import glfw + + +class RawRenderCanvas(GlfwRenderCanvas): + pass + + +RawRenderCanvas.select_loop(loop) + + +class RawHelper(NativeHelper): + def close_canvas(self, canvas): + glfw.set_window_should_close(canvas._window, 1) + + +@pytest.mark.parametrize("func", BACKEND_TEST_FUNCS) +def test_backend_raw(func): + func(RawRenderCanvas, loop, RawHelper()) + + +if __name__ == "__main__": + run_tests(globals()) diff --git a/tests/test_backend_terminal.py b/tests/test_backend_terminal.py new file mode 100644 index 0000000..3da215f --- /dev/null +++ b/tests/test_backend_terminal.py @@ -0,0 +1,42 @@ +""" +Tests for the terminal backend. +""" + +import io +import sys + +import rendercanvas +from rendercanvas.terminal import RenderCanvas, loop + +import pytest +from testutils import run_tests +from testutils_backends import BACKEND_TEST_FUNCS, NativeHelper + + +def setup_module(): + rendercanvas.terminal.term_stream = io.StringIO() + + +def teardown_module(): + rendercanvas.terminal.term_stream = sys.__stdout__ + + +class TerminalHelper(NativeHelper): + def close_canvas(self, canvas): + canvas.close() + + +EXCLUDES = ["backend_sizing"] + + +@pytest.mark.parametrize("func", BACKEND_TEST_FUNCS) +def test_backend_terminal(func): + if func.__name__ in EXCLUDES: + pytest.skip() + func(RenderCanvas, loop, TerminalHelper()) + + +if __name__ == "__main__": + setup_module() + run_tests(globals()) + teardown_module() diff --git a/tests/test_backend_trio.py b/tests/test_backend_trio.py new file mode 100644 index 0000000..03f1f02 --- /dev/null +++ b/tests/test_backend_trio.py @@ -0,0 +1,38 @@ +""" +Tests for the trio loop. We use the glfw canvas to test it with. +""" + +from rendercanvas.trio import loop +from rendercanvas.glfw import GlfwRenderCanvas + +import pytest +from testutils import run_tests, can_use_glfw +from testutils_backends import BACKEND_TEST_FUNCS, NativeHelper + + +if not can_use_glfw: + pytest.skip("Skipping tests that needs glfw", allow_module_level=True) + + +import glfw + + +class TrioRenderCanvas(GlfwRenderCanvas): + pass + + +TrioRenderCanvas.select_loop(loop) + + +class TrioHelper(NativeHelper): + def close_canvas(self, canvas): + glfw.set_window_should_close(canvas._window, 1) + + +@pytest.mark.parametrize("func", BACKEND_TEST_FUNCS) +def test_backend_trio(func): + func(TrioRenderCanvas, loop, TrioHelper()) + + +if __name__ == "__main__": + run_tests(globals()) From 182c94fc349df46056934cb30c21479ea0fcdb1b Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 17 Jul 2026 16:12:48 +0200 Subject: [PATCH 09/18] fix tests --- rendercanvas/core/loop.py | 2 -- rendercanvas/core/scheduler.py | 9 +++--- tests/test_asyncs.py | 32 ++++++++++++++++++-- tests/test_backend_glfw.py | 54 +++++++++++++++++++++------------- tests/test_backend_raw.py | 28 +++++++++++------- tests/test_backend_terminal.py | 24 ++++++++++++++- tests/test_backend_trio.py | 28 +++++++++++------- 7 files changed, 127 insertions(+), 50 deletions(-) diff --git a/rendercanvas/core/loop.py b/rendercanvas/core/loop.py index a0ec3f1..9011687 100644 --- a/rendercanvas/core/loop.py +++ b/rendercanvas/core/loop.py @@ -83,8 +83,6 @@ class BaseLoop: """ - _stop_when_no_canvases = True - def __init__(self): self.__tasks = set() # only used by the async adapter self.__canvas_groups = set() diff --git a/rendercanvas/core/scheduler.py b/rendercanvas/core/scheduler.py index 3879081..8e8df17 100644 --- a/rendercanvas/core/scheduler.py +++ b/rendercanvas/core/scheduler.py @@ -51,11 +51,11 @@ def __init__( # ... = canvas.get_context() -> No, context creation should be lazy! # Scheduling variables - self.set_enabled(True) - self.set_update_mode(update_mode, min_fps=min_fps, max_fps=max_fps) + self._enabled = True self._draw_requested = True # Start with a draw in ondemand mode self._ready_for_present = None self._just_cancelled_a_frame = False + self.set_update_mode(update_mode, min_fps=min_fps, max_fps=max_fps) # Keep track of fps self._draw_stats = 0, time.perf_counter() @@ -95,8 +95,8 @@ def set_update_mode(self, update_mode, *, min_fps=None, max_fps=None): self._max_fps = max(1, float(max_fps)) def set_enabled(self, enabled: bool): - self._enabled = bool(enabled) - if self._enabled: + if not self._enabled: + self._enabled = bool(enabled) self._draw_requested = True def request_draw(self): @@ -195,6 +195,7 @@ async def __scheduler_task(self): # terms of user-experience, the cost of this delay is probably # larger than the benefit of the potential fps increase. + last_draw_time = time.perf_counter() self._ready_for_present = Event() canvas._rc_request_draw() del canvas diff --git a/tests/test_asyncs.py b/tests/test_asyncs.py index 8077dfb..5e42f57 100644 --- a/tests/test_asyncs.py +++ b/tests/test_asyncs.py @@ -7,6 +7,7 @@ import os import time +from rendercanvas.base import BaseCanvasGroup, BaseRenderCanvas from rendercanvas.asyncio import AsyncioLoop from rendercanvas.trio import TrioLoop from rendercanvas.raw import RawLoop @@ -20,6 +21,28 @@ loop_classes = [RawLoop, AsyncioLoop, TrioLoop] +class FooCanvasGroup(BaseCanvasGroup): + pass + + +class FooCanvas(BaseRenderCanvas): + _rc_canvas_group = FooCanvasGroup(None) + + def __init__(self): + super().__init__() + self._is_closed = False + self._final_canvas_init() + + def _rc_gui_poll(self): + pass + + def _rc_close(self): + self._is_closed = True + + def _rc_get_closed(self): + return self._is_closed + + @pytest.mark.parametrize("SomeLoop", loop_classes) def test_sleep(SomeLoop): leeway = 0.20 if os.getenv("CI") else 0 @@ -35,7 +58,8 @@ async def coro(): loop.stop() loop = SomeLoop() - loop._stop_when_no_canvases = False + FooCanvas.select_loop(loop) + _canvas = FooCanvas() loop.add_task(coro) loop.run() @@ -65,7 +89,8 @@ async def coro(): loop.stop() loop = SomeLoop() - loop._stop_when_no_canvases = False + FooCanvas.select_loop(loop) + _canvas = FooCanvas() loop.add_task(coro) loop.run() @@ -105,7 +130,8 @@ async def coro2(): loop.stop() loop = SomeLoop() - loop._stop_when_no_canvases = False + FooCanvas.select_loop(loop) + _canvas = FooCanvas() loop.add_task(coro1) loop.add_task(coro2) loop.run() diff --git a/tests/test_backend_glfw.py b/tests/test_backend_glfw.py index 883e5ce..4e34457 100644 --- a/tests/test_backend_glfw.py +++ b/tests/test_backend_glfw.py @@ -6,10 +6,11 @@ available. """ +import gc import os +import sys import weakref import asyncio -import gc import pytest from testutils import run_tests, can_use_glfw, can_use_wgpu_lib, is_pypy @@ -20,22 +21,38 @@ pytest.skip("Skipping tests that needs glfw", allow_module_level=True) import glfw -# def setup_module(): -# import glfw -# glfw.init() -# -# def teardown_module(): -# import glfw -# from rendercanvas.glfw import poll_glfw_briefly -# poll_glfw_briefly() -# # Terminate; otherwise it gets in the way of tests for the Qt or wx loop. -# glfw.terminate() - - -from rendercanvas.base import BaseRenderCanvas + + +from rendercanvas.base import BaseRenderCanvas, BaseCanvasGroup from rendercanvas.glfw import RenderCanvas, GlfwRenderCanvas, loop +# Only run when running directly (through Python or pytest) +if not (__name__ == "__main__" or any(__name__ in a for a in sys.argv)): + pytest.skip(f"Skipping backend specific tests {__name__}", allow_module_level=True) + + +# ----- A fresh canvas class and loop, for use in these tests + + +class GlfwLoop(loop.__class__): + pass + + +loop = GlfwLoop() + + +class CanvasGroup(BaseCanvasGroup): + pass + + +class GlfwCanvas(RenderCanvas): + _rc_canvas_group = CanvasGroup(loop) + + +# ----- + + def test_is_canvas_classes(): assert GlfwRenderCanvas is RenderCanvas assert issubclass(RenderCanvas, BaseRenderCanvas) @@ -48,11 +65,10 @@ def close_canvas(self, canvas): @pytest.mark.parametrize("func", BACKEND_TEST_FUNCS) def test_backend_glfw(func): - func(RenderCanvas, loop, GlfwHelper()) + func(GlfwCanvas, loop, GlfwHelper()) def test_glfw_canvas_del(): - from rendercanvas.glfw import RenderCanvas, loop aio_loop = asyncio.new_event_loop() loop_task = aio_loop.create_task(loop.run_async()) @@ -60,7 +76,7 @@ def test_glfw_canvas_del(): def run_briefly(): aio_loop.run_until_complete(asyncio.sleep(0.5)) - canvas = RenderCanvas() + canvas = GlfwCanvas() ref = weakref.ref(canvas) assert ref() is not None @@ -86,8 +102,6 @@ def test_glfw_canvas_render(): pytest.skip("Skipping tests that needs the wgpu lib") import wgpu - from rendercanvas.glfw import RenderCanvas - from rendercanvas.asyncio import loop aio_loop = asyncio.new_event_loop() loop_task = aio_loop.create_task(loop.run_async()) @@ -95,7 +109,7 @@ def test_glfw_canvas_render(): def run_briefly(): aio_loop.run_until_complete(asyncio.sleep(0.5)) - canvas = RenderCanvas(max_fps=9999, update_mode="ondemand") + canvas = GlfwCanvas(max_fps=9999, update_mode="ondemand") device = wgpu.gpu.request_adapter_sync().request_device_sync() draw_frame1 = _get_draw_function(device, canvas) diff --git a/tests/test_backend_raw.py b/tests/test_backend_raw.py index df90ba5..5f1680a 100644 --- a/tests/test_backend_raw.py +++ b/tests/test_backend_raw.py @@ -1,37 +1,45 @@ """ -Tests for the raw loop. We use the glfw canvas to test it with. +Tests for the raw loop. We use the offscreen canvas to test it with. """ +from rendercanvas.base import BaseCanvasGroup from rendercanvas.raw import loop -from rendercanvas.glfw import GlfwRenderCanvas +from rendercanvas.offscreen import RenderCanvas import pytest -from testutils import run_tests, can_use_glfw +from testutils import run_tests from testutils_backends import BACKEND_TEST_FUNCS, NativeHelper -if not can_use_glfw: - pytest.skip("Skipping tests that needs glfw", allow_module_level=True) +# ----- A fresh canvas class and loop, for use in these tests -import glfw +class RawLoop(loop.__class__): + pass + + +loop = RawLoop() -class RawRenderCanvas(GlfwRenderCanvas): +class CanvasGroup(BaseCanvasGroup): pass -RawRenderCanvas.select_loop(loop) +class RawCanvas(RenderCanvas): + _rc_canvas_group = CanvasGroup(loop) + + +# ----- class RawHelper(NativeHelper): def close_canvas(self, canvas): - glfw.set_window_should_close(canvas._window, 1) + canvas.close() @pytest.mark.parametrize("func", BACKEND_TEST_FUNCS) def test_backend_raw(func): - func(RawRenderCanvas, loop, RawHelper()) + func(RawCanvas, loop, RawHelper()) if __name__ == "__main__": diff --git a/tests/test_backend_terminal.py b/tests/test_backend_terminal.py index 3da215f..4349481 100644 --- a/tests/test_backend_terminal.py +++ b/tests/test_backend_terminal.py @@ -6,6 +6,7 @@ import sys import rendercanvas +from rendercanvas.base import BaseCanvasGroup from rendercanvas.terminal import RenderCanvas, loop import pytest @@ -21,6 +22,27 @@ def teardown_module(): rendercanvas.terminal.term_stream = sys.__stdout__ +# ----- A fresh canvas class and loop, for use in these tests + + +class TerminalLoop(loop.__class__): + pass + + +loop = TerminalLoop() + + +class CanvasGroup(BaseCanvasGroup): + pass + + +class TerminalCanvas(RenderCanvas): + _rc_canvas_group = CanvasGroup(loop) + + +# ----- + + class TerminalHelper(NativeHelper): def close_canvas(self, canvas): canvas.close() @@ -33,7 +55,7 @@ def close_canvas(self, canvas): def test_backend_terminal(func): if func.__name__ in EXCLUDES: pytest.skip() - func(RenderCanvas, loop, TerminalHelper()) + func(TerminalCanvas, loop, TerminalHelper()) if __name__ == "__main__": diff --git a/tests/test_backend_trio.py b/tests/test_backend_trio.py index 03f1f02..10f3936 100644 --- a/tests/test_backend_trio.py +++ b/tests/test_backend_trio.py @@ -1,37 +1,45 @@ """ -Tests for the trio loop. We use the glfw canvas to test it with. +Tests for the trio loop. We use the offscreem canvas to test it with. """ +from rendercanvas.base import BaseCanvasGroup from rendercanvas.trio import loop -from rendercanvas.glfw import GlfwRenderCanvas +from rendercanvas.offscreen import RenderCanvas import pytest -from testutils import run_tests, can_use_glfw +from testutils import run_tests from testutils_backends import BACKEND_TEST_FUNCS, NativeHelper -if not can_use_glfw: - pytest.skip("Skipping tests that needs glfw", allow_module_level=True) +# ----- A fresh canvas class and loop, for use in these tests -import glfw +class TrioLoop(loop.__class__): + pass + + +loop = TrioLoop() -class TrioRenderCanvas(GlfwRenderCanvas): +class CanvasGroup(BaseCanvasGroup): pass -TrioRenderCanvas.select_loop(loop) +class TrioCanvas(RenderCanvas): + _rc_canvas_group = CanvasGroup(loop) + + +# ----- class TrioHelper(NativeHelper): def close_canvas(self, canvas): - glfw.set_window_should_close(canvas._window, 1) + canvas.close() @pytest.mark.parametrize("func", BACKEND_TEST_FUNCS) def test_backend_trio(func): - func(TrioRenderCanvas, loop, TrioHelper()) + func(TrioCanvas, loop, TrioHelper()) if __name__ == "__main__": From f4cb58f0f816058007c2b067a815eafcd67a6a25 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 17 Jul 2026 16:17:00 +0200 Subject: [PATCH 10/18] fix tests --- .github/workflows/ci.yml | 23 +++++++++++++---------- tests/test_backend_terminal.py | 5 +++++ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 970f081..861c769 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -155,20 +155,18 @@ jobs: # Loops # - backendname: 'asyncio' -> via glfw - backendname: 'raw' - deps: 'glfw' + deps: '' + os: windows-latest + - backendname: 'asyncio' + deps: 'rio' os: windows-latest - backendname: 'trio' - deps: 'glfw' + deps: 'trio' os: windows-latest - # Native + # GUI - backendname: 'glfw' deps: 'glfw' os: windows-latest - - backendname: 'terminal' - os: windows-latest - - backendname: 'offscreen' - os: windows-latest - # GUI - backendname: 'pyside6' deps: 'PySide6' testname: 'qt' @@ -184,6 +182,12 @@ jobs: - backendname: 'wx' deps: 'wxPython' os: windows-latest + # Other + - backendname: 'terminal' + deps: 'blessed' + os: windows-latest + - backendname: 'offscreen' + os: windows-latest steps: - uses: actions/checkout@v7 - name: Set up Python @@ -199,8 +203,7 @@ jobs: - name: Install package and dev dependencies run: | python -m pip install --upgrade pip - pip install .[tests] - pip install ${{ matrix.deps }} + pip install .[tests] ${{ matrix.deps }} rm -r rendercanvas - name: Test import run: | diff --git a/tests/test_backend_terminal.py b/tests/test_backend_terminal.py index 4349481..c549f5e 100644 --- a/tests/test_backend_terminal.py +++ b/tests/test_backend_terminal.py @@ -14,6 +14,11 @@ from testutils_backends import BACKEND_TEST_FUNCS, NativeHelper +# Only run when running directly (through Python or pytest) +if not (__name__ == "__main__" or any(__name__ in a for a in sys.argv)): + pytest.skip(f"Skipping backend specific tests {__name__}", allow_module_level=True) + + def setup_module(): rendercanvas.terminal.term_stream = io.StringIO() From c71cbdd534825bad55e34460f2311858cd3b7cac Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 17 Jul 2026 16:20:05 +0200 Subject: [PATCH 11/18] fix tests --- tests/test_backend_asyncio.py | 46 ++++++++++++++++++++++++++++++++++ tests/test_backend_glfw.py | 9 +++---- tests/test_backend_terminal.py | 3 ++- 3 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 tests/test_backend_asyncio.py diff --git a/tests/test_backend_asyncio.py b/tests/test_backend_asyncio.py new file mode 100644 index 0000000..4ae3213 --- /dev/null +++ b/tests/test_backend_asyncio.py @@ -0,0 +1,46 @@ +""" +Tests for the asyncio loop. We use the offscreem canvas to test it with. +""" + +from rendercanvas.base import BaseCanvasGroup +from rendercanvas.asyncio import loop +from rendercanvas.offscreen import RenderCanvas + +import pytest +from testutils import run_tests +from testutils_backends import BACKEND_TEST_FUNCS, NativeHelper + + +# ----- A fresh canvas class and loop, for use in these tests + + +class AsyncioLoop(loop.__class__): + pass + + +loop = AsyncioLoop() + + +class CanvasGroup(BaseCanvasGroup): + pass + + +class AsyncioCanvas(RenderCanvas): + _rc_canvas_group = CanvasGroup(loop) + + +# ----- + + +class AsyncioHelper(NativeHelper): + def close_canvas(self, canvas): + canvas.close() + + +@pytest.mark.parametrize("func", BACKEND_TEST_FUNCS) +def test_backend_asyncio(func): + func(AsyncioCanvas, loop, AsyncioHelper()) + + +if __name__ == "__main__": + run_tests(globals()) diff --git a/tests/test_backend_glfw.py b/tests/test_backend_glfw.py index 4e34457..c7b9908 100644 --- a/tests/test_backend_glfw.py +++ b/tests/test_backend_glfw.py @@ -16,10 +16,14 @@ from testutils import run_tests, can_use_glfw, can_use_wgpu_lib, is_pypy from testutils_backends import BACKEND_TEST_FUNCS, NativeHelper, _get_draw_function +# Only run when running directly (through Python or pytest) +if not (__name__ == "__main__" or any(__name__ in a for a in sys.argv)): + pytest.skip(f"Skipping backend specific tests {__name__}", allow_module_level=True) if not can_use_glfw: pytest.skip("Skipping tests that needs glfw", allow_module_level=True) + import glfw @@ -27,11 +31,6 @@ from rendercanvas.glfw import RenderCanvas, GlfwRenderCanvas, loop -# Only run when running directly (through Python or pytest) -if not (__name__ == "__main__" or any(__name__ in a for a in sys.argv)): - pytest.skip(f"Skipping backend specific tests {__name__}", allow_module_level=True) - - # ----- A fresh canvas class and loop, for use in these tests diff --git a/tests/test_backend_terminal.py b/tests/test_backend_terminal.py index c549f5e..c430285 100644 --- a/tests/test_backend_terminal.py +++ b/tests/test_backend_terminal.py @@ -7,7 +7,6 @@ import rendercanvas from rendercanvas.base import BaseCanvasGroup -from rendercanvas.terminal import RenderCanvas, loop import pytest from testutils import run_tests @@ -18,6 +17,8 @@ if not (__name__ == "__main__" or any(__name__ in a for a in sys.argv)): pytest.skip(f"Skipping backend specific tests {__name__}", allow_module_level=True) +from rendercanvas.terminal import RenderCanvas, loop + def setup_module(): rendercanvas.terminal.term_stream = io.StringIO() From 44ff82a3f6a1ac496ab31867a4f8f3501c03e10a Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 17 Jul 2026 16:31:55 +0200 Subject: [PATCH 12/18] fix tests --- .github/workflows/ci.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 861c769..cf5f3be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,12 +153,11 @@ jobs: matrix: include: # Loops - # - backendname: 'asyncio' -> via glfw - backendname: 'raw' deps: '' os: windows-latest - backendname: 'asyncio' - deps: 'rio' + deps: '' os: windows-latest - backendname: 'trio' deps: 'trio' @@ -208,9 +207,6 @@ jobs: - name: Test import run: | python -c 'import rendercanvas.${{ matrix.backendname }}' - - name: Test loop - run: | - pytest -v tests/test_loop.py -k ${{ matrix.backendname }} - name: Test backend canvas run: | pytest -v tests/test_backend_${{ matrix.testname || matrix.backendname }}.py From 9219a9a2d2cc4420d5aae970e4ae4ca8c3b5f454 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 17 Jul 2026 16:35:07 +0200 Subject: [PATCH 13/18] fix test name --- tests/{test_backend_offsceen.py => test_backend_offscreen.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_backend_offsceen.py => test_backend_offscreen.py} (100%) diff --git a/tests/test_backend_offsceen.py b/tests/test_backend_offscreen.py similarity index 100% rename from tests/test_backend_offsceen.py rename to tests/test_backend_offscreen.py From 0b7d8c1d79b8004266bbc3df0097e39136596d0f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 17 Jul 2026 16:54:04 +0200 Subject: [PATCH 14/18] update docstring --- rendercanvas/core/loop.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/rendercanvas/core/loop.py b/rendercanvas/core/loop.py index 9011687..602aeba 100644 --- a/rendercanvas/core/loop.py +++ b/rendercanvas/core/loop.py @@ -36,7 +36,7 @@ class BaseLoop: 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). + running on ``raw``, ``asyncio`` or ``trio``). 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. @@ -54,7 +54,7 @@ class BaseLoop: * 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). + * A co-routine is created that will detect when the loop starts running, so some things can be initialized at the right moment. * running: * Entered when ``loop.run()`` is called. * The loop is now running. @@ -65,21 +65,18 @@ class BaseLoop: * 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. + * This is detected via the aforementioned co-routine. * 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. + * Stopping the loop (via ``.stop()``) closes all canvases. * 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 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. """ From b662d4cc9bd6058f918c0048308db57335065984 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 17 Jul 2026 17:03:58 +0200 Subject: [PATCH 15/18] docs --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2b5ac1b..f59cace 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ See the [contribution guide](CONTRIBUTING.md). * Use `ruff check` to check for linting errors. * Use `pytest tests` to run the tests. * Use `pytest examples` to run a subset of the examples. +* Use `pytest tests/test_backend_xx.py` to run backend-specific tests, e.g. for `glfw`, `qt`, `wx`. ### Code of Conduct From 711d42f99e985c3b140bc41605d2f4a3c01f4e60 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Sun, 2 Aug 2026 00:17:28 +0200 Subject: [PATCH 16/18] Simpler close mechanics for backends --- README.md | 4 +++- rendercanvas/anywidget.py | 6 ------ rendercanvas/base.py | 44 ++++++++++++++++++--------------------- rendercanvas/core/loop.py | 3 +-- rendercanvas/glfw.py | 3 --- rendercanvas/http.py | 5 ----- rendercanvas/jupyter.py | 6 +----- rendercanvas/offscreen.py | 6 +----- rendercanvas/pyodide.py | 3 --- rendercanvas/qt.py | 8 +------ rendercanvas/stub.py | 3 --- rendercanvas/terminal.py | 6 +----- rendercanvas/wx.py | 21 +++++++++++-------- tests/test_asyncs.py | 10 --------- tests/test_backends.py | 5 +++++ tests/test_loop.py | 13 ------------ tests/test_scheduling.py | 7 ------- tests/test_sniffio.py | 5 ----- 18 files changed, 45 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index f59cace..26db5c4 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,14 @@ from rendercanvas.auto import RenderCanvas, loop canvas = RenderCanvas(update_mode="continuous") context = canvas.get_bitmap_context() + @canvas.request_draw def animate(): w, h = canvas.get_logical_size() bitmap = np.random.uniform(0, 255, (h, w)).astype(np.uint8) context.set_bitmap(bitmap) + loop.run() ``` @@ -106,8 +108,8 @@ Embed in a Qt application: from PySide6 import QtWidgets from rendercanvas.qt import QRenderWidget -class Main(QtWidgets.QWidget): +class Main(QtWidgets.QWidget): def __init__(self): super().__init__() diff --git a/rendercanvas/anywidget.py b/rendercanvas/anywidget.py index 9ee7874..2c807bc 100644 --- a/rendercanvas/anywidget.py +++ b/rendercanvas/anywidget.py @@ -74,8 +74,6 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._is_closed = False - self._rfb_draw_requested = False self._rfb_frame_index = 0 self._rfb_last_confirmed_index = 0 @@ -370,10 +368,6 @@ def _rc_set_logical_size(self, width, height): def _rc_close(self): anywidget.AnyWidget.close(self) self._rfb_handle_msg(self, {"type": "close"}, []) - self._is_closed = True - - def _rc_get_closed(self): - return self._is_closed def _rc_set_title(self, title): self._title = str(title) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 8d99c0e..4243b97 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -19,7 +19,7 @@ from .core.events import EventEmitter from .core.loop import BaseLoop from .core.scheduler import Scheduler -from .core.coreutils import logger, log_exception +from .core.coreutils import log_exception if TYPE_CHECKING: @@ -46,7 +46,7 @@ class BaseCanvasGroup: """Represents a group of canvas objects from the same class, that share a loop.""" - def __init__(self, default_loop: BaseLoop): + def __init__(self, default_loop: BaseLoop | None): self._canvases = weakref.WeakSet() self._loop = None self.select_loop(default_loop) @@ -63,7 +63,7 @@ def _unregister_canvas(self, canvas): """Used by the canvas to unregister itself when closed.""" self._canvases.discard(canvas) - def select_loop(self, loop: BaseLoop) -> None: + def select_loop(self, loop: BaseLoop | None) -> None: """Select the loop to use for this group of canvases.""" if not (loop is None or isinstance(loop, BaseLoop)): raise TypeError("select_loop() requires a loop instance or None.") @@ -108,7 +108,7 @@ class BaseRenderCanvas: """ - _rc_canvas_group = None + _rc_canvas_group: BaseCanvasGroup = None # type: ignore - type applies to subtypes """Class attribute that refers to the ``CanvasGroup`` instance to use for canvases of this class. It specifies what loop is used, and enables users to changing the used loop. """ @@ -171,7 +171,13 @@ def __init__( self._present_method = present_method self._present_to_screen: bool | None = None # set in .get_context() + # Runtime check + assert self._rc_canvas_group is not None, ( + "_rc_canvas_group must be set on classes that subclass BaseRenderCanvas" + ) + # Variables and flags used internally + self.__is_closed = False self.__is_drawing = False self.__title_info = { "raw": "", @@ -566,7 +572,7 @@ def _draw_and_present(self, *, force_sync: bool): size_is_nill or context is None or self._draw_frame is None - or self._rc_get_closed() + or self.__is_closed ): if scheduler is not None: scheduler.on_cancel_draw() @@ -676,8 +682,12 @@ def get_pixel_ratio(self) -> float: def close(self) -> None: """Close the canvas.""" + # Note that this function may be called multiple times, and may even be re-entered. + if self.__is_closed: + return + self.__is_closed = True errors = [] - # Close the canvas natively, the canvas may only be marked as closed once this is done + # Close the native canvas try: self._rc_close() except Exception as err: @@ -707,13 +717,7 @@ def close(self) -> None: def get_closed(self) -> bool: """Get whether the window is closed.""" - return self._rc_get_closed() - - def is_closed(self): - logger.warning( - "canvas.is_closed() is deprecated, use canvas.get_closed() instead." - ) - return self._rc_get_closed() + return self.__is_closed # %% Secondary canvas management methods @@ -864,23 +868,18 @@ def _rc_set_logical_size(self, width: float, height: float): def _rc_close(self): """Close the canvas. - This is the place to delete the native widget, and to maybe set a flag - that ``_rc_get_closed()`` uses. It is not necessary to emit a close event, - because the base class handles that. + This is the place to delete the native widget. It is not necessary to + emit a close event, because the base class handles that. In a backend, all flows that lead to a close must call ``.close()``, so that ``BaseRenderCanvas`` has a clear place to handle closing. It calls - ``_rc_close()`` from there. + ``_rc_close()`` from there. The base class will call this method exactly once. Note that backends may also have a ``close()`` method, which is overridden by the base class. """ pass - def _rc_get_closed(self) -> bool: - """Get whether the canvas is closed. A typical implementation uses a flag that is set in ``_rc_close()``.""" - return False - def _rc_set_title(self, title: str): """Set the canvas title. May be ignored when it makes no sense. @@ -964,6 +963,3 @@ def close(self) -> None: def get_closed(self) -> bool: return self._subwidget.get_closed() - - def is_closed(self): - return self._subwidget.is_closed() diff --git a/rendercanvas/core/loop.py b/rendercanvas/core/loop.py index 602aeba..18772d4 100644 --- a/rendercanvas/core/loop.py +++ b/rendercanvas/core/loop.py @@ -54,7 +54,7 @@ class BaseLoop: * 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 co-routine is created that will detect when the loop starts running, so some things can be initialized at the right moment. + * A co-routine is created that will detect when the loop starts running, so things can be initialized at the right moment. * running: * Entered when ``loop.run()`` is called. * The loop is now running. @@ -154,7 +154,6 @@ def _ensure_initialized(self): self.__is_initialized = True self._rc_init() - # self._rc_add_task(wrapper, "loop-task") self._rc_add_task(self._loop_start_detection_task, "loop-start-detection-task") self.__using_adapter = len(self.__tasks) > 0 diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index deaffac..6bc9b0b 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -365,9 +365,6 @@ def _rc_close(self): if not self._rc_canvas_group.get_canvases(): poll_glfw_briefly(0.05) - def _rc_get_closed(self): - return self._window is None - def _rc_set_title(self, title): if self._window is not None: glfw.set_window_title(self._window, title) diff --git a/rendercanvas/http.py b/rendercanvas/http.py index a0e7b23..aa95929 100644 --- a/rendercanvas/http.py +++ b/rendercanvas/http.py @@ -315,7 +315,6 @@ def __init__(self, *args, **kwargs): # Note: we assume there is only a single canvas on the page asgi._event_callback = self._on_event - self._is_closed = False self._draw_requested = False self._pending_maybe_draw = False @@ -582,10 +581,6 @@ def _rc_set_logical_size(self, width, height): def _rc_close(self): asgi.close() - self._is_closed = True - - def _rc_get_closed(self): - return self._is_closed def _rc_set_title(self, title): asgi.send_all({"type": "title", "value": title}) diff --git a/rendercanvas/jupyter.py b/rendercanvas/jupyter.py index 6a1dc3e..1c28b04 100644 --- a/rendercanvas/jupyter.py +++ b/rendercanvas/jupyter.py @@ -37,7 +37,6 @@ def __init__(self, *args, **kwargs): # Internal variables self._last_image = None - self._is_closed = False self._draw_request_time = 0 # The send_frame() method was added in jupyter_rfb 1.0, but it was always there as a private method, @@ -102,9 +101,6 @@ def _rc_set_logical_size(self, width, height): def _rc_close(self): RemoteFrameBuffer.close(self) - def _rc_get_closed(self): - return self._is_closed - def _rc_set_title(self, title): self.title = title self.has_titlebar = bool(title) # show titlebar when a title is set @@ -117,7 +113,7 @@ def _rc_set_cursor(self, cursor): def handle_event(self, event): event_type = event.get("event_type") if event_type == "close": - self._is_closed = True + self.close() elif event_type == "resize": logical_size = event["width"], event["height"] pixel_ratio = event["pixel_ratio"] diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index 73d9203..8d52a0e 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -30,7 +30,6 @@ class OffscreenRenderCanvas(BaseRenderCanvas): def __init__(self, *args, pixel_ratio=1.0, format="rgba-u8", **kwargs): super().__init__(*args, **kwargs) self._pixel_ratio = float(pixel_ratio) - self._closed = False self._last_image = None self._present_formats = ["rgba-u8", "rgba-f16", "rgba-f32", "rgba-u16"] @@ -84,10 +83,7 @@ def _rc_set_logical_size(self, width, height): self._size_info.set_physical_size(pwidth, pheight, pixel_ratio) def _rc_close(self): - self._closed = True - - def _rc_get_closed(self): - return self._closed + pass def _rc_set_title(self, title): pass diff --git a/rendercanvas/pyodide.py b/rendercanvas/pyodide.py index 623613c..06aa30d 100644 --- a/rendercanvas/pyodide.py +++ b/rendercanvas/pyodide.py @@ -219,9 +219,6 @@ def _rc_close(self): # Removing the element from the page. One can argue whether you want this or not. canvas_element.remove() - def _rc_get_closed(self): - return self._canvas_element is None - def _rc_set_title(self, title: str): # A canvas element doesn't have a title directly. # We assume that when the canvas sets a title it's the only one, and we set the title of the document. diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 9ee85ab..2edbde8 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -278,7 +278,6 @@ def __init__(self, *args, **kwargs): # Determine present method self._last_image = None self._last_winid = None - self._is_closed = False self._pending_present_params = None self.setAutoFillBackground(False) @@ -473,17 +472,12 @@ def _rc_set_logical_size(self, width, height): self.resize(width, height) # See comment on pixel ratio def _rc_close(self): - if self._is_closed: - return parent = self.parent() if isinstance(parent, QRenderCanvas): QtWidgets.QWidget.close(parent) else: QtWidgets.QWidget.close(self) - def _rc_get_closed(self): - return self._is_closed - def _rc_set_title(self, title): # A QWidgets title can actually be shown when the widget is shown in a dock. # But the application should probably determine that title, not us. @@ -636,7 +630,7 @@ def resizeEvent(self, event): # noqa: N802 def closeEvent(self, event): # noqa: N802 # Happens e.g. when closing the widget from within an app that dynamically created and closes canvases. super().closeEvent(event) - self._is_closed = True + self.close() class QRenderCanvas(WrapperRenderCanvas, QtWidgets.QWidget): diff --git a/rendercanvas/stub.py b/rendercanvas/stub.py index 276d8e7..b4e42c9 100644 --- a/rendercanvas/stub.py +++ b/rendercanvas/stub.py @@ -116,9 +116,6 @@ def _rc_set_logical_size(self, width, height): def _rc_close(self): pass - def _rc_get_closed(self): - return False - def _rc_set_title(self, title): pass diff --git a/rendercanvas/terminal.py b/rendercanvas/terminal.py index ac9a0d8..f063321 100644 --- a/rendercanvas/terminal.py +++ b/rendercanvas/terminal.py @@ -171,7 +171,6 @@ def __init__(self, *args, pixel_ratio=0.125, **kwargs): self._pixel_ratio = pixel_ratio - self._closed = False self._title = "" self._term_size = 0, 0 self._pointer_pos = (0, 0) @@ -435,10 +434,7 @@ def _rc_set_logical_size(self, width, height): pass # we ignore setting the size, we simply take the full size of the window def _rc_close(self): - self._closed = True - - def _rc_get_closed(self): - return self._closed + pass # we rely on the loop closing to exit the secondary screen etc. def _rc_set_title(self, title): self._title = title diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index ae9039f..f2d76a2 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -3,7 +3,14 @@ can be used as a standalone window or in a larger GUI. """ -__all__ = ["RenderCanvas", "WxLoop", "WxRenderCanvas", "WxRenderWidget", "loop"] +__all__ = [ + "RenderCanvas", + "RenderWidget", + "WxLoop", + "WxRenderCanvas", + "WxRenderWidget", + "loop", +] import sys import time @@ -218,7 +225,6 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._last_image = None - self._is_closed = False self._pointer_inside = None self._is_pointer_inside_according_to_wx = False @@ -367,7 +373,6 @@ def _rc_set_logical_size(self, width, height): pass def _rc_close(self): - self._is_closed = True try: parent = self.Parent except RuntimeError: @@ -382,9 +387,6 @@ def _rc_close(self): while time.perf_counter() < end_time: wx.Yield() - def _rc_get_closed(self): - return self._is_closed - def _rc_set_title(self, title): # Set title only on frame parent = self.Parent @@ -573,11 +575,12 @@ def _on_focus(self, event: wx.FocusEvent): self.submit_event(ev) def _on_close(self, _event): - if not self._is_closed: - self.close() + self.close() class WxRenderFrame(wx.Frame): + """Stub class to identify that the frame is from rendercanvas.""" + pass @@ -606,7 +609,7 @@ def _on_close(self, _event): @property def frame(self): - """The wx.Frame that this class wraps.""" + """The wx.Frame object that this class wraps.""" return self._frame diff --git a/tests/test_asyncs.py b/tests/test_asyncs.py index 5e42f57..3fa2289 100644 --- a/tests/test_asyncs.py +++ b/tests/test_asyncs.py @@ -30,18 +30,8 @@ class FooCanvas(BaseRenderCanvas): def __init__(self): super().__init__() - self._is_closed = False self._final_canvas_init() - def _rc_gui_poll(self): - pass - - def _rc_close(self): - self._is_closed = True - - def _rc_get_closed(self): - return self._is_closed - @pytest.mark.parametrize("SomeLoop", loop_classes) def test_sleep(SomeLoop): diff --git a/tests/test_backends.py b/tests/test_backends.py index 76efd1e..6d49dc6 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -81,6 +81,11 @@ def get_rc_methods(self, class_def): name = statement.targets[0].id if name.startswith("_rc_"): rc_methods.add(name) + elif isinstance(statement, ast.AnnAssign): + if isinstance(statement.target, ast.Name): + name = statement.target.id + if name.startswith("_rc_"): + rc_methods.add(name) return rc_methods def check_rc_methods(self, rc_methods, ref_rc_methods): diff --git a/tests/test_loop.py b/tests/test_loop.py index c81e6cf..72c5295 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -40,21 +40,8 @@ class FooCanvas(BaseRenderCanvas): def __init__(self): super().__init__() - self._is_closed = False self._final_canvas_init() - def _rc_gui_poll(self): - pass - - def _rc_close(self): - # Note: in earlier rendercanvas versions, canvases could ignore - # the signal to close. Now, closing is not a request but a command, - # and the basse class and loop will consider the canvas as closed. - self._is_closed = True - - def _rc_get_closed(self): - return self._is_closed - def manually_close(self): self.close() diff --git a/tests/test_scheduling.py b/tests/test_scheduling.py index fe11106..9a233cc 100644 --- a/tests/test_scheduling.py +++ b/tests/test_scheduling.py @@ -32,19 +32,12 @@ class MyCanvas(BaseRenderCanvas): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._closed = False self.draw_count = 0 self.events_count = 0 self._gui_draw_requested = False self._present_to_screen = False self._canvas_context = StubContext() - def _rc_close(self): - self._closed = True - - def _rc_get_closed(self): - return self._closed - def _process_events(self): self.events_count += 1 return super()._process_events() diff --git a/tests/test_sniffio.py b/tests/test_sniffio.py index fd14a81..49971d0 100644 --- a/tests/test_sniffio.py +++ b/tests/test_sniffio.py @@ -31,7 +31,6 @@ def _rc_present(self, force_sync=False): class RealRenderCanvas(BaseRenderCanvas): _rc_canvas_group = CanvasGroup(asyncio_loop) - _is_closed = False def __init__(self): super().__init__() @@ -40,12 +39,8 @@ def __init__(self): self._canvas_context = StubContext() def _rc_close(self): - self._is_closed = True self.submit_event({"event_type": "close"}) - def _rc_get_closed(self): - return self._is_closed - def get_sniffio_name(): try: From e4fd32b232f9fbfa1a159eb9b01172e1fbbf9d97 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Sun, 2 Aug 2026 00:35:49 +0200 Subject: [PATCH 17/18] proper type for _rc_canvas_group --- rendercanvas/base.py | 20 +++++++++----------- tests/test_base.py | 8 +++++++- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 4243b97..2c35907 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -108,7 +108,7 @@ class BaseRenderCanvas: """ - _rc_canvas_group: BaseCanvasGroup = None # type: ignore - type applies to subtypes + _rc_canvas_group: BaseCanvasGroup | None = None """Class attribute that refers to the ``CanvasGroup`` instance to use for canvases of this class. It specifies what loop is used, and enables users to changing the used loop. """ @@ -171,11 +171,6 @@ def __init__( self._present_method = present_method self._present_to_screen: bool | None = None # set in .get_context() - # Runtime check - assert self._rc_canvas_group is not None, ( - "_rc_canvas_group must be set on classes that subclass BaseRenderCanvas" - ) - # Variables and flags used internally self.__is_closed = False self.__is_drawing = False @@ -184,9 +179,7 @@ def __init__( "fps": "?", "ms": "?", "backend": self.__class__.__name__, - "loop": self._rc_canvas_group.get_loop().__class__.__name__ - if (self._rc_canvas_group and self._rc_canvas_group.get_loop()) - else "no-loop", + "loop": "no-loop", # set below in most cases } self._size_info = SizeInfo() @@ -199,6 +192,9 @@ def __init__( # Group, but no loop: no scheduling self._rc_canvas_group._register_canvas(self, None) else: + self.__title_info["loop"] = ( + self._rc_canvas_group.get_loop().__class__.__name__ + ) self.__scheduler = Scheduler( self, self._events, @@ -693,7 +689,10 @@ def close(self) -> None: except Exception as err: errors.append(err) # Unregister - self._rc_canvas_group._unregister_canvas(self) + loop = None + if self._rc_canvas_group is not None: + loop = self._rc_canvas_group.get_loop() + self._rc_canvas_group._unregister_canvas(self) # Clear the draw-function, to avoid it holding onto e.g. wgpu objects. self._draw_frame = None # type: ignore # Clear the canvas context too. @@ -707,7 +706,6 @@ def close(self) -> None: self._events.close() # Stop the loop if this was the last canvas. It's important to do now, # because the native loop may stop right after, giving us no flow to properly close. - loop = self._rc_canvas_group.get_loop() if loop is not None: if not loop.get_canvases(): loop.stop() diff --git a/tests/test_base.py b/tests/test_base.py index 6625542..3bdf188 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -17,7 +17,13 @@ def _rc_present(self, force_sync=False): return {"method": "skip"} -class CanvasThatRaisesErrorsDuringDrawing(rendercanvas.BaseRenderCanvas): +class SomeCanvasGroup(rendercanvas.base.BaseCanvasGroup): + pass + + +class CanvasThatRaisesErrorsDuringDrawing(rendercanvas.base.BaseRenderCanvas): + _rc_canvas_group = SomeCanvasGroup(None) + def __init__(self): super().__init__() self._count = 0 From 570dfdae6a634977d9604b61ff95c6ac2c89494e Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Sun, 2 Aug 2026 00:37:37 +0200 Subject: [PATCH 18/18] logger name is expected in base.py --- rendercanvas/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 2c35907..1bf2dc2 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -19,7 +19,7 @@ from .core.events import EventEmitter from .core.loop import BaseLoop from .core.scheduler import Scheduler -from .core.coreutils import log_exception +from .core.coreutils import logger, log_exception # noqa: F401 if TYPE_CHECKING: