pythonnative 0.19.0__py3-none-any.whl → 0.21.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 +1 -1
- pythonnative/cli/pn.py +450 -956
- pythonnative/hooks.py +30 -6
- pythonnative/native_views/android.py +22 -2
- 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 +285 -3
- pythonnative/screen.py +23 -27
- {pythonnative-0.19.0.dist-info → pythonnative-0.21.0.dist-info}/METADATA +7 -2
- {pythonnative-0.19.0.dist-info → pythonnative-0.21.0.dist-info}/RECORD +21 -12
- {pythonnative-0.19.0.dist-info → pythonnative-0.21.0.dist-info}/WHEEL +0 -0
- {pythonnative-0.19.0.dist-info → pythonnative-0.21.0.dist-info}/entry_points.txt +0 -0
- {pythonnative-0.19.0.dist-info → pythonnative-0.21.0.dist-info}/licenses/LICENSE +0 -0
- {pythonnative-0.19.0.dist-info → pythonnative-0.21.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
"""Typed model and loader for ``pythonnative.toml``.
|
|
2
|
+
|
|
3
|
+
Every PythonNative project is described by a single ``pythonnative.toml``
|
|
4
|
+
at its root. This module parses and validates that file into a typed
|
|
5
|
+
[`AppConfig`][pythonnative.project.config.AppConfig] tree that the rest
|
|
6
|
+
of the build system consumes. Keeping all parsing/validation here means
|
|
7
|
+
the configurators ([`ios`][pythonnative.project.ios],
|
|
8
|
+
[`android`][pythonnative.project.android]) and the
|
|
9
|
+
[`builder`][pythonnative.project.builder] can assume a fully-validated,
|
|
10
|
+
defaulted config object.
|
|
11
|
+
|
|
12
|
+
The canonical schema:
|
|
13
|
+
|
|
14
|
+
```toml
|
|
15
|
+
[app]
|
|
16
|
+
id = "com.example.myapp" # reverse-DNS app/bundle id (required)
|
|
17
|
+
name = "myapp" # short project name (required)
|
|
18
|
+
display_name = "My App" # home-screen label (defaults to name)
|
|
19
|
+
version = "1.0.0" # marketing version
|
|
20
|
+
build = 1 # integer build number
|
|
21
|
+
python_version = "3.11" # embedded CPython version
|
|
22
|
+
orientation = "portrait" # portrait | landscape | all
|
|
23
|
+
entry_point = "app/main.py" # module whose `App` is mounted
|
|
24
|
+
|
|
25
|
+
[permissions] # see pythonnative.project.permissions
|
|
26
|
+
camera = "Scan receipts."
|
|
27
|
+
notifications = true
|
|
28
|
+
|
|
29
|
+
[assets]
|
|
30
|
+
icon = "assets/icon.png" # 1024x1024 source icon
|
|
31
|
+
splash = "assets/splash.png" # splash/launch image
|
|
32
|
+
|
|
33
|
+
[requirements]
|
|
34
|
+
packages = ["humanize", "httpx"]
|
|
35
|
+
|
|
36
|
+
[ios]
|
|
37
|
+
deployment_target = "13.0"
|
|
38
|
+
development_team = "ABCDE12345"
|
|
39
|
+
bundle_id = "com.example.myapp" # optional override of app.id
|
|
40
|
+
|
|
41
|
+
[ios.signing]
|
|
42
|
+
export_method = "app-store" # development | ad-hoc | app-store | enterprise
|
|
43
|
+
provisioning_profile = "My App Distribution"
|
|
44
|
+
|
|
45
|
+
[android]
|
|
46
|
+
min_sdk = 24
|
|
47
|
+
target_sdk = 34
|
|
48
|
+
abi_filters = ["arm64-v8a", "x86_64"]
|
|
49
|
+
|
|
50
|
+
[android.signing]
|
|
51
|
+
keystore = "release.keystore"
|
|
52
|
+
key_alias = "myapp"
|
|
53
|
+
```
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import re
|
|
59
|
+
from dataclasses import dataclass, field
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
62
|
+
|
|
63
|
+
from . import permissions as _permissions
|
|
64
|
+
|
|
65
|
+
try: # Python 3.11+
|
|
66
|
+
import tomllib as _toml
|
|
67
|
+
except ModuleNotFoundError: # Python 3.10
|
|
68
|
+
import tomli as _toml
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
CONFIG_FILENAME = "pythonnative.toml"
|
|
72
|
+
"""The fixed config filename looked up at the project root."""
|
|
73
|
+
|
|
74
|
+
SUPPORTED_PYTHON_VERSIONS = ("3.10", "3.11", "3.12")
|
|
75
|
+
"""CPython versions accepted in ``app.python_version``."""
|
|
76
|
+
|
|
77
|
+
IOS_SUPPORTED_PYTHON_VERSION = "3.11"
|
|
78
|
+
"""The CPython version with a pinned, verified iOS support build."""
|
|
79
|
+
|
|
80
|
+
VALID_ORIENTATIONS = ("portrait", "landscape", "all")
|
|
81
|
+
"""Accepted values for ``app.orientation``."""
|
|
82
|
+
|
|
83
|
+
VALID_IOS_EXPORT_METHODS = ("development", "ad-hoc", "app-store", "enterprise")
|
|
84
|
+
"""Accepted values for ``[ios.signing].export_method``."""
|
|
85
|
+
|
|
86
|
+
# Reserved Java keywords can't appear as Android package segments.
|
|
87
|
+
_JAVA_KEYWORDS = frozenset("""
|
|
88
|
+
abstract assert boolean break byte case catch char class const continue default do double else enum
|
|
89
|
+
extends final finally float for goto if implements import instanceof int interface long native new
|
|
90
|
+
package private protected public return short static strictfp super switch synchronized this throw
|
|
91
|
+
throws transient try void volatile while true false null
|
|
92
|
+
""".split())
|
|
93
|
+
|
|
94
|
+
_APP_ID_SEGMENT = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
95
|
+
_VERSION_RE = re.compile(r"^\d+(\.\d+){0,3}$")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ConfigError(Exception):
|
|
99
|
+
"""Raised when ``pythonnative.toml`` is missing, malformed, or invalid.
|
|
100
|
+
|
|
101
|
+
The message is intended to be shown directly to the user by the CLI,
|
|
102
|
+
so it should be specific and actionable.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ======================================================================
|
|
107
|
+
# Sub-models
|
|
108
|
+
# ======================================================================
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class IOSSigning:
|
|
113
|
+
"""iOS code-signing / export configuration (``[ios.signing]``).
|
|
114
|
+
|
|
115
|
+
Attributes:
|
|
116
|
+
export_method: How the archive is exported — one of
|
|
117
|
+
``development``, ``ad-hoc``, ``app-store``, ``enterprise``.
|
|
118
|
+
provisioning_profile: Optional provisioning profile name or UUID
|
|
119
|
+
for manual signing.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
export_method: str = "development"
|
|
123
|
+
provisioning_profile: Optional[str] = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class IOSConfig:
|
|
128
|
+
"""iOS-specific settings (``[ios]``).
|
|
129
|
+
|
|
130
|
+
Attributes:
|
|
131
|
+
deployment_target: Minimum iOS version (e.g., ``"13.0"``).
|
|
132
|
+
development_team: Apple Developer Team ID used for signing.
|
|
133
|
+
bundle_id: Optional override of ``app.id`` for the iOS bundle
|
|
134
|
+
identifier.
|
|
135
|
+
extra_info_plist: Arbitrary additional ``Info.plist`` key/values
|
|
136
|
+
merged verbatim into the generated plist.
|
|
137
|
+
signing: Nested [`IOSSigning`][pythonnative.project.config.IOSSigning].
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
deployment_target: str = "13.0"
|
|
141
|
+
development_team: Optional[str] = None
|
|
142
|
+
bundle_id: Optional[str] = None
|
|
143
|
+
extra_info_plist: Dict[str, Any] = field(default_factory=dict)
|
|
144
|
+
signing: IOSSigning = field(default_factory=IOSSigning)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class AndroidSigning:
|
|
149
|
+
"""Android release signing configuration (``[android.signing]``).
|
|
150
|
+
|
|
151
|
+
Passwords are never stored in the config; they're read from the
|
|
152
|
+
environment at build time (see ``store_password_env`` /
|
|
153
|
+
``key_password_env``).
|
|
154
|
+
|
|
155
|
+
Attributes:
|
|
156
|
+
keystore: Path to the release keystore, relative to the project
|
|
157
|
+
root.
|
|
158
|
+
key_alias: Key alias within the keystore.
|
|
159
|
+
store_password_env: Environment variable holding the keystore
|
|
160
|
+
password.
|
|
161
|
+
key_password_env: Environment variable holding the key password.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
keystore: Optional[str] = None
|
|
165
|
+
key_alias: Optional[str] = None
|
|
166
|
+
store_password_env: str = "PN_ANDROID_KEYSTORE_PASSWORD"
|
|
167
|
+
key_password_env: str = "PN_ANDROID_KEY_PASSWORD"
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def is_configured(self) -> bool:
|
|
171
|
+
"""Whether both a keystore path and key alias are present."""
|
|
172
|
+
return bool(self.keystore and self.key_alias)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class AndroidConfig:
|
|
177
|
+
"""Android-specific settings (``[android]``).
|
|
178
|
+
|
|
179
|
+
Attributes:
|
|
180
|
+
min_sdk: Minimum supported Android API level.
|
|
181
|
+
target_sdk: Target Android API level.
|
|
182
|
+
compile_sdk: SDK level the project compiles against.
|
|
183
|
+
application_id: Optional override of ``app.id`` for the Android
|
|
184
|
+
application id (and package).
|
|
185
|
+
abi_filters: Native ABIs to include (e.g., ``"arm64-v8a"``).
|
|
186
|
+
permissions: Extra raw Android permission strings appended to the
|
|
187
|
+
ones derived from ``[permissions]``.
|
|
188
|
+
signing: Nested
|
|
189
|
+
[`AndroidSigning`][pythonnative.project.config.AndroidSigning].
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
min_sdk: int = 24
|
|
193
|
+
target_sdk: int = 34
|
|
194
|
+
compile_sdk: int = 34
|
|
195
|
+
application_id: Optional[str] = None
|
|
196
|
+
abi_filters: List[str] = field(default_factory=lambda: ["armeabi-v7a", "arm64-v8a", "x86", "x86_64"])
|
|
197
|
+
permissions: List[str] = field(default_factory=list)
|
|
198
|
+
signing: AndroidSigning = field(default_factory=AndroidSigning)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ======================================================================
|
|
202
|
+
# Root model
|
|
203
|
+
# ======================================================================
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass
|
|
207
|
+
class AppConfig:
|
|
208
|
+
"""A fully-parsed, validated ``pythonnative.toml``.
|
|
209
|
+
|
|
210
|
+
Use [`load`][pythonnative.project.config.AppConfig.load] to read it
|
|
211
|
+
from a project directory, or
|
|
212
|
+
[`from_dict`][pythonnative.project.config.AppConfig.from_dict] to
|
|
213
|
+
build one from an already-parsed mapping (e.g., in tests).
|
|
214
|
+
|
|
215
|
+
Attributes:
|
|
216
|
+
app_id: Reverse-DNS identifier (``app.id``); the default bundle
|
|
217
|
+
id / application id for both platforms.
|
|
218
|
+
name: Short project name (``app.name``).
|
|
219
|
+
display_name: Home-screen label (``app.display_name``).
|
|
220
|
+
version: Marketing version string (``app.version``).
|
|
221
|
+
build: Integer build number (``app.build``).
|
|
222
|
+
python_version: Embedded CPython version (``app.python_version``).
|
|
223
|
+
orientation: ``"portrait"``, ``"landscape"``, or ``"all"``.
|
|
224
|
+
entry_point: Path to the entry module (``app.entry_point``).
|
|
225
|
+
permissions: The declared capability map (``[permissions]``).
|
|
226
|
+
icon: Optional source icon path (``[assets].icon``).
|
|
227
|
+
splash: Optional splash image path (``[assets].splash``).
|
|
228
|
+
requirements: Third-party pip packages (``[requirements].packages``).
|
|
229
|
+
ios: Nested [`IOSConfig`][pythonnative.project.config.IOSConfig].
|
|
230
|
+
android: Nested
|
|
231
|
+
[`AndroidConfig`][pythonnative.project.config.AndroidConfig].
|
|
232
|
+
project_root: Absolute path to the directory containing the config.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
app_id: str
|
|
236
|
+
name: str
|
|
237
|
+
display_name: str
|
|
238
|
+
version: str = "1.0.0"
|
|
239
|
+
build: int = 1
|
|
240
|
+
python_version: str = "3.11"
|
|
241
|
+
orientation: str = "portrait"
|
|
242
|
+
entry_point: str = "app/main.py"
|
|
243
|
+
permissions: Dict[str, _permissions.PermissionValue] = field(default_factory=dict)
|
|
244
|
+
icon: Optional[str] = None
|
|
245
|
+
splash: Optional[str] = None
|
|
246
|
+
requirements: List[str] = field(default_factory=list)
|
|
247
|
+
ios: IOSConfig = field(default_factory=IOSConfig)
|
|
248
|
+
android: AndroidConfig = field(default_factory=AndroidConfig)
|
|
249
|
+
project_root: Path = field(default_factory=Path.cwd)
|
|
250
|
+
|
|
251
|
+
# -- Derived values -------------------------------------------------
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def application_id(self) -> str:
|
|
255
|
+
"""The Android application id (``[android].application_id`` or ``app.id``)."""
|
|
256
|
+
return self.android.application_id or self.app_id
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def bundle_id(self) -> str:
|
|
260
|
+
"""The iOS bundle identifier (``[ios].bundle_id`` or ``app.id``)."""
|
|
261
|
+
return self.ios.bundle_id or self.app_id
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def entry_module(self) -> str:
|
|
265
|
+
"""The dotted import path for ``entry_point`` (e.g., ``"app.main"``)."""
|
|
266
|
+
return entrypoint_to_module(self.entry_point)
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def android_package_path(self) -> str:
|
|
270
|
+
"""The Android source directory path for ``application_id``.
|
|
271
|
+
|
|
272
|
+
``"com.example.myapp"`` → ``"com/example/myapp"``.
|
|
273
|
+
"""
|
|
274
|
+
return self.application_id.replace(".", "/")
|
|
275
|
+
|
|
276
|
+
def resolve_path(self, relative: str) -> Path:
|
|
277
|
+
"""Resolve a config-relative path against the project root.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
relative: A path string from the config (e.g., an icon path).
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
An absolute [`Path`][pathlib.Path].
|
|
284
|
+
"""
|
|
285
|
+
candidate = Path(relative)
|
|
286
|
+
if candidate.is_absolute():
|
|
287
|
+
return candidate
|
|
288
|
+
return (self.project_root / candidate).resolve()
|
|
289
|
+
|
|
290
|
+
def resolved_permissions(self) -> _permissions.ResolvedPermissions:
|
|
291
|
+
"""Resolve declared capabilities into native permission artifacts.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
A
|
|
295
|
+
[`ResolvedPermissions`][pythonnative.project.permissions.ResolvedPermissions]
|
|
296
|
+
combining ``[permissions]`` with any extra
|
|
297
|
+
``[android].permissions``.
|
|
298
|
+
"""
|
|
299
|
+
return _permissions.resolve_permissions(
|
|
300
|
+
self.permissions,
|
|
301
|
+
extra_android_permissions=self.android.permissions,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# -- Construction ---------------------------------------------------
|
|
305
|
+
|
|
306
|
+
@classmethod
|
|
307
|
+
def load(cls, project_root: Optional[Path] = None) -> "AppConfig":
|
|
308
|
+
"""Load and validate ``pythonnative.toml`` from a project directory.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
project_root: Directory containing the config. Defaults to the
|
|
312
|
+
current working directory.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
A validated [`AppConfig`][pythonnative.project.config.AppConfig].
|
|
316
|
+
|
|
317
|
+
Raises:
|
|
318
|
+
ConfigError: If the file is missing, isn't valid TOML, or
|
|
319
|
+
fails validation.
|
|
320
|
+
"""
|
|
321
|
+
root = Path(project_root) if project_root is not None else Path.cwd()
|
|
322
|
+
config_path = root / CONFIG_FILENAME
|
|
323
|
+
if not config_path.is_file():
|
|
324
|
+
legacy = root / "pythonnative.json"
|
|
325
|
+
hint = ""
|
|
326
|
+
if legacy.is_file():
|
|
327
|
+
hint = (
|
|
328
|
+
"\nFound a legacy 'pythonnative.json'. PythonNative now uses "
|
|
329
|
+
"'pythonnative.toml'.\nRun 'pn init --force' to scaffold one, then "
|
|
330
|
+
"port your settings over."
|
|
331
|
+
)
|
|
332
|
+
raise ConfigError(f"No {CONFIG_FILENAME} found in {root}.{hint}")
|
|
333
|
+
try:
|
|
334
|
+
with open(config_path, "rb") as handle:
|
|
335
|
+
data = _toml.load(handle)
|
|
336
|
+
except _toml.TOMLDecodeError as exc:
|
|
337
|
+
raise ConfigError(f"Could not parse {CONFIG_FILENAME}: {exc}") from exc
|
|
338
|
+
return cls.from_dict(data, project_root=root)
|
|
339
|
+
|
|
340
|
+
@classmethod
|
|
341
|
+
def from_dict(cls, data: Mapping[str, Any], *, project_root: Optional[Path] = None) -> "AppConfig":
|
|
342
|
+
"""Build an [`AppConfig`][pythonnative.project.config.AppConfig] from a mapping.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
data: A parsed TOML mapping (top-level tables: ``app``,
|
|
346
|
+
``permissions``, ``assets``, ``requirements``, ``ios``,
|
|
347
|
+
``android``).
|
|
348
|
+
project_root: Directory the config came from, used to resolve
|
|
349
|
+
relative paths.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
A validated config.
|
|
353
|
+
|
|
354
|
+
Raises:
|
|
355
|
+
ConfigError: On any structural or value validation failure.
|
|
356
|
+
"""
|
|
357
|
+
root = Path(project_root) if project_root is not None else Path.cwd()
|
|
358
|
+
app = _expect_table(data, "app")
|
|
359
|
+
|
|
360
|
+
config = cls(
|
|
361
|
+
app_id=_require_str(app, "id", "app"),
|
|
362
|
+
name=_require_str(app, "name", "app"),
|
|
363
|
+
display_name=_opt_str(app, "display_name") or _require_str(app, "name", "app"),
|
|
364
|
+
version=_opt_str(app, "version") or "1.0.0",
|
|
365
|
+
build=_opt_int(app, "build", default=1),
|
|
366
|
+
python_version=_opt_str(app, "python_version") or "3.11",
|
|
367
|
+
orientation=(_opt_str(app, "orientation") or "portrait").lower(),
|
|
368
|
+
entry_point=_opt_str(app, "entry_point") or "app/main.py",
|
|
369
|
+
permissions=dict(_expect_table(data, "permissions", optional=True)),
|
|
370
|
+
ios=_parse_ios(_expect_table(data, "ios", optional=True)),
|
|
371
|
+
android=_parse_android(_expect_table(data, "android", optional=True)),
|
|
372
|
+
project_root=root,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
assets = _expect_table(data, "assets", optional=True)
|
|
376
|
+
config.icon = _opt_str(assets, "icon")
|
|
377
|
+
config.splash = _opt_str(assets, "splash")
|
|
378
|
+
|
|
379
|
+
requirements = _expect_table(data, "requirements", optional=True)
|
|
380
|
+
config.requirements = _opt_str_list(requirements, "packages")
|
|
381
|
+
|
|
382
|
+
config.validate()
|
|
383
|
+
return config
|
|
384
|
+
|
|
385
|
+
# -- Validation -----------------------------------------------------
|
|
386
|
+
|
|
387
|
+
def validate(self) -> None:
|
|
388
|
+
"""Validate all fields, raising [`ConfigError`][pythonnative.project.config.ConfigError] on first problem."""
|
|
389
|
+
_validate_app_id(self.app_id)
|
|
390
|
+
if self.android.application_id:
|
|
391
|
+
_validate_app_id(self.android.application_id)
|
|
392
|
+
if self.ios.bundle_id:
|
|
393
|
+
_validate_app_id(self.ios.bundle_id, allow_hyphen=True)
|
|
394
|
+
|
|
395
|
+
if not self.name.strip():
|
|
396
|
+
raise ConfigError("app.name must not be empty.")
|
|
397
|
+
|
|
398
|
+
if not _VERSION_RE.match(self.version):
|
|
399
|
+
raise ConfigError(f"app.version {self.version!r} must be a dotted number like '1.0.0'.")
|
|
400
|
+
|
|
401
|
+
if self.build < 1:
|
|
402
|
+
raise ConfigError("app.build must be a positive integer.")
|
|
403
|
+
|
|
404
|
+
if self.python_version not in SUPPORTED_PYTHON_VERSIONS:
|
|
405
|
+
supported = ", ".join(SUPPORTED_PYTHON_VERSIONS)
|
|
406
|
+
raise ConfigError(
|
|
407
|
+
f"app.python_version {self.python_version!r} is unsupported (choose one of: {supported})."
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
if self.orientation not in VALID_ORIENTATIONS:
|
|
411
|
+
valid = ", ".join(VALID_ORIENTATIONS)
|
|
412
|
+
raise ConfigError(f"app.orientation {self.orientation!r} is invalid (choose one of: {valid}).")
|
|
413
|
+
|
|
414
|
+
if not self.entry_point.strip():
|
|
415
|
+
raise ConfigError("app.entry_point must not be empty.")
|
|
416
|
+
|
|
417
|
+
unknown = _permissions.unknown_capabilities(self.permissions.keys())
|
|
418
|
+
if unknown:
|
|
419
|
+
known = ", ".join(sorted(_permissions.CAPABILITIES))
|
|
420
|
+
raise ConfigError("Unknown permission(s): " + ", ".join(unknown) + f".\nValid capabilities: {known}")
|
|
421
|
+
|
|
422
|
+
_validate_requirements(self.requirements)
|
|
423
|
+
|
|
424
|
+
if self.ios.signing.export_method not in VALID_IOS_EXPORT_METHODS:
|
|
425
|
+
valid = ", ".join(VALID_IOS_EXPORT_METHODS)
|
|
426
|
+
raise ConfigError(
|
|
427
|
+
f"[ios.signing].export_method {self.ios.signing.export_method!r} is invalid (choose one of: {valid})."
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
if self.android.min_sdk < 21:
|
|
431
|
+
raise ConfigError("[android].min_sdk must be at least 21 (Chaquopy requirement).")
|
|
432
|
+
if self.android.target_sdk < self.android.min_sdk:
|
|
433
|
+
raise ConfigError("[android].target_sdk must be >= min_sdk.")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# ======================================================================
|
|
437
|
+
# Parsing helpers
|
|
438
|
+
# ======================================================================
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _parse_ios(table: Mapping[str, Any]) -> IOSConfig:
|
|
442
|
+
signing_table = _expect_table(table, "signing", optional=True, parent="ios")
|
|
443
|
+
extra = table.get("extra_info_plist") or {}
|
|
444
|
+
if extra and not isinstance(extra, dict):
|
|
445
|
+
raise ConfigError("[ios].extra_info_plist must be a table.")
|
|
446
|
+
return IOSConfig(
|
|
447
|
+
deployment_target=_opt_str(table, "deployment_target") or "13.0",
|
|
448
|
+
development_team=_opt_str(table, "development_team"),
|
|
449
|
+
bundle_id=_opt_str(table, "bundle_id"),
|
|
450
|
+
extra_info_plist=dict(extra),
|
|
451
|
+
signing=IOSSigning(
|
|
452
|
+
export_method=(_opt_str(signing_table, "export_method") or "development").lower(),
|
|
453
|
+
provisioning_profile=_opt_str(signing_table, "provisioning_profile"),
|
|
454
|
+
),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _parse_android(table: Mapping[str, Any]) -> AndroidConfig:
|
|
459
|
+
signing_table = _expect_table(table, "signing", optional=True, parent="android")
|
|
460
|
+
cfg = AndroidConfig(
|
|
461
|
+
min_sdk=_opt_int(table, "min_sdk", default=24),
|
|
462
|
+
target_sdk=_opt_int(table, "target_sdk", default=34),
|
|
463
|
+
compile_sdk=_opt_int(table, "compile_sdk", default=34),
|
|
464
|
+
application_id=_opt_str(table, "application_id"),
|
|
465
|
+
permissions=_opt_str_list(table, "permissions"),
|
|
466
|
+
signing=AndroidSigning(
|
|
467
|
+
keystore=_opt_str(signing_table, "keystore"),
|
|
468
|
+
key_alias=_opt_str(signing_table, "key_alias"),
|
|
469
|
+
store_password_env=_opt_str(signing_table, "store_password_env") or "PN_ANDROID_KEYSTORE_PASSWORD",
|
|
470
|
+
key_password_env=_opt_str(signing_table, "key_password_env") or "PN_ANDROID_KEY_PASSWORD",
|
|
471
|
+
),
|
|
472
|
+
)
|
|
473
|
+
abis = _opt_str_list(table, "abi_filters")
|
|
474
|
+
if abis:
|
|
475
|
+
cfg.abi_filters = abis
|
|
476
|
+
return cfg
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _expect_table(
|
|
480
|
+
data: Mapping[str, Any], key: str, *, optional: bool = False, parent: Optional[str] = None
|
|
481
|
+
) -> Mapping[str, Any]:
|
|
482
|
+
label = f"[{parent}.{key}]" if parent else f"[{key}]"
|
|
483
|
+
if key not in data:
|
|
484
|
+
if optional:
|
|
485
|
+
return {}
|
|
486
|
+
raise ConfigError(f"Missing required {label} table in {CONFIG_FILENAME}.")
|
|
487
|
+
value = data[key]
|
|
488
|
+
if not isinstance(value, Mapping):
|
|
489
|
+
raise ConfigError(f"{label} must be a table.")
|
|
490
|
+
return value
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _require_str(table: Mapping[str, Any], key: str, parent: str) -> str:
|
|
494
|
+
if key not in table:
|
|
495
|
+
raise ConfigError(f"Missing required '{key}' in [{parent}] ({CONFIG_FILENAME}).")
|
|
496
|
+
value = table[key]
|
|
497
|
+
if not isinstance(value, str) or not value.strip():
|
|
498
|
+
raise ConfigError(f"[{parent}].{key} must be a non-empty string.")
|
|
499
|
+
return value.strip()
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _opt_str(table: Mapping[str, Any], key: str) -> Optional[str]:
|
|
503
|
+
if key not in table:
|
|
504
|
+
return None
|
|
505
|
+
value = table[key]
|
|
506
|
+
if value is None:
|
|
507
|
+
return None
|
|
508
|
+
if not isinstance(value, str):
|
|
509
|
+
raise ConfigError(f"'{key}' must be a string (got {type(value).__name__}).")
|
|
510
|
+
return value.strip() or None
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _opt_int(table: Mapping[str, Any], key: str, *, default: int) -> int:
|
|
514
|
+
if key not in table:
|
|
515
|
+
return default
|
|
516
|
+
value = table[key]
|
|
517
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
518
|
+
raise ConfigError(f"'{key}' must be an integer (got {type(value).__name__}).")
|
|
519
|
+
return value
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _opt_str_list(table: Mapping[str, Any], key: str) -> List[str]:
|
|
523
|
+
if key not in table:
|
|
524
|
+
return []
|
|
525
|
+
value = table[key]
|
|
526
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
527
|
+
raise ConfigError(f"'{key}' must be a list of strings.")
|
|
528
|
+
return [item.strip() for item in value if item.strip()]
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _validate_app_id(app_id: str, *, allow_hyphen: bool = False) -> None:
|
|
532
|
+
segments = app_id.split(".")
|
|
533
|
+
if len(segments) < 2:
|
|
534
|
+
raise ConfigError(f"app id {app_id!r} must be reverse-DNS with at least two segments (e.g. 'com.example.app').")
|
|
535
|
+
for segment in segments:
|
|
536
|
+
normalized = segment.replace("-", "_") if allow_hyphen else segment
|
|
537
|
+
if not _APP_ID_SEGMENT.match(normalized):
|
|
538
|
+
raise ConfigError(
|
|
539
|
+
f"app id segment {segment!r} is invalid; use lowercase letters, digits and underscores, "
|
|
540
|
+
"and start each segment with a letter."
|
|
541
|
+
)
|
|
542
|
+
if normalized in _JAVA_KEYWORDS:
|
|
543
|
+
raise ConfigError(f"app id segment {segment!r} is a reserved word and cannot be used.")
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _validate_requirements(requirements: List[str]) -> None:
|
|
547
|
+
for spec in requirements:
|
|
548
|
+
pkg = re.split(r"[\[><=!;~ ]", spec, maxsplit=1)[0].strip()
|
|
549
|
+
if pkg.lower().replace("-", "_") == "pythonnative":
|
|
550
|
+
raise ConfigError(
|
|
551
|
+
"Do not list 'pythonnative' in [requirements].packages; the pn CLI bundles it automatically."
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
# ======================================================================
|
|
556
|
+
# Misc utilities
|
|
557
|
+
# ======================================================================
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def entrypoint_to_module(entry_point: str) -> str:
|
|
561
|
+
"""Convert an ``entry_point`` path into an importable module path.
|
|
562
|
+
|
|
563
|
+
``"app/main.py"`` → ``"app.main"``. Returns ``"app.main"`` for empty
|
|
564
|
+
or unusable input so callers always have a sane default.
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
entry_point: A path like ``"app/main.py"``.
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
A dotted module path.
|
|
571
|
+
"""
|
|
572
|
+
normalized = (entry_point or "").strip().replace("\\", "/")
|
|
573
|
+
if normalized.endswith(".py"):
|
|
574
|
+
normalized = normalized[:-3]
|
|
575
|
+
normalized = normalized.strip("/").replace("/", ".")
|
|
576
|
+
return normalized or "app.main"
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def render_default_toml(*, name: str, app_id: str, python_version: str = "3.11") -> str:
|
|
580
|
+
"""Render a starter ``pythonnative.toml`` for ``pn init``.
|
|
581
|
+
|
|
582
|
+
Args:
|
|
583
|
+
name: Project name.
|
|
584
|
+
app_id: Reverse-DNS app identifier.
|
|
585
|
+
python_version: Embedded CPython version.
|
|
586
|
+
|
|
587
|
+
Returns:
|
|
588
|
+
The TOML file contents as a string, with commented-out examples
|
|
589
|
+
for the optional tables.
|
|
590
|
+
"""
|
|
591
|
+
display = name.replace("_", " ").replace("-", " ").strip().title() or name
|
|
592
|
+
return f"""# PythonNative project configuration.
|
|
593
|
+
# Docs: https://docs.pythonnative.com/guide/configuration/
|
|
594
|
+
|
|
595
|
+
[app]
|
|
596
|
+
id = "{app_id}"
|
|
597
|
+
name = "{name}"
|
|
598
|
+
display_name = "{display}"
|
|
599
|
+
version = "1.0.0"
|
|
600
|
+
build = 1
|
|
601
|
+
python_version = "{python_version}"
|
|
602
|
+
orientation = "portrait" # portrait | landscape | all
|
|
603
|
+
entry_point = "app/main.py"
|
|
604
|
+
|
|
605
|
+
# Declare the device capabilities your app needs. A string becomes the
|
|
606
|
+
# iOS permission prompt text; `true` uses a sensible default.
|
|
607
|
+
# See: https://docs.pythonnative.com/guide/permissions/
|
|
608
|
+
[permissions]
|
|
609
|
+
# camera = "Scan receipts with your camera."
|
|
610
|
+
# location_when_in_use = "Show nearby stores."
|
|
611
|
+
# notifications = true
|
|
612
|
+
# face_id = "Unlock the app with Face ID."
|
|
613
|
+
|
|
614
|
+
# App icon and splash. Provide a 1024x1024 PNG icon; it is resized for
|
|
615
|
+
# every density/idiom automatically.
|
|
616
|
+
[assets]
|
|
617
|
+
# icon = "assets/icon.png"
|
|
618
|
+
# splash = "assets/splash.png"
|
|
619
|
+
|
|
620
|
+
# Third-party pip packages bundled into the app (pure-Python or, on
|
|
621
|
+
# Android, anything Chaquopy can build). Do NOT list "pythonnative".
|
|
622
|
+
[requirements]
|
|
623
|
+
packages = []
|
|
624
|
+
|
|
625
|
+
[ios]
|
|
626
|
+
deployment_target = "13.0"
|
|
627
|
+
# development_team = "ABCDE12345"
|
|
628
|
+
# bundle_id = "{app_id}"
|
|
629
|
+
|
|
630
|
+
[ios.signing]
|
|
631
|
+
export_method = "development" # development | ad-hoc | app-store | enterprise
|
|
632
|
+
# provisioning_profile = "My App Distribution"
|
|
633
|
+
|
|
634
|
+
[android]
|
|
635
|
+
min_sdk = 24
|
|
636
|
+
target_sdk = 34
|
|
637
|
+
abi_filters = ["armeabi-v7a", "arm64-v8a", "x86", "x86_64"]
|
|
638
|
+
|
|
639
|
+
[android.signing]
|
|
640
|
+
# keystore = "release.keystore"
|
|
641
|
+
# key_alias = "{name}"
|
|
642
|
+
"""
|