pythonnative 0.25.0__py3-none-any.whl → 0.26.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 (44) hide show
  1. pythonnative/__init__.py +1 -1
  2. pythonnative/cli/pn.py +172 -12
  3. pythonnative/diagnostics.py +27 -0
  4. pythonnative/native_modules/app_state.py +2 -1
  5. pythonnative/native_modules/battery.py +3 -2
  6. pythonnative/native_modules/biometrics.py +2 -1
  7. pythonnative/native_modules/camera.py +16 -7
  8. pythonnative/native_modules/clipboard.py +3 -2
  9. pythonnative/native_modules/file_system.py +3 -2
  10. pythonnative/native_modules/haptics.py +7 -6
  11. pythonnative/native_modules/linking.py +62 -6
  12. pythonnative/native_modules/location.py +11 -8
  13. pythonnative/native_modules/net_info.py +2 -1
  14. pythonnative/native_modules/notifications.py +97 -6
  15. pythonnative/native_modules/permissions.py +3 -2
  16. pythonnative/native_modules/share.py +2 -1
  17. pythonnative/native_views/android.py +142 -122
  18. pythonnative/native_views/desktop.py +50 -49
  19. pythonnative/native_views/ios.py +228 -160
  20. pythonnative/project/android.py +15 -3
  21. pythonnative/project/builder.py +172 -70
  22. pythonnative/project/config.py +28 -6
  23. pythonnative/project/devices.py +291 -0
  24. pythonnative/project/doctor.py +5 -4
  25. pythonnative/project/ios.py +51 -77
  26. pythonnative/project/permissions.py +19 -1
  27. pythonnative/project/runtime_assets.py +78 -166
  28. pythonnative/templates/android_template/app/build.gradle +5 -0
  29. pythonnative/templates/android_template/app/src/main/AndroidManifest.xml +1 -0
  30. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/MainActivity.kt +88 -12
  31. pythonnative/templates/ios_template/ios_template/AppDelegate.swift +41 -17
  32. pythonnative/templates/ios_template/ios_template/BridgingHeader.h +14 -0
  33. pythonnative/templates/ios_template/ios_template/Info.plist +0 -2
  34. pythonnative/templates/ios_template/ios_template/PythonRuntime.swift +397 -0
  35. pythonnative/templates/ios_template/ios_template/SceneDelegate.swift +31 -20
  36. pythonnative/templates/ios_template/ios_template/ViewController.swift +77 -198
  37. pythonnative/templates/ios_template/ios_template.xcodeproj/project.pbxproj +72 -47
  38. {pythonnative-0.25.0.dist-info → pythonnative-0.26.0.dist-info}/METADATA +1 -1
  39. {pythonnative-0.25.0.dist-info → pythonnative-0.26.0.dist-info}/RECORD +43 -41
  40. pythonnative/templates/ios_template/ios_template/Base.lproj/Main.storyboard +0 -24
  41. {pythonnative-0.25.0.dist-info → pythonnative-0.26.0.dist-info}/WHEEL +0 -0
  42. {pythonnative-0.25.0.dist-info → pythonnative-0.26.0.dist-info}/entry_points.txt +0 -0
  43. {pythonnative-0.25.0.dist-info → pythonnative-0.26.0.dist-info}/licenses/LICENSE +0 -0
  44. {pythonnative-0.25.0.dist-info → pythonnative-0.26.0.dist-info}/top_level.txt +0 -0
@@ -4,27 +4,38 @@
4
4
  ``Intent(ACTION_VIEW)`` so a Python app can hand a URL (``https:``,
5
5
  ``mailto:``, ``tel:``, a custom scheme, …) to the OS.
6
6
 
