diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67399d9..cf5f3be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,27 +152,40 @@ jobs: fail-fast: false matrix: include: + # Loops + - backendname: 'raw' + deps: '' + os: windows-latest + - backendname: 'asyncio' + deps: '' + os: windows-latest + - backendname: 'trio' + deps: 'trio' + os: windows-latest + # GUI - backendname: 'glfw' deps: 'glfw' os: windows-latest - 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 + # Other + - backendname: 'terminal' + deps: 'blessed' + os: windows-latest + - backendname: 'offscreen' os: windows-latest steps: - uses: actions/checkout@v7 @@ -189,16 +202,11 @@ 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: | python -c 'import rendercanvas.${{ matrix.backendname }}' - - name: Test loop - if: matrix.loopname - run: | - pytest -v tests/test_loop.py -k ${{ matrix.loopname }} - name: Test backend canvas run: | pytest -v tests/test_backend_${{ matrix.testname || matrix.backendname }}.py 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 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 719927f..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,18 +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 - # Clean events. Should already have happened in loop, but the loop may not be running. + # Close the event at least after get_closed() would return True self._events.close() - # Let the subclass clean up. - self._rc_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.""" @@ -853,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..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,26 +65,21 @@ 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. """ - _stop_when_no_canvases = True - def __init__(self): self.__tasks = set() # only used by the async adapter self.__canvas_groups = set() @@ -141,11 +136,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 +151,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 +305,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 +326,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 +338,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 +368,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 +385,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/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/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 452adea..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,26 +158,36 @@ 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 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) @@ -185,14 +196,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() @@ -217,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() @@ -349,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) @@ -366,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 @@ -377,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): @@ -562,36 +573,41 @@ 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 WxRenderFrame(wx.Frame): + pass -class WxRenderCanvas(WrapperRenderCanvas, wx.Frame): - """A toplevel wx Frame providing a render canvas.""" +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.Destroy()) + 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_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_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 176d93d..c7b9908 100644 --- a/tests/test_backend_glfw.py +++ b/tests/test_backend_glfw.py @@ -6,48 +6,68 @@ 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 -from testutils_backends import BACKEND_TEST_FUNCS, _get_draw_function +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) -# 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() +import glfw -def test_is_canvas_classes(): - from rendercanvas.base import BaseRenderCanvas - from rendercanvas.glfw import RenderCanvas, GlfwRenderCanvas +from rendercanvas.base import BaseRenderCanvas, BaseCanvasGroup +from rendercanvas.glfw import RenderCanvas, GlfwRenderCanvas, loop + + +# ----- 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) -@pytest.mark.parametrize("backend", ["glfw"]) +class GlfwHelper(NativeHelper): + def close_canvas(self, canvas): + glfw.set_window_should_close(canvas._window, 1) + + @pytest.mark.parametrize("func", BACKEND_TEST_FUNCS) -def test_backend_generic(func, backend): - func(backend) +def test_backend_glfw(func): + 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()) @@ -55,7 +75,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 @@ -81,8 +101,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()) @@ -90,7 +108,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_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 diff --git a/tests/test_backend_qt.py b/tests/test_backend_qt.py index dbed962..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,10 +56,14 @@ def test_is_canvas_classes(): assert issubclass(RenderCanvas, QtWidgets.QWidget) # toplevel -@pytest.mark.parametrize("backend", [backend_name]) +class QtHelper(NativeHelper): + def close_canvas(self, canvas): + QtWidgets.QWidget.close(canvas) + + @pytest.mark.parametrize("func", BACKEND_TEST_FUNCS) -def test_backend_generic(func, backend): - func(backend) +def test_backend_qt(func): + func(RenderCanvas, loop, QtHelper()) if __name__ == "__main__": diff --git a/tests/test_backend_raw.py b/tests/test_backend_raw.py new file mode 100644 index 0000000..5f1680a --- /dev/null +++ b/tests/test_backend_raw.py @@ -0,0 +1,46 @@ +""" +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.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 RawLoop(loop.__class__): + pass + + +loop = RawLoop() + + +class CanvasGroup(BaseCanvasGroup): + pass + + +class RawCanvas(RenderCanvas): + _rc_canvas_group = CanvasGroup(loop) + + +# ----- + + +class RawHelper(NativeHelper): + def close_canvas(self, canvas): + canvas.close() + + +@pytest.mark.parametrize("func", BACKEND_TEST_FUNCS) +def test_backend_raw(func): + func(RawCanvas, 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..c430285 --- /dev/null +++ b/tests/test_backend_terminal.py @@ -0,0 +1,70 @@ +""" +Tests for the terminal backend. +""" + +import io +import sys + +import rendercanvas +from rendercanvas.base import BaseCanvasGroup + +import pytest +from testutils import run_tests +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) + +from rendercanvas.terminal import RenderCanvas, loop + + +def setup_module(): + rendercanvas.terminal.term_stream = io.StringIO() + + +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() + + +EXCLUDES = ["backend_sizing"] + + +@pytest.mark.parametrize("func", BACKEND_TEST_FUNCS) +def test_backend_terminal(func): + if func.__name__ in EXCLUDES: + pytest.skip() + func(TerminalCanvas, 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..10f3936 --- /dev/null +++ b/tests/test_backend_trio.py @@ -0,0 +1,46 @@ +""" +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.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 TrioLoop(loop.__class__): + pass + + +loop = TrioLoop() + + +class CanvasGroup(BaseCanvasGroup): + pass + + +class TrioCanvas(RenderCanvas): + _rc_canvas_group = CanvasGroup(loop) + + +# ----- + + +class TrioHelper(NativeHelper): + def close_canvas(self, canvas): + canvas.close() + + +@pytest.mark.parametrize("func", BACKEND_TEST_FUNCS) +def test_backend_trio(func): + func(TrioCanvas, loop, TrioHelper()) + + +if __name__ == "__main__": + run_tests(globals()) diff --git a/tests/test_backend_wx.py b/tests/test_backend_wx.py index 39b3fe0..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,13 +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 + + +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) +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 716c109..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 PySide6Loop" etc. -* Run "python tests/test_loop.py WxLoop" 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,251 +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 "RawLoop" in sys.argv: - loop_classes.append(RawLoop) -elif "AsyncioLoop" in sys.argv: - loop_classes.append(AsyncioLoop) -elif "TrioLoop" in sys.argv: - loop_classes.append(TrioLoop) -elif "QtLoop" in sys.argv: - from rendercanvas.pyside6 import QtLoop - loop_classes.append(QtLoop) -elif "PySide6Loop" in sys.argv: - from rendercanvas.pyside6 import QtLoop - - class PySide6Loop(QtLoop): - pass - - loop_classes.append(PySide6Loop) -elif "PyQt6Loop" in sys.argv: - from rendercanvas.pyqt6 import QtLoop - - class PyQt6Loop(QtLoop): - pass - loop_classes.append(PyQt6Loop) -elif "PyQt5Loop" in sys.argv: - from rendercanvas.pyqt5 import QtLoop - - class PyQt5Loop(QtLoop): - pass - - loop_classes.append(PyQt5Loop) -elif "PySide2Loop" 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. - 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(): +class FooCanvasGroup(BaseCanvasGroup): pass -class CanvasGroup(BaseCanvasGroup): - pass - - -class FakeEventEmitter: - is_closed = False - - def close(self): - self.is_closed = True - +class FooCanvas(BaseRenderCanvas): + _rc_canvas_group = FooCanvasGroup(None) -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 - - 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): - 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) @@ -290,305 +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. - - 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. - - 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 - - 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) - - t0 = time.time() - loop.run() - et = time.time() - t0 +def test_loop_lifetime_nocanvas(SomeLoop): + # No canvas; the loop flushes the queue and stops - 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. - - 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) @@ -597,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) @@ -632,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) @@ -645,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()) @@ -687,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(): @@ -747,7 +305,7 @@ async def main(): assert (times[2] - times[1]) > 0.20 -# %%%%% tasks +# ==================== Tasks @pytest.mark.parametrize("SomeLoop", loop_classes) @@ -758,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}") @@ -772,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 @@ -783,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) @@ -816,7 +374,7 @@ async def user_task(): assert flag == ["start", "stop"], flag -# %%%%% Misc +# ==================== Misc def test_not_using_loop_debug_thread(): @@ -838,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): @@ -876,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): @@ -887,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): @@ -917,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. @@ -935,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 @@ -957,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 @@ -981,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 @@ -1004,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: @@ -1026,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 4b531a2..7dd4125 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") @@ -42,16 +44,25 @@ def run_tests(scope): argnames = [func.__code__.co_varnames[i] for i in range(nargs)] if not argnames: print(f"Running {func.__name__} ...") - func() + try: + func() + 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}...") - func(arg) + try: + func(arg) + 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}...") - func(arg1, arg2) + try: + func(arg1, arg2) + except pytest.skip.Exception: + print(f"SKIPPING {func.__name__} by pytest skip") elif argnames == ["caplog"]: print(f"Running {func.__name__} ...") logging.root.addHandler(caplog) @@ -60,6 +71,7 @@ def run_tests(scope): 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 b84343f..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,20 +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): - 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 + + print(et) + assert 0.25 < et < 0.50 + leeway + + assert events_is_closed(canvas1) + assert events_is_closed(canvas2) + assert loop._BaseLoop__state == "off" + + +@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. + + 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: helper.close_canvas(canvas1)) # noqa: F821 + loop.call_later(0.6, lambda: helper.close_canvas(canvas2)) # noqa: F821 loop.run() assert canvas1.get_closed() @@ -44,26 +358,14 @@ def canvas_close_by_canvas(backend): 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): - 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): - 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() @@ -88,12 +390,14 @@ def canvas_sizing(backend): canvas.close() canvas._rc_gui_poll() assert canvas.get_closed() + assert loop._BaseLoop__state == "off" -@BACKEND_TEST_FUNCS.append -def canvas_render_bitmap(backend): - 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() @@ -121,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): +@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() @@ -151,6 +454,7 @@ def canvas_render_wgpu(backend): gc.collect() assert canvas_ref() is None + assert loop._BaseLoop__state == "off" def _get_draw_function(device, canvas):