pythonnative 0.7.0__py3-none-any.whl → 0.8.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 CHANGED
@@ -14,12 +14,13 @@ Public API::
14
14
  )
15
15
  """
16
16
 
17
- __version__ = "0.7.0"
17
+ __version__ = "0.8.0"
18
18
 
19
19
  from .components import (
20
20
  ActivityIndicator,
21
21
  Button,
22
22
  Column,
23
+ ErrorBoundary,
23
24
  FlatList,
24
25
  Image,
25
26
  Modal,
@@ -39,6 +40,7 @@ from .components import (
39
40
  from .element import Element
40
41
  from .hooks import (
41
42
  Provider,
43
+ batch_updates,
42
44
  component,
43
45
  create_context,
44
46
  use_callback,
@@ -46,9 +48,18 @@ from .hooks import (
46
48
  use_effect,
47
49
  use_memo,
48
50
  use_navigation,
51
+ use_reducer,
49
52
  use_ref,
50
53
  use_state,
51
54
  )
55
+ from .navigation import (
56
+ NavigationContainer,
57
+ create_drawer_navigator,
58
+ create_stack_navigator,
59
+ create_tab_navigator,
60
+ use_focus_effect,
61
+ use_route,
62
+ )
52
63
  from .page import create_page
53
64
  from .style import StyleSheet, ThemeContext
54
65
 
@@ -57,6 +68,7 @@ __all__ = [
57
68
  "ActivityIndicator",
58
69
  "Button",
59
70
  "Column",
71
+ "ErrorBoundary",
60
72
  "FlatList",
61
73
  "Image",
62
74
  "Modal",
@@ -76,16 +88,25 @@ __all__ = [
76
88
  "Element",
77
89
  "create_page",
78
90
  # Hooks
91
+ "batch_updates",
79
92
  "component",
80
93
  "create_context",
81
94
  "use_callback",
82
95
  "use_context",
83
96
  "use_effect",
97
+ "use_focus_effect",
84
98
  "use_memo",
85
99
  "use_navigation",
100
+ "use_reducer",
86
101
  "use_ref",
102
+ "use_route",
87
103
  "use_state",
88
104
  "Provider",
105
+ # Navigation
106
+ "NavigationContainer",
107
+ "create_drawer_navigator",
108
+ "create_stack_navigator",
109
+ "create_tab_navigator",
89
110
  # Styling
90
111
  "StyleSheet",
91
112
  "ThemeContext",
@@ -9,12 +9,18 @@ which accepts a dict or a list of dicts (later entries override earlier).
9
9
 
10
10
  Layout properties supported by all components::
11
11
 
12
- width, height, flex, margin, min_width, max_width, min_height,
13
- max_height, align_self
12
+ width, height, flex, flex_grow, flex_shrink, margin,
13
+ min_width, max_width, min_height, max_height, align_self
14
14
 
15
- Container-specific layout properties (Column / Row)::
15
+ Flex container properties (View / Column / Row)::
16
16
 
17
- spacing, padding, align_items, justify_content
17
+ flex_direction, justify_content, align_items, overflow,
18
+ spacing, padding
19
+
20
+ ``View`` is the universal flex container (like React Native's ``View``).
21
+ It defaults to ``flex_direction: "column"``. ``Column`` and ``Row``
22
+ are convenience wrappers that fix the direction to ``"column"`` and
23
+ ``"row"`` respectively.
18
24
  """
19
25
 
20
26
  from typing import Any, Callable, Dict, List, Optional
