pythonnative 0.23.0__py3-none-any.whl → 0.25.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pythonnative/runtime.py CHANGED
@@ -1,41 +1,46 @@
1
- """Asyncio runtime for PythonNative.
2
-
3
- PythonNative runs a single, framework-wide ``asyncio`` event loop on
4
- a dedicated daemon thread. Every awaitable surface in the framework
5
- schedules its work on this loop via
6
- [`run_async`][pythonnative.runtime.run_async]: the async hooks
7
- ([`use_async_effect`][pythonnative.hooks.use_async_effect],
8
- [`use_query`][pythonnative.hooks.use_query],
9
- [`use_mutation`][pythonnative.hooks.use_mutation]), the
10
- [`fetch`][pythonnative.net.fetch] HTTP client,
11
- [`AsyncStorage`][pythonnative.storage.AsyncStorage], the awaitable
12
- native modules
13
- ([`Camera`][pythonnative.native_modules.camera.Camera] /
14
- [`Location`][pythonnative.native_modules.location.Location] /
15
- [`Notifications`][pythonnative.native_modules.notifications.Notifications]),
16
- and awaited animations.
17
-
18
- The reconciler is **not** asyncio-aware; it still runs synchronously on
19
- the platform main thread. Coroutines that want to mutate component
20
- state simply call the regular ``use_state`` setter, and the existing
21
- deferred-render path inside the screen host marshals the re-render
22
- onto the main thread. The runtime is therefore additive: it gives
23
- coroutines somewhere to live without changing the rendering contract.
1
+ """Asyncio runtime: one event loop, hosted on the platform main thread.
2
+
3
+ PythonNative is **async-first**: the whole framework (rendering,
4
+ effects, native modules, animations, timers) shares a single
5
+ ``asyncio`` event loop that lives on the platform's main thread. There
6
+ is no background runtime thread; coroutines interleave with rendering
7
+ on the same thread, so async code can touch component state and (via
8
+ the commit) native views without any cross-thread marshaling.
9
+
10
+ Because UIKit and the Android view system own the main thread's run
11
+ loop, the framework loop cannot call ``run_forever`` and block.
12
+ Instead it runs as a **guest**: whenever work is scheduled
13
+ (``call_soon`` / ``call_soon_threadsafe`` / timers), the runtime asks
14
+ the platform to *pump* the loop on the next main-queue turn
15
+ (``dispatch_async`` on iOS, ``Handler.post`` on Android, the Tk poll
16
+ loop in ``pn preview``). One pump runs every ready callback and due
17
+ timer, then returns control to the platform.
18
+
19
+ Key entry points:
20
+
21
+ - [`get_loop`][pythonnative.runtime.get_loop]: the framework loop. If
22
+ a loop is already running on the calling thread (an ``async`` test,
23
+ ``asyncio.run``), that loop is adopted instead.
24
+ - [`run_async`][pythonnative.runtime.run_async]: schedule a coroutine
25
+ from synchronous code (an event handler, an effect). Returns an
26
+ ``asyncio.Task`` when called on the loop's thread, or a
27
+ ``concurrent.futures.Future`` when called from another thread.
28
+ - [`run_blocking`][pythonnative.runtime.run_blocking] /
29
+ [`drain`][pythonnative.runtime.drain]: drive the guest loop from
30
+ synchronous code. Used by tests and plain scripts, where no platform
31
+ pump exists.
24
32
 
25
33
  Example:
26
34
  ```python
27
- import asyncio
28
-
29
35
  import pythonnative as pn
30
36
 
31
37
 
32
- async def hello() -> str:
33
- await asyncio.sleep(0.1)
34
- return "hi"
35
-
38
+ @pn.component
39
+ def SaveButton():
40
+ async def save():
41
+ await pn.AsyncStorage.set_item("draft", "…")
36
42
 
37
- future = pn.runtime.run_async(hello())
38
- print(future.result(timeout=1.0)) # "hi"
43
+ return pn.Button("Save", on_press=lambda: pn.run_async(save()))
39
44
  ```
40
45
  """
41
46
 
@@ -46,134 +51,208 @@ import ctypes
46
51
  import ctypes.util
47
52
  import inspect
48
53
  import threading
49
- from concurrent.futures import Future as _ThreadFuture
54
+ import time as _time
50
55
  from typing import Any, Awaitable, Callable, Coroutine, Dict, Optional, TypeVar, Union
51
56
 
52
57
  T = TypeVar("T")
53
58
 
54
59
 
55
60
  # ======================================================================
56
- # Module-level loop singleton
61
+ # The guest loop
57
62
  # ======================================================================
58
63
 
59
- _loop: Optional[asyncio.AbstractEventLoop] = None
60
- _thread: Optional[threading.Thread] = None
61
- _lock = threading.Lock()
62
64
 
65
+ class _GuestLoop(asyncio.SelectorEventLoop):
66
+ """A selector event loop that asks the platform to pump it.
63
67
 
