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.
@@ -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
+ """