pythonnative 0.32.0__py3-none-any.whl → 0.34.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.
Files changed (78) hide show
  1. pythonnative/__init__.py +27 -15
  2. pythonnative/animated.py +7 -13
  3. pythonnative/cli/pn.py +1 -2
  4. pythonnative/component.py +255 -0
  5. pythonnative/components/__init__.py +106 -0
  6. pythonnative/components/_base.py +132 -0
  7. pythonnative/components/controls.py +515 -0
  8. pythonnative/components/layout.py +459 -0
  9. pythonnative/components/lists.py +809 -0
  10. pythonnative/components/media.py +204 -0
  11. pythonnative/components/overlays.py +107 -0
  12. pythonnative/components/pressable.py +252 -0
  13. pythonnative/components/structural.py +167 -0
  14. pythonnative/components/text.py +291 -0
  15. pythonnative/diagnostics.py +1 -1
  16. pythonnative/element.py +108 -29
  17. pythonnative/gestures.py +1 -1
  18. pythonnative/hooks.py +366 -671
  19. pythonnative/hosts/__init__.py +85 -0
  20. pythonnative/hosts/android.py +269 -0
  21. pythonnative/hosts/base.py +665 -0
  22. pythonnative/hosts/desktop.py +107 -0
  23. pythonnative/hosts/ios.py +404 -0
  24. pythonnative/hot_reload.py +16 -24
  25. pythonnative/layout.py +372 -66
  26. pythonnative/native_modules/__init__.py +23 -0
  27. pythonnative/native_modules/net_info.py +5 -1
  28. pythonnative/native_views/__init__.py +1 -1
  29. pythonnative/native_views/android.py +50 -6
  30. pythonnative/native_views/base.py +64 -1
  31. pythonnative/native_views/desktop.py +11 -3
  32. pythonnative/native_views/ios.py +72 -12
  33. pythonnative/navigation/__init__.py +102 -0
  34. pythonnative/navigation/container.py +154 -0
  35. pythonnative/navigation/handle.py +573 -0
  36. pythonnative/navigation/hooks.py +94 -0
  37. pythonnative/navigation/host.py +58 -0
  38. pythonnative/navigation/linking.py +200 -0
  39. pythonnative/navigation/navigators.py +637 -0
  40. pythonnative/navigation/screen.py +149 -0
  41. pythonnative/navigation/state.py +248 -0
  42. pythonnative/net.py +4 -0
  43. pythonnative/platform_metrics.py +1 -1
  44. pythonnative/preview.py +21 -19
  45. pythonnative/project/android.py +7 -1
  46. pythonnative/project/doctor.py +1 -1
  47. pythonnative/project/ios.py +1 -0
  48. pythonnative/project/runtime_assets.py +1 -1
  49. pythonnative/reconciler/__init__.py +29 -0
  50. pythonnative/reconciler/boundaries.py +365 -0
  51. pythonnative/reconciler/children.py +88 -0
  52. pythonnative/reconciler/core.py +1153 -0
  53. pythonnative/reconciler/layout_pass.py +359 -0
  54. pythonnative/reconciler/vnode.py +266 -0
  55. pythonnative/scheduler.py +159 -0
  56. pythonnative/sdk/_components.py +2 -4
  57. pythonnative/style.py +95 -13
  58. pythonnative/suspense.py +8 -12
  59. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +85 -1
  60. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/ScreenFragment.kt +6 -7
  61. pythonnative/templates/android_template/app/src/main/res/navigation/nav_graph.xml +1 -1
  62. pythonnative/templates/android_template/app/src/main/res/values/strings.xml +3 -1
  63. pythonnative/templates/ios_template/ios_template/AppDelegate.swift +33 -0
  64. pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +1 -1
  65. pythonnative/templates/ios_template/ios_template/ViewController.swift +12 -10
  66. pythonnative/testing/__init__.py +53 -0
  67. pythonnative/testing/backend.py +276 -0
  68. pythonnative/testing/harness.py +391 -0
  69. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/METADATA +2 -21
  70. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/RECORD +74 -43
  71. pythonnative/components.py +0 -2856
  72. pythonnative/navigation.py +0 -1031
  73. pythonnative/reconciler.py +0 -2298
  74. pythonnative/screen.py +0 -2068
  75. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/WHEEL +0 -0
  76. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/entry_points.txt +0 -0
  77. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/licenses/LICENSE +0 -0
  78. {pythonnative-0.32.0.dist-info → pythonnative-0.34.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,365 @@
1
+ """``ErrorBoundary`` and ``Suspense`` handling for the reconciler.
2
+
3
+ Both boundaries are transparent wrappers whose children are swapped
4
+ for a fallback subtree when something goes wrong beneath them:
5
+
6
+ - an **error boundary** catches exceptions raised while rendering its
7
+ subtree, mounts its ``fallback`` (optionally receiving the error and
8
+ a ``reset`` callable), and rebuilds the content when ``reset`` runs;
9
+ - a **Suspense** boundary catches [`Suspend`][pythonnative.suspense.Suspend]
10
+ signals (an ``async`` body or a [`use_resource`][pythonnative.use_resource]
11
+ read that isn't ready), shows its ``fallback``, and retries the
12
+ content once the awaited work completes. Hook states of components
13
+ that had already rendered are preserved across the retry so cached
14
+ resources aren't refetched.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import inspect
20
+ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Tuple
21
+
22
+ from .. import diagnostics
23
+ from ..element import Element
24
+ from ..hooks import HookState
25
+ from ..suspense import Suspend
26
+ from .vnode import VNode, normalize_children
27
+
28
+ if TYPE_CHECKING:
29
+ from ..scheduler import TransitionQueue
30
+
31
+ HydrationMap = Dict[Tuple[int, Any], List[HookState]]
32
+
33
+
34
+ class BoundaryMixin:
35
+ """Error-boundary and Suspense behavior mixed into the reconciler."""
36
+
37
+ # Attributes provided by the concrete reconciler.
38
+ _dirty_boundaries: Dict[int, VNode]
39
+ _dirty_suspense: Dict[int, VNode]
40
+ _hydration: Optional[HydrationMap]
41
+ _suspense_salvage: Optional[HydrationMap]
42
+ transitions: "TransitionQueue"
43
+
44
+ # Methods provided by the concrete reconciler.
45
+ def _create_child_list(self, elements: List[Element]) -> List[VNode]: ... # pragma: no cover
46
+
47
+ def _reconcile_child_list(self, old: List[VNode], new: List[Element]) -> List[VNode]: ... # pragma: no cover
48
+
49
+ def _destroy_tree(self, node: VNode, salvage: Optional[HydrationMap] = None) -> None: ... # pragma: no cover
50
+
51
+ def _refresh_identity(self, node: VNode) -> None: ... # pragma: no cover
52
+
53
+ def request_render(self) -> None: # pragma: no cover
54
+ """Ask for another render pass (provided by the concrete reconciler)."""
55
+ ...
56
+
57
+ # ------------------------------------------------------------------
58
+ # Error boundaries
59
+ # ------------------------------------------------------------------
60
+
61
+ def _create_error_boundary(self, element: Element) -> VNode:
62
+ node = VNode(element, [])
63
+ try:
64
+ children = self._create_child_list(normalize_children(element.children, owner="ErrorBoundary"))
65
+ except Exception as exc:
66
+ self._activate_boundary(node, exc)
67
+ return node
68
+ for child in children:
69
+ child.parent = node
70
+ node.children = children
71
+ self._refresh_identity(node)
72
+ return node
73
+
74
+ def _reconcile_error_boundary(self, old: VNode, new_el: Element) -> VNode:
75
+ old.element = new_el
76
+ if old.error is not None:
77
+ # Fallback is showing; keep showing it (rebuilt against the
78
+ # latest fallback prop) until reset() clears the error.
79
+ fallback_els = self._error_fallback_elements(old, old.error)
80
+ old.children = self._reconcile_child_list(old.children, fallback_els)
81
+ for child in old.children:
82
+ child.parent = old
83
+ self._refresh_identity(old)
84
+ return old
85
+ try:
86
+ children = self._reconcile_child_list(
87
+ old.children, normalize_children(new_el.children, owner="ErrorBoundary")
88
+ )
89
+ old.children = children
90
+ for child in children:
91
+ child.parent = old
92
+ self._refresh_identity(old)
93
+ except Exception as exc:
94
+ self._activate_boundary(old, exc)
95
+ return old
96
+
97
+ def _activate_boundary(self, node: VNode, exc: BaseException) -> None:
98
+ """Destroy a boundary's failed subtree and mount its fallback.
99
+
100
+ Calls the ``on_error`` prop (if any), records the error on the
101
+ node, and replaces the children with the rendered fallback.
102
+ Re-raises when the boundary has no fallback, letting an outer
103
+ boundary (or the screen host) take over.
104
+ """
105
+ props = node.element.props
106
+ on_error = props.get("on_error")
107
+ if callable(on_error):
108
+ try:
109
+ on_error(exc)
110
+ except Exception as cb_exc:
111
+ diagnostics.warn(f"ErrorBoundary on_error callback raised {cb_exc!r}")
112
+ if props.get("fallback") is None:
113
+ raise exc
114
+ for child in node.children:
115
+ self._destroy_tree(child)
116
+ node.children = []
117
+ node.error = exc
118
+ children = self._create_child_list(self._error_fallback_elements(node, exc))
119
+ for child in children:
120
+ child.parent = node
121
+ node.children = children
122
+ self._refresh_identity(node)
123
+
124
+ def _error_fallback_elements(self, node: VNode, exc: BaseException) -> List[Element]:
125
+ """Render a boundary's fallback prop into a normalized child list."""
126
+ fallback = node.element.props.get("fallback")
127
+ if fallback is None:
128
+ return []
129
+ result: Any = fallback
130
+ if callable(fallback) and not isinstance(fallback, Element):
131
+ arity = _positional_arity(fallback)
132
+ if arity >= 2:
133
+ result = fallback(exc, self._make_boundary_reset(node))
134
+ elif arity == 1:
135
+ result = fallback(exc)
136
+ else:
137
+ result = fallback()
138
+ return normalize_children(result, owner="ErrorBoundary.fallback")
139
+
140
+ def _make_boundary_reset(self, node: VNode) -> Callable[[], None]:
141
+ """Return the ``reset`` callable handed to a boundary's fallback."""
142
+
143
+ def reset() -> None:
144
+ if not node.mounted or node.error is None:
145
+ return
146
+ node.error = None
147
+ self._dirty_boundaries[id(node)] = node
148
+ from ..scheduler import schedule_trigger
149
+
150
+ schedule_trigger(self.request_render)
151
+
152
+ return reset
153
+
154
+ # ------------------------------------------------------------------
155
+ # Suspense boundaries
156
+ # ------------------------------------------------------------------
157
+
158
+ def _create_suspense(self, element: Element) -> VNode:
159
+ node = VNode(element, [])
160
+ self._attempt_suspense_content(node)
161
+ return node
162
+
163
+ def _reconcile_suspense(self, old: VNode, new_el: Element) -> VNode:
164
+ old.element = new_el
165
+ if old.suspense_showing_fallback:
166
+ # Fallback showing; keep it in sync with the latest fallback
167
+ # prop. Content retries are driven by waitable completions,
168
+ # not by parent re-renders.
169
+ old.children = self._reconcile_child_list(old.children, self._suspense_fallback_elements(old))
170
+ for child in old.children:
171
+ child.parent = old
172
+ self._refresh_identity(old)
173
+ return old
174
+ try:
175
+ children = self._reconcile_child_list(old.children, normalize_children(new_el.children, owner="Suspense"))
176
+ old.children = children
177
+ for child in children:
178
+ child.parent = old
179
+ self._refresh_identity(old)
180
+ except Suspend as signal:
181
+ self._teardown_suspense_content(old)
182
+ self._suspend_boundary(old, signal)
183
+ return old
184
+
185
+ def _attempt_suspense_content(self, node: VNode) -> None:
186
+ """Build a boundary's content from scratch (initial mount or retry).
187
+
188
+ Components re-adopt hook states preserved from the previous
189
+ attempt (the hydration map), so cached resources resolve
190
+ instead of refetching. On success any still-showing fallback
191
+ is swapped out for the content; on suspension the fallback
192
+ mounts (or stays) and a retry is wired to the pending work.
193
+ """
194
+ hydration: HydrationMap = node.suspense_hydration or {}
195
+ node.suspense_hydration = None
196
+ saved_hydration = self._hydration
197
+ saved_salvage = self._suspense_salvage
198
+ self._hydration = hydration
199
+ self._suspense_salvage = None
200
+ try:
201
+ children = self._create_child_list(normalize_children(node.element.children, owner="Suspense"))
202
+ except Suspend as signal:
203
+ # Unclaimed hook states from the previous attempt stay
204
+ # preserved for the next retry; salvaged sibling states are
205
+ # folded in by _suspend_boundary.
206
+ merged = {key: states for key, states in hydration.items() if states}
207
+ node.suspense_hydration = merged or None
208
+ self._suspend_boundary(node, signal)
209
+ return
210
+ finally:
211
+ self._hydration = saved_hydration
212
+ self._suspense_salvage = saved_salvage
213
+
214
+ self._dispose_hydration(hydration)
215
+ for child in node.children:
216
+ self._destroy_tree(child)
217
+ for child in children:
218
+ child.parent = node
219
+ node.children = children
220
+ node.suspense_showing_fallback = False
221
+ node.suspense_waits = None
222
+ self._refresh_identity(node)
223
+
224
+ def _teardown_suspense_content(self, node: VNode) -> None:
225
+ """Destroy a boundary's live content, salvaging its hook states.
226
+
227
+ Used when an *update* under the boundary suspends: the content
228
+ components' hook states move into the boundary's hydration map,
229
+ so when the retry re-mounts them their state, caches, and
230
+ effect bookkeeping carry over (the fallback round-trip doesn't
231
+ reset the subtree).
232
+ """
233
+ salvage: HydrationMap = {}
234
+ for child in node.children:
235
+ self._destroy_tree(child, salvage=salvage)
236
+ node.children = []
237
+ node.suspense_showing_fallback = False
238
+ if salvage:
239
+ hydration = node.suspense_hydration
240
+ if hydration is None:
241
+ hydration = node.suspense_hydration = {}
242
+ for key, states in salvage.items():
243
+ hydration.setdefault(key, []).extend(states)
244
+
245
+ def _suspend_boundary(self, node: VNode, signal: Suspend) -> None:
246
+ """Show a boundary's fallback and schedule a retry for ``signal``.
247
+
248
+ A boundary without a fallback is transparent: the suspension
249
+ propagates to the next Suspense ancestor (mirroring how an
250
+ ErrorBoundary without a fallback re-raises).
251
+ """
252
+ if node.element.props.get("fallback") is None:
253
+ raise signal
254
+
255
+ # Fold in hook states salvaged while the Suspend unwound
256
+ # (already-rendered siblings of the suspender), plus the
257
+ # suspender's own state carried on the signal.
258
+ salvage = self._suspense_salvage
259
+ self._suspense_salvage = None
260
+ if salvage or (signal.hook_state is not None and signal.key is not None):
261
+ hydration = node.suspense_hydration
262
+ if hydration is None:
263
+ hydration = node.suspense_hydration = {}
264
+ if salvage:
265
+ for key, states in salvage.items():
266
+ hydration.setdefault(key, []).extend(states)
267
+ if signal.hook_state is not None and signal.key is not None:
268
+ bucket = hydration.setdefault(signal.key, [])
269
+ if signal.hook_state not in bucket:
270
+ bucket.append(signal.hook_state)
271
+
272
+ if not node.suspense_showing_fallback:
273
+ children = self._create_child_list(self._suspense_fallback_elements(node))
274
+ for child in children:
275
+ child.parent = node
276
+ node.children = children
277
+ node.suspense_showing_fallback = True
278
+ self._refresh_identity(node)
279
+
280
+ self._watch_waitable(node, signal.waitable)
281
+
282
+ @staticmethod
283
+ def _suspense_fallback_elements(node: VNode) -> List[Element]:
284
+ fallback = node.element.props.get("fallback")
285
+ if fallback is None:
286
+ return []
287
+ result: Any = fallback
288
+ if callable(fallback) and not isinstance(fallback, Element):
289
+ result = fallback()
290
+ return normalize_children(result, owner="Suspense.fallback")
291
+
292
+ def _watch_waitable(self, node: VNode, waitable: Any) -> None:
293
+ """Queue a content retry for ``node`` when ``waitable`` completes."""
294
+ waits = node.suspense_waits
295
+ if waits is None:
296
+ waits = node.suspense_waits = set()
297
+ marker = id(waitable)
298
+ if marker in waits:
299
+ return
300
+ waits.add(marker)
301
+
302
+ def _on_done(_w: Any = None) -> None:
303
+ live_waits = node.suspense_waits
304
+ if live_waits is not None:
305
+ live_waits.discard(marker)
306
+ if not node.mounted or not node.suspense_showing_fallback:
307
+ return
308
+ self._dirty_suspense[id(node)] = node
309
+ from ..scheduler import schedule_trigger
310
+
311
+ schedule_trigger(self.request_render)
312
+
313
+ waitable.add_done_callback(_on_done)
314
+
315
+ def _take_hydrated_hook_state(self, element: Element) -> Optional[HookState]:
316
+ """Pop a preserved hook state matching ``element`` from the hydration map."""
317
+ hydration = self._hydration
318
+ if not hydration:
319
+ return None
320
+ bucket = hydration.get((id(element.type), element.key))
321
+ if not bucket:
322
+ return None
323
+ return bucket.pop(0)
324
+
325
+ @staticmethod
326
+ def _dispose_hydration(hydration: HydrationMap) -> None:
327
+ """Clean up preserved hook states that no component reclaimed."""
328
+ for states in hydration.values():
329
+ for hook_state in states:
330
+ try:
331
+ hook_state.cleanup_all_effects()
332
+ except Exception as exc:
333
+ diagnostics.warn(f"Error disposing a suspended component's state: {exc!r}")
334
+ hydration.clear()
335
+
336
+ def _discard_salvage(self) -> None:
337
+ """Dispose hook states salvaged during a Suspend that no boundary caught."""
338
+ salvage = self._suspense_salvage
339
+ self._suspense_salvage = None
340
+ if salvage:
341
+ self._dispose_hydration(salvage)
342
+
343
+ @staticmethod
344
+ def _missing_suspense_error(signal: Suspend) -> RuntimeError:
345
+ who = signal.label or "A component"
346
+ return RuntimeError(
347
+ f"{who} suspended while rendering, but no Suspense ancestor provides a "
348
+ "fallback. Wrap the async part of the tree in pn.Suspense(..., "
349
+ "fallback=...) to declare its loading state."
350
+ )
351
+
352
+
353
+ def _positional_arity(fn: Callable[..., Any]) -> int:
354
+ """Count positional parameters ``fn`` accepts (2+ means unbounded is fine)."""
355
+ try:
356
+ sig = inspect.signature(fn)
357
+ except (TypeError, ValueError):
358
+ return 1
359
+ count = 0
360
+ for p in sig.parameters.values():
361
+ if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD):
362
+ count += 1
363
+ elif p.kind == inspect.Parameter.VAR_POSITIONAL:
364
+ return 2
365
+ return count
@@ -0,0 +1,88 @@
1
+ """Pure planning of native child-list moves.
2
+
3
+ Given the tags a native container currently holds and the tags it
4
+ should hold after a pass, [`plan_child_moves`][pythonnative.reconciler.children.plan_child_moves]
5
+ returns the minimal sequence of ``(tag, index)`` insertions (with
6
+ move-aware ``insert_child`` semantics: a child already attached is
7
+ re-positioned) that transforms one into the other.
8
+
9
+ The algorithm keeps the longest increasing subsequence of surviving
10
+ children in place and moves only the rest, processing right to left so
11
+ every moved child is inserted directly before an already-settled
12
+ neighbor. That yields ``n - len(LIS)`` operations, the optimum for a
13
+ move-aware insert primitive.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from bisect import bisect_left
19
+ from typing import Dict, List, Optional, Sequence, Tuple
20
+
21
+ __all__ = ["longest_increasing_subsequence", "plan_child_moves"]
22
+
23
+
24
+ def longest_increasing_subsequence(values: Sequence[int]) -> List[int]:
25
+ """Return the indices (into ``values``) of one longest strictly increasing subsequence."""
26
+ if not values:
27
+ return []
28
+ tails: List[int] = [] # values
29
+ tails_idx: List[int] = [] # index into values of each tail
30
+ prev: List[int] = [-1] * len(values)
31
+ for i, v in enumerate(values):
32
+ pos = bisect_left(tails, v)
33
+ if pos == len(tails):
34
+ tails.append(v)
35
+ tails_idx.append(i)
36
+ else:
37
+ tails[pos] = v
38
+ tails_idx[pos] = i
39
+ prev[i] = tails_idx[pos - 1] if pos > 0 else -1
40
+ out: List[int] = []
41
+ cursor = tails_idx[-1]
42
+ while cursor != -1:
43
+ out.append(cursor)
44
+ cursor = prev[cursor]
45
+ out.reverse()
46
+ return out
47
+
48
+
49
+ def plan_child_moves(before: Sequence[int], after: Sequence[int]) -> List[Tuple[int, int]]:
50
+ """Compute ``(tag, index)`` insert ops turning ``before`` into ``after``.
51
+
52
+ Args:
53
+ before: Tags currently attached to the container, in order.
54
+ Tags absent from ``after`` are assumed to be detached
55
+ already (the reconciler destroys them first) and must not
56
+ appear here.
57
+ after: Desired child tags, in order. Tags absent from
58
+ ``before`` are fresh children.
59
+
60
+ Returns:
61
+ Ordered insert operations. Applying each as "detach ``tag`` if
62
+ attached, then insert at ``index``" reproduces ``after``.
63
+ """
64
+ if list(before) == list(after):
65
+ return []
66
+ position_after: Dict[int, int] = {tag: i for i, tag in enumerate(after)}
67
+ # Surviving children in their current order, mapped to their target
68
+ # positions; the LIS of that sequence is the set we never touch.
69
+ surviving_targets = [position_after[tag] for tag in before if tag in position_after]
70
+ keep_positions = set(surviving_targets[i] for i in longest_increasing_subsequence(surviving_targets))
71
+
72
+ sim: List[int] = list(before)
73
+ ops: List[Tuple[int, int]] = []
74
+ anchor: Optional[int] = None
75
+ for i in range(len(after) - 1, -1, -1):
76
+ tag = after[i]
77
+ if i in keep_positions:
78
+ anchor = tag
79
+ continue
80
+ try:
81
+ sim.remove(tag)
82
+ except ValueError:
83
+ pass
84
+ index = sim.index(anchor) if anchor is not None else len(sim)
85
+ sim.insert(index, tag)
86
+ ops.append((tag, index))
87
+ anchor = tag
88
+ return ops