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/__init__.py +44 -53
- pythonnative/animated.py +3 -3
- pythonnative/cli/pn.py +3 -3
- pythonnative/components.py +309 -513
- pythonnative/diagnostics.py +214 -0
- pythonnative/element.py +5 -2
- pythonnative/events.py +13 -8
- pythonnative/hooks.py +761 -114
- pythonnative/hot_reload.py +9 -1
- pythonnative/native_modules/notifications.py +36 -4
- pythonnative/native_modules/permissions.py +24 -1
- pythonnative/native_views/android.py +85 -3
- pythonnative/native_views/desktop.py +38 -1
- pythonnative/native_views/ios.py +252 -7
- pythonnative/navigation.py +41 -17
- pythonnative/preview.py +43 -7
- pythonnative/reconciler.py +1360 -457
- pythonnative/runtime.py +407 -215
- pythonnative/screen.py +377 -34
- pythonnative/storage.py +5 -5
- pythonnative/style.py +83 -0
- pythonnative/suspense.py +417 -0
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +22 -0
- {pythonnative-0.23.0.dist-info → pythonnative-0.25.0.dist-info}/METADATA +6 -4
- {pythonnative-0.23.0.dist-info → pythonnative-0.25.0.dist-info}/RECORD +29 -27
- {pythonnative-0.23.0.dist-info → pythonnative-0.25.0.dist-info}/WHEEL +1 -1
- {pythonnative-0.23.0.dist-info → pythonnative-0.25.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.23.0.dist-info → pythonnative-0.25.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.23.0.dist-info → pythonnative-0.25.0.dist-info}/top_level.txt +0 -0
pythonnative/runtime.py
CHANGED
|
@@ -1,41 +1,46 @@
|
|
|
1
|
-
"""Asyncio runtime
|
|
2
|
-
|
|
3
|
-
PythonNative
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
(
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
(
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
@pn.component
|
|
39
|
+
def SaveButton():
|
|
40
|
+
async def save():
|
|
41
|
+
await pn.AsyncStorage.set_item("draft", "…")
|
|
36
42
|
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
|
190
|
+
"""Return the framework event loop.
|
|
131
191
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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
|
|
209
|
+
with _loop_lock:
|
|
143
210
|
if _loop is None or _loop.is_closed():
|
|
144
|
-
_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
|
-
"""
|
|
227
|
+
"""Close the guest loop and reset module state (test isolation).
|
|
150
228
|
|
|
151
|
-
Cancels every pending task,
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
158
|
-
with
|
|
234
|
+
global _loop
|
|
235
|
+
with _loop_lock:
|
|
159
236
|
loop = _loop
|
|
160
|
-
thread = _thread
|
|
161
237
|
_loop = None
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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]) ->
|
|
188
|
-
"""Schedule ``awaitable`` on the framework loop
|
|
266
|
+
def run_async(awaitable: Awaitlike[T]) -> Any:
|
|
267
|
+
"""Schedule ``awaitable`` on the framework loop.
|
|
189
268
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
:class:`
|
|
193
|
-
:
|
|
194
|
-
|
|
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:
|
|
198
|
-
awaitable.
|
|
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
|
|
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
|
-
|
|
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)
|
|
232
|
-
|
|
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
|
|
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
|
|
419
|
+
"""Create a future bound to the framework loop.
|
|
271
420
|
|
|
272
|
-
Safe to call from any thread.
|
|
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]
|
|
423
|
+
[`reject_future`][pythonnative.runtime.reject_future].
|
|
277
424
|
"""
|
|
278
425
|
return get_loop().create_future()
|
|
279
426
|
|
|
280
427
|
|
|
281
428
|
# ======================================================================
|
|
282
|
-
#
|
|
429
|
+
# Main-queue dispatch
|
|
283
430
|
# ======================================================================
|
|
284
431
|
#
|
|
285
|
-
#
|
|
286
|
-
#
|
|
287
|
-
#
|
|
288
|
-
#
|
|
289
|
-
#
|
|
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
|
|
295
|
-
#
|
|
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
|
-
|
|
308
|
-
|
|
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
|
|
320
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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.
|
|
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-
|
|
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),
|
|
356
|
-
#
|
|
357
|
-
# absurdly slow
|
|
358
|
-
#
|
|
359
|
-
#
|
|
360
|
-
#
|
|
361
|
-
#
|
|
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
|
-
#
|
|
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
|
|
444
|
-
"""
|
|
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
|
-
"""
|
|
681
|
+
"""Run ``fn`` on the Android main thread (inline when already there)."""
|
|
480
682
|
try:
|
|
481
|
-
from java import
|
|
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
|
-
|
|
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
|
]
|