64
- # ======================================================================
65
- # Apple QoS bump (iOS / macOS)
66
- # ======================================================================
67
- #
68
- # By default ``threading.Thread`` lands at a low QoS class on Apple
69
- # platforms, which iOS subjects to wake-up coalescing; the asyncio
70
- # loop only gets ~2 timeslices per second (~500ms granularity). Bumping
71
- # the thread to ``QOS_CLASS_USER_INTERACTIVE`` is *necessary* for it to
72
- # be treated as foreground work, but on the simulator it's not
73
- # *sufficient* on its own. The companion fix is the PyDLL-based main
74
- # thread dispatch further down in this file, which keeps the GIL held
75
- # (and the thread off the kernel's coalescing grid) for the actual
76
- # bg→main hop.
77
-
78
- # qos_class_t value from <sys/qos.h>
79
- _QOS_CLASS_USER_INTERACTIVE = 0x21
80
-
81
-
82
- def _apply_apple_thread_qos() -> None:
83
- """Bump the calling thread to ``USER_INTERACTIVE`` on iOS.
84
-
85
- No-op on other platforms or if the symbol can't be loaded. Must be
86
- called from inside the target thread (the underlying syscall is
87
- ``pthread_set_qos_class_self_np``). Empirically ``USER_INTERACTIVE``
88
- is required on iOS; anything lower triggers wake-up coalescing on
89
- the background asyncio thread, which adds ~500ms latency to every
90
- cross-thread dispatch.
68
+ Overrides every scheduling entry point so that queuing work also
69
+ requests a pump on the platform main queue. The pump itself
70
+ (:func:`_pump`) runs one ``run_forever`` iteration bounded by an
71
+ immediate ``stop``, which executes all ready callbacks and due
72
+ timers, then reschedules itself while work remains.
91
73
  """
92
- from .platform import Platform
93
74
 
94
- if not Platform.is_ios:
95
- return
96
- try:
97
- libc_path = ctypes.util.find_library("c")
98
- if libc_path is None:
75
+ def __init__(self) -> None:
76
+ super().__init__()
77
+ self._pn_owner_thread = threading.current_thread()
78
+ self._pn_pumping = False
79
+ self._pn_pump_queued = False
80
+ self._pn_timer: Optional[threading.Timer] = None
81
+ self._pn_timer_when: float = float("inf")
82
+ self._pn_lock = threading.Lock()
83
+
84
+ # -- scheduling overrides ------------------------------------------
85
+ #
86
+ # ``type: ignore[override]``: the typeshed stubs type these with a
87
+ # ``TypeVarTuple`` linking ``callback`` to ``*args``; the plain
88
+ # ``Callable[..., Any]`` form used here accepts the same calls.
89
+
90
+ def call_soon(self, callback: Callable[..., Any], *args: Any, context: Any = None) -> Any: # type: ignore[override]
91
+ handle = super().call_soon(callback, *args, context=context)
92
+ self._pn_request_pump(0.0)
93
+ return handle
94
+
95
+ def call_soon_threadsafe( # type: ignore[override]
96
+ self, callback: Callable[..., Any], *args: Any, context: Any = None
97
+ ) -> Any:
98
+ handle = super().call_soon_threadsafe(callback, *args, context=context)
99
+ self._pn_request_pump(0.0)
100
+ return handle
101
+
102
+ def call_at( # type: ignore[override]
103
+ self, when: float, callback: Callable[..., Any], *args: Any, context: Any = None
104
+ ) -> Any:
105
+ handle = super().call_at(when, callback, *args, context=context)
106
+ self._pn_request_pump(max(0.0, when - self.time()))
107
+ return handle
108
+
109
+ # -- pumping --------------------------------------------------------
110
+
111
+ def _pn_request_pump(self, delay: float) -> None:
112
+ """Ask the platform to pump this loop after ``delay`` seconds.
113
+
114
+ No-op while a pump is executing (the post-pump check
115
+ reschedules if work remains) and when no platform dispatcher
116
+ exists (headless tests drive the loop with
117
+ [`drain`][pythonnative.runtime.drain] /
118
+ [`run_blocking`][pythonnative.runtime.run_blocking] instead).
119
+ """
120
+ if self._pn_pumping or self.is_running() or self.is_closed():
99
121
  return
