pythonnative 0.4.0__py3-none-any.whl → 0.6.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 +94 -66
- pythonnative/cli/pn.py +153 -24
- pythonnative/components.py +563 -0
- pythonnative/element.py +53 -0
- pythonnative/hooks.py +287 -0
- pythonnative/hot_reload.py +143 -0
- pythonnative/native_modules/__init__.py +19 -0
- pythonnative/native_modules/camera.py +105 -0
- pythonnative/native_modules/file_system.py +131 -0
- pythonnative/native_modules/location.py +61 -0
- pythonnative/native_modules/notifications.py +151 -0
- pythonnative/native_views.py +1334 -0
- pythonnative/page.py +320 -247
- pythonnative/reconciler.py +262 -0
- pythonnative/style.py +115 -0
- pythonnative/templates/android_template/app/build.gradle +2 -7
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PageFragment.kt +2 -1
- pythonnative/templates/android_template/build.gradle +1 -1
- pythonnative/utils.py +21 -29
- {pythonnative-0.4.0.dist-info → pythonnative-0.6.0.dist-info}/METADATA +20 -19
- {pythonnative-0.4.0.dist-info → pythonnative-0.6.0.dist-info}/RECORD +25 -40
- pythonnative/activity_indicator_view.py +0 -71
- pythonnative/button.py +0 -113
- pythonnative/collection_view.py +0 -0
- pythonnative/date_picker.py +0 -76
- pythonnative/image_view.py +0 -78
- pythonnative/label.py +0 -133
- pythonnative/list_view.py +0 -76
- pythonnative/material_activity_indicator_view.py +0 -71
- pythonnative/material_bottom_navigation_view.py +0 -0
- pythonnative/material_button.py +0 -69
- pythonnative/material_date_picker.py +0 -87
- pythonnative/material_progress_view.py +0 -70
- pythonnative/material_search_bar.py +0 -69
- pythonnative/material_switch.py +0 -69
- pythonnative/material_time_picker.py +0 -76
- pythonnative/material_toolbar.py +0 -0
- pythonnative/picker_view.py +0 -69
- pythonnative/progress_view.py +0 -70
- pythonnative/scroll_view.py +0 -101
- pythonnative/search_bar.py +0 -69
- pythonnative/stack_view.py +0 -199
- pythonnative/switch.py +0 -68
- pythonnative/text_field.py +0 -132
- pythonnative/text_view.py +0 -135
- pythonnative/time_picker.py +0 -77
- pythonnative/view.py +0 -173
- pythonnative/web_view.py +0 -60
- {pythonnative-0.4.0.dist-info → pythonnative-0.6.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.4.0.dist-info → pythonnative-0.6.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.4.0.dist-info → pythonnative-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.4.0.dist-info → pythonnative-0.6.0.dist-info}/top_level.txt +0 -0
pythonnative/hooks.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Hook primitives for function components.
|
|
2
|
+
|
|
3
|
+
Provides React-like hooks for managing state, effects, memoisation,
|
|
4
|
+
and context within function components decorated with :func:`component`.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
import pythonnative as pn
|
|
9
|
+
|
|
10
|
+
@pn.component
|
|
11
|
+
def counter(initial=0):
|
|
12
|
+
count, set_count = pn.use_state(initial)
|
|
13
|
+
return pn.Column(
|
|
14
|
+
pn.Text(f"Count: {count}"),
|
|
15
|
+
pn.Button("+", on_click=lambda: set_count(count + 1)),
|
|
16
|
+
)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import inspect
|
|
20
|
+
import threading
|
|
21
|
+
from typing import Any, Callable, List, Optional, Tuple, TypeVar
|
|
22
|
+
|
|
23
|
+
from .element import Element
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
|
|
27
|
+
_SENTINEL = object()
|
|
28
|
+
|
|
29
|
+
_hook_context: threading.local = threading.local()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ======================================================================
|
|
33
|
+
# Hook state container
|
|
34
|
+
# ======================================================================
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class HookState:
|
|
38
|
+
"""Stores all hook data for a single function component instance."""
|
|
39
|
+
|
|
40
|
+
__slots__ = ("states", "effects", "memos", "refs", "hook_index", "_trigger_render")
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
self.states: List[Any] = []
|
|
44
|
+
self.effects: List[Tuple[Any, Any]] = []
|
|
45
|
+
self.memos: List[Tuple[Any, Any]] = []
|
|
46
|
+
self.refs: List[dict] = []
|
|
47
|
+
self.hook_index: int = 0
|
|
48
|
+
self._trigger_render: Optional[Callable[[], None]] = None
|
|
49
|
+
|
|
50
|
+
def reset_index(self) -> None:
|
|
51
|
+
self.hook_index = 0
|
|
52
|
+
|
|
53
|
+
def run_pending_effects(self) -> None:
|
|
54
|
+
"""Execute effects whose deps changed during the last render pass."""
|
|
55
|
+
for i, (deps, cleanup) in enumerate(self.effects):
|
|
56
|
+
if deps is _SENTINEL:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
def cleanup_all_effects(self) -> None:
|
|
60
|
+
"""Run all outstanding cleanup functions (called on unmount)."""
|
|
61
|
+
for i, (deps, cleanup) in enumerate(self.effects):
|
|
62
|
+
if callable(cleanup):
|
|
63
|
+
try:
|
|
64
|
+
cleanup()
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
self.effects[i] = (_SENTINEL, None)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ======================================================================
|
|
71
|
+
# Thread-local context helpers
|
|
72
|
+
# ======================================================================
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _get_hook_state() -> Optional[HookState]:
|
|
76
|
+
return getattr(_hook_context, "current", None)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _set_hook_state(state: Optional[HookState]) -> None:
|
|
80
|
+
_hook_context.current = state
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _deps_changed(prev: Any, current: Any) -> bool:
|
|
84
|
+
if prev is _SENTINEL:
|
|
85
|
+
return True
|
|
86
|
+
if prev is None or current is None:
|
|
87
|
+
return True
|
|
88
|
+
if len(prev) != len(current):
|
|
89
|
+
return True
|
|
90
|
+
return any(p is not c and p != c for p, c in zip(prev, current))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ======================================================================
|
|
94
|
+
# Public hooks
|
|
95
|
+
# ======================================================================
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def use_state(initial: Any = None) -> Tuple[Any, Callable]:
|
|
99
|
+
"""Return ``(value, setter)`` for component-local state.
|
|
100
|
+
|
|
101
|
+
If *initial* is callable it is invoked once (lazy initialisation).
|
|
102
|
+
The setter accepts a value **or** a ``current -> new`` callable.
|
|
103
|
+
"""
|
|
104
|
+
ctx = _get_hook_state()
|
|
105
|
+
if ctx is None:
|
|
106
|
+
raise RuntimeError("use_state must be called inside a @component function")
|
|
107
|
+
|
|
108
|
+
idx = ctx.hook_index
|
|
109
|
+
ctx.hook_index += 1
|
|
110
|
+
|
|
111
|
+
if idx >= len(ctx.states):
|
|
112
|
+
val = initial() if callable(initial) else initial
|
|
113
|
+
ctx.states.append(val)
|
|
114
|
+
|
|
115
|
+
current = ctx.states[idx]
|
|
116
|
+
|
|
117
|
+
def setter(new_value: Any) -> None:
|
|
118
|
+
if callable(new_value):
|
|
119
|
+
new_value = new_value(ctx.states[idx])
|
|
120
|
+
if ctx.states[idx] is not new_value and ctx.states[idx] != new_value:
|
|
121
|
+
ctx.states[idx] = new_value
|
|
122
|
+
if ctx._trigger_render:
|
|
123
|
+
ctx._trigger_render()
|
|
124
|
+
|
|
125
|
+
return current, setter
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def use_effect(effect: Callable, deps: Optional[list] = None) -> None:
|
|
129
|
+
"""Schedule *effect* to run after render.
|
|
130
|
+
|
|
131
|
+
*deps* controls when the effect re-runs:
|
|
132
|
+
|
|
133
|
+
- ``None`` -> every render
|
|
134
|
+
- ``[]`` -> mount only
|
|
135
|
+
- ``[a, b]``-> when *a* or *b* change
|
|
136
|
+
|
|
137
|
+
*effect* may return a cleanup callable.
|
|
138
|
+
"""
|
|
139
|
+
ctx = _get_hook_state()
|
|
140
|
+
if ctx is None:
|
|
141
|
+
raise RuntimeError("use_effect must be called inside a @component function")
|
|
142
|
+
|
|
143
|
+
idx = ctx.hook_index
|
|
144
|
+
ctx.hook_index += 1
|
|
145
|
+
|
|
146
|
+
if idx >= len(ctx.effects):
|
|
147
|
+
ctx.effects.append((_SENTINEL, None))
|
|
148
|
+
|
|
149
|
+
prev_deps, prev_cleanup = ctx.effects[idx]
|
|
150
|
+
if _deps_changed(prev_deps, deps):
|
|
151
|
+
if callable(prev_cleanup):
|
|
152
|
+
try:
|
|
153
|
+
prev_cleanup()
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
cleanup = effect()
|
|
157
|
+
ctx.effects[idx] = (list(deps) if deps is not None else None, cleanup)
|
|
158
|
+
else:
|
|
159
|
+
ctx.effects[idx] = (prev_deps, prev_cleanup)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def use_memo(factory: Callable[[], T], deps: list) -> T:
|
|
163
|
+
"""Return a memoised value, recomputed only when *deps* change."""
|
|
164
|
+
ctx = _get_hook_state()
|
|
165
|
+
if ctx is None:
|
|
166
|
+
raise RuntimeError("use_memo must be called inside a @component function")
|
|
167
|
+
|
|
168
|
+
idx = ctx.hook_index
|
|
169
|
+
ctx.hook_index += 1
|
|
170
|
+
|
|
171
|
+
if idx >= len(ctx.memos):
|
|
172
|
+
value = factory()
|
|
173
|
+
ctx.memos.append((list(deps), value))
|
|
174
|
+
return value
|
|
175
|
+
|
|
176
|
+
prev_deps, prev_value = ctx.memos[idx]
|
|
177
|
+
if not _deps_changed(prev_deps, deps):
|
|
178
|
+
return prev_value
|
|
179
|
+
|
|
180
|
+
value = factory()
|
|
181
|
+
ctx.memos[idx] = (list(deps), value)
|
|
182
|
+
return value
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def use_callback(callback: Callable, deps: list) -> Callable:
|
|
186
|
+
"""Return a stable reference to *callback*, updated only when *deps* change."""
|
|
187
|
+
return use_memo(lambda: callback, deps)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def use_ref(initial: Any = None) -> dict:
|
|
191
|
+
"""Return a mutable ref dict ``{"current": initial}`` that persists across renders."""
|
|
192
|
+
ctx = _get_hook_state()
|
|
193
|
+
if ctx is None:
|
|
194
|
+
raise RuntimeError("use_ref must be called inside a @component function")
|
|
195
|
+
|
|
196
|
+
idx = ctx.hook_index
|
|
197
|
+
ctx.hook_index += 1
|
|
198
|
+
|
|
199
|
+
if idx >= len(ctx.refs):
|
|
200
|
+
ref: dict = {"current": initial}
|
|
201
|
+
ctx.refs.append(ref)
|
|
202
|
+
return ref
|
|
203
|
+
|
|
204
|
+
return ctx.refs[idx]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# ======================================================================
|
|
208
|
+
# Context
|
|
209
|
+
# ======================================================================
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class Context:
|
|
213
|
+
"""A context object created by :func:`create_context`."""
|
|
214
|
+
|
|
215
|
+
def __init__(self, default: Any = None) -> None:
|
|
216
|
+
self.default = default
|
|
217
|
+
self._stack: List[Any] = []
|
|
218
|
+
|
|
219
|
+
def _current(self) -> Any:
|
|
220
|
+
return self._stack[-1] if self._stack else self.default
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def create_context(default: Any = None) -> Context:
|
|
224
|
+
"""Create a new context with an optional default value."""
|
|
225
|
+
return Context(default)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def use_context(context: Context) -> Any:
|
|
229
|
+
"""Read the current value of *context* from the nearest ``Provider`` ancestor."""
|
|
230
|
+
ctx = _get_hook_state()
|
|
231
|
+
if ctx is None:
|
|
232
|
+
raise RuntimeError("use_context must be called inside a @component function")
|
|
233
|
+
return context._current()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ======================================================================
|
|
237
|
+
# Provider element helper
|
|
238
|
+
# ======================================================================
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def Provider(context: Context, value: Any, child: Element) -> Element:
|
|
242
|
+
"""Create a context provider element.
|
|
243
|
+
|
|
244
|
+
All descendants of *child* will read *value* via ``use_context(context)``.
|
|
245
|
+
"""
|
|
246
|
+
return Element("__Provider__", {"__context__": context, "__value__": value}, [child])
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ======================================================================
|
|
250
|
+
# @component decorator
|
|
251
|
+
# ======================================================================
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def component(func: Callable) -> Callable[..., Element]:
|
|
255
|
+
"""Decorator that turns a Python function into a PythonNative component.
|
|
256
|
+
|
|
257
|
+
The decorated function can use hooks (``use_state``, ``use_effect``, etc.)
|
|
258
|
+
and returns an ``Element`` tree. Each call site creates an independent
|
|
259
|
+
component instance with its own hook state.
|
|
260
|
+
"""
|
|
261
|
+
sig = inspect.signature(func)
|
|
262
|
+
positional_params = [
|
|
263
|
+
name
|
|
264
|
+
for name, p in sig.parameters.items()
|
|
265
|
+
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
266
|
+
]
|
|
267
|
+
has_var_positional = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values())
|
|
268
|
+
|
|
269
|
+
def wrapper(*args: Any, **kwargs: Any) -> Element:
|
|
270
|
+
props: dict = dict(kwargs)
|
|
271
|
+
|
|
272
|
+
if args:
|
|
273
|
+
if has_var_positional:
|
|
274
|
+
props["children"] = list(args)
|
|
275
|
+
else:
|
|
276
|
+
for i, arg in enumerate(args):
|
|
277
|
+
if i < len(positional_params):
|
|
278
|
+
props[positional_params[i]] = arg
|
|
279
|
+
|
|
280
|
+
key = props.pop("key", None)
|
|
281
|
+
return Element(func, props, [], key=key)
|
|
282
|
+
|
|
283
|
+
wrapper.__wrapped__ = func # noqa: B010
|
|
284
|
+
wrapper.__name__ = func.__name__
|
|
285
|
+
wrapper.__qualname__ = func.__qualname__
|
|
286
|
+
wrapper._pn_component = True # noqa: B010
|
|
287
|
+
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)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Cross-platform file system access.
|
|
2
|
+
|
|
3
|
+
Provides helpers for reading and writing files within the app's
|
|
4
|
+
sandboxed storage area.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from ..utils import IS_ANDROID
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FileSystem:
|
|
14
|
+
"""App-scoped file I/O."""
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def app_dir() -> str:
|
|
18
|
+
"""Return the app's writable data directory."""
|
|
19
|
+
if IS_ANDROID:
|
|
20
|
+
try:
|
|
21
|
+
from ..utils import get_android_context
|
|
22
|
+
|
|
23
|
+
return str(get_android_context().getFilesDir().getAbsolutePath())
|
|
24
|
+
except Exception:
|
|
25
|
+
pass
|
|
26
|
+
else:
|
|
27
|
+
try:
|
|
28
|
+
from rubicon.objc import ObjCClass
|
|
29
|
+
|
|
30
|
+
NSSearchPathForDirectoriesInDomains = ObjCClass(
|
|
31
|
+
"NSFileManager"
|
|
32
|
+
).defaultManager.URLsForDirectory_inDomains_
|
|
33
|
+
docs = NSSearchPathForDirectoriesInDomains(9, 1) # NSDocumentDirectory, NSUserDomainMask
|
|
34
|
+
if docs and docs.count > 0:
|
|
35
|
+
return str(docs.objectAtIndex_(0).path)
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
return os.path.join(os.path.expanduser("~"), ".pythonnative_data")
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def read_text(path: str, encoding: str = "utf-8") -> Optional[str]:
|
|
42
|
+
"""Read a text file relative to :meth:`app_dir` (or an absolute path)."""
|
|
43
|
+
full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path)
|
|
44
|
+
try:
|
|
45
|
+
with open(full, encoding=encoding) as f:
|
|
46
|
+
return f.read()
|
|
47
|
+
except OSError:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def write_text(path: str, content: str, encoding: str = "utf-8") -> bool:
|
|
52
|
+
"""Write a text file. Returns ``True`` on success."""
|
|
53
|
+
full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path)
|
|
54
|
+
try:
|
|
55
|
+
os.makedirs(os.path.dirname(full), exist_ok=True)
|
|
56
|
+
with open(full, "w", encoding=encoding) as f:
|
|
57
|
+
f.write(content)
|
|
58
|
+
return True
|
|
59
|
+
except OSError:
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def exists(path: str) -> bool:
|
|
64
|
+
"""Check if a file or directory exists."""
|
|
65
|
+
full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path)
|
|
66
|
+
return os.path.exists(full)
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def delete(path: str) -> bool:
|
|
70
|
+
"""Delete a file. Returns ``True`` on success."""
|
|
71
|
+
full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path)
|
|
72
|
+
try:
|
|
73
|
+
os.remove(full)
|
|
74
|
+
return True
|
|
75
|
+
except OSError:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def list_dir(path: str = "") -> list:
|
|
80
|
+
"""List entries in a directory."""
|
|
81
|
+
full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path)
|
|
82
|
+
try:
|
|
83
|
+
return os.listdir(full)
|
|
84
|
+
except OSError:
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def read_bytes(path: str) -> Optional[bytes]:
|
|
89
|
+
"""Read a binary file."""
|
|
90
|
+
full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path)
|
|
91
|
+
try:
|
|
92
|
+
with open(full, "rb") as f:
|
|
93
|
+
return f.read()
|
|
94
|
+
except OSError:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def write_bytes(path: str, data: bytes) -> bool:
|
|
99
|
+
"""Write a binary file. Returns ``True`` on success."""
|
|
100
|
+
full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path)
|
|
101
|
+
try:
|
|
102
|
+
os.makedirs(os.path.dirname(full), exist_ok=True)
|
|
103
|
+
with open(full, "wb") as f:
|
|
104
|
+
f.write(data)
|
|
105
|
+
return True
|
|
106
|
+
except OSError:
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
@staticmethod
|
|
110
|
+
def get_size(path: str) -> Optional[int]:
|
|
111
|
+
"""Return file size in bytes, or ``None`` if not found."""
|
|
112
|
+
full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path)
|
|
113
|
+
try:
|
|
114
|
+
return os.path.getsize(full)
|
|
115
|
+
except OSError:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def ensure_dir(path: str) -> bool:
|
|
120
|
+
"""Create a directory (and parents) if it doesn't exist."""
|
|
121
|
+
full = path if os.path.isabs(path) else os.path.join(FileSystem.app_dir(), path)
|
|
122
|
+
try:
|
|
123
|
+
os.makedirs(full, exist_ok=True)
|
|
124
|
+
return True
|
|
125
|
+
except OSError:
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def join(*parts: Any) -> str:
|
|
130
|
+
"""Join path components."""
|
|
131
|
+
return os.path.join(*[str(p) for p in parts])
|