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.
- pythonnative/__init__.py +14 -3
- pythonnative/animated.py +420 -135
- pythonnative/cli/pn.py +450 -956
- pythonnative/components.py +519 -235
- pythonnative/events.py +210 -0
- pythonnative/gestures.py +875 -0
- pythonnative/layout.py +463 -149
- pythonnative/mutations.py +130 -0
- pythonnative/native_views/__init__.py +161 -97
- pythonnative/native_views/android.py +1050 -1124
- pythonnative/native_views/base.py +108 -18
- pythonnative/native_views/desktop.py +460 -417
- pythonnative/native_views/ios.py +1918 -1916
- pythonnative/project/__init__.py +68 -0
- pythonnative/project/android.py +504 -0
- pythonnative/project/builder.py +555 -0
- pythonnative/project/config.py +642 -0
- pythonnative/project/doctor.py +233 -0
- pythonnative/project/icons.py +247 -0
- pythonnative/project/ios.py +344 -0
- pythonnative/project/permissions.py +343 -0
- pythonnative/project/runtime_assets.py +272 -0
- pythonnative/reconciler.py +540 -470
- pythonnative/screen.py +5 -2
- pythonnative/sdk/_components.py +2 -2
- pythonnative/templates/android_template/app/build.gradle +2 -0
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/METADATA +10 -2
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/RECORD +32 -21
- pythonnative/templates/android_template/app/src/main/java/com/pythonnative/android_template/PNVirtualListView.java +0 -129
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.20.0.dist-info → pythonnative-0.22.0.dist-info}/licenses/LICENSE +0 -0
- {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)
|