100
- libc = ctypes.CDLL(libc_path)
101
- set_fn = libc.pthread_set_qos_class_self_np
102
- set_fn.argtypes = [ctypes.c_int, ctypes.c_int]
103
- set_fn.restype = ctypes.c_int
104
- set_fn(_QOS_CLASS_USER_INTERACTIVE, 0)
105
- except Exception as exc:
106
- print(f"[pn.runtime] could not bump thread QoS: {exc!r}")
107
-
108
-
109
- def _spawn_loop() -> asyncio.AbstractEventLoop:
110
- """Create a fresh event loop on a daemon thread and block until it's running."""
111
- loop = asyncio.new_event_loop()
112
- ready = threading.Event()
113
-
114
- def _run() -> None:
115
- _apply_apple_thread_qos()
116
- asyncio.set_event_loop(loop)
117
- ready.set()
118
- loop.run_forever()
122
+ if delay <= 0.0:
123
+ with self._pn_lock:
124
+ if self._pn_pump_queued:
125
+ return
126
+ if not _has_pump_dispatcher():
127
+ return
128
+ self._pn_pump_queued = True
129
+ _dispatch_to_main_queue(self._pn_pump)
130
+ return
131
+ # Delayed work: keep a single earliest-deadline timer that
132
+ # forwards onto the main queue when it fires.
133
+ now = _time.monotonic()
134
+ when = now + delay
135
+ with self._pn_lock:
136
+ if not _has_pump_dispatcher():
137
+ return
138
+ if self._pn_timer is not None and self._pn_timer_when <= when:
139
+ return
140
+ if self._pn_timer is not None:
141
+ self._pn_timer.cancel()
142
+ timer = threading.Timer(delay, self._pn_timer_fired)
143
+ timer.daemon = True
144
+ self._pn_timer = timer
145
+ self._pn_timer_when = when
146
+ timer.start()
147
+
148
+ def _pn_timer_fired(self) -> None:
149
+ with self._pn_lock:
150
+ self._pn_timer = None
151
+ self._pn_timer_when = float("inf")
152
+ self._pn_request_pump(0.0)
153
+
154
+ def _pn_pump(self) -> None:
155
+ """Run one loop iteration on the main thread, then reschedule."""
156
+ with self._pn_lock:
157
+ self._pn_pump_queued = False
158
+ if self.is_closed() or self.is_running() or self._pn_pumping:
159
+ return
160
+ if threading.current_thread() is not self._pn_owner_thread:
161
+ # A dispatcher delivered the pump on the wrong thread
162
+ # (shouldn't happen on device); drop it rather than run the
163
+ # loop off its owner thread.
164
+ return
165
+ self._pn_pumping = True
166
+ try:
167
+ super().call_soon(self.stop)
168
+ self.run_forever()
169
+ except Exception as exc: # pragma: no cover - platform-level guard
170
+ print(f"[pn.runtime] loop pump raised: {exc!r}")
171
+ finally:
172
+ self._pn_pumping = False
173
+ # Reschedule while work remains: immediately for ready
174
+ # callbacks, at the earliest deadline for timers.
175
+ ready = getattr(self, "_ready", None)
176
+ if ready:
177
+ self._pn_request_pump(0.0)
178
+ return
179
+ scheduled = getattr(self, "_scheduled", None)
180
+ if scheduled:
181
+ next_when = scheduled[0].when()
182
+ self._pn_request_pump(max(0.0, next_when - self.time()))
119
183
 
120
- thread = threading.Thread(target=_run, daemon=True, name="pn-asyncio")
121
- thread.start()
122
- ready.wait()
123
184
 
124
- global _thread
125
- _thread = thread
126
- return loop
185
+ _loop: Optional[_GuestLoop] = None
186
+ _loop_lock = threading.Lock()
127
187
 
128
188
 
129
189
  def get_loop() -> asyncio.AbstractEventLoop:
130
- """Return the framework-wide event loop, starting it on first use.
190
+ """Return the framework event loop.
131
191
 
132
- The loop runs on a daemon thread (``"pn-asyncio"``) and lives for
133
- the duration of the process. It is safe to call this from any
134
- thread.
192
+ If a loop is already running on the calling thread (an ``async``
193
+ test, or app code inside ``asyncio.run``), that loop is adopted so
194
+ every framework awaitable lives on the caller's loop. Otherwise
195
+ the shared guest loop is returned, created on first use and owned
196
+ by the creating thread (the platform main thread on device).
135
197
 
136
198
  Returns:
137
- The shared :class:`asyncio.AbstractEventLoop`.
199
+ The :class:`asyncio.AbstractEventLoop` all framework work
200
+ should be scheduled on.
138
201
  """
202
+ try:
203
+ return asyncio.get_running_loop()
204
+ except RuntimeError:
205
+ pass
139
206
  global _loop
140
207
  if _loop is not None and not _loop.is_closed():
141
208
  return _loop
142
- with _lock:
209
+ with _loop_lock:
143
210
  if _loop is None or _loop.is_closed():
144
- _loop = _spawn_loop()
211
+ _loop = _GuestLoop()
145
212
  return _loop
146
213
 
147
214
 
215
+ def _on_loop_thread(loop: asyncio.AbstractEventLoop) -> bool:
216
+ """Whether the current thread may schedule non-threadsafe work on ``loop``."""
217
+ owner = getattr(loop, "_pn_owner_thread", None)
218
+ if owner is not None:
219
+ return threading.current_thread() is owner
220
+ try:
221
+ return asyncio.get_running_loop() is loop
222
+ except RuntimeError:
223
+ return False
224
+
225
+
148
226
  def _shutdown_for_tests() -> None:
149
- """Stop the runtime loop, primarily for test isolation.
227
+ """Close the guest loop and reset module state (test isolation).
150
228
 
151
- Cancels every pending task, stops the loop, joins the thread, and
152
- clears the module-level state so the next call to
153
- [`get_loop`][pythonnative.runtime.get_loop] starts a fresh loop.
154
- Production code should not call this; the loop is a daemon and
155
- will be torn down with the process.
229
+ Cancels every pending task, lets cancellations propagate, and
230
+ closes the loop so the next [`get_loop`][pythonnative.runtime.get_loop]
231
+ starts fresh. Production code never calls this; the loop lives for
232
+ the process.
156
233
  """
157
- global _loop, _thread
158
- with _lock:
234
+ global _loop
235
+ with _loop_lock:
159
236
  loop = _loop
160
- thread = _thread
161
237
  _loop = None
162
- _thread = None
163
- if loop is None:
238
+ if loop is None or loop.is_closed():
239
+ return
240
+ timer = loop._pn_timer
241
+ if timer is not None:
242
+ timer.cancel()
243
+ if loop.is_running(): # pragma: no cover - misuse guard
164
244
  return
165
245
  try:
166
- for task in asyncio.all_tasks(loop):
167
- loop.call_soon_threadsafe(task.cancel)
168
- loop.call_soon_threadsafe(loop.stop)
169
- except RuntimeError:
246
+ tasks = [t for t in asyncio.all_tasks(loop) if not t.done()]
247
+ for task in tasks:
248
+ task.cancel()
249
+ if tasks:
250
+ loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
251
+ loop.run_until_complete(loop.shutdown_asyncgens())
252
+ except Exception:
170
253
  pass
171
- if thread is not None:
172
- thread.join(timeout=2.0)
173
- try:
254
+ finally:
174
255
  loop.close()
175
- except RuntimeError:
176
- pass
177
256
 
178
257
 
179
258
  # ======================================================================
@@ -184,23 +263,22 @@ def _shutdown_for_tests() -> None:
184
263
  Awaitlike = Union[Coroutine[Any, Any, T], Awaitable[T]]
185
264
 
186
265
 
187
- def run_async(awaitable: Awaitlike[T]) -> "_ThreadFuture[T]":
188
- """Schedule ``awaitable`` on the framework loop and return a thread future.
266
+ def run_async(awaitable: Awaitlike[T]) -> Any:
267
+ """Schedule ``awaitable`` on the framework loop.
189
268
 
190
- Use this when calling async code from synchronous code (e.g. an
191
- event handler, a hook setup function, or a test). The returned
192
- :class:`concurrent.futures.Future` is created by
193
- :func:`asyncio.run_coroutine_threadsafe` so it can be ``result()``-ed
194
- from the calling thread and ``cancel()``-ed from anywhere.
269
+ The standard bridge from synchronous code (event handlers, effect
270
+ setups) into async code. On the loop's own thread this returns an
271
+ :class:`asyncio.Task`; from another thread it returns the
272
+ :class:`concurrent.futures.Future` produced by
273
+ :func:`asyncio.run_coroutine_threadsafe`. Both support
274
+ ``cancel()`` and ``add_done_callback()``.
195
275
 
196
276
  Args:
197
- awaitable: Either a coroutine object (the typical case) or any
198
- awaitable. Awaitables that are not coroutines are wrapped
199
- with :func:`asyncio.ensure_future` on the loop.
277
+ awaitable: A coroutine object (the typical case) or any
278
+ awaitable.
200
279
 
201
280
  Returns:
202
- A thread-safe future that resolves with the coroutine's return
203
- value, or raises its exception.
281
+ A future-like handle for the scheduled work.
204
282
 
205
283
  Example:
206
284
  ```python
@@ -209,11 +287,12 @@ def run_async(awaitable: Awaitlike[T]) -> "_ThreadFuture[T]":
209
287
  async def work():
210
288
  return 42
211
289
 
212
- fut = pn.runtime.run_async(work())
213
- assert fut.result(timeout=1.0) == 42
290
+ task = pn.run_async(work())
214
291
  ```
