pythonnative 0.5.0__py3-none-any.whl → 0.7.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 (28) hide show
  1. pythonnative/__init__.py +53 -15
  2. pythonnative/cli/pn.py +150 -30
  3. pythonnative/components.py +217 -107
  4. pythonnative/element.py +14 -8
  5. pythonnative/hooks.py +334 -0
  6. pythonnative/hot_reload.py +143 -0
  7. pythonnative/native_modules/__init__.py +19 -0
  8. pythonnative/native_modules/camera.py +105 -0
  9. pythonnative/native_modules/file_system.py +131 -0
  10. pythonnative/native_modules/location.py +61 -0
  11. pythonnative/native_modules/notifications.py +151 -0
  12. pythonnative/native_views.py +638 -34
  13. pythonnative/page.py +138 -171
  14. pythonnative/reconciler.py +153 -20
  15. pythonnative/style.py +135 -0
  16. pythonnative/templates/android_template/app/build.gradle +2 -7
  17. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt +2 -9
  18. pythonnative/templates/android_template/build.gradle +1 -1
  19. pythonnative/templates/ios_template/ios_template/ViewController.swift +7 -20
  20. {pythonnative-0.5.0.dist-info → pythonnative-0.7.0.dist-info}/METADATA +18 -38
  21. {pythonnative-0.5.0.dist-info → pythonnative-0.7.0.dist-info}/RECORD +25 -20
  22. pythonnative/collection_view.py +0 -0
  23. pythonnative/material_bottom_navigation_view.py +0 -0
  24. pythonnative/material_toolbar.py +0 -0
  25. {pythonnative-0.5.0.dist-info → pythonnative-0.7.0.dist-info}/WHEEL +0 -0
  26. {pythonnative-0.5.0.dist-info → pythonnative-0.7.0.dist-info}/entry_points.txt +0 -0
  27. {pythonnative-0.5.0.dist-info → pythonnative-0.7.0.dist-info}/licenses/LICENSE +0 -0
  28. {pythonnative-0.5.0.dist-info → pythonnative-0.7.0.dist-info}/top_level.txt +0 -0