@@ -204,15 +210,48 @@ def Slider(
204
210
  # ======================================================================
205
211
 
206
212
 
213
+ def View(
214
+ *children: Element,
215
+ style: StyleValue = None,
216
+ key: Optional[str] = None,
217
+ ) -> Element:
218
+ """Universal flex container (like React Native's ``View``).
219
+
220
+ Defaults to ``flex_direction: "column"``. Override via ``style``::
221
+
222
+ pn.View(child_a, child_b, style={"flex_direction": "row"})
223
+
224
+ Flex container properties (inside ``style``):
225
+
226
+ - ``flex_direction`` — ``"column"`` (default), ``"row"``,
227
+ ``"column_reverse"``, ``"row_reverse"``
228
+ - ``justify_content`` — main-axis distribution:
229
+ ``"flex_start"`` (default), ``"center"``, ``"flex_end"``,
230
+ ``"space_between"``, ``"space_around"``, ``"space_evenly"``
231
+ - ``align_items`` — cross-axis alignment:
232
+ ``"stretch"`` (default), ``"flex_start"``, ``"center"``,
233
+ ``"flex_end"``
234
+ - ``overflow`` — ``"visible"`` (default) or ``"hidden"``
235
+ - ``spacing``, ``padding``, ``background_color``
236
+ """
237
+ props: Dict[str, Any] = {"flex_direction": "column"}
238
+ props.update(resolve_style(style))
239
+ return Element("View", props, list(children), key=key)
240
+
241
+
207
242
  def Column(
208
243
  *children: Element,
209
244
  style: StyleValue = None,
210
245
  key: Optional[str] = None,
211
246
  ) -> Element:
212
- """Arrange children vertically.
247
+ """Arrange children vertically (``flex_direction: "column"``).
248
+
249
+ Convenience wrapper around :func:`View`. The direction is fixed;
250
+ use :func:`View` directly if you need ``flex_direction: "row"``.
213
251
 
214
252
  Style properties: ``spacing``, ``padding``, ``align_items``,
215
- ``justify_content``, ``background_color``, plus common layout props.
253
+ ``justify_content``, ``background_color``, ``overflow``,
254
+ plus common layout props.
216
255
 
217
256
  ``align_items`` controls cross-axis (horizontal) alignment:
218
257
  ``"stretch"`` (default), ``"flex_start"``/``"leading"``,
@@ -222,8 +261,9 @@ def Column(
222
261
  ``"flex_start"`` (default), ``"center"``, ``"flex_end"``,
223
262
  ``"space_between"``, ``"space_around"``, ``"space_evenly"``.
224
263
  """
225
- props: Dict[str, Any] = {}
264
+ props: Dict[str, Any] = {"flex_direction": "column"}
226
265
  props.update(resolve_style(style))
266
+ props["flex_direction"] = "column"
227
267
  return Element("Column", props, list(children), key=key)
228
268
 
229
269
 
@@ -232,10 +272,14 @@ def Row(
232
272
  style: StyleValue = None,
233
273
  key: Optional[str] = None,
234
274
  ) -> Element:
235
- """Arrange children horizontally.
275
+ """Arrange children horizontally (``flex_direction: "row"``).
276
+
277
+ Convenience wrapper around :func:`View`. The direction is fixed;
278
+ use :func:`View` directly if you need ``flex_direction: "column"``.
236
279
 
237
280
  Style properties: ``spacing``, ``padding``, ``align_items``,
238
- ``justify_content``, ``background_color``, plus common layout props.
281
+ ``justify_content``, ``background_color``, ``overflow``,
282
+ plus common layout props.
239
283
 
240
284
  ``align_items`` controls cross-axis (vertical) alignment:
241
285
  ``"stretch"`` (default), ``"flex_start"``/``"top"``,
@@ -245,8 +289,9 @@ def Row(
245
289
  ``"flex_start"`` (default), ``"center"``, ``"flex_end"``,
246
290
  ``"space_between"``, ``"space_around"``, ``"space_evenly"``.
247
291
  """
248
- props: Dict[str, Any] = {}
292
+ props: Dict[str, Any] = {"flex_direction": "row"}
249
293
  props.update(resolve_style(style))
294
+ props["flex_direction"] = "row"
250
295
  return Element("Row", props, list(children), key=key)
251
296
 
252
297
 
@@ -263,17 +308,6 @@ def ScrollView(
263
308
  return Element("ScrollView", props, children, key=key)
264
309
 
265
310
 
266
- def View(
267
- *children: Element,
268
- style: StyleValue = None,
269
- key: Optional[str] = None,
270
- ) -> Element:
271
- """Generic container view (``UIView`` / ``android.view.View``)."""
272
- props: Dict[str, Any] = {}
273
- props.update(resolve_style(style))
274
- return Element("View", props, list(children), key=key)
275
-
276
-
277
311
  def SafeAreaView(
278
312
  *children: Element,
279
313
  style: StyleValue = None,
@@ -323,6 +357,29 @@ def Pressable(
323
357
  return Element("Pressable", props, children, key=key)
324
358
 
325
359
 
360
+ def ErrorBoundary(
361
+ child: Optional[Element] = None,
362
+ *,
363
+ fallback: Optional[Any] = None,
364
+ key: Optional[str] = None,
365
+ ) -> Element:
366
+ """Catch render errors in *child* and display *fallback* instead.
367
+
368
+ *fallback* may be an ``Element`` or a callable that receives the
369
+ exception and returns an ``Element``::
370
+
371
+ pn.ErrorBoundary(
372
+ MyRiskyComponent(),
373
+ fallback=lambda err: pn.Text(f"Error: {err}"),
374
+ )
375
+ """
376
+ props: Dict[str, Any] = {}
377
+ if fallback is not None:
378
+ props["__fallback__"] = fallback
379
+ children = [child] if child is not None else []
380
+ return Element("__ErrorBoundary__", props, children, key=key)
381
+
382
+
326
383
  def FlatList(
327
384
  *,
328
385
  data: Optional[List[Any]] = None,
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