7
- All methods are synchronous and return a ``bool`` describing whether
8
- the platform accepted the request. On desktop they return ``False``.
7
+ Outbound methods are synchronous and return a ``bool`` describing
8
+ whether the platform accepted the request. On desktop they return
9
+ ``False``.
10
+
11
+ Inbound deep links flow the other way: declare your schemes in
12
+ ``pythonnative.toml`` (``app.url_schemes``) and the native host calls
13
+ [`dispatch_url`][pythonnative.native_modules.linking.dispatch_url] for
14
+ every URL that opens the app. The URL that cold-started the app is kept
15
+ and returned by ``get_initial_url``; later URLs reach subscribers added
16
+ with ``add_listener``.
9
17
 
10
18
  Example:
11
19
  ```python
12
20
  import pythonnative as pn
13
21
 
14
- if pn.Linking.can_open_url("tel:+15551234567"):
15
- pn.Linking.open_url("tel:+15551234567")
22
+ if url := pn.Linking.get_initial_url():
23
+ navigate_to(url)
24
+
25
+ unsubscribe = pn.Linking.add_listener(navigate_to)
16
26
  ```
17
27
  """
18
28
 
19
29
  from __future__ import annotations
20
30
 
21
- from typing import Any, Optional
31
+ from typing import Any, Callable, List, Optional
22
32
 
23
33
  from ..utils import IS_ANDROID, IS_IOS
24
34
 
25
35
  # Populated by the native host when the app is launched from a deep
26
- # link; ``get_initial_url`` returns it once.
36
+ # link; ``get_initial_url`` returns it.
27
37
  _initial_url: Optional[str] = None
38
+ _url_listeners: List[Callable[[str], None]] = []
28
39
 
29
40
 
30
41
  class Linking:
@@ -62,6 +73,28 @@ class Linking:
62
73
  """Return the URL that launched the app, if any."""
63
74
  return _initial_url
64
75
 
76
+ @staticmethod
77
+ def add_listener(callback: Callable[[str], None]) -> Callable[[], None]:
78
+ """Subscribe to deep links that arrive while the app is running.
79
+
80
+ Args:
81
+ callback: Called with the full URL string for every inbound
82
+ deep link (including the initial one, which is
83
+ dispatched right after startup).
84
+
85
+ Returns:
86
+ A zero-arg function that unsubscribes when called.
87
+ """
88
+ _url_listeners.append(callback)
89
+
90
+ def _unsubscribe() -> None:
91
+ try:
92
+ _url_listeners.remove(callback)
93
+ except ValueError:
94
+ pass
95
+
96
+ return _unsubscribe
97
+
65
98
 
66
99
  def set_initial_url(url: Optional[str]) -> None:
67
100
  """Record the launch URL (called by the native host on cold start)."""
@@ -69,6 +102,29 @@ def set_initial_url(url: Optional[str]) -> None:
69
102
  _initial_url = url
70
103
 
71
104
 
105
+ def dispatch_url(url: str) -> None:
106
+ """Deliver an inbound deep link from the native host.
107
+
108
+ The first URL ever dispatched is also recorded as the initial URL
109
+ (a cold start from a deep link reaches Python only after the
110
+ interpreter boots, so the host can't call ``set_initial_url``
111
+ earlier than this).
112
+
113
+ Args:
114
+ url: The full URL string that opened the app.
115
+ """
116
+ global _initial_url
117
+ if _initial_url is None:
118
+ _initial_url = url
119
+ for listener in list(_url_listeners):
120
+ try:
121
+ listener(url)
122
+ except Exception as exc:
123
+ from .. import diagnostics
124
+
125
+ diagnostics.warn(f"Linking listener raised while handling {url!r}: {exc!r}")
126
+
127
+
72
128
  # ======================================================================
73
129
  # iOS
74
130
  # ======================================================================
@@ -27,6 +27,7 @@ from __future__ import annotations
27
27
  import asyncio
28
28
  from typing import Any, Callable, Dict, Optional, Tuple
29
29
 
30
+ from .. import diagnostics
30
31
  from ..runtime import resolve_future
31
32
  from ..utils import IS_ANDROID, IS_IOS
32
33
 
@@ -94,7 +95,7 @@ def _ios_get(on_result: Callable[[Optional[Coords]], None]) -> None:
94
95
  if self._manager is not None:
95
96
  self._manager.stopUpdatingLocation()
96
97
  except Exception:
97
- pass
98
+ diagnostics.swallowed("location._ios_get._PNLocationDelegate._finish")
98
99
  cb = self._callback
99
100
  self._callback = None
100
101
  _pending_delegates.pop(id(self), None)
@@ -102,7 +103,7 @@ def _ios_get(on_result: Callable[[Optional[Coords]], None]) -> None:
102
103
  try:
103
104
  cb(coords)
104
105
  except Exception:
105
- pass
106
+ diagnostics.swallowed("location._ios_get._PNLocationDelegate._finish")
106
107
 
107
108
  @objc_method
108
109
  def locationManager_didUpdateLocations_(self, manager: Any, locations: Any) -> None:
@@ -131,7 +132,9 @@ def _ios_get(on_result: Callable[[Optional[Coords]], None]) -> None:
131
132
  try:
132
133
  manager.startUpdatingLocation()
133
134
  except Exception:
134
- pass
135
+ diagnostics.swallowed(
136
+ "location._ios_get._PNLocationDelegate.locationManagerDidChangeAuthorization_"
137
+ )
135
138
 
136
139
  delegate = _PNLocationDelegate.new()
137
140
  delegate._callback = on_result
@@ -143,11 +146,11 @@ def _ios_get(on_result: Callable[[Optional[Coords]], None]) -> None:
143
146
  try:
144
147
  manager.requestWhenInUseAuthorization()
145
148
  except Exception:
146
- pass
149
+ diagnostics.swallowed("location._ios_get")
147
150
  try:
148
151
  manager.startUpdatingLocation()
149
152
  except Exception:
150
- pass
153
+ diagnostics.swallowed("location._ios_get")
151
154
  except Exception:
152
155
  on_result(None)
153
156
 
@@ -176,7 +179,7 @@ def _android_get(on_result: Callable[[Optional[Coords]], None]) -> None:
176
179
  on_result((float(loc.getLatitude()), float(loc.getLongitude())))
177
180
  return
178
181
  except Exception:
179
- pass
182
+ diagnostics.swallowed("location._android_get")
180
183
 
181
184
  LocationListener = jclass("android.location.LocationListener")
182
185
  delivered = [False]
@@ -188,12 +191,12 @@ def _android_get(on_result: Callable[[Optional[Coords]], None]) -> None:
188
191
  try:
189
192
  lm.removeUpdates(listener)
190
193
  except Exception:
191
- pass
194
+ diagnostics.swallowed("location._android_get._finish")
192
195
  _pending_delegates.pop(id(listener), None)
193
196
  try:
194
197
  on_result(coords)
195
198
  except Exception:
196
- pass
199
+ diagnostics.swallowed("location._android_get._finish")
197
200
 
198
201
  class _PNLocationListener(dynamic_proxy(LocationListener)): # type: ignore[misc]
199
202
  def onLocationChanged(self, loc: Any) -> None:
@@ -19,6 +19,7 @@ from __future__ import annotations
19
19
 
20
20
  from typing import Callable, Dict, List
21
21
 
22
+ from .. import diagnostics
22
23
  from ..hooks import use_effect, use_state
23
24
  from ..utils import IS_ANDROID, IS_IOS
24
25
 
@@ -67,7 +68,7 @@ def dispatch_net_info(state: NetInfoState) -> None:
67
68
  try:
68
69
  listener(dict(state))
69
70
  except Exception:
70
- pass
71
+ diagnostics.swallowed("net_info.dispatch_net_info")
71
72
 
72
73
 
73
74
  def use_net_info() -> NetInfoState:
@@ -1,4 +1,4 @@
1
- """Cross-platform local notifications.
1
+ """Cross-platform local notifications and remote push registration.
2
2
 
3
3
  Provides coroutines for requesting permission and scheduling /
4
4
  cancelling local push notifications. Uses Android's
@@ -9,6 +9,14 @@ scheduling. On Android 13+ the runtime permission should be requested
9
9
  through standard Android APIs (the manifest declaration is otherwise
10
10
  sufficient).
11
11
 
12
+ For remote (server-sent) pushes, enable the ``remote_notifications``
13
+ capability in ``pythonnative.toml`` and call
14
+ ``Notifications.get_device_token()`` to register with APNs and receive
15
+ the device token your server needs. Android remote push requires
16
+ Firebase Cloud Messaging, which needs a per-app ``google-services.json``
17
+ and is not wired up by the built-in module; ``get_device_token`` returns
18
+ ``None`` there.
19
+
12
20
  Example:
13
21
  ```python
14
22
  import pythonnative as pn
@@ -28,10 +36,11 @@ Example:
28
36
  from __future__ import annotations
29
37
 
30
38
  import asyncio
31
- from typing import Any, Dict
39
+ from typing import Any, Dict, List, Optional
32
40
 
33
- from ..runtime import resolve_future
34
- from ..utils import IS_ANDROID
41
+ from .. import diagnostics
42
+ from ..runtime import reject_future, resolve_future
43
+ from ..utils import IS_ANDROID, IS_IOS
35
44
 
36
45
  # Delayed Android deliveries in flight, keyed by identifier, so
37
46
  # ``cancel`` can abort a notification that hasn't posted yet. (iOS
@@ -110,6 +119,28 @@ class Notifications:
110
119
  return
111
120
  await asyncio.to_thread(_ios_cancel, identifier)
112
121
 
122
+ @staticmethod
123
+ async def get_device_token() -> Optional[str]:
124
+ """Register for remote notifications and return the device token.
125
+
126
+ On iOS this calls ``registerForRemoteNotifications`` and waits
127
+ for the APNs callback; the token is a lowercase hex string your
128
+ server passes to APNs. Requires the ``remote_notifications``
129
+ capability (which adds the ``aps-environment`` entitlement) and
130
+ a real device (the simulator has no APNs connection).
131
+
132
+ Returns:
133
+ The APNs token, or ``None`` on platforms without built-in
134
+ remote push support (Android and desktop).
135
+
136
+ Raises:
137
+ RuntimeError: If APNs registration fails, with the native
138
+ error description.
139
+ """
140
+ if not IS_IOS:
141
+ return None
142
+ return await _ios_get_device_token()
143
+
113
144
 
114
145
  # ======================================================================
115
146
  # Android implementation
@@ -171,7 +202,7 @@ def _android_cancel(identifier: str) -> None:
171
202
  nm = ctx.getSystemService(jclass("android.content.Context").NOTIFICATION_SERVICE)
172
203
  nm.cancel(abs(hash(identifier)) % (2**31))
173
204
  except Exception:
174
- pass
205
+ diagnostics.swallowed("notifications._android_cancel")
175
206
 
176
207
 
177
208
  # ======================================================================
@@ -235,4 +266,64 @@ def _ios_cancel(identifier: str) -> None:
235
266
  arr = NSArray.arrayWithObject_(identifier)
236
267
  center.removePendingNotificationRequestsWithIdentifiers_(arr)
237
268
  except Exception:
238
- pass
269
+ diagnostics.swallowed("notifications._ios_cancel")
270
+
271
+
272
+ # ----------------------------------------------------------------------
273
+ # Remote notifications (APNs)
274
+ # ----------------------------------------------------------------------
275
+ #
276
+ # The registration result arrives through UIApplicationDelegate
277
+ # callbacks in the native template, which forward here via
278
+ # dispatch_device_token / dispatch_device_token_error. The token is
279
+ # cached so later get_device_token() calls resolve immediately.
280
+
281
+ _device_token: Optional[str] = None
282
+ _device_token_error: Optional[str] = None
283
+ _token_waiters: List["asyncio.Future[str]"] = []
284
+
285
+
286
+ async def _ios_get_device_token() -> str:
287
+ if _device_token is not None:
288
+ return _device_token
289
+ if _device_token_error is not None:
290
+ raise RuntimeError(f"APNs registration failed: {_device_token_error}")
291
+
292
+ loop = asyncio.get_running_loop()
293
+ future: asyncio.Future[str] = loop.create_future()
294
+ _token_waiters.append(future)
295
+
296
+ def _register() -> None:
297
+ from rubicon.objc import ObjCClass
298
+
299
+ ObjCClass("UIApplication").sharedApplication.registerForRemoteNotifications()
300
+
301
+ try:
302
+ # registerForRemoteNotifications must run on the main thread.
303
+ from ..runtime import call_on_main_thread
304
+
305
+ call_on_main_thread(_register)
306
+ except Exception as exc:
307
+ _token_waiters.remove(future)
308
+ raise RuntimeError(f"Could not start APNs registration: {exc}") from exc
309
+ return await future
310
+
311
+
312
+ def dispatch_device_token(token: str) -> None:
313
+ """Deliver the APNs device token from the native host."""
314
+ global _device_token, _device_token_error
315
+ _device_token = token
316
+ _device_token_error = None
317
+ waiters, _token_waiters[:] = list(_token_waiters), []
318
+ for future in waiters:
319
+ resolve_future(future, token)
320
+
321
+
322
+ def dispatch_device_token_error(message: str) -> None:
323
+ """Deliver an APNs registration failure from the native host."""
324
+ global _device_token_error
325
+ _device_token_error = message
326
+ waiters, _token_waiters[:] = list(_token_waiters), []
327
+ error = RuntimeError(f"APNs registration failed: {message}")
328
+ for future in waiters:
329
+ reject_future(future, error)
@@ -21,6 +21,7 @@ from __future__ import annotations
21
21
  import asyncio
22
22
  from typing import Any, Callable, Dict
23
23
 
24
+ from .. import diagnostics
24
25
  from ..runtime import resolve_future
25
26
  from ..utils import IS_ANDROID, IS_IOS
26
27
 
@@ -141,7 +142,7 @@ def deliver_android_permission_result(request_code: int, permissions: Any, grant
141
142
  try:
142
143
  cb(status)
143
144
  except Exception:
144
- pass
145
+ diagnostics.swallowed("permissions.deliver_android_permission_result")
145
146
  return True
146
147
 
147
148
 
@@ -215,7 +216,7 @@ def _ios_request(permission: str, on_done: Callable[[PermissionStatus], None]) -
215
216
  center.requestAuthorizationWithOptions_completionHandler_(0x07, Block(_notif_done, None, bool, object))
216
217
  return
217
218
  except Exception:
218
- pass
219
+ diagnostics.swallowed("permissions._ios_request")
219
220
  # Fall back to reporting the current status for permissions whose
220
221
  # request flow isn't wired up natively (location needs a
221
222
  # CLLocationManager delegate; use the Location module's own
@@ -22,6 +22,7 @@ from __future__ import annotations
22
22
  import asyncio
23
23
  from typing import Any, Callable, Dict, Optional
24
24
 
25
+ from .. import diagnostics
25
26
  from ..runtime import resolve_future
26
27
  from ..utils import IS_ANDROID, IS_IOS
27
28
 
@@ -102,7 +103,7 @@ def _ios_share(
102
103
  try:
103
104
  on_done(bool(completed))
104
105
  except Exception:
105
- pass
106
+ diagnostics.swallowed("share._ios_share._completion")
106
107
 
107
108
  controller.setCompletionWithItemsHandler_(
108
109
  Block(_completion, None, ObjCClass("NSString"), Block, ObjCClass("NSArray"), ObjCClass("NSError"))