pythonnative/hooks.py ADDED
@@ -0,0 +1,334 @@
1
+ """Hook primitives for function components.
2
+
3
+ Provides React-like hooks for managing state, effects, memoisation,
4
+ context, and navigation within function components decorated with
5
+ :func:`component`.
6
+
7
+ Usage::
8
+
9
+ import pythonnative as pn
10
+
11
+ @pn.component
12
+ def counter(initial=0):
13
+ count, set_count = pn.use_state(initial)
14
+ return pn.Column(
15
+ pn.Text(f"Count: {count}"),
16
+ pn.Button("+", on_click=lambda: set_count(count + 1)),
17
+ )
18
+ """
19
+
20
+ import inspect
21
+ import threading
22
+ from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar
23
+
24
+ from .element import Element
25
+
26
+ T = TypeVar("T")
27
+
28
+ _SENTINEL = object()
29
+
30
+ _hook_context: threading.local = threading.local()
31
+
32
+
33
+ # ======================================================================
34
+ # Hook state container
35
+ # ======================================================================
36
+
37
+
38
+ class HookState:
39
+ """Stores all hook data for a single function component instance."""
40
+
41
+ __slots__ = ("states", "effects", "memos", "refs", "hook_index", "_trigger_render")
42
+
43
+ def __init__(self) -> None:
44
+ self.states: List[Any] = []
45
+ self.effects: List[Tuple[Any, Any]] = []
46
+ self.memos: List[Tuple[Any, Any]] = []
47
+ self.refs: List[dict] = []
48
+ self.hook_index: int = 0
49
+ self._trigger_render: Optional[Callable[[], None]] = None
50
+
51
+ def reset_index(self) -> None:
52
+ self.hook_index = 0
53
+
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
59
+
60
+ def cleanup_all_effects(self) -> None:
61
+ """Run all outstanding cleanup functions (called on unmount)."""
62
+ for i, (deps, cleanup) in enumerate(self.effects):
63
+ if callable(cleanup):
64
+ try:
65
+ cleanup()
66
+ except Exception:
67
+ pass
68
+ self.effects[i] = (_SENTINEL, None)
69
+
70
+
71
+ # ======================================================================
72
+ # Thread-local context helpers
73
+ # ======================================================================
74
+
75
+
76
+ def _get_hook_state() -> Optional[HookState]:
77
+ return getattr(_hook_context, "current", None)
78
+
79
+
80
+ def _set_hook_state(state: Optional[HookState]) -> None:
81
+ _hook_context.current = state
82
+
83
+
84
+ def _deps_changed(prev: Any, current: Any) -> bool:
85
+ if prev is _SENTINEL:
86
+ return True
87
+ if prev is None or current is None:
88
+ return True
89
+ if len(prev) != len(current):
90
+ return True
91
+ return any(p is not c and p != c for p, c in zip(prev, current))
92
+
93
+
94
+ # ======================================================================
95
+ # Public hooks
96
+ # ======================================================================
97
+
98
+
99
+ def use_state(initial: Any = None) -> Tuple[Any, Callable]:
100
+ """Return ``(value, setter)`` for component-local state.
101
+
102
+ If *initial* is callable it is invoked once (lazy initialisation).
103
+ The setter accepts a value **or** a ``current -> new`` callable.
104
+ """
105
+ ctx = _get_hook_state()
106
+ if ctx is None:
107
+ raise RuntimeError("use_state must be called inside a @component function")
108
+
109
+ idx = ctx.hook_index
110
+ ctx.hook_index += 1
111
+
112
+ if idx >= len(ctx.states):
113
+ val = initial() if callable(initial) else initial
114
+ ctx.states.append(val)
115
+
116
+ current = ctx.states[idx]
117
+
118
+ def setter(new_value: Any) -> None:
119
+ if callable(new_value):
120
+ new_value = new_value(ctx.states[idx])
121
+ if ctx.states[idx] is not new_value and ctx.states[idx] != new_value:
122
+ ctx.states[idx] = new_value
123
+ if ctx._trigger_render:
124
+ ctx._trigger_render()
125
+
126
+ return current, setter
127
+
128
+
129
+ def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
130
+ """Schedule *effect* to run after render.
131
+
132
+ *deps* controls when the effect re-runs:
133
+
134
+ - ``None`` -> every render
135
+ - ``[]`` -> mount only
136
+ - ``[a, b]``-> when *a* or *b* change
137
+
138
+ *effect* may return a cleanup callable.
139
+ """
140
+ ctx = _get_hook_state()
141
+ if ctx is None:
142
+ raise RuntimeError("use_effect must be called inside a @component function")
143
+
144
+ idx = ctx.hook_index
145
+ ctx.hook_index += 1
146
+
147
+ if idx >= len(ctx.effects):
148
+ ctx.effects.append((_SENTINEL, None))
149
+
150
+ prev_deps, prev_cleanup = ctx.effects[idx]
151
+ 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)
161
+
162
+
163
+ def use_memo(factory: Callable[[], T], deps: list) -> T:
164
+ """Return a memoised value, recomputed only when *deps* change."""
165
+ ctx = _get_hook_state()
166
+ if ctx is None:
167
+ raise RuntimeError("use_memo must be called inside a @component function")
168
+
169
+ idx = ctx.hook_index
170
+ ctx.hook_index += 1
171
+
172
+ if idx >= len(ctx.memos):
173
+ value = factory()
174
+ ctx.memos.append((list(deps), value))
175
+ return value
176
+
177
+ prev_deps, prev_value = ctx.memos[idx]
178
+ if not _deps_changed(prev_deps, deps):
179
+ return prev_value
180
+
181
+ value = factory()
182
+ ctx.memos[idx] = (list(deps), value)
183
+ return value
184
+
185
+
186
+ def use_callback(callback: Callable, deps: list) -> Callable:
187
+ """Return a stable reference to *callback*, updated only when *deps* change."""
188
+ return use_memo(lambda: callback, deps)
189
+
190
+
191
+ def use_ref(initial: Any = None) -> dict:
192
+ """Return a mutable ref dict ``{"current": initial}`` that persists across renders."""
193
+ ctx = _get_hook_state()
194
+ if ctx is None:
195
+ raise RuntimeError("use_ref must be called inside a @component function")
196
+
197
+ idx = ctx.hook_index
198
+ ctx.hook_index += 1
199
+
200
+ if idx >= len(ctx.refs):
201
+ ref: dict = {"current": initial}
202
+ ctx.refs.append(ref)
203
+ return ref
204
+
205
+ return ctx.refs[idx]
206
+
207
+
208
+ # ======================================================================
209
+ # Context
210
+ # ======================================================================
211
+
212
+
213
+ class Context:
214
+ """A context object created by :func:`create_context`."""
215
+
216
+ def __init__(self, default: Any = None) -> None:
217
+ self.default = default
218
+ self._stack: List[Any] = []
219
+
220
+ def _current(self) -> Any:
221
+ return self._stack[-1] if self._stack else self.default
222
+
223
+
224
+ def create_context(default: Any = None) -> Context:
225
+ """Create a new context with an optional default value."""
226
+ return Context(default)
227
+
228
+
229
+ def use_context(context: Context) -> Any:
230
+ """Read the current value of *context* from the nearest ``Provider`` ancestor."""
231
+ ctx = _get_hook_state()
232
+ if ctx is None:
233
+ raise RuntimeError("use_context must be called inside a @component function")
234
+ return context._current()
235
+
236
+
237
+ # ======================================================================
238
+ # Provider element helper
239
+ # ======================================================================
240
+
241
+
242
+ def Provider(context: Context, value: Any, child: Element) -> Element:
243
+ """Create a context provider element.
244
+
245
+ All descendants of *child* will read *value* via ``use_context(context)``.
246
+ """
247
+ return Element("__Provider__", {"__context__": context, "__value__": value}, [child])
248
+
249
+
250
+ # ======================================================================
251
+ # Navigation
252
+ # ======================================================================
253
+
254
+ _NavigationContext: Context = create_context(None)
255
+
256
+
257
+ class NavigationHandle:
258
+ """Object returned by :func:`use_navigation` providing push/pop/get_args.
259
+
260
+ Navigates by component reference rather than string path, e.g.::
261
+
262
+ nav = pn.use_navigation()
263
+ nav.push(DetailScreen, args={"id": 42})
264
+ """
265
+
266
+ def __init__(self, host: Any) -> None:
267
+ self._host = host
268
+
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)
272
+
273
+ def pop(self) -> None:
274
+ """Navigate back to the previous screen."""
275
+ self._host._pop()
276
+
277
+ def get_args(self) -> Dict[str, Any]:
278
+ """Return arguments passed from the previous screen."""
279
+ return self._host._get_nav_args()
280
+
281
+
282
+ def use_navigation() -> NavigationHandle:
283
+ """Return a :class:`NavigationHandle` for the current screen.
284
+
285
+ Must be called inside a ``@component`` function rendered by PythonNative.
286
+ """
287
+ handle = use_context(_NavigationContext)
288
+ if handle is None:
289
+ raise RuntimeError(
290
+ "use_navigation() called outside a PythonNative page. "
291
+ "Ensure your component is rendered via create_page()."
292
+ )
293
+ return handle
294
+
295
+
296
+ # ======================================================================
297
+ # @component decorator
298
+ # ======================================================================
299
+
300
+
301
+ def component(func: Callable) -> Callable[..., Element]:
302
+ """Decorator that turns a Python function into a PythonNative component.
303
+
304
+ The decorated function can use hooks (``use_state``, ``use_effect``, etc.)
305
+ and returns an ``Element`` tree. Each call site creates an independent
306
+ component instance with its own hook state.
307
+ """
308
+ sig = inspect.signature(func)
309
+ positional_params = [
310
+ name
311
+ for name, p in sig.parameters.items()
312
+ if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
313
+ ]
314
+ has_var_positional = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values())
315
+
316
+ def wrapper(*args: Any, **kwargs: Any) -> Element:
317
+ props: dict = dict(kwargs)
318
+
319
+ if args:
320
+ if has_var_positional:
321
+ props["children"] = list(args)
322
+ else:
323
+ for i, arg in enumerate(args):
324
+ if i < len(positional_params):
325
+ props[positional_params[i]] = arg
326
+
327
+ key = props.pop("key", None)
328
+ return Element(func, props, [], key=key)
329
+
330
+ wrapper.__wrapped__ = func # noqa: B010
331
+ wrapper.__name__ = func.__name__
332
+ wrapper.__qualname__ = func.__qualname__
333
+ wrapper._pn_component = True # noqa: B010
334
+ return wrapper
@@ -0,0 +1,143 @@
1
+ """Hot-reload support for PythonNative development.
2
+
3
+ Host-side
4
+ ~~~~~~~~~
5
+ :class:`FileWatcher` monitors the ``app/`` directory for changes and
6
+ triggers a push-and-reload cycle via ``adb push`` (Android) or
7
+ ``simctl`` file copy (iOS).
8
+
9
+ Device-side
10
+ ~~~~~~~~~~~
11
+ :class:`ModuleReloader` reloads changed Python modules using
12
+ ``importlib.reload`` and triggers a page re-render.
13
+
14
+ Usage (host-side, integrated into ``pn run --hot-reload``)::
15
+
16
+ watcher = FileWatcher("app/", on_change=push_files)
17
+ watcher.start()
18
+ """
19
+
20
+ import importlib
21
+ import os
22
+ import sys
23
+ import threading
24
+ import time
25
+ from typing import Any, Callable, Dict, List, Optional
26
+
27
+ # ======================================================================
28
+ # Host-side file watcher
29
+ # ======================================================================
30
+
31
+
32
+ class FileWatcher:
33
+ """Watch a directory tree for ``.py`` file changes.
34
+
35
+ Parameters
36
+ ----------
37
+ watch_dir:
38
+ Directory to watch.
39
+ on_change:
40
+ Called with a list of changed file paths when modifications are detected.
41
+ interval:
42
+ Polling interval in seconds.
43
+ """
44
+
45
+ def __init__(self, watch_dir: str, on_change: Callable[[List[str]], None], interval: float = 1.0) -> None:
46
+ self.watch_dir = watch_dir
47
+ self.on_change = on_change
48
+ self.interval = interval
49
+ self._running = False
50
+ self._thread: Optional[threading.Thread] = None
51
+ self._mtimes: Dict[str, float] = {}
52
+
53
+ def start(self) -> None:
54
+ """Begin watching in a background daemon thread."""
55
+ self._running = True
56
+ self._scan()
57
+ self._thread = threading.Thread(target=self._loop, daemon=True)
58
+ self._thread.start()
59
+
60
+ def stop(self) -> None:
61
+ """Stop the watcher."""
62
+ self._running = False
63
+ if self._thread is not None:
64
+ self._thread.join(timeout=self.interval * 2)
65
+ self._thread = None
66
+
67
+ def _scan(self) -> List[str]:
68
+ changed: List[str] = []
69
+ current_files: set = set()
70
+
71
+ for root, _dirs, files in os.walk(self.watch_dir):
72
+ for fname in files:
73
+ if not fname.endswith(".py"):
74
+ continue
75
+ fpath = os.path.join(root, fname)
76
+ current_files.add(fpath)
77
+ try:
78
+ mtime = os.path.getmtime(fpath)
79
+ except OSError:
80
+ continue
81
+ if fpath in self._mtimes:
82
+ if mtime > self._mtimes[fpath]:
83
+ changed.append(fpath)
84
+ self._mtimes[fpath] = mtime
85
+
86
+ for old in list(self._mtimes):
87
+ if old not in current_files:
88
+ del self._mtimes[old]
89
+
90
+ return changed
91
+
92
+ def _loop(self) -> None:
93
+ while self._running:
94
+ time.sleep(self.interval)
95
+ changed = self._scan()
96
+ if changed:
97
+ try:
98
+ self.on_change(changed)
99
+ except Exception:
100
+ pass
101
+
102
+
103
+ # ======================================================================
104
+ # Device-side module reloader
105
+ # ======================================================================
106
+
107
+
108
+ class ModuleReloader:
109
+ """Reload changed Python modules on device and trigger re-render."""
110
+
111
+ @staticmethod
112
+ def reload_module(module_name: str) -> bool:
113
+ """Reload a single module by its dotted name.
114
+
115
+ Returns ``True`` if the module was found and reloaded successfully.
116
+ """
117
+ mod = sys.modules.get(module_name)
118
+ if mod is None:
119
+ return False
120
+ try:
121
+ importlib.reload(mod)
122
+ return True
123
+ except Exception:
124
+ return False
125
+
126
+ @staticmethod
127
+ def file_to_module(file_path: str, base_dir: str = "") -> Optional[str]:
128
+ """Convert a file path to a dotted module name relative to *base_dir*."""
129
+ rel = os.path.relpath(file_path, base_dir) if base_dir else file_path
130
+ if rel.endswith(".py"):
131
+ rel = rel[:-3]
132
+ parts = rel.replace(os.sep, ".").split(".")
133
+ if parts[-1] == "__init__":
134
+ parts = parts[:-1]
135
+ return ".".join(parts) if parts else None
136
+
137
+ @staticmethod
138
+ def reload_page(page_instance: Any) -> None:
139
+ """Force a page re-render after module reload."""
140
+ from .page import _re_render
141
+
142
+ if hasattr(page_instance, "_reconciler") and page_instance._reconciler is not None:
143
+ _re_render(page_instance)
@@ -0,0 +1,19 @@
1
+ """Native API modules for device capabilities.
2
+
3
+ Provides cross-platform Python interfaces to common device APIs:
4
+
5
+ - :mod:`~.camera` — photo capture and gallery picking
6
+ - :mod:`~.location` — GPS / location services
7
+ - :mod:`~.file_system` — app-scoped file I/O
8
+ - :mod:`~.notifications` — local push notifications
9
+
10
+ Each module auto-detects the platform and calls the appropriate native
11
+ APIs via Chaquopy (Android) or rubicon-objc (iOS).
12
+ """
13
+
14
+ from .camera import Camera
15
+ from .file_system import FileSystem
16
+ from .location import Location
17
+ from .notifications import Notifications
18
+
19
+ __all__ = ["Camera", "FileSystem", "Location", "Notifications"]
@@ -0,0 +1,105 @@
1
+ """Cross-platform camera access.
2
+
3
+ Provides methods for capturing photos and picking images from the gallery.
4
+ Uses Android's ``Intent``/``MediaStore`` or iOS's ``UIImagePickerController``.
5
+ """
6
+
7
+ from typing import Any, Callable, Optional
8
+
9
+ from ..utils import IS_ANDROID
10
+
11
+
12
+ class Camera:
13
+ """Camera and image picker interface.
14
+
15
+ All methods accept an ``on_result`` callback that receives the image
16
+ file path (or ``None`` on cancellation).
17
+ """
18
+
19
+ @staticmethod
20
+ def take_photo(on_result: Optional[Callable[[Optional[str]], None]] = None, **options: Any) -> None:
21
+ """Launch the device camera to capture a photo.
22
+
23
+ Parameters
24
+ ----------
25
+ on_result:
26
+ ``(path_or_none) -> None`` called with the saved image path,
27
+ or ``None`` if the user cancelled.
28
+ """
29
+ if IS_ANDROID:
30
+ Camera._android_take_photo(on_result, **options)
31
+ else:
32
+ Camera._ios_take_photo(on_result, **options)
33
+
34
+ @staticmethod
35
+ def pick_from_gallery(on_result: Optional[Callable[[Optional[str]], None]] = None, **options: Any) -> None:
36
+ """Open the system gallery picker.
37
+
38
+ Parameters
39
+ ----------
40
+ on_result:
41
+ ``(path_or_none) -> None`` called with the selected image path,
42
+ or ``None`` if the user cancelled.
43
+ """
44
+ if IS_ANDROID:
45
+ Camera._android_pick_gallery(on_result, **options)
46
+ else:
47
+ Camera._ios_pick_gallery(on_result, **options)
48
+
49
+ # -- Android implementations -----------------------------------------
50
+
51
+ @staticmethod
52
+ def _android_take_photo(on_result: Optional[Callable] = None, **options: Any) -> None:
53
+ try:
54
+ from java import jclass
55
+
56
+ Intent = jclass("android.content.Intent")
57
+ MediaStore = jclass("android.provider.MediaStore")
58
+ intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
59
+ from ..utils import get_android_context
60
+
61
+ ctx = get_android_context()
62
+ ctx.startActivity(intent)
63
+ except Exception:
64
+ if on_result:
65
+ on_result(None)
66
+
67
+ @staticmethod
68
+ def _android_pick_gallery(on_result: Optional[Callable] = None, **options: Any) -> None:
69
+ try:
70
+ from java import jclass
71
+
72
+ Intent = jclass("android.content.Intent")
73
+ intent = Intent(Intent.ACTION_PICK)
74
+ intent.setType("image/*")
75
+ from ..utils import get_android_context
76
+
77
+ ctx = get_android_context()
78
+ ctx.startActivity(intent)
79
+ except Exception:
80
+ if on_result:
81
+ on_result(None)
82
+
83
+ # -- iOS implementations ---------------------------------------------
84
+
85
+ @staticmethod
86
+ def _ios_take_photo(on_result: Optional[Callable] = None, **options: Any) -> None:
87
+ try:
88
+ from rubicon.objc import ObjCClass
89
+
90
+ picker = ObjCClass("UIImagePickerController").alloc().init()
91
+ picker.setSourceType_(1) # UIImagePickerControllerSourceTypeCamera
92
+ except Exception:
93
+ if on_result:
94
+ on_result(None)
95
+
96
+ @staticmethod
97
+ def _ios_pick_gallery(on_result: Optional[Callable] = None, **options: Any) -> None:
98
+ try:
99
+ from rubicon.objc import ObjCClass
100
+
101
+ picker = ObjCClass("UIImagePickerController").alloc().init()
102
+ picker.setSourceType_(0) # PhotoLibrary
103
+ except Exception:
104
+ if on_result:
105
+ on_result(None)