Skip to content

Commit 863f2bf

Browse files
authored
Drop sniffio and improved limetime (#157)
1 parent 9f2aebd commit 863f2bf

17 files changed

Lines changed: 502 additions & 152 deletions

docs/backends.rst

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ An example using PyScript (which uses Pyodide):
297297
<body>
298298
<canvas id='canvas' style="background:#aaa; width: 640px; height: 480px;"></canvas>
299299
<br>
300-
<script type="py" src="yourcode.py" config='{"packages": ["numpy", "sniffio", "rendercanvas"]}'>
300+
<script type="py" src="yourcode.py" config='{"packages": ["numpy", "rendercanvas"]}'>
301301
</script>
302302
</body>
303303
</html>
@@ -335,7 +335,6 @@ An example using Pyodide directly:
335335
await pyodide.loadPackage("micropip");
336336
const micropip = pyodide.pyimport("micropip");
337337
await micropip.install("numpy");
338-
await micropip.install("sniffio");
339338
await micropip.install("rendercanvas");
340339
// have to call as runPythonAsync
341340
pyodide.runPythonAsync(pythonCode);

docs/start.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ If you like callbacks, ``loop.call_later()`` always works. If you like async, us
132132
If you make use of async functions (co-routines), and want to keep your code portable across
133133
different canvas backends, restrict your use of async features to ``sleep`` and ``Event``;
134134
these are the only features currently implemented in our async adapter utility.
135-
We recommend importing these from :doc:`rendercanvas.utils.asyncs <utils_asyncs>` or use ``sniffio`` to detect the library that they can be imported from.
135+
We recommend importing these from :doc:`rendercanvas.utils.asyncs <utils_asyncs>` if you want your code to be portable across different event loop backends.
136136

137137
On the other hand, if you know your code always runs on the asyncio loop, you can fully make use of ``asyncio``. Ditto for Trio.
138138

docs/static/_pyodide_iframe.html

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ <h1>Loading...</h1>
2222
let pyodide = await loadPyodide();
2323
await pyodide.loadPackage("micropip");
2424
const micropip = pyodide.pyimport("micropip");
25-
await micropip.install('sniffio');
2625
await micropip.install('numpy');
2726
// The below loads rendercanvas from pypi. But we will replace it with the name of the wheel,
2827
// so that it's loaded from the docs (in _static).

examples/pyscript.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ <h1>Loading...</h1>
3131

3232
<canvas id="canvas" style="background:#aaa; width: 90%; height: 480px;"></canvas>
3333
<br>
34-
<script type="py" src="drag.py" config='{"packages": ["numpy", "sniffio", "rendercanvas"]}'>
34+
<script type="py" src="drag.py" config='{"packages": ["numpy", "rendercanvas"]}'>
3535
</script>
3636
</body>
3737

examples/serve_browser_examples.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def get_html_index():
104104
105105
<canvas id="canvas" style="background:#aaa; width: 90%; height: 480px;"></canvas>
106106
<script type="py" src="example.py" ,
107-
config='{"packages": ["numpy", "sniffio", "rendercanvas"]}'>
107+
config='{"packages": ["numpy", "rendercanvas"]}'>
108108
</script>
109109
</body>
110110

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ keywords = [
1818
"jupyter",
1919
]
2020
requires-python = ">= 3.10"
21-
dependencies = ["sniffio"]
21+
dependencies = [] # Zero hard dependencies!
2222
[project.optional-dependencies]
2323
# For users
2424
jupyter = ["jupyter_rfb>=0.4.2"]

rendercanvas/_loop.py

Lines changed: 110 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -31,44 +31,66 @@
3131
class BaseLoop:
3232
"""The base class for an event-loop object.
3333
34+
The rendercanvas ``loop`` object is a proxy to a real event-loop. It abstracts away methods like ``run()``,
35+
``call_later``, ``call_soon_threadsafe()``, and more.
36+
3437
Canvas backends can implement their own loop subclass (like qt and wx do), but a
3538
canvas backend can also rely on one of multiple loop implementations (like glfw
3639
running on asyncio or trio).
3740
38-
The lifecycle states of a loop are:
39-
40-
* off: the initial state, the subclass should probably not even import dependencies yet.
41-
* ready: the first canvas is created, ``_rc_init()`` is called to get the loop ready for running.
42-
* active: the loop is active (we detect it because our task is running), but we don't know how.
43-
* interactive: the loop is inter-active in e.g. an IDE, reported by the backend.
44-
* running: the loop is running via ``_rc_run()`` or ``_rc_run_async()``.
45-
46-
Notes:
41+
In the majority of use-cases, users don't need to know much about the loop. It will typically
42+
run once. In more complex scenario's the section below explains the working of the loop in more detail.
43+
44+
**Details about loop lifetime**
45+
46+
The rendercanvas loop object is a proxy, which has to support a variety of backends.
47+
To realize this, it has the following lifetime model:
48+
49+
* off:
50+
* Entered when the loop is instantiated, and when the loop has stopped.
51+
* This is the 'idle' state.
52+
* The backend probably has not even imported dependencies yet.
53+
* ready:
54+
* Entered when the first canvas is created that is associated with this loop, or when a task is added.
55+
* It is assumed that the loop will become active soon.
56+
* This is when ``_rc_init()`` is called to get the backend ready for running.
57+
* A special 'loop-task' is created (a coroutine, which is not yet running).
58+
* running:
59+
* Entered when ``loop.run()`` is called.
60+
* The loop is now running.
61+
* Signal handlers and asyncgen hooks are installed if applicable.
62+
* interactive:
63+
* Entered in ``_rc_init()`` when the backend detects that the loop is interactive.
64+
* Example use-cases are a notebook or interactive IDE, usually via asyncio.
65+
* This means there is a persistent native loop already running, which rendercanvas makes use of.
66+
* active:
67+
* Entered when the backend-loop starts running, but not via the loop's ``run()`` method.
68+
* This is detected via the loop-task.
69+
* Signal handlers and asyncgen hooks are installed if applicable.
70+
* Detecting loop stopping occurs by the loop-task being cancelled.
71+
72+
Notes related to starting and stopping:
4773
4874
* The loop goes back to the "off" state once all canvases are closed.
4975
* Stopping the loop (via ``.stop()``) closes the canvases, which will then stop the loop.
5076
* From there it can go back to the ready state (which would call ``_rc_init()`` again).
5177
* In backends like Qt, the native loop can be started without us knowing: state "active".
5278
* In interactive settings like an IDE that runs an asyncio or Qt loop, the
53-
loop can become "active" as soon as the first canvas is created.
54-
55-
The lifecycle of this loop does not necessarily co-inside with the native loop's cycle:
56-
57-
* The rendercanvas loop can be in the 'off' state while the native loop is running.
58-
* When we stop the loop, the native loop likely runs slightly longer.
59-
* When the loop is interactive (asyncio or Qt) the native loop keeps running when rendercanvas' loop stops.
60-
* For async loops (asyncio or trio), the native loop may run before and after this loop.
61-
* On Qt, we detect the app's aboutToQuit to stop this loop.
62-
* On wx, we detect all windows closed to stop this loop.
79+
loop becomes "interactive" as soon as the first canvas is created.
80+
* The rendercanvas loop can be in the 'off' state while the native loop is running (especially for the 'interactive' case).
81+
* On Qt, the app's 'aboutToQuit' signal is used to stop this loop.
82+
* On wx, the loop is stopped when all windows are closed.
6383
6484
"""
6585

6686
def __init__(self):
67-
self.__tasks = set()
87+
self.__tasks = set() # only used by the async adapter
6888
self.__canvas_groups = set()
6989
self.__should_stop = 0
7090
self.__state = LoopState.off
7191
self.__is_initialized = False
92+
self.__hook_data = None
93+
self.__using_adapter = False # set to True if using our asyncadapter
7294
self._asyncgens = weakref.WeakSet()
7395
# self._setup_debug_thread()
7496

@@ -143,6 +165,7 @@ async def wrapper():
143165
self.__is_initialized = True
144166
self._rc_init()
145167
self._rc_add_task(wrapper, "loop-task")
168+
self.__using_adapter = len(self.__tasks) > 0
146169

147170
async def _loop_task(self):
148171
# This task has multiple purposes:
@@ -162,6 +185,10 @@ async def _loop_task(self):
162185
# because its minimized (applies to backends that implement
163186
# _rc_gui_poll).
164187

188+
# In some cases the task may run after the loop was closed
189+
if self.__state == LoopState.off:
190+
return
191+
165192
# The loop has started!
166193
self.__start()
167194

@@ -295,8 +322,7 @@ def run(self) -> None:
295322

296323
self._ensure_initialized()
297324

298-
# Register interrupt handler
299-
prev_sig_handlers = self.__setup_interrupt()
325+
need_unregister = self.__setup_hooks()
300326

301327
# Run. We could be in this loop for a long time. Or we can exit immediately if
302328
# the backend already has an (interactive) event loop and did not call _mark_as_interactive().
@@ -307,14 +333,15 @@ def run(self) -> None:
307333
# Mark state as not 'running', but also not to 'off', that happens elsewhere.
308334
if self.__state == LoopState.running:
309335
self.__state = LoopState.active
310-
for sig, cb in prev_sig_handlers.items():
311-
signal.signal(sig, cb)
336+
if need_unregister:
337+
self.__restore_hooks()
312338

313339
async def run_async(self) -> None:
314340
""" "Alternative to ``run()``, to enter the mainloop from a running async framework.
315341
316342
Only supported by the asyncio and trio loops.
317343
"""
344+
318345
# Can we enter the loop?
319346
if self.__state in (LoopState.off, LoopState.ready):
320347
pass
@@ -337,7 +364,7 @@ async def run_async(self) -> None:
337364
def stop(self, *, force=False) -> None:
338365
"""Close all windows and stop the currently running event-loop.
339366
340-
If the loop is active but not running via our ``run()`` method, the loop
367+
If the loop is active but not running via the ``run()`` method, the loop
341368
moves back to its off-state, but the underlying loop is not stopped.
342369
343370
Normally, the windows are closed and the underlying event loop is given
@@ -368,17 +395,47 @@ def stop(self, *, force=False) -> None:
368395
if len(canvases) == 0 or self.__should_stop >= 2:
369396
self.__stop()
370397

398+
def __setup_hooks(self):
399+
"""Setup asycgen hooks and interrupt hooks."""
400+
if self.__hook_data is not None:
401+
return False
402+
403+
# Setup asyncgen hooks
404+
prev_asyncgen_hooks = self.__setup_asyncgen_hooks()
405+
406+
# Set interrupts
407+
prev_interrupt_hooks = self.__setup_interrupt_hooks()
408+
409+
self.__hook_data = prev_asyncgen_hooks, prev_interrupt_hooks
410+
return True
411+
412+
def __restore_hooks(self):
413+
"""Unregister hooks."""
414+
415+
# This is separated from stop(), so that a loop can be 'active' by repeated calls to ``run()``, but will only
416+
# actually have registered hooks while inside ``run()``. The StubLoop has this behavior, and it may be a bit silly
417+
# to organize for this special use-case, but it does make it more clean/elegant, and maybe someday we will want another
418+
# loop class that runs for short periods. This now works, even when another loop is running.
419+
420+
if self.__hook_data is None:
421+
return
422+
423+
prev_asyncgen_hooks, prev_interrupt_hooks = self.__hook_data
424+
self.__hook_data = None
425+
426+
if prev_asyncgen_hooks is not None:
427+
sys.set_asyncgen_hooks(*prev_asyncgen_hooks)
428+
429+
for sig, cb in prev_interrupt_hooks.items():
430+
signal.signal(sig, cb)
431+
371432
def __start(self):
372433
"""Move to running state."""
373-
374434
# Update state, but leave 'interactive' and 'running'
375435
if self.__state in (LoopState.off, LoopState.ready):
376436
self.__state = LoopState.active
377437

378-
# Setup asyncgen hooks. This is done when we detect the loop starting,
379-
# not in run(), because most event-loops will handle interrupts, while
380-
# e.g. qt won't care about async generators.
381-
self.__setup_asyncgen_hooks()
438+
self.__setup_hooks()
382439

383440
def __stop(self):
384441
"""Move to the off-state."""
@@ -390,8 +447,6 @@ def __stop(self):
390447
self.__state = LoopState.off
391448
self.__should_stop = 0
392449

393-
self.__finish_asyncgen_hooks()
394-
395450
# If we used the async adapter, cancel any tasks. If we could assume
396451
# that the backend processes pending events before actually shutting
397452
# down, we could only call .cancel(), and leave the event-loop to do the
@@ -407,11 +462,20 @@ def __stop(self):
407462
# Note that backends that do not use the asyncadapter are responsible
408463
# for cancelling pending tasks.
409464

465+
self.__restore_hooks()
466+
467+
# Cancel async gens
468+
if len(self._asyncgens):
469+
closing_agens = list(self._asyncgens)
470+
self._asyncgens.clear()
471+
for agen in closing_agens:
472+
close_agen(agen)
473+
410474
# Tell the backend to stop the loop. This usually means it will stop
411475
# soon, but not *now*; remember that we're currently in a task as well.
412476
self._rc_stop()
413477

414-
def __setup_interrupt(self):
478+
def __setup_interrupt_hooks(self):
415479
"""Setup the interrupt handlers."""
416480

417481
def on_interrupt(sig, _frame):
@@ -445,29 +509,20 @@ def __setup_asyncgen_hooks(self):
445509
# the generator in the user's code. Note that when a proper async
446510
# framework (asyncio or trio) is used, all of this does not apply; only
447511
# for the qt/wx/raw loop do we do this, an in these cases we don't
448-
# expect fancy async stuff.
449-
450-
current_asyncgen_hooks = sys.get_asyncgen_hooks()
451-
if (
452-
current_asyncgen_hooks.firstiter is None
453-
and current_asyncgen_hooks.finalizer is None
454-
):
455-
sys.set_asyncgen_hooks(
456-
firstiter=self._asyncgen_firstiter_hook,
457-
finalizer=self._asyncgen_finalizer_hook,
458-
)
459-
else:
460-
# Assume that the hooks are from asyncio/trio on which this loop is running.
461-
pass
512+
# expect fancy async stuff. Oh, and the sleep and Event actually become no-ops when the
513+
# asyncgen hooks are restored, so that error message should in theory never happen anyway.
462514

463-
def __finish_asyncgen_hooks(self):
464-
sys.set_asyncgen_hooks(None, None)
515+
# Only register hooks if we use the asyncadapter; async frameworks install their own hooks.
516+
if not self.__using_adapter:
517+
return None
465518

466-
if len(self._asyncgens):
467-
closing_agens = list(self._asyncgens)
468-
self._asyncgens.clear()
469-
for agen in closing_agens:
470-
close_agen(agen)
519+
prev_asyncgen_hooks = sys.get_asyncgen_hooks()
520+
sys.set_asyncgen_hooks(
521+
firstiter=self._asyncgen_firstiter_hook,
522+
finalizer=self._asyncgen_finalizer_hook,
523+
)
524+
525+
return prev_asyncgen_hooks
471526

472527
def _asyncgen_firstiter_hook(self, agen):
473528
self._asyncgens.add(agen)
@@ -518,7 +573,7 @@ def _rc_stop(self):
518573
raise NotImplementedError()
519574

520575
def _rc_add_task(self, async_func, name):
521-
"""Add an async task to the running loop.
576+
"""Add an async task to this loop.
522577
523578
True async loop-backends (like asyncio and trio) should implement this.
524579
When they do, ``_rc_call_later`` is not used.

rendercanvas/asyncio.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
__all__ = ["AsyncioLoop", "loop"]
77

88
from .base import BaseLoop
9-
10-
import sniffio
9+
from .utils.asyncs import detect_current_async_lib
1110

1211

1312
class AsyncioLoop(BaseLoop):
@@ -43,7 +42,7 @@ async def _rc_run_async(self):
4342
import asyncio
4443

4544
# Protect against usage of wrong loop object
46-
libname = sniffio.current_async_library()
45+
libname = detect_current_async_lib()
4746
if libname != "asyncio":
4847
raise TypeError(f"Attempt to run AsyncioLoop with {libname}.")
4948

@@ -74,7 +73,8 @@ def _rc_stop(self):
7473
task = self.__tasks.pop()
7574
task.cancel() # is a no-op if the task is no longer running
7675
# Signal that we stopped
77-
self._stop_event.set()
76+
if self._stop_event is not None:
77+
self._stop_event.set()
7878
self._stop_event = None
7979
self._run_loop = None
8080
# Note how we don't explicitly stop a loop, not the interactive loop, nor the running loop

0 commit comments

Comments
 (0)