215
292
  """
216
293
  loop = get_loop()
294
+ if _on_loop_thread(loop):
295
+ return asyncio.ensure_future(awaitable, loop=loop)
217
296
  if inspect.iscoroutine(awaitable):
218
297
  return asyncio.run_coroutine_threadsafe(awaitable, loop)
219
298
 
@@ -223,14 +302,84 @@ def run_async(awaitable: Awaitlike[T]) -> "_ThreadFuture[T]":
223
302
  return asyncio.run_coroutine_threadsafe(_wrap(), loop)
224
303
 
225
304
 
305
+ def run_blocking(awaitable: Awaitlike[T], timeout: Optional[float] = None) -> T:
306
+ """Run ``awaitable`` to completion on the framework loop, blocking.
307
+
308
+ For synchronous scripts and tests. Must not be called while the
309
+ loop is already running (i.e., from inside a coroutine); ``await``
310
+ directly there instead.
311
+
312
+ Args:
313
+ awaitable: The coroutine or awaitable to drive.
314
+ timeout: Optional seconds before :class:`TimeoutError`.
315
+
316
+ Returns:
317
+ The awaitable's result.
318
+ """
319
+ loop = get_loop()
320
+ if loop.is_running():
321
+ raise RuntimeError("run_blocking() cannot be used while the event loop is running; use `await` instead")
322
+
323
+ async def _driver() -> T:
324
+ if timeout is not None:
325
+ try:
326
+ return await asyncio.wait_for(_ensure_coro(awaitable), timeout)
327
+ except asyncio.TimeoutError:
328
+ # On Python 3.10, asyncio.TimeoutError is not the builtin
329
+ # TimeoutError (they were unified in 3.11); normalize so
330
+ # callers can always catch the builtin.
331
+ raise TimeoutError(f"run_blocking() timed out after {timeout}s") from None
332
+ return await _ensure_coro(awaitable)
333
+
334
+ return loop.run_until_complete(_driver())
335
+
336
+
337
+ async def _ensure_coro(awaitable: Awaitlike[T]) -> T:
338
+ return await awaitable
339
+
340
+
341
+ def drain(timeout: float = 1.0, *, until: Optional[Callable[[], bool]] = None) -> bool:
342
+ """Pump the framework loop until it goes idle (or ``until`` holds).
343
+
344
+ Runs ready callbacks, due timers, and task steps repeatedly. Used
345
+ by synchronous tests to settle async effects, resources, and
346
+ transition flushes deterministically; on-device the platform pump
347
+ makes this unnecessary.
348
+
349
+ Args:
350
+ timeout: Maximum seconds to keep pumping.
351
+ until: Optional predicate; draining stops early once it
352
+ returns truthy.
353
+
354
+ Returns:
355
+ ``True`` if the loop went idle (or ``until`` matched) before
356
+ the timeout, ``False`` otherwise.
357
+ """
358
+ loop = get_loop()
359
+ if loop.is_running():
360
+ raise RuntimeError("drain() cannot be used while the event loop is running; use `await` instead")
361
+ deadline = _time.monotonic() + timeout
362
+ while _time.monotonic() < deadline:
363
+ loop.run_until_complete(asyncio.sleep(0))
364
+ if until is not None and until():
365
+ return True
366
+ pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
367
+ ready = bool(getattr(loop, "_ready", ()))
368
+ scheduled = bool(getattr(loop, "_scheduled", ()))
369
+ if until is None and not pending and not ready and not scheduled:
370
+ return True
371
+ if pending or scheduled:
372
+ loop.run_until_complete(asyncio.sleep(0.002))
373
+ return False
374
+
375
+
226
376
  def call_threadsafe(callback: Callable[..., Any], *args: Any) -> None:
227
- """Schedule ``callback(*args)`` on the loop thread.
377
+ """Schedule ``callback(*args)`` on the framework loop from any thread.
228
378
 
229
379
  Thin wrapper around
230
380
  :meth:`asyncio.AbstractEventLoop.call_soon_threadsafe`. Useful from
231
- native delegates (which may fire on arbitrary threads) when you
232
- need to hop onto the runtime thread before touching asyncio
233
- primitives.
381
+ native delegates (which may fire on arbitrary OS threads) to hop
382
+ onto the main-thread loop before touching asyncio primitives.
234
383
  """
235
384
  get_loop().call_soon_threadsafe(callback, *args)
236
385
 
@@ -243,7 +392,7 @@ def resolve_future(future: "asyncio.Future[T]", value: T) -> None:
243
392
  it's on, only that it must not race with cancellation.
244
393
 
245
394
  Args:
246
- future: An :class:`asyncio.Future` bound to the runtime loop.
395
+ future: An :class:`asyncio.Future` bound to the framework loop.
247
396
  value: The value to deliver as the future's result.
