Skip to content

Add a fully functioning pure-Python event loop #53

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jan 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/backends.rst
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,14 @@ There are also two loop-backends. These are mainly intended for use with the glf
* - **backend module**
- **names**
- **purpose**
* - ``raw``
- | ``RawLoop``
| ``loop``
- | Provide a pure Python event loop.
* - ``asyncio``
- | ``AsyncoLoop``
| ``loop``
- | Provide a generic loop based on Asyncio.
- | Provide a generic loop based on Asyncio. Recommended.
* - ``trio``
- | ``TrioLoop``
| ``loop``
Expand Down
23 changes: 23 additions & 0 deletions examples/cube_rawloop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""
Cube raw loop
-------------

Run a wgpu example on the glfw backend, and the raw loop.
"""

from rendercanvas.glfw import RenderCanvas
from rendercanvas.raw import loop
from rendercanvas.utils.cube import setup_drawing_sync


RenderCanvas.select_loop(loop)

canvas = RenderCanvas(
title="The wgpu cube on $backend with $loop", update_mode="continuous"
)
draw_frame = setup_drawing_sync(canvas)
canvas.request_draw(draw_frame)


if __name__ == "__main__":
loop.run()
2 changes: 1 addition & 1 deletion examples/cube_trio.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Cube trio
---------

Run a wgpu example on the glfw backend, and the trio loop
Run a wgpu example on the glfw backend, and the trio loop.
"""

import trio
Expand Down
2 changes: 1 addition & 1 deletion examples/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

