pythonnative 0.7.0__py3-none-any.whl → 0.9.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/hooks.py CHANGED
@@ -19,7 +19,8 @@ Usage::
19
19
 
20
20
  import inspect
21
21
  import threading
22
- from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar
22
+ from contextlib import contextmanager
23
+ from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, TypeVar
23
24
 
24
25
  from .element import Element
25
26
 
@@ -29,6 +30,7 @@ _SENTINEL = object()
29
30
 
30
31
  _hook_context: threading.local = threading.local()
31
32
 
33
+ _batch_context: threading.local = threading.local()
32
34
 
33
35
  # ======================================================================
34
36
  # Hook state container
@@ -36,9 +38,22 @@ _hook_context: threading.local = threading.local()
36
38
 
37
39
 
38
40
  class HookState:
39
- """Stores all hook data for a single function component instance."""
41
+ """Stores all hook data for a single function component instance.
40
42
 
41
- __slots__ = ("states", "effects", "memos", "refs", "hook_index", "_trigger_render")
43
+ Effects are **queued** during the render phase and **flushed** after
44
+ the reconciler commits native-view mutations. This guarantees that
45
+ effect callbacks can safely interact with the committed native tree.
46
+ """
47
+
48
+ __slots__ = (
49
+ "states",
50
+ "effects",
51
+ "memos",
52
+ "refs",
53
+ "hook_index",
54
+ "_trigger_render",
55
+ "_pending_effects",
56
+ )
42
57
 
43
58
  def __init__(self) -> None:
44
59
  self.states: List[Any] = []
@@ -47,15 +62,24 @@ class HookState:
47
62
  self.refs: List[dict] = []
48
63
  self.hook_index: int = 0
49
64
  self._trigger_render: Optional[Callable[[], None]] = None
65
+ self._pending_effects: List[Tuple[int, Callable, Any]] = []
50
66
 
51
67
  def reset_index(self) -> None:
52
68
  self.hook_index = 0
53
69
 
54
- def run_pending_effects(self) -> None:
55
- """Execute effects whose deps changed during the last render pass."""
56
- for i, (deps, cleanup) in enumerate(self.effects):
57
- if deps is _SENTINEL:
58
- continue
70
+ def flush_pending_effects(self) -> None:
71
+ """Execute effects queued during the render pass (called after commit)."""
72
+ pending = self._pending_effects
73
+ self._pending_effects = []
74
+ for idx, effect_fn, deps in pending:
75
+ _, prev_cleanup = self.effects[idx]
76
+ if callable(prev_cleanup):
77
+ try:
78
+ prev_cleanup()
79
+ except Exception:
80
+ pass
81
+ cleanup = effect_fn()
82
+ self.effects[idx] = (list(deps) if deps is not None else None, cleanup)
59
83
 
60
84
  def cleanup_all_effects(self) -> None:
61
85
  """Run all outstanding cleanup functions (called on unmount)."""
@@ -66,6 +90,7 @@ class HookState:
66
90
  except Exception:
67
91
  pass
68
92
  self.effects[i] = (_SENTINEL, None)
93
+ self._pending_effects = []
69
94
 
70
95
 
71
96
  # ======================================================================
@@ -91,6 +116,45 @@ def _deps_changed(prev: Any, current: Any) -> bool:
91
116
  return any(p is not c and p != c for p, c in zip(prev, current))
92
117
 
93
118
 
119
+ # ======================================================================
120
+ # Batching helpers
121
+ # ======================================================================
122
+
123
+
124
+ def _schedule_trigger(trigger: Callable[[], None]) -> None:
125
+ """Call *trigger* now, or defer it if inside :func:`batch_updates`."""
126
+ if getattr(_batch_context, "depth", 0) > 0:
127
+ _batch_context.pending_trigger = trigger
128
+ else:
129
+ trigger()
130
+
131
+
132
+ @contextmanager
133
+ def batch_updates() -> Generator[None, None, None]:
134
+ """Batch multiple state updates so only one re-render occurs.
135
+
136
+ Usage::
137
+
138
+ with pn.batch_updates():
139
+ set_count(1)
140
+ set_name("hello")
141
+ # single re-render happens here
142
+ """
143
+ depth = getattr(_batch_context, "depth", 0)
144
+ _batch_context.depth = depth + 1
145
+ if depth == 0:
146
+ _batch_context.pending_trigger = None
147
+ try:
148
+ yield
149
+ finally:
150
+ _batch_context.depth -= 1
151
+ if _batch_context.depth == 0:
152
+ trigger = _batch_context.pending_trigger
153
+ _batch_context.pending_trigger = None
154
+ if trigger is not None:
155
+ trigger()
156
+
157
+
94
158
  # ======================================================================