248
397
  """
249
398
  loop = future.get_loop()
@@ -267,68 +416,81 @@ def _set_future_exception(future: "asyncio.Future[Any]", error: BaseException) -
267
416
 
268
417
 
269
418
  def create_future() -> "asyncio.Future[Any]":
270
- """Create a future bound to the framework runtime loop.
419
+ """Create a future bound to the framework loop.
271
420
 
272
- Safe to call from any thread. The returned future is **not**
273
- attached to whatever loop is current on the caller; instead it
274
- lives on the framework's shared loop so any thread can call
421
+ Safe to call from any thread. Native delegates deliver into it via
275
422
  [`resolve_future`][pythonnative.runtime.resolve_future] /
276
- [`reject_future`][pythonnative.runtime.reject_future] on it.
423
+ [`reject_future`][pythonnative.runtime.reject_future].
277
424
  """
278
425
  return get_loop().create_future()
279
426
 
280
427
 
281
428
  # ======================================================================
282
- # UI-thread bridge
429
+ # Main-queue dispatch
283
430
  # ======================================================================
284
431
  #
285
- # Every awaitable that touches the platform's UI APIs runs on the
286
- # framework's background asyncio loop, but UIKit and the Android view
287
- # system require all UI calls on the platform main thread. The helpers
288
- # below marshal a synchronous Python callable onto the right thread per
289
- # platform, so awaitable code (alerts, animations, etc.) can transparently
290
- # bridge back when it needs to talk to native UI.
432
+ # The guest loop is pumped by enqueueing a callable on the platform's
433
+ # main queue. Unlike ``call_on_main_thread`` (which runs inline when
434
+ # already on the main thread), pump dispatch is ALWAYS queued: pumping
435
+ # inline from arbitrary call sites (say, a ``call_soon`` issued in the
436
+ # middle of a reconciler commit) would re-enter the renderer.
291
437
 
292
438
 
293
439
  # Desktop (Tkinter) main-thread dispatcher, installed by
294
- # ``pythonnative.preview`` while a ``pn preview`` session is live. Tk is
295
- # not thread-safe, so UI work scheduled from the asyncio worker thread
296
- # (animations, alerts) must hop onto the Tk main thread; the preview's
297
- # poll loop drains whatever this dispatcher enqueues. When no preview is
298
- # running (plain scripts / tests) the dispatcher stays ``None`` and work
299
- # runs inline.
440
+ # ``pythonnative.preview`` while a ``pn preview`` session is live; the
441
+ # preview's poll loop drains whatever this dispatcher enqueues.
300
442
  _desktop_main_dispatch: Optional[Callable[[Callable[[], None]], None]] = None
301
443
 
302
444
 
303
445
  def set_desktop_main_dispatch(dispatch: Optional[Callable[[Callable[[], None]], None]]) -> None:
304
446
  """Install (or clear) the desktop main-thread dispatcher.
305
447
 
306
- Called by ``pythonnative.preview`` with a
307
- function that marshals ``fn`` onto the Tk main thread, and with
308
- ``None`` when the preview window closes.
448
+ Called by ``pythonnative.preview`` with a function that marshals
449
+ work onto the Tk main thread, and with ``None`` when the preview
450
+ window closes.
309
451
  """
310
452
  global _desktop_main_dispatch
311
453
  _desktop_main_dispatch = dispatch
312
454
 
313
455
 
456
+ def _has_pump_dispatcher() -> bool:
457
+ """Whether a queued main-thread dispatcher exists on this platform."""
458
+ from .platform import Platform
459
+
460
+ if Platform.is_ios or Platform.is_android:
461
+ return True
462
+ return _desktop_main_dispatch is not None
463
+
464
+
465
+ def _dispatch_to_main_queue(fn: Callable[[], None]) -> None:
466
+ """Enqueue ``fn`` on the platform main queue (never runs inline)."""
467
+ from .platform import Platform
468
+
469
+ if Platform.is_ios:
470
+ _ios_dispatch_async(fn)
471
+ elif Platform.is_android:
472
+ _android_post_to_main(fn)
473
+ elif _desktop_main_dispatch is not None:
474
+ _desktop_main_dispatch(fn)
475
+ # Headless: nothing to dispatch to; drain()/run_blocking() pump.
476
+
477
+
314
478
  def call_on_main_thread(fn: Callable[[], None]) -> None:
315
479
  """Run ``fn()`` on the platform UI thread.
316
480
 
317
481
  - **iOS**: dispatches ``fn`` onto the main dispatch queue via
318
482
  ``libdispatch.dispatch_async_f`` (called through
319
- :class:`ctypes.PyDLL` to keep the GIL held; see the
320
- ``_ios_call_on_main`` comment block for why this matters).
483
+ :class:`ctypes.PyDLL` to keep the GIL held), or runs inline when
484
+ already on the main thread.
321
485
  - **Android**: posts a ``Runnable`` to
322
- ``Handler(Looper.getMainLooper())``.
323
- - **Desktop**: enqueues ``fn`` for the ``pn preview`` poll loop to
324
- run on the Tk main thread (or runs inline if no preview is live).
486
+ ``Handler(Looper.getMainLooper())``, or runs inline on the main
487
+ looper thread.
488
+ - **Desktop**: enqueues ``fn`` for the ``pn preview`` poll loop
489
+ (or runs inline if no preview is live).
325
490
  - **Tests**: runs ``fn()`` inline.
326
491
 
327
492
  Exceptions raised by ``fn`` are caught and printed; they must not
328
- propagate into UIKit / the Android Looper. If you need to surface
329
- a result back to the asyncio loop, do it via
330
- [`resolve_future`][pythonnative.runtime.resolve_future] from
331
- inside ``fn``.
493
+ propagate into UIKit / the Android Looper.
332
494
 
333
495
  Args:
334
496
  fn: A zero-arg callable. Runs on the main thread when the
@@ -347,20 +509,18 @@ def call_on_main_thread(fn: Callable[[], None]) -> None:
347
509
 
348
510
 
349
511
  # ----------------------------------------------------------------------
350
- # iOS main-thread dispatch (PyDLL-based, no GIL release)
512
+ # iOS main-queue dispatch (PyDLL-based, no GIL release)
351
513
  # ----------------------------------------------------------------------
352
514
  #
353
515
  # rubicon-objc loads libobjc via ctypes.CDLL, which RELEASES THE GIL
354
516
  # around every ObjC method call. On the iOS simulator (and to a lesser
355
- # extent on-device), the asyncio thread is then parked by the kernel
356
- # until the next wakeup-coalescing tick (~500ms), making each ObjC call
357
- # absurdly slow when made from a background thread.
358
- #
359
- # To avoid this, we call libdispatch's ``dispatch_async_f`` directly
360
- # via ``ctypes.PyDLL`` (which keeps the GIL held), and pass a
361
- # ``CFUNCTYPE`` trampoline as the work function. The trampoline runs
362
- # on the main thread, looks up the pending Python callable by an
363
- # integer key, and invokes it.
517
+ # extent on-device), a thread is then parked by the kernel until the
518
+ # next wakeup-coalescing tick (~500ms), making cross-thread ObjC calls
519
+ # absurdly slow. To avoid this, we call libdispatch's
520
+ # ``dispatch_async_f`` directly via ``ctypes.PyDLL`` (which keeps the
521
+ # GIL held), and pass a ``CFUNCTYPE`` trampoline as the work function.
522
+ # The trampoline runs on the main thread, looks up the pending Python
523
+ # callable by an integer key, and invokes it.
364
524
 
365
525
  _dispatch_lib: Optional[Any] = None
366
526
  _dispatch_async_f_c: Optional[Any] = None
@@ -395,7 +555,7 @@ def _ensure_libdispatch_loaded() -> bool:
395
555
  return True
396
556
  try:
397
557
  # PyDLL keeps the GIL held during C calls, preventing the
398
- # asyncio thread from being parked after dispatch_async_f.
558
+ # calling thread from being parked after dispatch_async_f.
399
559
  lib = ctypes.PyDLL("/usr/lib/system/libdispatch.dylib")
400
560
  except OSError:
401
561
  try:
@@ -440,31 +600,16 @@ def _ios_is_main_thread() -> bool:
440
600
  return threading.current_thread().name == "MainThread"
441
601
 
442
602
 
443
- def _ios_call_on_main(fn: Callable[[], None]) -> None:
444
- """Dispatch ``fn`` to the iOS main thread without releasing the GIL.
445
-
446
- Uses libdispatch's ``dispatch_async_f`` via ``ctypes.PyDLL`` to
447
- avoid the rubicon-objc / CDLL GIL release that causes the asyncio
448
- thread to be parked by the kernel for ~500ms on each call.
449
- """
603
+ def _ios_dispatch_async(fn: Callable[[], None]) -> None:
604
+ """Enqueue ``fn`` on the iOS main dispatch queue (always queued)."""
450
605
  global _main_next_id
451
- if _ios_is_main_thread():
452
- try:
453
- fn()
454
- except Exception as exc:
455
- print(f"[pn.runtime] main-inline callback raised: {exc!r}")
456
- return
457
-
458
606
  if not _ensure_libdispatch_loaded():
459
- # Last-resort fallback: run inline (UI calls may crash, but at
460
- # least we don't deadlock).
461
607
  print("[pn.runtime] libdispatch unavailable; running callback inline")
462
608
  try:
463
609
  fn()
464
610
  except Exception as exc:
465
611
  print(f"[pn.runtime] inline fallback raised: {exc!r}")
466
612
  return
467
-
468
613
  with _main_pending_lock:
469
614
  _main_next_id += 1
470
615
  key = _main_next_id
@@ -475,38 +620,85 @@ def _ios_call_on_main(fn: Callable[[], None]) -> None:
475
620
  _dispatch_async_f_c(_dispatch_main_q_ptr, key, _main_trampoline_c)
476
621
 
477
622
 
623
+ def _ios_call_on_main(fn: Callable[[], None]) -> None:
624
+ """Run ``fn`` on the iOS main thread (inline when already there)."""
625
+ if _ios_is_main_thread():
626
+ try:
627
+ fn()
628
+ except Exception as exc:
629
+ print(f"[pn.runtime] main-inline callback raised: {exc!r}")
630
+ return
631
+ _ios_dispatch_async(fn)
632
+
633
+
634
+ # Cached Android main-queue plumbing. Posting a pump is a hot path (it
635
+ # runs for every batch of scheduled loop work), so the Runnable proxy
636
+ # class and the main-looper Handler are created once and reused;
637
+ # defining a ``dynamic_proxy`` class per call would register a new JNI
638
+ # class every time.
639
+ _android_runnable_cls: Optional[Any] = None
640
+ _android_main_handler: Optional[Any] = None
641
+
642
+
643
+ def _ensure_android_main_handler() -> Any:
644
+ global _android_runnable_cls, _android_main_handler
645
+ if _android_main_handler is not None:
646
+ return _android_main_handler
647
+ from java import dynamic_proxy, jclass
648
+
649
+ Runnable = jclass("java.lang.Runnable")
650
+
651
+ class _PNMainRunnable(dynamic_proxy(Runnable)): # type: ignore[misc]
652
+ def __init__(self, fn: Callable[[], None]) -> None:
653
+ super().__init__()
654
+ self._fn = fn
655
+
656
+ def run(self) -> None:
657
+ try:
658
+ self._fn()
659
+ except Exception as exc: # pragma: no cover - last resort
660
+ print(f"[pn.runtime] main-thread runnable raised: {exc!r}")
661
+
662
+ Looper = jclass("android.os.Looper")
663
+ Handler = jclass("android.os.Handler")
664
+ _android_runnable_cls = _PNMainRunnable
665
+ _android_main_handler = Handler(Looper.getMainLooper())
666
+ return _android_main_handler
667
+
668
+
669
+ def _android_post_to_main(fn: Callable[[], None]) -> None:
670
+ """Enqueue ``fn`` on the Android main looper (always queued)."""
671
+ try:
672
+ handler = _ensure_android_main_handler()
673
+ assert _android_runnable_cls is not None
674
+ handler.post(_android_runnable_cls(fn))
675
+ except Exception as exc:
676
+ print(f"[pn.runtime] _android_post_to_main failed, falling back inline: {exc!r}")
677
+ fn()
678
+
679
+
478
680
  def _android_call_on_main(fn: Callable[[], None]) -> None:
479
- """Android-only: post ``fn`` to the main looper via Handler."""
681
+ """Run ``fn`` on the Android main thread (inline when already there)."""
480
682
  try:
481
- from java import dynamic_proxy, jclass
683
+ from java import jclass
482
684
 
483
685
  Looper = jclass("android.os.Looper")
484
686
  if Looper.myLooper() == Looper.getMainLooper():
485
687
  fn()
486
688
  return
487
-
488
- Runnable = jclass("java.lang.Runnable")
489
-
490
- class _PNMainRunnable(dynamic_proxy(Runnable)): # type: ignore[misc]
491
- def run(self) -> None:
492
- try:
493
- fn()
494
- except Exception as exc: # pragma: no cover - last resort
495
- print(f"[pn.runtime] main-thread runnable raised: {exc!r}")
496
-
497
- Handler = jclass("android.os.Handler")
498
- Handler(Looper.getMainLooper()).post(_PNMainRunnable())
499
- except Exception as exc:
500
- print(f"[pn.runtime] _android_call_on_main failed, falling back inline: {exc!r}")
501
- fn()
689
+ except Exception:
690
+ pass
691
+ _android_post_to_main(fn)
502
692
 
503
693
 
504
694
  __all__ = [
505
695
  "call_on_main_thread",
506
696
  "call_threadsafe",
507
697
  "create_future",
698
+ "drain",
508
699
  "get_loop",
509
700
  "reject_future",
510
701
  "resolve_future",
511
702
  "run_async",
703
+ "run_blocking",
512
704
  ]