canvas = RenderCanvas(
size=(640, 480),
title="Canvas events with $backend - $fps fps",
title="Canvas events with $backend on $loop - $fps fps",
max_fps=10,
update_mode="continuous",
present_method="",
Expand Down
6 changes: 3 additions & 3 deletions rendercanvas/_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ async def _loop_task(self):
del canvas

finally:
self._stop()
self.__stop()

def add_task(self, async_func, *args, name="unnamed"):
"""Run an async function in the event-loop.
Expand Down Expand Up @@ -260,9 +260,9 @@ def stop(self):
self.__should_stop += 1
if self.__should_stop >= 4:
# If for some reason the tick method is no longer being called, but the loop is still running, we can still stop it by spamming stop() :)
self._stop()
self.__stop()

def _stop(self):
def __stop(self):
"""Move to the off-state."""
# If we used the async adapter, cancel any tasks
while self.__tasks:
Expand Down
95 changes: 95 additions & 0 deletions rendercanvas/raw.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""
Implements a pure Python raw event-loop for backends that don't have an event-loop by themselves, like glfw.

There is not really an advantage over say the asyncio loop, except perhaps that it does not use
asyncio, so you can start an asyncio loop from a callback. Other than that, this is more
for educational purposes: look how simple a loop can be!
"""

__all__ = ["RawLoop", "loop"]

import time
import heapq
import logging
import threading
from itertools import count

from .base import BaseLoop


logger = logging.getLogger("rendercanvas")
counter = count()


class CallAtWrapper:
def __init__(self, time, callback):
self.index = next(counter)
self.time = time
self.callback = callback

def __lt__(self, other):
return (self.time, self.index) < (other.time, other.index)

def cancel(self):
self.callback = None


class RawLoop(BaseLoop):
def __init__(self):
super().__init__()
self._queue = [] # prioriry queue
self._should_stop = False
self._event = threading.Event()

def _rc_init(self):
# This gets called when the first canvas is created (possibly after having run and stopped before).
pass

def _rc_run(self):
while not self._should_stop:
self._event.clear()

# Get wrapper for callback that is first to be called
try:
wrapper = heapq.heappop(self._queue)
except IndexError:
wrapper = None

if wrapper is None:
# Empty queue, exit
break
else:
# Wait until its time for it to be called
wait_time = wrapper.time - time.perf_counter()
self._event.wait(max(wait_time, 0))

# Put it back or call it?
if time.perf_counter() < wrapper.time:
heapq.heappush(self._queue, wrapper)
elif wrapper.callback is not None:
try:
wrapper.callback()
except Exception as err:
logger.error(f"Error in callback: {err}")

async def _rc_run_async(self):
raise NotImplementedError()

def _rc_stop(self):
# Note: is only called when we're inside _rc_run
self._should_stop = True
self._event.set()

def _rc_add_task(self, async_func, name):
# we use the async adapter with call_later
return super()._rc_add_task(async_func, name)

def _rc_call_later(self, delay, callback):
now = time.perf_counter()
time_at = now + max(0, delay)
wrapper = CallAtWrapper(time_at, callback)
heapq.heappush(self._queue, wrapper)
self._event.set()


loop = RawLoop()
16 changes: 13 additions & 3 deletions tests/test_backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,9 @@ def test_meta():
continue
module_name = fname.split(".")[0]
test_func_name = f"test_{module_name}_module"
assert (
test_func_name in all_test_names
), f"Test missing for {module_name} module"
assert test_func_name in all_test_names, (
f"Test missing for {module_name} module"
)


def test_ref_rc_methods():
Expand Down Expand Up @@ -192,6 +192,16 @@ def test_auto_module():
# %% Test modules that only provide a loop


def test_raw_module():
m = Module("raw")

assert "loop" in m.names
assert m.names["loop"]
loop_class = m.names["RawLoop"]
m.check_loop(loop_class)
assert loop_class.name == "RawLoop"


def test_asyncio_module():
m = Module("asyncio")

Expand Down
2 changes: 1 addition & 1 deletion tests/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ def handler(event):
ee.sync_flush()
t2 = time.perf_counter() - t0

print(f"add_handler: {1000*t1:0.0f} ms, emit: {1000*t2:0.0f} ms")
print(f"add_handler: {1000 * t1:0.0f} ms, emit: {1000 * t2:0.0f} ms")


if __name__ == "__main__":
Expand Down
53 changes: 33 additions & 20 deletions tests/test_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
Some tests for the base loop and asyncio loop.
"""

# ruff: noqa: N803

import time
import signal
import asyncio
Expand All @@ -10,6 +12,7 @@
from rendercanvas.base import BaseCanvasGroup, BaseRenderCanvas
from rendercanvas.asyncio import AsyncioLoop
from rendercanvas.trio import TrioLoop
from rendercanvas.raw import RawLoop
from rendercanvas.utils.asyncs import sleep as async_sleep
from testutils import run_tests
import trio
Expand Down Expand Up @@ -71,17 +74,19 @@ def _rc_request_draw(self):
loop.call_soon(self._draw_frame_and_present)


def test_run_loop_and_close_bc_no_canvases():
@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop])
def test_run_loop_and_close_bc_no_canvases(SomeLoop):
# Run the loop without canvas; closes immediately
loop = AsyncioLoop()
loop = SomeLoop()
loop.call_later(0.1, print, "hi from loop!")
loop.run()


def test_loop_detects_canvases():
@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop])
def test_loop_detects_canvases(SomeLoop):
# After all canvases are closed, it can take one tick before its detected.

loop = AsyncioLoop()
loop = SomeLoop()

group1 = CanvasGroup(loop)
group2 = CanvasGroup(loop)
Expand All @@ -104,10 +109,11 @@ def test_loop_detects_canvases():
assert len(loop.get_canvases()) == 3


def test_run_loop_without_canvases():
@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop])
def test_run_loop_without_canvases(SomeLoop):
# After all canvases are closed, it can take one tick before its detected.

loop = AsyncioLoop()
loop = SomeLoop()
group = CanvasGroup(loop)

# The loop is in its stopped state, but it fires up briefly to do one tick
Expand Down Expand Up @@ -147,10 +153,11 @@ def test_run_loop_without_canvases():
assert 0.0 <= et < 0.15


def test_run_loop_and_close_canvases():
@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop])
def test_run_loop_and_close_canvases(SomeLoop):
# After all canvases are closed, it can take one tick before its detected.

loop = AsyncioLoop()
loop = SomeLoop()
group = CanvasGroup(loop)

canvas1 = FakeCanvas()
Expand All @@ -173,9 +180,10 @@ def test_run_loop_and_close_canvases():
assert canvas2._events.is_closed


def test_run_loop_and_close_by_loop_stop():
@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop])
def test_run_loop_and_close_by_loop_stop(SomeLoop):
# Close, then wait at most one tick to close canvases, and another to conform close.
loop = AsyncioLoop()
loop = SomeLoop()
group = CanvasGroup(loop)

canvas1 = FakeCanvas()
Expand All @@ -197,9 +205,10 @@ def test_run_loop_and_close_by_loop_stop():
assert canvas2._events.is_closed


def test_run_loop_and_close_by_loop_stop_via_async():
@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop])
def test_run_loop_and_close_by_loop_stop_via_async(SomeLoop):
# Close using a coro
loop = AsyncioLoop()
loop = SomeLoop()
group = CanvasGroup(loop)

canvas1 = FakeCanvas()
Expand Down Expand Up @@ -252,10 +261,11 @@ async def stopper():
assert 0.35 < et < 0.65


def test_run_loop_and_close_by_deletion():
@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop])
def test_run_loop_and_close_by_deletion(SomeLoop):
# Make the canvases be deleted by the gc.

loop = AsyncioLoop()
loop = SomeLoop()
group = CanvasGroup(loop)

canvases = [FakeCanvas() for _ in range(2)]
Expand Down Expand Up @@ -297,10 +307,11 @@ def test_run_loop_and_close_by_deletion_real():
assert 0.25 < et < 0.55


def test_run_loop_and_interrupt():
@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop])
def test_run_loop_and_interrupt(SomeLoop):
# Interrupt, calls close, can take one tick to close canvases, and anoter to conform close.

loop = AsyncioLoop()
loop = SomeLoop()
group = CanvasGroup(loop)

canvas1 = FakeCanvas()
Expand Down Expand Up @@ -329,10 +340,11 @@ def interrupt_soon():
assert canvas2._events.is_closed


def test_run_loop_and_interrupt_harder():
@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop])
def test_run_loop_and_interrupt_harder(SomeLoop):
# In the next tick after the second interupt, it stops the loop without closing the canvases

loop = AsyncioLoop()
loop = SomeLoop()
group = CanvasGroup(loop)

canvas1 = FakeCanvas(refuse_close=True)
Expand Down Expand Up @@ -364,8 +376,9 @@ def interrupt_soon():
assert not canvas2._events.is_closed


def test_loop_threaded():
t = threading.Thread(target=test_run_loop_and_close_by_loop_stop)
@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop])
def test_loop_threaded(SomeLoop):
t = threading.Thread(target=test_run_loop_and_close_by_loop_stop, args=(SomeLoop,))
t.start()
t.join()

Expand Down
9 changes: 9 additions & 0 deletions tests/testutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,20 @@ def run_tests(scope):
caplog = LogCaptureHandler()
for func in list(scope.values()):
if callable(func) and func.__name__.startswith("test_"):
params = [
mark.args
for mark in getattr(func, "pytestmark", [])
if mark.name == "parametrize"
]
nargs = func.__code__.co_argcount
argnames = [func.__code__.co_varnames[i] for i in range(nargs)]
if not argnames:
print(f"Running {func.__name__} ...")
func()
elif nargs == 1 and len(params) == 1:
for arg in params[0][1]:
print(f"Running {func.__name__} with {arg}...")
func(arg)
elif argnames == ["caplog"]:
print(f"Running {func.__name__} ...")
logging.root.addHandler(caplog)
Expand Down
Loading