pythonnative 0.20.0__py3-none-any.whl → 0.22.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 (33) hide show
  1. pythonnative/__init__.py +14 -3
  2. pythonnative/animated.py +420 -135
  3. pythonnative/cli/pn.py +450 -956
  4. pythonnative/components.py +519 -235
  5. pythonnative/events.py +210 -0
  6. pythonnative/gestures.py +875 -0
  7. pythonnative/layout.py +463 -149
  8. pythonnative/mutations.py +130 -0
  9. pythonnative/native_views/__init__.py +161 -97
  10. pythonnative/native_views/android.py +1050 -1124
  11. pythonnative/native_views/base.py +108 -18
  12. pythonnative/native_views/desktop.py +460 -417
  13. pythonnative/native_views/ios.py +1918 -1916
  14. pythonnative/project/__init__.py +68 -0
  15. pythonnative/project/android.py +504 -0
  16. pythonnative/project/builder.py +555 -0
  17. pythonnative/project/config.py +642 -0
  18. pythonnative/project/doctor.py +233 -0
  19. pythonnative/project/icons.py +247 -0
  20. pythonnative/project/ios.py +344 -0
  21. pythonnative/project/permissions.py +343 -0
  22. pythonnative/project/runtime_assets.py +272 -0
  23. pythonnative/reconciler.py +540 -470
  24. pythonnative/screen.py +5 -2
  25. pythonnative/sdk/_components.py +2 -2
  26. pythonnative/templates/android_template/app/build.gradle +2 -0
  27. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/METADATA +10 -2
  28. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/RECORD +32 -21
  29. pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +0 -129
  30. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/WHEEL +0 -0
  31. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/entry_points.txt +0 -0
  32. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/licenses/LICENSE +0 -0
  33. {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,344 @@
1
+ """Config-driven iOS project configurator.
2
+
3
+ Adapts the bundled ``ios_template`` Xcode project to a specific
4
+ [`AppConfig`][pythonnative.project.config.AppConfig]. Unlike Android, the
5
+ iOS bundle identifier, version, team, and deployment target are *not*
6
+ baked into files — they're passed to ``xcodebuild`` as build-setting
7
+ overrides (see [`build_settings`][pythonnative.project.ios.build_settings]),
8
+ which avoids fragile ``project.pbxproj`` edits. This module owns the
9
+ parts that must live on disk:
10
+
11
+ - **Info.plist.** Display name, supported orientations, permission usage
12
+ descriptions (from ``[permissions]``), background modes, and any
13
+ ``[ios].extra_info_plist`` keys.
14
+ - **Branding.** The ``AppIcon`` asset and an optional ``Splash`` image
15
+ set plus a generated ``LaunchScreen`` storyboard.
16
+ - **Export options.** A plist for ``xcodebuild -exportArchive`` derived
17
+ from ``[ios.signing]``.
18
+
19
+ The embedded CPython runtime (``Python.framework``, the standard library,
20
+ ``rubicon-objc``, and user packages) is copied into the built ``.app``
21
+ *after* the build by [`embed_runtime`][pythonnative.project.ios.embed_runtime],
22
+ since PythonKit loads it at runtime rather than linking it.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import plistlib
28
+ import shutil
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+ from typing import Callable, Dict, List, Optional
32
+
33
+ from . import icons
34
+ from .config import AppConfig
35
+ from .runtime_assets import IOSRuntime
36
+
37
+ PROJECT_NAME = "ios_template"
38
+ """The fixed Xcode project/scheme/target name (kept stable on purpose)."""
39
+
40
+ PROJECT_FILE = "ios_template.xcodeproj"
41
+ """The ``.xcodeproj`` directory name within the staged template."""
42
+
43
+ APP_BUNDLE_NAME = "ios_template.app"
44
+ """The built ``.app`` bundle name (product name is left as the target name)."""
45
+
46
+ _ORIENTATIONS: Dict[str, List[str]] = {
47
+ "portrait": ["UIInterfaceOrientationPortrait"],
48
+ "landscape": [
49
+ "UIInterfaceOrientationLandscapeLeft",
50
+ "UIInterfaceOrientationLandscapeRight",
51
+ ],
52
+ "all": [
53
+ "UIInterfaceOrientationPortrait",
54
+ "UIInterfaceOrientationLandscapeLeft",
55
+ "UIInterfaceOrientationLandscapeRight",
56
+ ],
57
+ }
58
+
59
+ _EXPORT_METHODS = {
60
+ "development": "development",
61
+ "ad-hoc": "ad-hoc",
62
+ "app-store": "app-store",
63
+ "enterprise": "enterprise",
64
+ }
65
+
66
+ Logger = Callable[[str], None]
67
+
68
+
69
+ @dataclass
70
+ class IOSLayout:
71
+ """Resolved paths within a configured iOS project.
72
+
73
+ Attributes:
74
+ project_dir: The staged ``ios_template`` directory.
75
+ info_plist: Path to the app ``Info.plist``.
76
+ bundle_id: The resolved iOS bundle identifier.
77
+ """
78
+
79
+ project_dir: Path
80
+ info_plist: Path
81
+ bundle_id: str
82
+
83
+
84
+ def configure(project_dir: Path, config: AppConfig, *, log: Optional[Logger] = None) -> IOSLayout:
85
+ """Configure a staged iOS template for ``config``.
86
+
87
+ Args:
88
+ project_dir: The staged ``ios_template`` directory.
89
+ config: The validated app configuration.
90
+ log: Optional progress logger.
91
+
92
+ Returns:
93
+ An [`IOSLayout`][pythonnative.project.ios.IOSLayout].
94
+ """
95
+ emit: Logger = log or (lambda _message: None)
96
+ project_dir = Path(project_dir)
97
+ info_plist = project_dir / "ios_template" / "Info.plist"
98
+
99
+ configure_info_plist(info_plist, config)
100
+ _apply_branding(project_dir, config, emit)
101
+
102
+ emit(f"Configured iOS project ({config.bundle_id}).")
103
+ return IOSLayout(project_dir=project_dir, info_plist=info_plist, bundle_id=config.bundle_id)
104
+
105
+
106
+ # ======================================================================
107
+ # Info.plist
108
+ # ======================================================================
109
+
110
+
111
+ def configure_info_plist(info_plist: Path, config: AppConfig) -> None:
112
+ """Write display name, orientation, permissions, and extras to the plist.
113
+
114
+ Args:
115
+ info_plist: Path to the app ``Info.plist``.
116
+ config: The validated app configuration.
117
+ """
118
+ with open(info_plist, "rb") as handle:
119
+ plist = plistlib.load(handle)
120
+
121
+ plist["CFBundleDisplayName"] = config.display_name
122
+ plist["CFBundleName"] = config.name
123
+
124
+ orientations = _ORIENTATIONS.get(config.orientation, _ORIENTATIONS["portrait"])
125
+ plist["UISupportedInterfaceOrientations"] = list(orientations)
126
+ plist["UISupportedInterfaceOrientations~ipad"] = list(orientations)
127
+
128
+ resolved = config.resolved_permissions()
129
+ for key, reason in resolved.ios_usage_descriptions.items():
130
+ plist[key] = reason
131
+ if resolved.ios_background_modes:
132
+ plist["UIBackgroundModes"] = list(resolved.ios_background_modes)
133
+
134
+ if config.splash:
135
+ plist["UILaunchStoryboardName"] = "LaunchScreen"
136
+
137
+ for key, value in config.ios.extra_info_plist.items():
138
+ plist[key] = value
139
+
140
+ with open(info_plist, "wb") as handle:
141
+ plistlib.dump(plist, handle)
142
+
143
+
144
+ # ======================================================================
145
+ # Build settings / export options
146
+ # ======================================================================
147
+
148
+
149
+ def build_settings(config: AppConfig, *, for_archive: bool = False) -> List[str]:
150
+ """Return ``KEY=VALUE`` ``xcodebuild`` overrides for this config.
151
+
152
+ Args:
153
+ config: The validated app configuration.
154
+ for_archive: When ``True``, include signing settings appropriate
155
+ for a device archive.
156
+
157
+ Returns:
158
+ A list of ``"SETTING=value"`` strings to append to an
159
+ ``xcodebuild`` invocation.
160
+ """
161
+ orientations = _ORIENTATIONS.get(config.orientation, _ORIENTATIONS["portrait"])
162
+ orientation_value = " ".join(orientations)
163
+ settings = [
164
+ f"PRODUCT_BUNDLE_IDENTIFIER={config.bundle_id}",
165
+ f"MARKETING_VERSION={config.version}",
166
+ f"CURRENT_PROJECT_VERSION={config.build}",
167
+ f"IPHONEOS_DEPLOYMENT_TARGET={config.ios.deployment_target}",
168
+ f"INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone={orientation_value}",
169
+ f"INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad={orientation_value}",
170
+ ]
171
+ if config.ios.development_team:
172
+ settings.append(f"DEVELOPMENT_TEAM={config.ios.development_team}")
173
+ if for_archive and not config.ios.signing.provisioning_profile:
174
+ settings.append("CODE_SIGN_STYLE=Automatic")
175
+ return settings
176
+
177
+
178
+ def write_export_options(config: AppConfig, dest: Path) -> Path:
179
+ """Write an ``exportOptions.plist`` for ``xcodebuild -exportArchive``.
180
+
181
+ Args:
182
+ config: The validated app configuration.
183
+ dest: Destination plist path.
184
+
185
+ Returns:
186
+ ``dest``.
187
+ """
188
+ signing = config.ios.signing
189
+ options: Dict[str, object] = {
190
+ "method": _EXPORT_METHODS.get(signing.export_method, "development"),
191
+ "compileBitcode": False,
192
+ "stripSwiftSymbols": True,
193
+ }
194
+ if config.ios.development_team:
195
+ options["teamID"] = config.ios.development_team
196
+ if signing.provisioning_profile:
197
+ options["signingStyle"] = "manual"
198
+ options["provisioningProfiles"] = {config.bundle_id: signing.provisioning_profile}
199
+ else:
200
+ options["signingStyle"] = "automatic"
201
+
202
+ dest.parent.mkdir(parents=True, exist_ok=True)
203
+ with open(dest, "wb") as handle:
204
+ plistlib.dump(options, handle)
205
+ return dest
206
+
207
+
208
+ # ======================================================================
209
+ # Branding
210
+ # ======================================================================
211
+
212
+
213
+ def _apply_branding(project_dir: Path, config: AppConfig, emit: Logger) -> None:
214
+ assets_dir = project_dir / "ios_template" / "Assets.xcassets"
215
+ icon_path = config.resolve_path(config.icon) if config.icon else None
216
+ splash_path = config.resolve_path(config.splash) if config.splash else None
217
+
218
+ if icon_path and icons.has_source(icon_path):
219
+ if icons.generate_ios_icons(icon_path, assets_dir / "AppIcon.appiconset"):
220
+ emit("Generated iOS app icon.")
221
+ else:
222
+ emit("Skipping iOS icon: Pillow not installed (pip install 'pythonnative[build]').")
223
+
224
+ if splash_path and icons.has_source(splash_path):
225
+ if icons.generate_ios_splash(splash_path, assets_dir / "Splash.imageset"):
226
+ _write_launch_storyboard(project_dir)
227
+ emit("Configured iOS splash screen.")
228
+ else:
229
+ emit("Skipping iOS splash: Pillow not installed (pip install 'pythonnative[build]').")
230
+
231
+
232
+ def _write_launch_storyboard(project_dir: Path) -> None:
233
+ """Overwrite ``LaunchScreen.storyboard`` with a centered splash image."""
234
+ storyboard = project_dir / "ios_template" / "Base.lproj" / "LaunchScreen.storyboard"
235
+ storyboard.write_text(_LAUNCH_STORYBOARD, encoding="utf-8")
236
+
237
+
238
+ # An edge-pinned image view (aspect-fit) over a white background. The
239
+ # image references the generated "Splash" asset set.
240
+ _LAUNCH_STORYBOARD = """<?xml version="1.0" encoding="UTF-8"?>
241
+ <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="22155" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
242
+ <dependencies>
243
+ <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22131"/>
244
+ <capability name="Safe area layout guides" minToolsVersion="9.0"/>
245
+ <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
246
+ </dependencies>
247
+ <scenes>
248
+ <scene sceneID="EHf-IW-A2E">
249
+ <objects>
250
+ <viewController id="01J-lp-oVM" sceneMemberID="viewController">
251
+ <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
252
+ <rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
253
+ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
254
+ <subviews>
255
+ <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" image="Splash" translatesAutoresizingMaskIntoConstraints="NO" id="Spl-aS-h00"/>
256
+ </subviews>
257
+ <viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
258
+ <color key="backgroundColor" systemColor="systemBackgroundColor"/>
259
+ <constraints>
260
+ <constraint firstItem="Spl-aS-h00" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="lea-Sp-001"/>
261
+ <constraint firstItem="Spl-aS-h00" firstAttribute="trailing" secondItem="Ze5-6b-2t3" secondAttribute="trailing" id="tra-Sp-002"/>
262
+ <constraint firstItem="Spl-aS-h00" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="top-Sp-003"/>
263
+ <constraint firstItem="Spl-aS-h00" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="bot-Sp-004"/>
264
+ </constraints>
265
+ </view>
266
+ </viewController>
267
+ <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
268
+ </objects>
269
+ <point key="canvasLocation" x="53" y="375"/>
270
+ </scene>
271
+ </scenes>
272
+ <resources>
273
+ <image name="Splash" width="393" height="393"/>
274
+ </resources>
275
+ </document>
276
+ """
277
+
278
+
279
+ # ======================================================================
280
+ # Runtime embedding (post-build)
281
+ # ======================================================================
282
+
283
+
284
+ def embed_runtime(
285
+ app_bundle: Path,
286
+ *,
287
+ runtime: IOSRuntime,
288
+ destination: str,
289
+ python_sources: Path,
290
+ site_packages: Optional[Path] = None,
291
+ log: Optional[Logger] = None,
292
+ ) -> None:
293
+ """Copy the embedded CPython runtime and app code into a built ``.app``.
294
+
295
+ PythonKit loads CPython at runtime, so the framework, standard
296
+ library, app sources, and pure-Python site packages are copied into
297
+ the bundle after the build/archive completes.
298
+
299
+ Args:
300
+ app_bundle: Path to the built ``.app`` directory.
301
+ runtime: The resolved iOS runtime paths.
302
+ destination: ``"simulator"`` or ``"device"`` (selects the slice).
303
+ python_sources: Directory whose children (``app``,
304
+ ``pythonnative``) are copied to the bundle root.
305
+ site_packages: Optional directory of installed pure-Python
306
+ packages (``rubicon-objc`` and user requirements) copied to
307
+ ``platform-site``.
308
+ log: Optional progress logger.
309
+ """
310
+ emit: Logger = log or (lambda _message: None)
311
+ app_bundle = Path(app_bundle)
312
+
313
+ framework = runtime.framework_for(destination)
314
+ if framework and framework.is_dir():
315
+ frameworks_dir = app_bundle / "Frameworks"
316
+ frameworks_dir.mkdir(parents=True, exist_ok=True)
317
+ shutil.copytree(framework, frameworks_dir / "Python.framework", dirs_exist_ok=True)
318
+ emit("Embedded Python.framework.")
319
+ else:
320
+ emit(f"Warning: no Python.framework for {destination}; the app may not start.")
321
+
322
+ stdlib = runtime.stdlib_for(destination)
323
+ if stdlib and stdlib.is_dir():
324
+ shutil.copytree(stdlib, app_bundle / "python-stdlib", dirs_exist_ok=True)
325
+ emit("Embedded Python standard library.")
326
+
327
+ if python_sources.is_dir():
328
+ for child in python_sources.iterdir():
329
+ target = app_bundle / child.name
330
+ if child.is_dir():
331
+ shutil.copytree(child, target, dirs_exist_ok=True)
332
+ else:
333
+ shutil.copy2(child, target)
334
+
335
+ if site_packages and site_packages.is_dir():
336
+ platform_site = app_bundle / "platform-site"
337
+ platform_site.mkdir(parents=True, exist_ok=True)
338
+ for child in site_packages.iterdir():
339
+ target = platform_site / child.name
340
+ if child.is_dir():
341
+ shutil.copytree(child, target, dirs_exist_ok=True)
342
+ else:
343
+ shutil.copy2(child, target)
344
+ emit("Embedded pure-Python site packages.")
@@ -0,0 +1,343 @@
1
+ """Cross-platform permission/capability catalog.
2
+
3
+ PythonNative apps declare the device capabilities they need in a single,
4
+ platform-agnostic ``[permissions]`` table in ``pythonnative.toml``:
5
+
6
+ ```toml
7
+ [permissions]
8
+ camera = "Scan receipts with your camera."
9
+ location_when_in_use = "Show nearby stores."
10
+ notifications = true
11
+ face_id = "Unlock the app with Face ID."
12
+ ```
13
+
14
+ This module maps each high-level capability to the concrete native
15
+ artifacts it requires:
16
+
17
+ - iOS: one or more ``Info.plist`` *usage description* keys (the strings
18
+ shown in the system permission prompt), plus optional
19
+ ``UIBackgroundModes`` entries.
20
+ - Android: one or more ``<uses-permission>`` entries in
21
+ ``AndroidManifest.xml``.
22
+
23
+ A capability's value may be either a string (used verbatim as the iOS
24
+ usage description) or ``true`` (use the capability's
25
+ [`default_reason`][pythonnative.project.permissions.Capability]). A value
26
+ of ``false`` disables the capability — useful for switching one off
27
+ without deleting the line.
28
+
29
+ The catalog is the single source of truth shared by the iOS and Android
30
+ configurators and by ``pn doctor``; adding a capability here is all that
31
+ is required to make it declarable.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ from dataclasses import dataclass, field
37
+ from typing import Dict, List, Mapping, Tuple, Union
38
+
39
+ PermissionValue = Union[bool, str]
40
+ """Type of a value in the ``[permissions]`` table: a reason string or a bool."""
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Capability:
45
+ """A declarable device capability and its native requirements.
46
+
47
+ Attributes:
48
+ key: The capability name as written in ``[permissions]``
49
+ (e.g., ``"camera"``).
50
+ summary: Human-readable description, shown by ``pn doctor`` and
51
+ the docs.
52
+ ios_usage_keys: ``Info.plist`` keys that receive the usage
53
+ description string (e.g., ``"NSCameraUsageDescription"``).
54
+ android_permissions: Fully-qualified Android permission names
55
+ (e.g., ``"android.permission.CAMERA"``).
56
+ ios_background_modes: ``UIBackgroundModes`` values to add
57
+ (e.g., ``"location"``).
58
+ default_reason: Fallback usage description used when the
59
+ capability is declared as ``true`` instead of a string.
60
+ needs_reason: Whether iOS requires a usage description for this
61
+ capability. When ``False`` (e.g., notifications), declaring
62
+ the capability as ``true`` is sufficient and no string is
63
+ needed.
64
+ """
65
+
66
+ key: str
67
+ summary: str
68
+ ios_usage_keys: Tuple[str, ...] = ()
69
+ android_permissions: Tuple[str, ...] = ()
70
+ ios_background_modes: Tuple[str, ...] = ()
71
+ default_reason: str = ""
72
+ needs_reason: bool = True
73
+
74
+
75
+ def _cap(*args: object, **kwargs: object) -> Capability:
76
+ return Capability(*args, **kwargs) # type: ignore[arg-type]
77
+
78
+
79
+ # ======================================================================
80
+ # The catalog
81
+ # ======================================================================
82
+
83
+ CAPABILITIES: Dict[str, Capability] = {
84
+ c.key: c
85
+ for c in [
86
+ Capability(
87
+ key="camera",
88
+ summary="Capture photos and video with the device camera.",
89
+ ios_usage_keys=("NSCameraUsageDescription",),
90
+ android_permissions=("android.permission.CAMERA",),
91
+ default_reason="This app uses the camera.",
92
+ ),
93
+ Capability(
94
+ key="microphone",
95
+ summary="Record audio from the microphone.",
96
+ ios_usage_keys=("NSMicrophoneUsageDescription",),
97
+ android_permissions=("android.permission.RECORD_AUDIO",),
98
+ default_reason="This app uses the microphone.",
99
+ ),
100
+ Capability(
101
+ key="photo_library",
102
+ summary="Read photos and videos from the photo library.",
103
+ ios_usage_keys=("NSPhotoLibraryUsageDescription",),
104
+ android_permissions=(
105
+ "android.permission.READ_MEDIA_IMAGES",
106
+ "android.permission.READ_MEDIA_VIDEO",
107
+ ),
108
+ default_reason="This app accesses your photo library.",
109
+ ),
110
+ Capability(
111
+ key="photo_library_add",
112
+ summary="Save photos and videos to the photo library.",
113
+ ios_usage_keys=("NSPhotoLibraryAddUsageDescription",),
114
+ android_permissions=(),
115
+ default_reason="This app saves photos to your library.",
116
+ ),
117
+ Capability(
118
+ key="location_when_in_use",
119
+ summary="Access location while the app is in the foreground.",
120
+ ios_usage_keys=("NSLocationWhenInUseUsageDescription",),
121
+ android_permissions=(
122
+ "android.permission.ACCESS_FINE_LOCATION",
123
+ "android.permission.ACCESS_COARSE_LOCATION",
124
+ ),
125
+ default_reason="This app uses your location.",
126
+ ),
127
+ Capability(
128
+ key="location_always",
129
+ summary="Access location in the foreground and background.",
130
+ ios_usage_keys=(
131
+ "NSLocationAlwaysAndWhenInUseUsageDescription",
132
+ "NSLocationWhenInUseUsageDescription",
133
+ ),
134
+ android_permissions=(
135
+ "android.permission.ACCESS_FINE_LOCATION",
136
+ "android.permission.ACCESS_COARSE_LOCATION",
137
+ "android.permission.ACCESS_BACKGROUND_LOCATION",
138
+ ),
139
+ ios_background_modes=("location",),
140
+ default_reason="This app uses your location, even in the background.",
141
+ ),
142
+ Capability(
143
+ key="contacts",
144
+ summary="Read the device address book.",
145
+ ios_usage_keys=("NSContactsUsageDescription",),
146
+ android_permissions=("android.permission.READ_CONTACTS",),
147
+ default_reason="This app accesses your contacts.",
148
+ ),
149
+ Capability(
150
+ key="calendars",
151
+ summary="Read and write calendar events.",
152
+ ios_usage_keys=("NSCalendarsUsageDescription",),
153
+ android_permissions=(
154
+ "android.permission.READ_CALENDAR",
155
+ "android.permission.WRITE_CALENDAR",
156
+ ),
157
+ default_reason="This app accesses your calendar.",
158
+ ),
159
+ Capability(
160
+ key="reminders",
161
+ summary="Read and write reminders (iOS only).",
162
+ ios_usage_keys=("NSRemindersUsageDescription",),
163
+ android_permissions=(),
164
+ default_reason="This app accesses your reminders.",
165
+ ),
166
+ Capability(
167
+ key="motion",
168
+ summary="Access motion and fitness / activity data.",
169
+ ios_usage_keys=("NSMotionUsageDescription",),
170
+ android_permissions=("android.permission.ACTIVITY_RECOGNITION",),
171
+ default_reason="This app uses motion and fitness data.",
172
+ ),
173
+ Capability(
174
+ key="face_id",
175
+ summary="Authenticate with Face ID / biometrics.",
176
+ ios_usage_keys=("NSFaceIDUsageDescription",),
177
+ android_permissions=("android.permission.USE_BIOMETRIC",),
178
+ default_reason="This app uses Face ID to authenticate you.",
179
+ ),
180
+ Capability(
181
+ key="bluetooth",
182
+ summary="Communicate with nearby Bluetooth devices.",
183
+ ios_usage_keys=("NSBluetoothAlwaysUsageDescription",),
184
+ android_permissions=(
185
+ "android.permission.BLUETOOTH_CONNECT",
186
+ "android.permission.BLUETOOTH_SCAN",
187
+ ),
188
+ default_reason="This app connects to Bluetooth devices.",
189
+ ),
190
+ Capability(
191
+ key="speech_recognition",
192
+ summary="Perform speech recognition (iOS only).",
193
+ ios_usage_keys=("NSSpeechRecognitionUsageDescription",),
194
+ android_permissions=(),
195
+ default_reason="This app uses speech recognition.",
196
+ ),
197
+ Capability(
198
+ key="notifications",
199
+ summary="Show local and push notifications.",
200
+ ios_usage_keys=(),
201
+ android_permissions=("android.permission.POST_NOTIFICATIONS",),
202
+ needs_reason=False,
203
+ ),
204
+ Capability(
205
+ key="vibration",
206
+ summary="Trigger haptic feedback / vibration.",
207
+ ios_usage_keys=(),
208
+ android_permissions=("android.permission.VIBRATE",),
209
+ needs_reason=False,
210
+ ),
211
+ Capability(
212
+ key="background_audio",
213
+ summary="Continue playing audio in the background (iOS).",
214
+ ios_usage_keys=(),
215
+ ios_background_modes=("audio",),
216
+ android_permissions=("android.permission.FOREGROUND_SERVICE",),
217
+ needs_reason=False,
218
+ ),
219
+ Capability(
220
+ key="background_fetch",
221
+ summary="Perform periodic background fetches (iOS).",
222
+ ios_usage_keys=(),
223
+ ios_background_modes=("fetch",),
224
+ android_permissions=(),
225
+ needs_reason=False,
226
+ ),
227
+ ]
228
+ }
229
+ """Mapping of capability key → [`Capability`][pythonnative.project.permissions.Capability]."""
230
+
231
+
232
+ # Permissions every app gets, regardless of declared capabilities. Both are
233
+ # "normal" (install-time) Android permissions that never prompt the user, and
234
+ # nearly every PythonNative app needs the network (``fetch``, ``use_query``,
235
+ # ``NetInfo``, remote images).
236
+ BASE_ANDROID_PERMISSIONS: Tuple[str, ...] = (
237
+ "android.permission.INTERNET",
238
+ "android.permission.ACCESS_NETWORK_STATE",
239
+ )
240
+ """Android permissions added to every app (network access)."""
241
+
242
+
243
+ @dataclass
244
+ class ResolvedPermissions:
245
+ """The native permission artifacts for a resolved capability set.
246
+
247
+ Attributes:
248
+ ios_usage_descriptions: ``Info.plist`` usage-description keys
249
+ mapped to their reason strings.
250
+ ios_background_modes: Ordered, de-duplicated ``UIBackgroundModes``
251
+ values.
252
+ android_permissions: Ordered, de-duplicated Android permission
253
+ names (including the always-on base set).
254
+ """
255
+
256
+ ios_usage_descriptions: Dict[str, str] = field(default_factory=dict)
257
+ ios_background_modes: List[str] = field(default_factory=list)
258
+ android_permissions: List[str] = field(default_factory=list)
259
+
260
+
261
+ def unknown_capabilities(keys: object) -> List[str]:
262
+ """Return any declared capability keys that aren't in the catalog.
263
+
264
+ Args:
265
+ keys: An iterable of capability key strings.
266
+
267
+ Returns:
268
+ The subset of ``keys`` not present in
269
+ [`CAPABILITIES`][pythonnative.project.permissions.CAPABILITIES],
270
+ in input order.
271
+ """
272
+ result: List[str] = []
273
+ for key in keys:
274
+ if key not in CAPABILITIES:
275
+ result.append(str(key))
276
+ return result
277
+
278
+
279
+ def resolve_permissions(
280
+ permissions: Mapping[str, PermissionValue],
281
+ *,
282
+ extra_android_permissions: object = (),
283
+ ) -> ResolvedPermissions:
284
+ """Resolve a declared capability map into native permission artifacts.
285
+
286
+ Args:
287
+ permissions: The ``[permissions]`` table — capability key to a
288
+ reason string or boolean. ``false``/``None`` values are
289
+ skipped (capability disabled).
290
+ extra_android_permissions: Additional raw Android permission
291
+ names (e.g., from ``[android].permissions``) to append.
292
+
293
+ Returns:
294
+ A [`ResolvedPermissions`][pythonnative.project.permissions.ResolvedPermissions]
295
+ with iOS usage descriptions, iOS background modes, and the full
296
+ ordered Android permission list (base set first).
297
+
298
+ Raises:
299
+ ValueError: If an unknown capability key is present. Validate
300
+ earlier with
301
+ [`unknown_capabilities`][pythonnative.project.permissions.unknown_capabilities]
302
+ for a friendlier error.
303
+ """
304
+ resolved = ResolvedPermissions()
305
+ android: List[str] = list(BASE_ANDROID_PERMISSIONS)
306
+ background: List[str] = []
307
+
308
+ for key, value in permissions.items():
309
+ if key not in CAPABILITIES:
310
+ raise ValueError(f"Unknown capability: {key!r}")
311
+ if value is False or value is None:
312
+ continue
313
+ cap = CAPABILITIES[key]
314
+ reason = value if isinstance(value, str) and value.strip() else cap.default_reason
315
+ for plist_key in cap.ios_usage_keys:
316
+ # First declaration wins for a shared key (e.g. location_*).
317
+ resolved.ios_usage_descriptions.setdefault(plist_key, reason)
318
+ for mode in cap.ios_background_modes:
319
+ if mode not in background:
320
+ background.append(mode)
321
+ for perm in cap.android_permissions:
322
+ if perm not in android:
323
+ android.append(perm)
324
+
325
+ for perm in extra_android_permissions:
326
+ if perm and perm not in android:
327
+ android.append(str(perm))
328
+
329
+ resolved.ios_background_modes = background
330
+ resolved.android_permissions = android
331
+ return resolved
332
+
333
+
334
+ def describe_catalog() -> str:
335
+ """Return a multi-line, human-readable listing of all capabilities.
336
+
337
+ Used by ``pn doctor`` / docs tooling to show what can be declared.
338
+ """
339
+ lines: List[str] = []
340
+ for key in sorted(CAPABILITIES):
341
+ cap = CAPABILITIES[key]
342
+ lines.append(f" {key:<22} {cap.summary}")
343
+ return "\n".join(lines)