pythonnative 0.24.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 +16 -4
- pythonnative/animated.py +2 -2
- pythonnative/components.py +59 -0
- pythonnative/hooks.py +364 -81
- pythonnative/native_modules/notifications.py +36 -4
- pythonnative/native_modules/permissions.py +24 -1
- pythonnative/native_views/android.py +7 -2
- pythonnative/native_views/ios.py +12 -1
- pythonnative/reconciler.py +518 -37
- pythonnative/runtime.py +407 -215
- pythonnative/screen.py +53 -7
- pythonnative/storage.py +2 -2
- pythonnative/suspense.py +417 -0
- {pythonnative-0.24.0.dist-info → pythonnative-0.25.0.dist-info}/METADATA +2 -1
- {pythonnative-0.24.0.dist-info → pythonnative-0.25.0.dist-info}/RECORD +19 -18
- {pythonnative-0.24.0.dist-info → pythonnative-0.25.0.dist-info}/WHEEL +1 -1
- {pythonnative-0.24.0.dist-info → pythonnative-0.25.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.24.0.dist-info → pythonnative-0.25.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.24.0.dist-info → pythonnative-0.25.0.dist-info}/top_level.txt +0 -0
pythonnative/hooks.py
CHANGED
|
@@ -15,7 +15,15 @@ Two effect phases exist, mirroring React:
|
|
|
15
15
|
layout pass have been applied. They can measure committed frames and
|
|
16
16
|
issue imperative view commands before the user sees the new frame.
|
|
17
17
|
- [`use_effect`][pythonnative.use_effect] callbacks (passive effects)
|
|
18
|
-
run after the layout effects, at the end of the same commit.
|
|
18
|
+
run after the layout effects, at the end of the same commit. An
|
|
19
|
+
effect may be an ``async def``; it runs as a task on the framework
|
|
20
|
+
loop and is cancelled when its dependencies change or the component
|
|
21
|
+
unmounts.
|
|
22
|
+
|
|
23
|
+
The current hook state travels in a :mod:`contextvars` context rather
|
|
24
|
+
than a plain global, so ``async def`` component bodies keep their hook
|
|
25
|
+
identity across ``await`` boundaries even when several coroutine
|
|
26
|
+
renders interleave on the event loop.
|
|
19
27
|
|
|
20
28
|
Example:
|
|
21
29
|
```python
|
|
@@ -33,21 +41,34 @@ Example:
|
|
|
33
41
|
|
|
34
42
|
import asyncio
|
|
35
43
|
import inspect
|
|
36
|
-
import threading
|
|
37
44
|
from contextlib import contextmanager
|
|
45
|
+
from contextvars import ContextVar, Token
|
|
38
46
|
from dataclasses import dataclass, field, replace
|
|
39
47
|
from typing import Any, Awaitable, Callable, Dict, Generator, Generic, List, Optional, Tuple, TypeVar
|
|
40
48
|
|
|
41
49
|
from . import diagnostics
|
|
42
50
|
from .element import Element
|
|
51
|
+
from .suspense import CoroDriver, Resource
|
|
43
52
|
|
|
44
53
|
T = TypeVar("T")
|
|
45
54
|
|
|
46
55
|
_SENTINEL = object()
|
|
47
56
|
|
|
48
|
-
|
|
57
|
+
# The component whose body is currently executing. A ContextVar (not a
|
|
58
|
+
# global or thread-local) so coroutine component bodies resume with the
|
|
59
|
+
# right hook state after every ``await``, no matter how renders
|
|
60
|
+
# interleave on the loop.
|
|
61
|
+
_hook_context: "ContextVar[Optional[HookState]]" = ContextVar("pn_hook_state", default=None)
|
|
62
|
+
|
|
63
|
+
# Depth of nested ``batch_updates`` blocks plus the trigger deferred by
|
|
64
|
+
# the outermost block, per execution context.
|
|
65
|
+
_batch_depth: "ContextVar[int]" = ContextVar("pn_batch_depth", default=0)
|
|
66
|
+
_batch_pending: "ContextVar[Optional[List[Callable[[], None]]]]" = ContextVar("pn_batch_pending", default=None)
|
|
49
67
|
|
|
50
|
-
|
|
68
|
+
# Whether state updates in the current execution context were marked as
|
|
69
|
+
# transitions (see ``use_transition``). Transition updates defer their
|
|
70
|
+
# re-render to a later loop turn so urgent updates stay responsive.
|
|
71
|
+
_transition_var: "ContextVar[bool]" = ContextVar("pn_transition", default=False)
|
|
51
72
|
|
|
52
73
|
|
|
53
74
|
# ======================================================================
|
|
@@ -121,21 +142,26 @@ class HookState:
|
|
|
121
142
|
"layout_effects",
|
|
122
143
|
"memos",
|
|
123
144
|
"refs",
|
|
145
|
+
"resources",
|
|
124
146
|
"state_index",
|
|
125
147
|
"effect_index",
|
|
126
148
|
"layout_effect_index",
|
|
127
149
|
"memo_index",
|
|
128
150
|
"ref_index",
|
|
151
|
+
"resource_index",
|
|
129
152
|
"context_deps",
|
|
130
153
|
"_trigger_render",
|
|
131
154
|
"_pending_effects",
|
|
132
155
|
"_pending_layout_effects",
|
|
156
|
+
"_pending_effects_mark",
|
|
157
|
+
"_pending_layout_effects_mark",
|
|
133
158
|
"_dirty",
|
|
134
159
|
"_vnode",
|
|
135
160
|
"_reconciler",
|
|
136
161
|
"_hook_log",
|
|
137
162
|
"_hook_signature",
|
|
138
163
|
"_component_name",
|
|
164
|
+
"_async_driver",
|
|
139
165
|
)
|
|
140
166
|
|
|
141
167
|
def __init__(self) -> None:
|
|
@@ -144,11 +170,14 @@ class HookState:
|
|
|
144
170
|
self.layout_effects: List[Tuple[Any, Any]] = []
|
|
145
171
|
self.memos: List[Tuple[Any, Any]] = []
|
|
146
172
|
self.refs: List[Ref] = []
|
|
173
|
+
# One ``(deps, Resource)`` per ``use_resource`` call.
|
|
174
|
+
self.resources: List[Tuple[Any, Resource]] = []
|
|
147
175
|
self.state_index: int = 0
|
|
148
176
|
self.effect_index: int = 0
|
|
149
177
|
self.layout_effect_index: int = 0
|
|
150
178
|
self.memo_index: int = 0
|
|
151
179
|
self.ref_index: int = 0
|
|
180
|
+
self.resource_index: int = 0
|
|
152
181
|
# Contexts read during the last completed render, keyed by
|
|
153
182
|
# ``id(context)``. The reconciler consults this when a
|
|
154
183
|
# Provider's value changes so consumers re-render even when a
|
|
@@ -176,6 +205,13 @@ class HookState:
|
|
|
176
205
|
self._hook_log: Optional[List[str]] = None
|
|
177
206
|
self._hook_signature: Optional[List[str]] = None
|
|
178
207
|
self._component_name: str = ""
|
|
208
|
+
# For ``async def`` components: the CoroDriver running the
|
|
209
|
+
# in-flight body, cancelled when a newer render supersedes it.
|
|
210
|
+
self._async_driver: Optional[CoroDriver] = None
|
|
211
|
+
# Effect-queue lengths at ``begin_render``, so a suspended
|
|
212
|
+
# render can be rolled back without double-queueing effects.
|
|
213
|
+
self._pending_effects_mark: int = 0
|
|
214
|
+
self._pending_layout_effects_mark: int = 0
|
|
179
215
|
|
|
180
216
|
def begin_render(self, component_name: str = "") -> None:
|
|
181
217
|
"""Prepare for a render pass: reset cursors and the dev-mode hook log.
|
|
@@ -189,10 +225,23 @@ class HookState:
|
|
|
189
225
|
self.layout_effect_index = 0
|
|
190
226
|
self.memo_index = 0
|
|
191
227
|
self.ref_index = 0
|
|
228
|
+
self.resource_index = 0
|
|
192
229
|
self.context_deps = {}
|
|
193
230
|
if component_name:
|
|
194
231
|
self._component_name = component_name
|
|
195
232
|
self._hook_log = [] if diagnostics.is_dev() else None
|
|
233
|
+
self._pending_effects_mark = len(self._pending_effects)
|
|
234
|
+
self._pending_layout_effects_mark = len(self._pending_layout_effects)
|
|
235
|
+
|
|
236
|
+
def abort_render(self) -> None:
|
|
237
|
+
"""Roll back a suspended render's effect queue.
|
|
238
|
+
|
|
239
|
+
A suspended body re-runs from the top on retry, so any effects
|
|
240
|
+
it queued before suspending would otherwise be queued twice.
|
|
241
|
+
"""
|
|
242
|
+
del self._pending_effects[self._pending_effects_mark :]
|
|
243
|
+
del self._pending_layout_effects[self._pending_layout_effects_mark :]
|
|
244
|
+
self._hook_log = None
|
|
196
245
|
|
|
197
246
|
def finish_render(self) -> None:
|
|
198
247
|
"""Finalize a successful render: lock in / verify the hook signature.
|
|
@@ -260,6 +309,7 @@ class HookState:
|
|
|
260
309
|
"""Run layout effects queued during render (commit phase, pre-paint)."""
|
|
261
310
|
pending = self._pending_layout_effects
|
|
262
311
|
self._pending_layout_effects = []
|
|
312
|
+
self._pending_layout_effects_mark = 0
|
|
263
313
|
for idx, effect_fn, deps in pending:
|
|
264
314
|
_, prev_cleanup = self.layout_effects[idx]
|
|
265
315
|
if callable(prev_cleanup):
|
|
@@ -267,7 +317,7 @@ class HookState:
|
|
|
267
317
|
prev_cleanup()
|
|
268
318
|
except Exception:
|
|
269
319
|
pass
|
|
270
|
-
cleanup = effect_fn
|
|
320
|
+
cleanup = _activate_effect(effect_fn)
|
|
271
321
|
self.layout_effects[idx] = (list(deps) if deps is not None else None, cleanup)
|
|
272
322
|
|
|
273
323
|
def flush_pending_effects(self) -> None:
|
|
@@ -275,10 +325,14 @@ class HookState:
|
|
|
275
325
|
|
|
276
326
|
For each pending effect, the previous cleanup is invoked first
|
|
277
327
|
(if any), then the new effect callback. The new return value
|
|
278
|
-
becomes the next cleanup.
|
|
328
|
+
becomes the next cleanup. Effects that are ``async def`` (or
|
|
329
|
+
that return an awaitable) run as tasks on the framework loop;
|
|
330
|
+
their cleanup cancels the task, and a callable returned by the
|
|
331
|
+
coroutine runs as an additional cleanup once it completed.
|
|
279
332
|
"""
|
|
280
333
|
pending = self._pending_effects
|
|
281
334
|
self._pending_effects = []
|
|
335
|
+
self._pending_effects_mark = 0
|
|
282
336
|
for idx, effect_fn, deps in pending:
|
|
283
337
|
_, prev_cleanup = self.effects[idx]
|
|
284
338
|
if callable(prev_cleanup):
|
|
@@ -286,15 +340,16 @@ class HookState:
|
|
|
286
340
|
prev_cleanup()
|
|
287
341
|
except Exception:
|
|
288
342
|
pass
|
|
289
|
-
cleanup = effect_fn
|
|
343
|
+
cleanup = _activate_effect(effect_fn)
|
|
290
344
|
self.effects[idx] = (list(deps) if deps is not None else None, cleanup)
|
|
291
345
|
|
|
292
346
|
def cleanup_all_effects(self) -> None:
|
|
293
347
|
"""Run every outstanding cleanup function, then clear state.
|
|
294
348
|
|
|
295
349
|
Layout-effect cleanups run before passive-effect cleanups,
|
|
296
|
-
matching the mount order in reverse.
|
|
297
|
-
|
|
350
|
+
matching the mount order in reverse. Also cancels in-flight
|
|
351
|
+
resources and any pending ``async def`` body. Called when the
|
|
352
|
+
component instance is unmounted by the reconciler.
|
|
298
353
|
"""
|
|
299
354
|
for i, (_deps, cleanup) in enumerate(self.layout_effects):
|
|
300
355
|
if callable(cleanup):
|
|
@@ -312,21 +367,38 @@ class HookState:
|
|
|
312
367
|
self.effects[i] = (_SENTINEL, None)
|
|
313
368
|
self._pending_effects = []
|
|
314
369
|
self._pending_layout_effects = []
|
|
370
|
+
self._pending_effects_mark = 0
|
|
371
|
+
self._pending_layout_effects_mark = 0
|
|
372
|
+
for _deps, resource in self.resources:
|
|
373
|
+
try:
|
|
374
|
+
resource.cancel()
|
|
375
|
+
except Exception:
|
|
376
|
+
pass
|
|
377
|
+
self.resources = []
|
|
378
|
+
driver = self._async_driver
|
|
379
|
+
self._async_driver = None
|
|
380
|
+
if driver is not None:
|
|
381
|
+
driver.cancel()
|
|
315
382
|
|
|
316
383
|
|
|
317
384
|
# ======================================================================
|
|
318
|
-
#
|
|
385
|
+
# Context helpers
|
|
319
386
|
# ======================================================================
|
|
320
387
|
|
|
321
388
|
|
|
322
389
|
def _get_hook_state() -> Optional[HookState]:
|
|
323
390
|
"""Return the active `HookState`, or `None` if no render is in flight."""
|
|
324
|
-
return
|
|
391
|
+
return _hook_context.get()
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _set_hook_state(state: Optional[HookState]) -> "Token[Optional[HookState]]":
|
|
395
|
+
"""Install `state` as the active `HookState`; returns the reset token."""
|
|
396
|
+
return _hook_context.set(state)
|
|
325
397
|
|
|
326
398
|
|
|
327
|
-
def
|
|
328
|
-
"""
|
|
329
|
-
_hook_context.
|
|
399
|
+
def _reset_hook_state(token: "Token[Optional[HookState]]") -> None:
|
|
400
|
+
"""Restore the hook state that was active before `_set_hook_state`."""
|
|
401
|
+
_hook_context.reset(token)
|
|
330
402
|
|
|
331
403
|
|
|
332
404
|
def _deps_changed(prev: Any, current: Any) -> bool:
|
|
@@ -340,19 +412,126 @@ def _deps_changed(prev: Any, current: Any) -> bool:
|
|
|
340
412
|
return any(p is not c and p != c for p, c in zip(prev, current))
|
|
341
413
|
|
|
342
414
|
|
|
415
|
+
def _activate_effect(effect_fn: Callable) -> Any:
|
|
416
|
+
"""Invoke an effect callback, running coroutine effects as tasks.
|
|
417
|
+
|
|
418
|
+
Synchronous effects return their cleanup directly. When the effect
|
|
419
|
+
is an ``async def`` (or returns an awaitable), the coroutine runs
|
|
420
|
+
as a task on the framework loop and the returned cleanup cancels
|
|
421
|
+
it; if the coroutine already finished and returned a callable, that
|
|
422
|
+
callable runs as the cleanup instead.
|
|
423
|
+
"""
|
|
424
|
+
result = effect_fn()
|
|
425
|
+
if not inspect.isawaitable(result):
|
|
426
|
+
return result
|
|
427
|
+
|
|
428
|
+
from .runtime import run_async
|
|
429
|
+
|
|
430
|
+
future = run_async(result)
|
|
431
|
+
|
|
432
|
+
def _observe(fut: Any) -> None:
|
|
433
|
+
# Surface unhandled async-effect crashes instead of letting the
|
|
434
|
+
# future's exception vanish unobserved: RedBox in dev mode,
|
|
435
|
+
# traceback in production.
|
|
436
|
+
if fut.cancelled():
|
|
437
|
+
return
|
|
438
|
+
exc = fut.exception()
|
|
439
|
+
if exc is None or isinstance(exc, asyncio.CancelledError):
|
|
440
|
+
return
|
|
441
|
+
if not diagnostics.report_error(exc, phase="async effect"):
|
|
442
|
+
import traceback
|
|
443
|
+
|
|
444
|
+
traceback.print_exception(type(exc), exc, exc.__traceback__)
|
|
445
|
+
|
|
446
|
+
future.add_done_callback(_observe)
|
|
447
|
+
|
|
448
|
+
def _cleanup() -> None:
|
|
449
|
+
if future.cancelled():
|
|
450
|
+
return
|
|
451
|
+
if future.done():
|
|
452
|
+
if future.exception() is None:
|
|
453
|
+
returned = future.result()
|
|
454
|
+
if callable(returned):
|
|
455
|
+
try:
|
|
456
|
+
returned()
|
|
457
|
+
except Exception:
|
|
458
|
+
pass
|
|
459
|
+
return
|
|
460
|
+
future.cancel()
|
|
461
|
+
|
|
462
|
+
return _cleanup
|
|
463
|
+
|
|
464
|
+
|
|
343
465
|
# ======================================================================
|
|
344
|
-
# Batching
|
|
466
|
+
# Batching and transition scheduling
|
|
345
467
|
# ======================================================================
|
|
346
468
|
|
|
347
469
|
|
|
348
470
|
def _schedule_trigger(trigger: Callable[[], None]) -> None:
|
|
349
471
|
"""Run ``trigger`` immediately, or defer it inside a `batch_updates` block."""
|
|
350
|
-
|
|
351
|
-
|
|
472
|
+
pending = _batch_pending.get()
|
|
473
|
+
if _batch_depth.get() > 0 and pending is not None:
|
|
474
|
+
if trigger not in pending:
|
|
475
|
+
pending.append(trigger)
|
|
352
476
|
else:
|
|
353
477
|
trigger()
|
|
354
478
|
|
|
355
479
|
|
|
480
|
+
# Transition scheduling state. Deferred triggers (and the callbacks
|
|
481
|
+
# that flip ``is_pending`` back off) run together on a later loop turn.
|
|
482
|
+
_deferred_triggers: List[Callable[[], None]] = []
|
|
483
|
+
_post_transition_callbacks: List[Callable[[], None]] = []
|
|
484
|
+
_transition_flush_scheduled = False
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _flush_transitions() -> None:
|
|
488
|
+
"""Run every deferred transition render, then the completion callbacks."""
|
|
489
|
+
global _transition_flush_scheduled
|
|
490
|
+
_transition_flush_scheduled = False
|
|
491
|
+
triggers = list(_deferred_triggers)
|
|
492
|
+
_deferred_triggers.clear()
|
|
493
|
+
callbacks = list(_post_transition_callbacks)
|
|
494
|
+
_post_transition_callbacks.clear()
|
|
495
|
+
for trigger in triggers:
|
|
496
|
+
try:
|
|
497
|
+
trigger()
|
|
498
|
+
except Exception as exc:
|
|
499
|
+
if not diagnostics.report_error(exc, phase="transition"):
|
|
500
|
+
raise
|
|
501
|
+
for callback in callbacks:
|
|
502
|
+
try:
|
|
503
|
+
callback()
|
|
504
|
+
except Exception as exc:
|
|
505
|
+
if not diagnostics.report_error(exc, phase="transition"):
|
|
506
|
+
raise
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _schedule_transition_flush() -> None:
|
|
510
|
+
"""Ensure a transition flush is queued on the framework loop."""
|
|
511
|
+
global _transition_flush_scheduled
|
|
512
|
+
if _transition_flush_scheduled:
|
|
513
|
+
return
|
|
514
|
+
_transition_flush_scheduled = True
|
|
515
|
+
from .runtime import get_loop
|
|
516
|
+
|
|
517
|
+
get_loop().call_soon(_flush_transitions)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _defer_transition_trigger(trigger: Callable[[], None]) -> None:
|
|
521
|
+
if trigger not in _deferred_triggers:
|
|
522
|
+
_deferred_triggers.append(trigger)
|
|
523
|
+
_schedule_transition_flush()
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _run_in_transition(fn: Callable[[], None]) -> None:
|
|
527
|
+
"""Run ``fn`` with its state updates marked as transitions."""
|
|
528
|
+
token = _transition_var.set(True)
|
|
529
|
+
try:
|
|
530
|
+
fn()
|
|
531
|
+
finally:
|
|
532
|
+
_transition_var.reset(token)
|
|
533
|
+
|
|
534
|
+
|
|
356
535
|
def _notify_state_changed(ctx: "HookState") -> None:
|
|
357
536
|
"""Mark ``ctx``'s component dirty and schedule a render after a state change.
|
|
358
537
|
|
|
@@ -361,7 +540,9 @@ def _notify_state_changed(ctx: "HookState") -> None:
|
|
|
361
540
|
``flush_dirty``, which re-renders only the components marked here
|
|
362
541
|
rather than the whole app. The dirty mark is eager (so several
|
|
363
542
|
setters coalesce), while the render trigger respects
|
|
364
|
-
[`batch_updates`][pythonnative.batch_updates]
|
|
543
|
+
[`batch_updates`][pythonnative.batch_updates] and defers to a later
|
|
544
|
+
loop turn inside a transition (see
|
|
545
|
+
[`use_transition`][pythonnative.use_transition]).
|
|
365
546
|
"""
|
|
366
547
|
ctx._dirty = True
|
|
367
548
|
reconciler = ctx._reconciler
|
|
@@ -369,7 +550,10 @@ def _notify_state_changed(ctx: "HookState") -> None:
|
|
|
369
550
|
if reconciler is not None and vnode is not None:
|
|
370
551
|
reconciler.mark_dirty(vnode)
|
|
371
552
|
if ctx._trigger_render:
|
|
372
|
-
|
|
553
|
+
if _transition_var.get():
|
|
554
|
+
_defer_transition_trigger(ctx._trigger_render)
|
|
555
|
+
else:
|
|
556
|
+
_schedule_trigger(ctx._trigger_render)
|
|
373
557
|
|
|
374
558
|
|
|
375
559
|
@contextmanager
|
|
@@ -393,18 +577,19 @@ def batch_updates() -> Generator[None, None, None]:
|
|
|
393
577
|
set_name("hello")
|
|
394
578
|
```
|
|
395
579
|
"""
|
|
396
|
-
depth =
|
|
397
|
-
|
|
580
|
+
depth = _batch_depth.get()
|
|
581
|
+
depth_token = _batch_depth.set(depth + 1)
|
|
582
|
+
pending_token = None
|
|
398
583
|
if depth == 0:
|
|
399
|
-
|
|
584
|
+
pending_token = _batch_pending.set([])
|
|
400
585
|
try:
|
|
401
586
|
yield
|
|
402
587
|
finally:
|
|
403
|
-
|
|
404
|
-
if
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
588
|
+
_batch_depth.reset(depth_token)
|
|
589
|
+
if pending_token is not None:
|
|
590
|
+
triggers = _batch_pending.get() or []
|
|
591
|
+
_batch_pending.reset(pending_token)
|
|
592
|
+
for trigger in triggers:
|
|
408
593
|
trigger()
|
|
409
594
|
|
|
410
595
|
|
|
@@ -548,12 +733,20 @@ def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
|
|
|
548
733
|
- `[]`: mount only.
|
|
549
734
|
- `[a, b]`: when `a` or `b` change (compared by identity, then `==`).
|
|
550
735
|
|
|
551
|
-
`effect` may return a cleanup callable; the previous
|
|
552
|
-
before the next effect (and on unmount).
|
|
736
|
+
A synchronous `effect` may return a cleanup callable; the previous
|
|
737
|
+
cleanup runs before the next effect (and on unmount).
|
|
738
|
+
|
|
739
|
+
An **async** `effect` (an ``async def``) runs as a task on the
|
|
740
|
+
framework loop. When `deps` change or the component unmounts, the
|
|
741
|
+
in-flight task is cancelled (:class:`asyncio.CancelledError` is
|
|
742
|
+
raised at its current ``await``), giving async effects structured
|
|
743
|
+
cancellation for free. If the coroutine finishes and returns a
|
|
744
|
+
callable, that callable runs as the cleanup instead.
|
|
553
745
|
|
|
554
746
|
Args:
|
|
555
|
-
effect: A zero-arg callable invoked after commit
|
|
556
|
-
|
|
747
|
+
effect: A zero-arg callable invoked after commit: either a
|
|
748
|
+
synchronous function (optionally returning a cleanup
|
|
749
|
+
callable) or an ``async def``.
|
|
557
750
|
deps: Dependency list, or `None` to run on every render.
|
|
558
751
|
|
|
559
752
|
Raises:
|
|
@@ -561,20 +754,21 @@ def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
|
|
|
561
754
|
|
|
562
755
|
Example:
|
|
563
756
|
```python
|
|
757
|
+
import asyncio
|
|
758
|
+
|
|
564
759
|
import pythonnative as pn
|
|
565
760
|
|
|
566
761
|
@pn.component
|
|
567
|
-
def
|
|
568
|
-
|
|
762
|
+
def Clock():
|
|
763
|
+
now, set_now = pn.use_state("")
|
|
569
764
|
|
|
570
|
-
def tick():
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
return t.cancel
|
|
765
|
+
async def tick():
|
|
766
|
+
while True:
|
|
767
|
+
set_now(time.strftime("%H:%M:%S"))
|
|
768
|
+
await asyncio.sleep(1)
|
|
575
769
|
|
|
576
|
-
pn.use_effect(tick, [
|
|
577
|
-
return pn.Text(
|
|
770
|
+
pn.use_effect(tick, [])
|
|
771
|
+
return pn.Text(now)
|
|
578
772
|
```
|
|
579
773
|
"""
|
|
580
774
|
ctx = _get_hook_state()
|
|
@@ -803,23 +997,85 @@ def use_imperative_handle(
|
|
|
803
997
|
# ======================================================================
|
|
804
998
|
|
|
805
999
|
|
|
806
|
-
def
|
|
807
|
-
|
|
808
|
-
deps: Optional[list] = None,
|
|
809
|
-
) -> None:
|
|
810
|
-
"""Schedule an async effect that's cancelled on re-run / unmount.
|
|
1000
|
+
def use_resource(fetcher: Callable[[], Any], deps: Optional[list] = None) -> Resource[Any]:
|
|
1001
|
+
"""Start an async fetch and cache it across renders.
|
|
811
1002
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1003
|
+
The fetch starts immediately (during render, not after commit) and
|
|
1004
|
+
the resulting [`Resource`][pythonnative.Resource] is cached until
|
|
1005
|
+
``deps`` change, at which point the old fetch is cancelled and a
|
|
1006
|
+
new one starts. Because results are cached, re-renders resolve
|
|
1007
|
+
instantly; only genuinely new data suspends.
|
|
1008
|
+
|
|
1009
|
+
Consume the resource with ``resource.read()`` (suspends the render
|
|
1010
|
+
while pending; pair with a [`Suspense`][pythonnative.Suspense]
|
|
1011
|
+
boundary) or ``await resource`` inside an ``async def`` component.
|
|
1012
|
+
Errors raised by the fetcher re-raise at the read site, so an
|
|
1013
|
+
enclosing [`ErrorBoundary`][pythonnative.ErrorBoundary] catches
|
|
1014
|
+
failures declaratively.
|
|
818
1015
|
|
|
819
1016
|
Args:
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1017
|
+
fetcher: Zero-arg ``async def`` (or plain callable) producing
|
|
1018
|
+
the value. Synchronous fetchers resolve immediately and
|
|
1019
|
+
never suspend.
|
|
1020
|
+
deps: Dependency list controlling when to refetch. Defaults to
|
|
1021
|
+
``[]`` (fetch once per component instance).
|
|
1022
|
+
|
|
1023
|
+
Returns:
|
|
1024
|
+
The cached [`Resource`][pythonnative.Resource].
|
|
1025
|
+
|
|
1026
|
+
Raises:
|
|
1027
|
+
RuntimeError: If called outside a ``@component`` function.
|
|
1028
|
+
|
|
1029
|
+
Example:
|
|
1030
|
+
```python
|
|
1031
|
+
import pythonnative as pn
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
@pn.component
|
|
1035
|
+
async def UserCard(user_id):
|
|
1036
|
+
user = await pn.use_resource(lambda: api.get_user(user_id), [user_id])
|
|
1037
|
+
return pn.Text(user["name"])
|
|
1038
|
+
```
|
|
1039
|
+
"""
|
|
1040
|
+
from .suspense import start_resource
|
|
1041
|
+
|
|
1042
|
+
ctx = _get_hook_state()
|
|
1043
|
+
if ctx is None:
|
|
1044
|
+
raise RuntimeError("use_resource must be called inside a @component function")
|
|
1045
|
+
ctx.record_hook("use_resource")
|
|
1046
|
+
|
|
1047
|
+
idx = ctx.resource_index
|
|
1048
|
+
ctx.resource_index += 1
|
|
1049
|
+
deps = [] if deps is None else deps
|
|
1050
|
+
|
|
1051
|
+
if idx >= len(ctx.resources):
|
|
1052
|
+
resource = start_resource(fetcher)
|
|
1053
|
+
ctx.resources.append((list(deps), resource))
|
|
1054
|
+
return resource
|
|
1055
|
+
|
|
1056
|
+
prev_deps, prev_resource = ctx.resources[idx]
|
|
1057
|
+
if not _deps_changed(prev_deps, deps):
|
|
1058
|
+
return prev_resource
|
|
1059
|
+
|
|
1060
|
+
prev_resource.cancel()
|
|
1061
|
+
resource = start_resource(fetcher)
|
|
1062
|
+
ctx.resources[idx] = (list(deps), resource)
|
|
1063
|
+
return resource
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def use_transition() -> Tuple[bool, Callable[[Callable[[], None]], None]]:
|
|
1067
|
+
"""Return ``(is_pending, start_transition)`` for low-priority updates.
|
|
1068
|
+
|
|
1069
|
+
State updates made inside ``start_transition(fn)`` are marked as
|
|
1070
|
+
*transitions*: instead of re-rendering synchronously, their render
|
|
1071
|
+
is deferred to a later turn of the framework loop, so urgent
|
|
1072
|
+
updates (typing, presses) queued in the meantime render first.
|
|
1073
|
+
``is_pending`` is ``True`` from the moment ``start_transition`` is
|
|
1074
|
+
called until the deferred render has committed, which is exactly
|
|
1075
|
+
when to show a lightweight busy indicator.
|
|
1076
|
+
|
|
1077
|
+
Returns:
|
|
1078
|
+
A 2-tuple ``(is_pending, start_transition)``.
|
|
823
1079
|
|
|
824
1080
|
Raises:
|
|
825
1081
|
RuntimeError: If called outside a ``@component`` function.
|
|
@@ -830,43 +1086,67 @@ def use_async_effect(
|
|
|
830
1086
|
|
|
831
1087
|
|
|
832
1088
|
@pn.component
|
|
833
|
-
def
|
|
834
|
-
|
|
1089
|
+
def Search():
|
|
1090
|
+
query, set_query = pn.use_state("")
|
|
1091
|
+
results_for, set_results_for = pn.use_state("")
|
|
1092
|
+
is_pending, start_transition = pn.use_transition()
|
|
835
1093
|
|
|
836
|
-
|
|
837
|
-
|
|
1094
|
+
def on_change(text):
|
|
1095
|
+
set_query(text) # urgent: keep the input responsive
|
|
1096
|
+
start_transition(lambda: set_results_for(text))
|
|
838
1097
|
|
|
839
|
-
pn.
|
|
840
|
-
|
|
1098
|
+
return pn.Column(
|
|
1099
|
+
pn.TextInput(value=query, on_change=on_change),
|
|
1100
|
+
pn.ActivityIndicator() if is_pending else Results(results_for),
|
|
1101
|
+
)
|
|
841
1102
|
```
|
|
842
1103
|
"""
|
|
843
|
-
|
|
1104
|
+
ctx = _get_hook_state()
|
|
1105
|
+
if ctx is None:
|
|
1106
|
+
raise RuntimeError("use_transition must be called inside a @component function")
|
|
844
1107
|
|
|
845
|
-
|
|
846
|
-
future = run_async(effect())
|
|
1108
|
+
is_pending, set_pending = use_state(False)
|
|
847
1109
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
return
|
|
854
|
-
exc = fut.exception()
|
|
855
|
-
if exc is None or isinstance(exc, asyncio.CancelledError):
|
|
856
|
-
return
|
|
857
|
-
if not diagnostics.report_error(exc, phase="async effect"):
|
|
858
|
-
import traceback
|
|
1110
|
+
def start_transition(fn: Callable[[], None]) -> None:
|
|
1111
|
+
set_pending(True)
|
|
1112
|
+
_run_in_transition(fn)
|
|
1113
|
+
_post_transition_callbacks.append(lambda: set_pending(False))
|
|
1114
|
+
_schedule_transition_flush()
|
|
859
1115
|
|
|
860
|
-
|
|
1116
|
+
start = use_callback(start_transition, [])
|
|
1117
|
+
return is_pending, start
|
|
861
1118
|
|
|
862
|
-
future.add_done_callback(_observe)
|
|
863
1119
|
|
|
864
|
-
|
|
865
|
-
|
|
1120
|
+
def use_deferred_value(value: Any) -> Any:
|
|
1121
|
+
"""Return a copy of ``value`` that lags behind during fast updates.
|
|
866
1122
|
|
|
867
|
-
|
|
1123
|
+
The returned value updates in a deferred (transition-priority)
|
|
1124
|
+
render after the urgent render that changed ``value`` has
|
|
1125
|
+
committed. Pass the deferred value to expensive subtrees (a
|
|
1126
|
+
filtered list, a chart) so the urgent part of the UI stays
|
|
1127
|
+
responsive while the expensive part catches up a beat later.
|
|
1128
|
+
|
|
1129
|
+
Args:
|
|
1130
|
+
value: The latest value.
|
|
1131
|
+
|
|
1132
|
+
Returns:
|
|
1133
|
+
The previous value while a newer one is still being adopted,
|
|
1134
|
+
then the latest value.
|
|
1135
|
+
|
|
1136
|
+
Raises:
|
|
1137
|
+
RuntimeError: If called outside a ``@component`` function.
|
|
1138
|
+
"""
|
|
1139
|
+
ctx = _get_hook_state()
|
|
1140
|
+
if ctx is None:
|
|
1141
|
+
raise RuntimeError("use_deferred_value must be called inside a @component function")
|
|
1142
|
+
|
|
1143
|
+
deferred, set_deferred = use_state(value)
|
|
1144
|
+
|
|
1145
|
+
def _adopt() -> None:
|
|
1146
|
+
_run_in_transition(lambda: set_deferred(value))
|
|
868
1147
|
|
|
869
|
-
use_effect(
|
|
1148
|
+
use_effect(_adopt, [value])
|
|
1149
|
+
return deferred
|
|
870
1150
|
|
|
871
1151
|
|
|
872
1152
|
@dataclass(frozen=True)
|
|
@@ -1012,7 +1292,10 @@ class MutationCall(Generic[T]):
|
|
|
1012
1292
|
self._future = future
|
|
1013
1293
|
|
|
1014
1294
|
def __await__(self) -> Any:
|
|
1015
|
-
|
|
1295
|
+
future = self._future
|
|
1296
|
+
if isinstance(future, asyncio.Future):
|
|
1297
|
+
return future.__await__()
|
|
1298
|
+
return asyncio.wrap_future(future).__await__()
|
|
1016
1299
|
|
|
1017
1300
|
def cancel(self) -> bool:
|
|
1018
1301
|
"""Cancel the underlying mutation. Returns whether cancellation succeeded."""
|