95
159
  # Public hooks
96
160
  # ======================================================================
@@ -121,13 +185,60 @@ def use_state(initial: Any = None) -> Tuple[Any, Callable]:
121
185
  if ctx.states[idx] is not new_value and ctx.states[idx] != new_value:
122
186
  ctx.states[idx] = new_value
123
187
  if ctx._trigger_render:
124
- ctx._trigger_render()
188
+ _schedule_trigger(ctx._trigger_render)
125
189
 
126
190
  return current, setter
127
191
 
128
192
 
193
+ def use_reducer(reducer: Callable[[Any, Any], Any], initial_state: Any) -> Tuple[Any, Callable]:
194
+ """Return ``(state, dispatch)`` for reducer-based state management.
195
+
196
+ The *reducer* is called as ``reducer(current_state, action)`` and
197
+ must return the new state. If *initial_state* is callable it is
198
+ invoked once (lazy initialisation).
199
+
200
+ Usage::
201
+
202
+ def reducer(state, action):
203
+ if action == "increment":
204
+ return state + 1
205
+ if action == "reset":
206
+ return 0
207
+ return state
208
+
209
+ count, dispatch = pn.use_reducer(reducer, 0)
210
+ # dispatch("increment") -> re-render with count = 1
211
+ """
212
+ ctx = _get_hook_state()
213
+ if ctx is None:
214
+ raise RuntimeError("use_reducer must be called inside a @component function")
215
+
216
+ idx = ctx.hook_index
217
+ ctx.hook_index += 1
218
+
219
+ if idx >= len(ctx.states):
220
+ val = initial_state() if callable(initial_state) else initial_state
221
+ ctx.states.append(val)
222
+
223
+ current = ctx.states[idx]
224
+
225
+ def dispatch(action: Any) -> None:
226
+ new_state = reducer(ctx.states[idx], action)
227
+ if ctx.states[idx] is not new_state and ctx.states[idx] != new_state:
228
+ ctx.states[idx] = new_state
229
+ if ctx._trigger_render:
230
+ _schedule_trigger(ctx._trigger_render)
231
+
232
+ return current, dispatch
233
+
234
+
129
235
  def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
130
- """Schedule *effect* to run after render.
236
+ """Schedule *effect* to run **after** the native tree is committed.
237
+
238
+ Effects are queued during the render pass and flushed once the
239
+ reconciler has finished applying all native-view mutations. This
240
+ means effects can safely measure layout or interact with committed
241
+ native views.
131
242
 
132
243
  *deps* controls when the effect re-runs:
133
244
 
@@ -146,18 +257,12 @@ def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
146
257
 
147
258
  if idx >= len(ctx.effects):
148
259
  ctx.effects.append((_SENTINEL, None))
260
+ ctx._pending_effects.append((idx, effect, deps))
261
+ return
149
262
 
150
- prev_deps, prev_cleanup = ctx.effects[idx]
263
+ prev_deps, _prev_cleanup = ctx.effects[idx]
151
264
  if _deps_changed(prev_deps, deps):
152
- if callable(prev_cleanup):
153
- try:
154
- prev_cleanup()
155
- except Exception:
156
- pass
157
- cleanup = effect()
158
- ctx.effects[idx] = (list(deps) if deps is not None else None, cleanup)
159
- else:
160
- ctx.effects[idx] = (prev_deps, prev_cleanup)
265
+ ctx._pending_effects.append((idx, effect, deps))
161
266
 
162
267
 
163
268
  def use_memo(factory: Callable[[], T], deps: list) -> T:
@@ -255,27 +360,28 @@ _NavigationContext: Context = create_context(None)
255
360
 
256
361
 
257
362
  class NavigationHandle:
258
- """Object returned by :func:`use_navigation` providing push/pop/get_args.
363
+ """Object returned by :func:`use_navigation` providing navigation methods.
259
364
 
260
- Navigates by component reference rather than string path, e.g.::
365
+ ::
261
366
 
262
367
  nav = pn.use_navigation()
263
- nav.push(DetailScreen, args={"id": 42})
368
+ nav.navigate(DetailScreen, params={"id": 42})
369
+ nav.go_back()
264
370
  """
265
371
 
266
372
  def __init__(self, host: Any) -> None:
267
373
  self._host = host
268
374
 
269
- def push(self, page: Any, args: Optional[Dict[str, Any]] = None) -> None:
270
- """Navigate forward to *page* (a ``@component`` function or class)."""
271
- self._host._push(page, args)
375
+ def navigate(self, page: Any, params: Optional[Dict[str, Any]] = None) -> None:
376
+ """Navigate forward to *page* with optional *params*."""
377
+ self._host._push(page, params)
272
378
 
273
- def pop(self) -> None:
379
+ def go_back(self) -> None:
274
380
  """Navigate back to the previous screen."""
275
381
  self._host._pop()
276
382
 
277
- def get_args(self) -> Dict[str, Any]:
278
- """Return arguments passed from the previous screen."""
383
+ def get_params(self) -> Dict[str, Any]:
384
+ """Return parameters passed from the previous screen."""
279
385
  return self._host._get_nav_args()
280
386
 
281
387
 
@@ -137,7 +137,7 @@ class ModuleReloader:
137
137
  @staticmethod
138
138
  def reload_page(page_instance: Any) -> None:
139
139
  """Force a page re-render after module reload."""
140
- from .page import _re_render
140
+ from .page import _request_render
141
141
 
142
142
  if hasattr(page_instance, "_reconciler") and page_instance._reconciler is not None:
143
- _re_render(page_instance)
143
+ _request_render(page_instance)
@@ -0,0 +1,87 @@
1
+ """Platform-specific native view creation and update logic.
2
+
3
+ This package provides the :class:`NativeViewRegistry` that maps element type
4
+ names to platform-specific :class:`~.base.ViewHandler` implementations.
5
+
6
+ Platform handlers live in dedicated submodules:
7
+
8
+ - :mod:`~.base` — shared :class:`~.base.ViewHandler` protocol and utilities
9
+ - :mod:`~.android` — Android handlers (Chaquopy / Java bridge)
10
+ - :mod:`~.ios` — iOS handlers (rubicon-objc)
11
+
12
+ All platform-branching is handled at registration time via lazy imports,
13
+ so the package can be imported on any platform for testing.
14
+ """
15
+
16
+ from typing import Any, Dict, Optional
17
+
18
+ from .base import ViewHandler
19
+
20
+
21
+ class NativeViewRegistry:
22
+ """Maps element type names to platform-specific :class:`ViewHandler` instances."""
23
+
24
+ def __init__(self) -> None:
25
+ self._handlers: Dict[str, ViewHandler] = {}
26
+
27
+ def register(self, type_name: str, handler: ViewHandler) -> None:
28
+ self._handlers[type_name] = handler
29
+
30
+ def create_view(self, type_name: str, props: Dict[str, Any]) -> Any:
31
+ handler = self._handlers.get(type_name)
32
+ if handler is None:
33
+ raise ValueError(f"Unknown element type: {type_name!r}")
34
+ return handler.create(props)
35
+
36
+ def update_view(self, native_view: Any, type_name: str, changed_props: Dict[str, Any]) -> None:
37
+ handler = self._handlers.get(type_name)
38
+ if handler is not None:
39
+ handler.update(native_view, changed_props)
40
+
41
+ def add_child(self, parent: Any, child: Any, parent_type: str) -> None:
42
+ handler = self._handlers.get(parent_type)
43
+ if handler is not None:
44
+ handler.add_child(parent, child)
45
+
46
+ def remove_child(self, parent: Any, child: Any, parent_type: str) -> None:
47
+ handler = self._handlers.get(parent_type)
48
+ if handler is not None:
49
+ handler.remove_child(parent, child)
50
+
51
+ def insert_child(self, parent: Any, child: Any, parent_type: str, index: int) -> None:
52
+ handler = self._handlers.get(parent_type)
53
+ if handler is not None:
54
+ handler.insert_child(parent, child, index)
55
+
56
+
57
+ # ======================================================================
58
+ # Singleton registry
59
+ # ======================================================================
60
+
61
+ _registry: Optional[NativeViewRegistry] = None
62
+
63
+
64
+ def get_registry() -> NativeViewRegistry:
65
+ """Return the singleton registry, lazily creating platform handlers."""
66
+ global _registry
67
+ if _registry is not None:
68
+ return _registry
69
+ _registry = NativeViewRegistry()
70
+
71
+ from ..utils import IS_ANDROID
72
+
73
+ if IS_ANDROID:
74
+ from .android import register_handlers
75
+
76
+ register_handlers(_registry)
77
+ else:
78
+ from .ios import register_handlers
79
+
80
+ register_handlers(_registry)
81
+ return _registry
82
+
83
+
84
+ def set_registry(registry: NativeViewRegistry) -> None:
85
+ """Inject a custom or mock registry (primarily for testing)."""
86
+ global _registry
87
+ _registry = registry