kivyforge 3.0.0.dev0__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 (225) hide show
  1. kivyforge/__init__.py +9 -0
  2. kivyforge/artifacts/__init__.py +41 -0
  3. kivyforge/artifacts/cache.py +81 -0
  4. kivyforge/artifacts/collect.py +354 -0
  5. kivyforge/artifacts/download.py +125 -0
  6. kivyforge/artifacts/frameworks.py +240 -0
  7. kivyforge/artifacts/native_stage_util.py +346 -0
  8. kivyforge/artifacts/runtime.py +65 -0
  9. kivyforge/artifacts/verify.py +45 -0
  10. kivyforge/artifacts/wheels.py +89 -0
  11. kivyforge/bundle/__init__.py +1 -0
  12. kivyforge/bundle/pycompile.py +161 -0
  13. kivyforge/cli/__init__.py +52 -0
  14. kivyforge/cli/_common.py +62 -0
  15. kivyforge/cli/_platform.py +84 -0
  16. kivyforge/cli/build.py +111 -0
  17. kivyforge/cli/clean.py +135 -0
  18. kivyforge/cli/doctor.py +52 -0
  19. kivyforge/cli/init.py +612 -0
  20. kivyforge/cli/init_writer.py +716 -0
  21. kivyforge/cli/lock.py +261 -0
  22. kivyforge/cli/open_cmd.py +15 -0
  23. kivyforge/cli/package.py +134 -0
  24. kivyforge/cli/run.py +162 -0
  25. kivyforge/cli/status.py +20 -0
  26. kivyforge/cli/upgrade.py +314 -0
  27. kivyforge/config/__init__.py +46 -0
  28. kivyforge/config/errors.py +43 -0
  29. kivyforge/config/icons.py +60 -0
  30. kivyforge/config/loader.py +3079 -0
  31. kivyforge/config/model.py +817 -0
  32. kivyforge/doctor/__init__.py +19 -0
  33. kivyforge/doctor/checks_common.py +95 -0
  34. kivyforge/doctor/probe.py +357 -0
  35. kivyforge/doctor/result.py +41 -0
  36. kivyforge/lock/__init__.py +42 -0
  37. kivyforge/lock/_pip_shim.py +63 -0
  38. kivyforge/lock/find_links.py +182 -0
  39. kivyforge/lock/model.py +69 -0
  40. kivyforge/lock/pep751.py +111 -0
  41. kivyforge/lock/reader.py +30 -0
  42. kivyforge/lock/resolver.py +124 -0
  43. kivyforge/lock/wheelruntime/__init__.py +70 -0
  44. kivyforge/lock/wheelruntime/builder.py +280 -0
  45. kivyforge/lock/wheelruntime/model.py +94 -0
  46. kivyforge/lock/wheelruntime/native_binaries.py +127 -0
  47. kivyforge/lock/wheelruntime/pbs_github.py +133 -0
  48. kivyforge/lock/wheelruntime/profile.py +104 -0
  49. kivyforge/lock/wheelruntime/resolver.py +248 -0
  50. kivyforge/lock/wheelruntime/runtime.py +161 -0
  51. kivyforge/lock/wheelruntime/serialize.py +245 -0
  52. kivyforge/platforms/__init__.py +114 -0
  53. kivyforge/platforms/android/__init__.py +140 -0
  54. kivyforge/platforms/android/adb.py +215 -0
  55. kivyforge/platforms/android/bootstrap/__init__.py +7 -0
  56. kivyforge/platforms/android/bootstrap/contract.py +166 -0
  57. kivyforge/platforms/android/bootstrap/render.py +244 -0
  58. kivyforge/platforms/android/bootstrap/templates/androidtest/KivyforgeContractTest.java +76 -0
  59. kivyforge/platforms/android/bootstrap/templates/bootstrap_py/_kivy_bootstrap.py +50 -0
  60. kivyforge/platforms/android/bootstrap/templates/bootstrap_py/_kivyforge_bootstrap.py +54 -0
  61. kivyforge/platforms/android/bootstrap/templates/bootstrap_py/_kivyforge_selftest.py +97 -0
  62. kivyforge/platforms/android/bootstrap/templates/cpp/CMakeLists.txt +20 -0
  63. kivyforge/platforms/android/bootstrap/templates/cpp/main.c +150 -0
  64. kivyforge/platforms/android/bootstrap/templates/java/org/jnius/NativeInvocationHandler.java +48 -0
  65. kivyforge/platforms/android/bootstrap/templates/java/org/kivy/android/PythonActivity.java +75 -0
  66. kivyforge/platforms/android/bootstrap/templates/java/org/kivy/android/PythonBundle.java +132 -0
  67. kivyforge/platforms/android/bootstrap/templates/java/org/kivy/android/PythonService.java +189 -0
  68. kivyforge/platforms/android/bootstrap/templates/java/org/renpy/android/Hardware.java +276 -0
  69. kivyforge/platforms/android/bootstrap/templates/sdl2/LICENSE-SDL.txt +18 -0
  70. kivyforge/platforms/android/bootstrap/templates/sdl2/SDL_REVISION.txt +3 -0
  71. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDevice.java +22 -0
  72. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDeviceBLESteamController.java +650 -0
  73. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDeviceManager.java +698 -0
  74. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDeviceUSB.java +309 -0
  75. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDL.java +90 -0
  76. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLActivity.java +2120 -0
  77. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLAudioManager.java +514 -0
  78. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLControllerManager.java +856 -0
  79. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLSurface.java +405 -0
  80. kivyforge/platforms/android/bootstrap/templates/sdl3/LICENSE-SDL.txt +18 -0
  81. kivyforge/platforms/android/bootstrap/templates/sdl3/SDL_REVISION.txt +3 -0
  82. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDevice.java +21 -0
  83. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDeviceBLESteamController.java +829 -0
  84. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDeviceManager.java +698 -0
  85. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDeviceUSB.java +354 -0
  86. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDL.java +90 -0
  87. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLActivity.java +2240 -0
  88. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLAudioManager.java +126 -0
  89. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLControllerManager.java +1010 -0
  90. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLDummyEdit.java +66 -0
  91. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLInputConnection.java +136 -0
  92. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLSensorManager.java +73 -0
  93. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLSurface.java +464 -0
  94. kivyforge/platforms/android/cli.py +1023 -0
  95. kivyforge/platforms/android/doctor.py +899 -0
  96. kivyforge/platforms/android/elf.py +148 -0
  97. kivyforge/platforms/android/generate/__init__.py +1 -0
  98. kivyforge/platforms/android/generate/manifest.py +261 -0
  99. kivyforge/platforms/android/generate/project.py +462 -0
  100. kivyforge/platforms/android/generate/services.py +187 -0
  101. kivyforge/platforms/android/gradle_wrapper/NOTICE +3 -0
  102. kivyforge/platforms/android/gradle_wrapper/SHA256SUMS +1 -0
  103. kivyforge/platforms/android/gradle_wrapper/gradle-wrapper.jar +0 -0
  104. kivyforge/platforms/android/gradle_wrapper/gradlew +164 -0
  105. kivyforge/platforms/android/gradle_wrapper/gradlew.bat +90 -0
  106. kivyforge/platforms/android/gradlew.py +60 -0
  107. kivyforge/platforms/android/icons.py +237 -0
  108. kivyforge/platforms/android/lock/__init__.py +1 -0
  109. kivyforge/platforms/android/lock/archives.py +109 -0
  110. kivyforge/platforms/android/lock/builder.py +387 -0
  111. kivyforge/platforms/android/lock/markers.py +71 -0
  112. kivyforge/platforms/android/lock/maven.py +252 -0
  113. kivyforge/platforms/android/lock/model.py +113 -0
  114. kivyforge/platforms/android/lock/python_meta.py +199 -0
  115. kivyforge/platforms/android/lock/reader.py +206 -0
  116. kivyforge/platforms/android/lock/resolver.py +276 -0
  117. kivyforge/platforms/android/lock/writer.py +121 -0
  118. kivyforge/platforms/android/policy.py +214 -0
  119. kivyforge/platforms/android/signing.py +145 -0
  120. kivyforge/platforms/android/smoke.py +43 -0
  121. kivyforge/platforms/android/splash.py +293 -0
  122. kivyforge/platforms/android/stage/__init__.py +1 -0
  123. kivyforge/platforms/android/stage/bundle.py +295 -0
  124. kivyforge/platforms/android/stage/jnilibs.py +186 -0
  125. kivyforge/platforms/android/stage/runtime.py +61 -0
  126. kivyforge/platforms/android/stage/wheels.py +106 -0
  127. kivyforge/platforms/android/toolchain.py +29 -0
  128. kivyforge/platforms/base.py +157 -0
  129. kivyforge/platforms/ios/__init__.py +129 -0
  130. kivyforge/platforms/ios/assets.py +193 -0
  131. kivyforge/platforms/ios/buildsettings.py +128 -0
  132. kivyforge/platforms/ios/cli.py +543 -0
  133. kivyforge/platforms/ios/doctor.py +410 -0
  134. kivyforge/platforms/ios/entitlements.py +34 -0
  135. kivyforge/platforms/ios/generator.py +318 -0
  136. kivyforge/platforms/ios/lock/__init__.py +71 -0
  137. kivyforge/platforms/ios/lock/builder.py +393 -0
  138. kivyforge/platforms/ios/lock/markers.py +69 -0
  139. kivyforge/platforms/ios/lock/model.py +95 -0
  140. kivyforge/platforms/ios/lock/python_meta.py +187 -0
  141. kivyforge/platforms/ios/lock/reader.py +191 -0
  142. kivyforge/platforms/ios/lock/resolver.py +304 -0
  143. kivyforge/platforms/ios/lock/spm.py +217 -0
  144. kivyforge/platforms/ios/lock/writer.py +114 -0
  145. kivyforge/platforms/ios/lock/xcframework.py +188 -0
  146. kivyforge/platforms/ios/materialize.py +59 -0
  147. kivyforge/platforms/ios/plist.py +91 -0
  148. kivyforge/platforms/ios/privacy.py +39 -0
  149. kivyforge/platforms/ios/skeleton.py +165 -0
  150. kivyforge/platforms/ios/sources.py +63 -0
  151. kivyforge/platforms/ios/staging.py +225 -0
  152. kivyforge/platforms/ios/swift_packages.py +324 -0
  153. kivyforge/platforms/ios/templates/kivyforge_bootstrap.h +34 -0
  154. kivyforge/platforms/ios/templates/kivyforge_bootstrap.m +146 -0
  155. kivyforge/platforms/ios/templates/main.m +15 -0
  156. kivyforge/platforms/ios/templates/main_config.h.in +12 -0
  157. kivyforge/platforms/ios/xcframeworks.py +194 -0
  158. kivyforge/platforms/ios/xcode/__init__.py +76 -0
  159. kivyforge/platforms/ios/xcode/commands.py +296 -0
  160. kivyforge/platforms/ios/xcode/runner.py +316 -0
  161. kivyforge/platforms/linux/__init__.py +120 -0
  162. kivyforge/platforms/linux/appimage.py +169 -0
  163. kivyforge/platforms/linux/bundle.py +235 -0
  164. kivyforge/platforms/linux/cli.py +232 -0
  165. kivyforge/platforms/linux/desktop.py +71 -0
  166. kivyforge/platforms/linux/doctor.py +438 -0
  167. kivyforge/platforms/linux/elftools.py +95 -0
  168. kivyforge/platforms/linux/icons.py +129 -0
  169. kivyforge/platforms/linux/launcher.py +107 -0
  170. kivyforge/platforms/linux/lock/__init__.py +126 -0
  171. kivyforge/platforms/linux/lock/profile.py +254 -0
  172. kivyforge/platforms/linux/lock/runtime.py +75 -0
  173. kivyforge/platforms/linux/native_stage.py +47 -0
  174. kivyforge/platforms/linux/runtime_stage.py +107 -0
  175. kivyforge/platforms/linux/wheels_stage.py +103 -0
  176. kivyforge/platforms/macos/__init__.py +121 -0
  177. kivyforge/platforms/macos/bundle.py +255 -0
  178. kivyforge/platforms/macos/cli.py +269 -0
  179. kivyforge/platforms/macos/doctor.py +480 -0
  180. kivyforge/platforms/macos/icns.py +75 -0
  181. kivyforge/platforms/macos/launcher.py +118 -0
  182. kivyforge/platforms/macos/lock/__init__.py +112 -0
  183. kivyforge/platforms/macos/lock/profile.py +119 -0
  184. kivyforge/platforms/macos/lock/runtime.py +58 -0
  185. kivyforge/platforms/macos/machotools.py +138 -0
  186. kivyforge/platforms/macos/native_stage.py +47 -0
  187. kivyforge/platforms/macos/notarize.py +143 -0
  188. kivyforge/platforms/macos/plist.py +37 -0
  189. kivyforge/platforms/macos/runtime_stage.py +110 -0
  190. kivyforge/platforms/macos/signing.py +78 -0
  191. kivyforge/platforms/macos/wheels_stage.py +108 -0
  192. kivyforge/platforms/windows/__init__.py +133 -0
  193. kivyforge/platforms/windows/assets.py +76 -0
  194. kivyforge/platforms/windows/bundle.py +298 -0
  195. kivyforge/platforms/windows/cli.py +314 -0
  196. kivyforge/platforms/windows/doctor.py +563 -0
  197. kivyforge/platforms/windows/fsswap.py +87 -0
  198. kivyforge/platforms/windows/icons.py +64 -0
  199. kivyforge/platforms/windows/launcher/__init__.py +193 -0
  200. kivyforge/platforms/windows/launcher/build_launcher.py +507 -0
  201. kivyforge/platforms/windows/launcher/launcher.c +379 -0
  202. kivyforge/platforms/windows/lock/__init__.py +112 -0
  203. kivyforge/platforms/windows/lock/profile.py +101 -0
  204. kivyforge/platforms/windows/lock/runtime.py +64 -0
  205. kivyforge/platforms/windows/naming.py +52 -0
  206. kivyforge/platforms/windows/native_stage.py +66 -0
  207. kivyforge/platforms/windows/petools.py +98 -0
  208. kivyforge/platforms/windows/rcedit.py +116 -0
  209. kivyforge/platforms/windows/runtime_stage.py +221 -0
  210. kivyforge/platforms/windows/signing.py +168 -0
  211. kivyforge/platforms/windows/vendor/LICENSE.rcedit +29 -0
  212. kivyforge/platforms/windows/vendor/NOTICE +17 -0
  213. kivyforge/platforms/windows/vendor/SHA256SUMS +4 -0
  214. kivyforge/platforms/windows/vendor/TOOLSET.txt +4 -0
  215. kivyforge/platforms/windows/vendor/__init__.py +12 -0
  216. kivyforge/platforms/windows/vendor/fetch_rcedit.py +97 -0
  217. kivyforge/platforms/windows/vendor/launcher-amd64.exe +0 -0
  218. kivyforge/platforms/windows/vendor/rcedit-x64.exe +0 -0
  219. kivyforge/platforms/windows/wheels_stage.py +139 -0
  220. kivyforge-3.0.0.dev0.dist-info/METADATA +609 -0
  221. kivyforge-3.0.0.dev0.dist-info/RECORD +225 -0
  222. kivyforge-3.0.0.dev0.dist-info/WHEEL +5 -0
  223. kivyforge-3.0.0.dev0.dist-info/entry_points.txt +3 -0
  224. kivyforge-3.0.0.dev0.dist-info/licenses/LICENSE +21 -0
  225. kivyforge-3.0.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3079 @@
1
+ """Load and validate a kivyforge ``pyproject.toml`` (spec 01).
2
+
3
+ Implements the spec's validation rules. tomllib surfaces line numbers for
4
+ *syntax* errors; for *semantic* errors we report the dotted key path plus a
5
+ best-effort source line located by scanning the raw text.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import keyword
11
+ import posixpath
12
+ import re
13
+ import tomllib
14
+ from pathlib import Path, PurePosixPath, PureWindowsPath
15
+
16
+ from packaging.specifiers import InvalidSpecifier, SpecifierSet
17
+ from packaging.version import InvalidVersion, Version
18
+
19
+ from .errors import ConfigError
20
+ from .model import (
21
+ ANDROID_MIN_SDK_FLOOR,
22
+ ANDROID_RELEASE_ONLY,
23
+ ANDROID_VERSION_CODE_CEILING,
24
+ DEFAULT_ANDROID_ABIS,
25
+ DEFAULT_ANDROID_BASE_THEME,
26
+ DEFAULT_ANDROID_KEY_PASSWORD_ENV,
27
+ DEFAULT_ANDROID_KIVY_GENERATION,
28
+ DEFAULT_ANDROID_MIN_SDK,
29
+ DEFAULT_ANDROID_STORE_PASSWORD_ENV,
30
+ DEFAULT_ANDROID_TARGET_SDK,
31
+ DEFAULT_DESKTOP_CATEGORIES,
32
+ DEFAULT_LINUX_ARCHS,
33
+ DEFAULT_MACOS_ARCHS,
34
+ DEFAULT_SIMULATOR_ARCHS,
35
+ DEFAULT_WINDOWS_ARCHS,
36
+ DEFAULT_WINDOWS_STORE_SCOPE,
37
+ DEFAULT_WINDOWS_TIMESTAMP_URL,
38
+ DESKTOP_RELEASE_ONLY,
39
+ FREEDESKTOP_MAIN_CATEGORIES,
40
+ MANAGED_ANDROID_ACTIVITY_ATTRS,
41
+ MANAGED_ANDROID_APPLICATION_ATTRS,
42
+ MANAGED_INFO_PLIST_KEYS,
43
+ RESERVED_ANDROID_GRADLE_PROPERTIES,
44
+ RESERVED_BUILD_SETTINGS,
45
+ SUPPORTED_ANDROID_SCHEMA_VERSION,
46
+ SUPPORTED_IOS_SCHEMA_VERSION,
47
+ SUPPORTED_LINUX_SCHEMA_VERSION,
48
+ SUPPORTED_MACOS_SCHEMA_VERSION,
49
+ SUPPORTED_WINDOWS_SCHEMA_VERSION,
50
+ VALID_ANDROID_ABIS,
51
+ VALID_ANDROID_DEBUG_SYMBOLS,
52
+ VALID_ANDROID_KIVY_GENERATIONS,
53
+ VALID_FOREGROUND_SERVICE_TYPES,
54
+ VALID_INTENT_DATA_KEYS,
55
+ VALID_LINUX_ARCHS,
56
+ VALID_MACOS_ARCHS,
57
+ VALID_ORIENTATIONS,
58
+ VALID_SIMULATOR_ARCHS,
59
+ VALID_SWIFT_REQUIREMENT_KINDS,
60
+ VALID_WINDOWS_ARCHS,
61
+ VALID_WINDOWS_STORE_SCOPES,
62
+ WINDOWS_APP_ID_MAX_LENGTH,
63
+ AndroidActivity,
64
+ AndroidArchiveDep,
65
+ AndroidBuildSettings,
66
+ AndroidConfig,
67
+ AndroidFeature,
68
+ AndroidGradleConfig,
69
+ AndroidIconConfig,
70
+ AndroidIncludeFile,
71
+ AndroidIntentFilter,
72
+ AndroidManifestConfig,
73
+ AndroidNotification,
74
+ AndroidPermissions,
75
+ AndroidPythonConfig,
76
+ AndroidService,
77
+ AndroidSigningConfig,
78
+ AndroidSplashConfig,
79
+ AndroidSrcConfig,
80
+ Author,
81
+ Config,
82
+ DesktopBuildSettings,
83
+ DesktopConfig,
84
+ IconConfig,
85
+ IosConfig,
86
+ KivyMeta,
87
+ LinuxConfig,
88
+ MacosConfig,
89
+ MacosSigningConfig,
90
+ NativeBinaryDep,
91
+ ProjectMeta,
92
+ SigningConfig,
93
+ SplashConfig,
94
+ SwiftPackageDep,
95
+ WindowsConfig,
96
+ WindowsSigningConfig,
97
+ XcframeworkDep,
98
+ )
99
+
100
+
101
+ def _is_absolute_source(value: str) -> bool:
102
+ """True if ``value`` is absolute under POSIX *or* Windows rules.
103
+
104
+ Repo-relative sources/paths in ``pyproject.toml`` must stay portable, so a
105
+ path that is absolute on any host is rejected everywhere. This is host
106
+ independent on purpose: ``os.path.isabs`` changed on Windows in Python 3.13
107
+ (a leading ``/`` is no longer "absolute"), which would otherwise let
108
+ ``"/abs/x"`` slip through the loader on Windows only.
109
+ """
110
+ return PurePosixPath(value).is_absolute() or PureWindowsPath(value).is_absolute()
111
+
112
+
113
+ def _posix_normpath(value: str) -> str:
114
+ """Normalize ``value`` under POSIX semantics, treating ``\\`` as a separator.
115
+
116
+ ``os.path.normpath`` produces backslash-separated results on Windows that
117
+ ``PurePosixPath`` will not split, so an escaping ``..\\..\\x`` would evade the
118
+ parts check on Windows. Normalizing to forward slashes keeps the escape and
119
+ ``.`` checks host independent.
120
+ """
121
+ return posixpath.normpath(value.replace("\\", "/"))
122
+
123
+
124
+ def load_config(
125
+ path: str | Path,
126
+ *,
127
+ require_ios: bool = True,
128
+ require_macos: bool = False,
129
+ require_linux: bool = False,
130
+ require_windows: bool = False,
131
+ require_android: bool = False,
132
+ ) -> Config:
133
+ """Parse and validate ``pyproject.toml`` at ``path``.
134
+
135
+ ``require_ios`` / ``require_macos`` / ``require_linux`` / ``require_windows``
136
+ / ``require_android`` enforce the presence of the respective
137
+ ``[tool.kivy.<platform>]`` overlay. A platform verb requires its own overlay;
138
+ contexts that only inspect the cross-platform tables set them all False.
139
+ """
140
+ path = Path(path)
141
+ text = path.read_text(encoding="utf-8")
142
+ return load_config_from_text(
143
+ text,
144
+ require_ios=require_ios,
145
+ require_macos=require_macos,
146
+ require_linux=require_linux,
147
+ require_windows=require_windows,
148
+ require_android=require_android,
149
+ project_root=path.parent,
150
+ )
151
+
152
+
153
+ def load_config_from_text(
154
+ text: str,
155
+ *,
156
+ require_ios: bool = True,
157
+ require_macos: bool = False,
158
+ require_linux: bool = False,
159
+ require_windows: bool = False,
160
+ require_android: bool = False,
161
+ project_root: Path | None = None,
162
+ ) -> Config:
163
+ try:
164
+ raw = tomllib.loads(text)
165
+ except tomllib.TOMLDecodeError as exc:
166
+ # tomllib embeds the line in the message; surface it directly.
167
+ raise ConfigError(f"invalid TOML: {exc}") from exc
168
+
169
+ finder = _LineFinder(text)
170
+ project = _parse_project(raw, finder)
171
+ kivy = _parse_kivy(raw, finder)
172
+ ios = _parse_ios(raw, finder, project, project_root=project_root)
173
+ macos = _parse_macos(raw, finder, project, project_root=project_root)
174
+ linux = _parse_linux(raw, finder, project, project_root=project_root)
175
+ windows = _parse_windows(raw, finder, project, project_root=project_root)
176
+ android = _parse_android(raw, finder, project, project_root=project_root)
177
+
178
+ if ios is None and require_ios:
179
+ raise ConfigError(
180
+ "missing [tool.kivy.ios] table",
181
+ key_path="tool.kivy.ios",
182
+ hint="add a [tool.kivy.ios] overlay; [tool.kivy] alone is not a "
183
+ "buildable iOS target. Run `kivyforge init`.",
184
+ )
185
+ if macos is None and require_macos:
186
+ raise ConfigError(
187
+ "missing [tool.kivy.macos] table",
188
+ key_path="tool.kivy.macos",
189
+ hint="add a [tool.kivy.macos] overlay; [tool.kivy] alone is not a "
190
+ "buildable macOS target.",
191
+ )
192
+ if linux is None and require_linux:
193
+ raise ConfigError(
194
+ "missing [tool.kivy.linux] table",
195
+ key_path="tool.kivy.linux",
196
+ hint="add a [tool.kivy.linux] overlay; [tool.kivy] alone is not a "
197
+ "buildable Linux target.",
198
+ )
199
+ if windows is None and require_windows:
200
+ raise ConfigError(
201
+ "missing [tool.kivy.windows] table",
202
+ key_path="tool.kivy.windows",
203
+ hint="add a [tool.kivy.windows] overlay; [tool.kivy] alone is not a "
204
+ "buildable Windows target.",
205
+ )
206
+ if android is None and require_android:
207
+ raise ConfigError(
208
+ "missing [tool.kivy.android] table",
209
+ key_path="tool.kivy.android",
210
+ hint="add a [tool.kivy.android] overlay; [tool.kivy] alone is not a "
211
+ "buildable Android target. Run `kivyforge init`.",
212
+ )
213
+
214
+ return Config(
215
+ project=project,
216
+ kivy=kivy,
217
+ ios=ios,
218
+ macos=macos,
219
+ linux=linux,
220
+ windows=windows,
221
+ android=android,
222
+ )
223
+
224
+
225
+ # --------------------------------------------------------------------------- #
226
+ # [project]
227
+ # --------------------------------------------------------------------------- #
228
+ def _parse_project(raw: dict, finder: _LineFinder) -> ProjectMeta:
229
+ project = raw.get("project")
230
+ if not isinstance(project, dict):
231
+ raise ConfigError(
232
+ "missing [project] table (PEP 621)",
233
+ key_path="project",
234
+ hint="every kivyforge app needs a [project] table with at least "
235
+ "name and version.",
236
+ )
237
+
238
+ name = project.get("name")
239
+ if not name or not isinstance(name, str):
240
+ raise ConfigError(
241
+ "missing or empty [project].name",
242
+ key_path="project.name",
243
+ line=finder.line("project"),
244
+ )
245
+ version = project.get("version")
246
+ if not version or not isinstance(version, str):
247
+ raise ConfigError(
248
+ "missing or empty [project].version",
249
+ key_path="project.version",
250
+ line=finder.line("project"),
251
+ )
252
+
253
+ deps = project.get("dependencies", [])
254
+ if not isinstance(deps, list) or not all(isinstance(d, str) for d in deps):
255
+ raise ConfigError(
256
+ "[project].dependencies must be a list of PEP 508 strings",
257
+ key_path="project.dependencies",
258
+ )
259
+
260
+ authors: list[Author] = []
261
+ for entry in project.get("authors", []) or []:
262
+ if isinstance(entry, dict):
263
+ authors.append(Author(name=entry.get("name"), email=entry.get("email")))
264
+
265
+ return ProjectMeta(
266
+ name=name,
267
+ version=version,
268
+ description=project.get("description"),
269
+ requires_python=project.get("requires-python"),
270
+ dependencies=tuple(deps),
271
+ authors=tuple(authors),
272
+ )
273
+
274
+
275
+ # --------------------------------------------------------------------------- #
276
+ # [tool.kivy]
277
+ # --------------------------------------------------------------------------- #
278
+ def _parse_kivy(raw: dict, finder: _LineFinder) -> KivyMeta:
279
+ tool = raw.get("tool", {})
280
+ kivy = tool.get("kivy") if isinstance(tool, dict) else None
281
+ if not isinstance(kivy, dict):
282
+ raise ConfigError(
283
+ "missing [tool.kivy] table",
284
+ key_path="tool.kivy",
285
+ hint="run `kivyforge init` to scaffold it.",
286
+ )
287
+
288
+ app_dir = _validate_app_dir(kivy.get("app_dir"), finder)
289
+
290
+ entry_point = kivy.get("entry_point", "main")
291
+ _validate_entry_point(entry_point, finder)
292
+
293
+ orientation = kivy.get("orientation", ["portrait"])
294
+ _validate_orientation(orientation, finder)
295
+
296
+ return KivyMeta(
297
+ app_dir=app_dir,
298
+ display_name=kivy.get("display_name"),
299
+ entry_point=entry_point,
300
+ orientation=tuple(orientation),
301
+ )
302
+
303
+
304
+ def _validate_app_dir(app_dir: object, finder: _LineFinder) -> str:
305
+ # Rule 6: app_dir is required and must name a subdirectory.
306
+ if app_dir is None:
307
+ raise ConfigError(
308
+ "missing required [tool.kivy].app_dir",
309
+ key_path="tool.kivy.app_dir",
310
+ hint='set app_dir to a subdirectory such as "src".',
311
+ )
312
+ if not isinstance(app_dir, str) or app_dir.strip() == "":
313
+ raise ConfigError(
314
+ "[tool.kivy].app_dir must be a non-empty string",
315
+ key_path="tool.kivy.app_dir",
316
+ line=finder.line("app_dir"),
317
+ )
318
+ if app_dir == "." or _posix_normpath(app_dir) == ".":
319
+ raise ConfigError(
320
+ 'app_dir = "." (project root) is not allowed',
321
+ key_path="tool.kivy.app_dir",
322
+ line=finder.line("app_dir"),
323
+ hint="keep app code in a subdirectory (e.g. src/) so the bundle "
324
+ "excludes .git/, .venv/, and the <app>-ios/ build output.",
325
+ )
326
+ if _is_absolute_source(app_dir):
327
+ raise ConfigError(
328
+ "app_dir must be relative, not an absolute path",
329
+ key_path="tool.kivy.app_dir",
330
+ line=finder.line("app_dir"),
331
+ )
332
+ parts = PurePosixPath(_posix_normpath(app_dir)).parts
333
+ if parts and parts[0] == "..":
334
+ raise ConfigError(
335
+ "app_dir must not escape the project directory",
336
+ key_path="tool.kivy.app_dir",
337
+ line=finder.line("app_dir"),
338
+ )
339
+ return app_dir
340
+
341
+
342
+ def _validate_entry_point(entry_point: object, finder: _LineFinder) -> None:
343
+ # Rule 5: entry_point must be a valid (dotted) Python identifier.
344
+ if not isinstance(entry_point, str) or entry_point == "":
345
+ raise ConfigError(
346
+ "[tool.kivy].entry_point must be a non-empty string",
347
+ key_path="tool.kivy.entry_point",
348
+ line=finder.line("entry_point"),
349
+ )
350
+ parts = entry_point.split(".")
351
+ if not all(p.isidentifier() and not keyword.iskeyword(p) for p in parts):
352
+ raise ConfigError(
353
+ f"entry_point {entry_point!r} is not a valid dotted Python identifier",
354
+ key_path="tool.kivy.entry_point",
355
+ line=finder.line("entry_point"),
356
+ hint='e.g. "main" for src/main.py, or "pkg.start" for src/pkg/start.py.',
357
+ )
358
+
359
+
360
+ def _validate_orientation(orientation: object, finder: _LineFinder) -> None:
361
+ # Rule 7: orientation values must be within the allowed set.
362
+ if not isinstance(orientation, list) or not orientation:
363
+ raise ConfigError(
364
+ "[tool.kivy].orientation must be a non-empty list",
365
+ key_path="tool.kivy.orientation",
366
+ line=finder.line("orientation"),
367
+ )
368
+ bad = [o for o in orientation if o not in VALID_ORIENTATIONS]
369
+ if bad:
370
+ raise ConfigError(
371
+ f"invalid orientation value(s): {', '.join(map(str, bad))}",
372
+ key_path="tool.kivy.orientation",
373
+ line=finder.line("orientation"),
374
+ hint=f"allowed: {', '.join(sorted(VALID_ORIENTATIONS))}.",
375
+ )
376
+
377
+
378
+ # --------------------------------------------------------------------------- #
379
+ # [tool.kivy.ios]
380
+ # --------------------------------------------------------------------------- #
381
+ def _parse_ios(
382
+ raw: dict,
383
+ finder: _LineFinder,
384
+ project: ProjectMeta,
385
+ *,
386
+ project_root: Path | None = None,
387
+ ) -> IosConfig | None:
388
+ tool = raw.get("tool", {})
389
+ kivy = tool.get("kivy", {}) if isinstance(tool, dict) else {}
390
+ ios = kivy.get("ios") if isinstance(kivy, dict) else None
391
+ if ios is None:
392
+ return None
393
+ if not isinstance(ios, dict):
394
+ raise ConfigError("[tool.kivy.ios] must be a table", key_path="tool.kivy.ios")
395
+
396
+ # Rule 3: schema_version required + supported.
397
+ schema_version = ios.get("schema_version")
398
+ if schema_version is None:
399
+ raise ConfigError(
400
+ "missing required [tool.kivy.ios].schema_version",
401
+ key_path="tool.kivy.ios.schema_version",
402
+ )
403
+ if not isinstance(schema_version, int) or isinstance(schema_version, bool):
404
+ raise ConfigError(
405
+ "[tool.kivy.ios].schema_version must be an integer",
406
+ key_path="tool.kivy.ios.schema_version",
407
+ line=finder.line("schema_version"),
408
+ )
409
+ if schema_version > SUPPORTED_IOS_SCHEMA_VERSION:
410
+ raise ConfigError(
411
+ f"[tool.kivy.ios].schema_version {schema_version} is newer than this "
412
+ f"kivyforge understands (max {SUPPORTED_IOS_SCHEMA_VERSION})",
413
+ key_path="tool.kivy.ios.schema_version",
414
+ line=finder.line("schema_version"),
415
+ hint="upgrade kivyforge.",
416
+ )
417
+ if schema_version < 1:
418
+ raise ConfigError(
419
+ f"unsupported [tool.kivy.ios].schema_version {schema_version}",
420
+ key_path="tool.kivy.ios.schema_version",
421
+ line=finder.line("schema_version"),
422
+ )
423
+
424
+ # Rule 4: bundle_id required.
425
+ bundle_id = ios.get("bundle_id")
426
+ if not bundle_id or not isinstance(bundle_id, str):
427
+ raise ConfigError(
428
+ "missing required [tool.kivy.ios].bundle_id",
429
+ key_path="tool.kivy.ios.bundle_id",
430
+ hint='e.g. bundle_id = "org.example.myapp".',
431
+ )
432
+ # A bundle identifier is a UTI: only alphanumerics, hyphen, and period are
433
+ # allowed. Catch invalid characters (commonly an underscore copied from a
434
+ # Python package name) here rather than as a cryptic Xcode build failure.
435
+ if not re.fullmatch(r"[A-Za-z0-9.-]+", bundle_id):
436
+ raise ConfigError(
437
+ f"invalid character in [tool.kivy.ios].bundle_id {bundle_id!r}",
438
+ key_path="tool.kivy.ios.bundle_id",
439
+ line=finder.line("bundle_id"),
440
+ hint=(
441
+ "bundle identifiers may contain only letters, digits, hyphen "
442
+ "(-), and period (.). Replace underscores with hyphens, e.g. "
443
+ '"org.example.hello-world".'
444
+ ),
445
+ )
446
+
447
+ build = ios.get("build", 1)
448
+ if not isinstance(build, int) or isinstance(build, bool):
449
+ raise ConfigError(
450
+ "[tool.kivy.ios].build must be an integer",
451
+ key_path="tool.kivy.ios.build",
452
+ line=finder.line("build"),
453
+ )
454
+
455
+ deployment_target = ios.get("deployment_target", "13.0")
456
+ if not isinstance(deployment_target, str):
457
+ raise ConfigError(
458
+ "[tool.kivy.ios].deployment_target must be a string",
459
+ key_path="tool.kivy.ios.deployment_target",
460
+ line=finder.line("deployment_target"),
461
+ )
462
+
463
+ simulator_archs = _parse_simulator_archs(ios, finder)
464
+
465
+ extra_index_urls = ios.get("extra_index_urls", [])
466
+ if not isinstance(extra_index_urls, list) or not all(
467
+ isinstance(u, str) for u in extra_index_urls
468
+ ):
469
+ raise ConfigError(
470
+ "[tool.kivy.ios].extra_index_urls must be a list of strings",
471
+ key_path="tool.kivy.ios.extra_index_urls",
472
+ line=finder.line("extra_index_urls"),
473
+ )
474
+
475
+ find_links = _parse_find_links(ios, finder, project_root=project_root)
476
+ exclude = _parse_exclude(ios, finder)
477
+
478
+ python_version = _parse_python_version(ios)
479
+ _check_requires_python(project, python_version, finder)
480
+
481
+ icons = _parse_icons(ios)
482
+ splash = _parse_splash(ios)
483
+ xcframeworks = _parse_xcframeworks(ios, finder)
484
+ swift_packages = _parse_swift_packages(ios, finder)
485
+ signing = _parse_signing(ios, finder)
486
+ info_plist = _parse_info_plist(ios, finder)
487
+ build_settings = _parse_build_settings(ios, finder)
488
+ python_build_settings = _parse_ios_python_build_settings(ios, finder)
489
+ privacy_source = _parse_privacy(ios)
490
+ entitlements = _parse_entitlements(ios)
491
+
492
+ return IosConfig(
493
+ schema_version=schema_version,
494
+ bundle_id=bundle_id,
495
+ build=build,
496
+ deployment_target=deployment_target,
497
+ simulator_archs=simulator_archs,
498
+ extra_index_urls=tuple(extra_index_urls),
499
+ find_links=tuple(find_links),
500
+ exclude=tuple(exclude),
501
+ python_version=python_version,
502
+ icons=icons,
503
+ splash=splash,
504
+ xcframeworks=tuple(xcframeworks),
505
+ swift_packages=tuple(swift_packages),
506
+ entitlements=entitlements,
507
+ signing=signing,
508
+ privacy_manifest_source=privacy_source,
509
+ info_plist=info_plist,
510
+ build_settings=build_settings,
511
+ python_build_settings=python_build_settings,
512
+ )
513
+
514
+
515
+ # --------------------------------------------------------------------------- #
516
+ # [tool.kivy.macos]
517
+ # --------------------------------------------------------------------------- #
518
+ def _parse_macos(
519
+ raw: dict,
520
+ finder: _LineFinder,
521
+ project: ProjectMeta,
522
+ *,
523
+ project_root: Path | None = None,
524
+ ) -> MacosConfig | None:
525
+ tool = raw.get("tool", {})
526
+ kivy = tool.get("kivy", {}) if isinstance(tool, dict) else {}
527
+ macos = kivy.get("macos") if isinstance(kivy, dict) else None
528
+ if macos is None:
529
+ return None
530
+ if not isinstance(macos, dict):
531
+ raise ConfigError(
532
+ "[tool.kivy.macos] must be a table", key_path="tool.kivy.macos"
533
+ )
534
+
535
+ schema_version = _parse_platform_schema_version(
536
+ macos,
537
+ finder,
538
+ key_path="tool.kivy.macos.schema_version",
539
+ supported=SUPPORTED_MACOS_SCHEMA_VERSION,
540
+ )
541
+
542
+ bundle_id = macos.get("bundle_id")
543
+ if not bundle_id or not isinstance(bundle_id, str):
544
+ raise ConfigError(
545
+ "missing required [tool.kivy.macos].bundle_id",
546
+ key_path="tool.kivy.macos.bundle_id",
547
+ hint='e.g. bundle_id = "org.example.myapp".',
548
+ )
549
+ if not re.fullmatch(r"[A-Za-z0-9.-]+", bundle_id):
550
+ raise ConfigError(
551
+ f"invalid character in [tool.kivy.macos].bundle_id {bundle_id!r}",
552
+ key_path="tool.kivy.macos.bundle_id",
553
+ line=finder.line("bundle_id"),
554
+ hint=(
555
+ "bundle identifiers may contain only letters, digits, hyphen "
556
+ "(-), and period (.). Replace underscores with hyphens, e.g. "
557
+ '"org.example.hello-world".'
558
+ ),
559
+ )
560
+
561
+ build = macos.get("build", 1)
562
+ if not isinstance(build, int) or isinstance(build, bool):
563
+ raise ConfigError(
564
+ "[tool.kivy.macos].build must be an integer",
565
+ key_path="tool.kivy.macos.build",
566
+ line=finder.line("build"),
567
+ )
568
+
569
+ minimum_system_version = macos.get("minimum_system_version")
570
+ if minimum_system_version is not None and not isinstance(
571
+ minimum_system_version, str
572
+ ):
573
+ raise ConfigError(
574
+ "[tool.kivy.macos].minimum_system_version must be a string",
575
+ key_path="tool.kivy.macos.minimum_system_version",
576
+ line=finder.line("minimum_system_version"),
577
+ )
578
+
579
+ archs = _parse_macos_archs(macos, finder)
580
+
581
+ extra_index_urls = macos.get("extra_index_urls", [])
582
+ if not isinstance(extra_index_urls, list) or not all(
583
+ isinstance(u, str) for u in extra_index_urls
584
+ ):
585
+ raise ConfigError(
586
+ "[tool.kivy.macos].extra_index_urls must be a list of strings",
587
+ key_path="tool.kivy.macos.extra_index_urls",
588
+ line=finder.line("extra_index_urls"),
589
+ )
590
+
591
+ find_links = _parse_platform_find_links(
592
+ macos,
593
+ finder,
594
+ key_path="tool.kivy.macos.find_links",
595
+ project_root=project_root,
596
+ )
597
+ exclude = _parse_platform_exclude(macos, finder, key_path="tool.kivy.macos.exclude")
598
+
599
+ python_version = _parse_platform_python_version(
600
+ macos, key_path="tool.kivy.macos.python"
601
+ )
602
+ _check_requires_python_generic(
603
+ project,
604
+ python_version,
605
+ finder,
606
+ key_path="tool.kivy.macos.python.version",
607
+ )
608
+
609
+ icons = _parse_platform_icons(macos, key_path="tool.kivy.macos.icons")
610
+ entitlements = _parse_macos_entitlements(macos)
611
+ signing = _parse_macos_signing(macos, finder)
612
+ _check_macos_entitlements_notarizable(entitlements, signing, finder)
613
+ binaries = _parse_native_binaries(macos, "macos", finder)
614
+ build_settings = _parse_desktop_build_settings(
615
+ macos.get("build_settings"), "tool.kivy.macos.build_settings", finder
616
+ )
617
+
618
+ return MacosConfig(
619
+ schema_version=schema_version,
620
+ bundle_id=bundle_id,
621
+ build=build,
622
+ minimum_system_version=minimum_system_version,
623
+ archs=archs,
624
+ extra_index_urls=tuple(extra_index_urls),
625
+ find_links=tuple(find_links),
626
+ exclude=tuple(exclude),
627
+ python_version=python_version,
628
+ icons=icons,
629
+ entitlements=entitlements,
630
+ signing=signing,
631
+ binaries=tuple(binaries),
632
+ build_settings=build_settings,
633
+ )
634
+
635
+
636
+ def _parse_macos_entitlements(macos: dict) -> dict[str, object]:
637
+ ent = macos.get("entitlements")
638
+ if ent is None:
639
+ return {}
640
+ if not isinstance(ent, dict):
641
+ raise ConfigError(
642
+ "[tool.kivy.macos.entitlements] must be a table",
643
+ key_path="tool.kivy.macos.entitlements",
644
+ )
645
+ return dict(ent)
646
+
647
+
648
+ def _check_macos_entitlements_notarizable(
649
+ entitlements: dict[str, object],
650
+ signing: MacosSigningConfig,
651
+ finder: _LineFinder,
652
+ ) -> None:
653
+ """Reject ``get-task-allow: true`` when Developer ID signing is configured.
654
+
655
+ Apple's notary service always rejects a submission carrying this
656
+ entitlement ("The executable requests the com.apple.security.get-task-
657
+ allow entitlement.") — see "Resolving common notarization issues". It's
658
+ only meaningful for local-debug (ad-hoc) builds, which never receive
659
+ ``[tool.kivy.macos.entitlements]`` (see ``sign_bundle_adhoc``), so this is
660
+ only checked once Developer ID signing is configured.
661
+ """
662
+ if not signing.configured:
663
+ return
664
+ key = "com.apple.security.get-task-allow"
665
+ if entitlements.get(key) is True:
666
+ raise ConfigError(
667
+ f"[tool.kivy.macos.entitlements] sets {key!r} to true, but "
668
+ "Developer ID signing is configured",
669
+ key_path=f"tool.kivy.macos.entitlements.{key}",
670
+ line=finder.line(key),
671
+ hint="notarization always rejects this entitlement; remove it (or "
672
+ "set it to false) before packaging for distribution.",
673
+ )
674
+
675
+
676
+ def _parse_macos_signing(macos: dict, finder: _LineFinder) -> MacosSigningConfig:
677
+ signing = macos.get("signing")
678
+ if signing is None:
679
+ return MacosSigningConfig()
680
+ if not isinstance(signing, dict):
681
+ raise ConfigError(
682
+ "[tool.kivy.macos.signing] must be a table",
683
+ key_path="tool.kivy.macos.signing",
684
+ )
685
+ for key in ("identity", "team_id", "notary_profile"):
686
+ value = signing.get(key, "")
687
+ if not isinstance(value, str):
688
+ raise ConfigError(
689
+ f"[tool.kivy.macos.signing].{key} must be a string",
690
+ key_path=f"tool.kivy.macos.signing.{key}",
691
+ line=finder.line(key),
692
+ )
693
+ return MacosSigningConfig(
694
+ identity=signing.get("identity", ""),
695
+ team_id=signing.get("team_id", ""),
696
+ notary_profile=signing.get("notary_profile", ""),
697
+ )
698
+
699
+
700
+ def _parse_macos_archs(macos: dict, finder: _LineFinder) -> tuple[str, ...]:
701
+ raw = macos.get("archs")
702
+ if raw is None:
703
+ return DEFAULT_MACOS_ARCHS
704
+ line = finder.line("archs")
705
+ if not isinstance(raw, list) or not all(isinstance(a, str) for a in raw):
706
+ raise ConfigError(
707
+ "[tool.kivy.macos].archs must be a list of strings",
708
+ key_path="tool.kivy.macos.archs",
709
+ line=line,
710
+ )
711
+ if not raw:
712
+ raise ConfigError(
713
+ "[tool.kivy.macos].archs must not be empty",
714
+ key_path="tool.kivy.macos.archs",
715
+ line=line,
716
+ hint='"arm64" is the only supported macOS arch.',
717
+ )
718
+ if "x86_64" in raw:
719
+ raise ConfigError(
720
+ "[tool.kivy.macos].archs no longer supports x86_64; kivyforge builds "
721
+ "arm64-only macOS apps",
722
+ key_path="tool.kivy.macos.archs",
723
+ line=line,
724
+ hint=(
725
+ 'set archs = ["arm64"] (or drop the key — arm64 is the default). '
726
+ "macOS 27 stops installing on Intel hardware and macOS 28 removes "
727
+ "Rosetta, so an x86_64 slice is one nothing can run or validate. "
728
+ "universal2 *wheels* are still resolved normally — this is the "
729
+ "build target, not the wheel tag."
730
+ ),
731
+ )
732
+ unknown = [a for a in raw if a not in VALID_MACOS_ARCHS]
733
+ if unknown:
734
+ valid = ", ".join(sorted(VALID_MACOS_ARCHS))
735
+ raise ConfigError(
736
+ f"unknown macOS arch(es) {unknown} in [tool.kivy.macos].archs",
737
+ key_path="tool.kivy.macos.archs",
738
+ line=line,
739
+ hint=f"valid values are: {valid}.",
740
+ )
741
+ seen: set[str] = set()
742
+ ordered: list[str] = []
743
+ for arch in raw:
744
+ if arch not in seen:
745
+ seen.add(arch)
746
+ ordered.append(arch)
747
+ return tuple(ordered)
748
+
749
+
750
+ # --------------------------------------------------------------------------- #
751
+ # [tool.kivy.linux]
752
+ # --------------------------------------------------------------------------- #
753
+ def _parse_linux(
754
+ raw: dict,
755
+ finder: _LineFinder,
756
+ project: ProjectMeta,
757
+ *,
758
+ project_root: Path | None = None,
759
+ ) -> LinuxConfig | None:
760
+ tool = raw.get("tool", {})
761
+ kivy = tool.get("kivy", {}) if isinstance(tool, dict) else {}
762
+ linux = kivy.get("linux") if isinstance(kivy, dict) else None
763
+ if linux is None:
764
+ return None
765
+ if not isinstance(linux, dict):
766
+ raise ConfigError(
767
+ "[tool.kivy.linux] must be a table", key_path="tool.kivy.linux"
768
+ )
769
+
770
+ schema_version = _parse_platform_schema_version(
771
+ linux,
772
+ finder,
773
+ key_path="tool.kivy.linux.schema_version",
774
+ supported=SUPPORTED_LINUX_SCHEMA_VERSION,
775
+ )
776
+
777
+ app_id = linux.get("app_id")
778
+ if not app_id or not isinstance(app_id, str):
779
+ raise ConfigError(
780
+ "missing required [tool.kivy.linux].app_id",
781
+ key_path="tool.kivy.linux.app_id",
782
+ hint='e.g. app_id = "org.example.myapp".',
783
+ )
784
+ # app_id names the <app_id>.desktop file, Icon= key, and StartupWMClass;
785
+ # restrict it to a valid .desktop basename (mirrors macOS bundle_id).
786
+ if not re.fullmatch(r"[A-Za-z0-9.-]+", app_id):
787
+ raise ConfigError(
788
+ f"invalid character in [tool.kivy.linux].app_id {app_id!r}",
789
+ key_path="tool.kivy.linux.app_id",
790
+ line=finder.line("app_id"),
791
+ hint=(
792
+ "app_id may contain only letters, digits, hyphen (-), and period "
793
+ '(.). Replace underscores with hyphens, e.g. "org.example.hello".'
794
+ ),
795
+ )
796
+
797
+ glibc_floor = linux.get("glibc_floor")
798
+ if glibc_floor is not None and not isinstance(glibc_floor, str):
799
+ raise ConfigError(
800
+ "[tool.kivy.linux].glibc_floor must be a string",
801
+ key_path="tool.kivy.linux.glibc_floor",
802
+ line=finder.line("glibc_floor"),
803
+ )
804
+
805
+ archs = _parse_linux_archs(linux, finder)
806
+
807
+ extra_index_urls = linux.get("extra_index_urls", [])
808
+ if not isinstance(extra_index_urls, list) or not all(
809
+ isinstance(u, str) for u in extra_index_urls
810
+ ):
811
+ raise ConfigError(
812
+ "[tool.kivy.linux].extra_index_urls must be a list of strings",
813
+ key_path="tool.kivy.linux.extra_index_urls",
814
+ line=finder.line("extra_index_urls"),
815
+ )
816
+
817
+ find_links = _parse_platform_find_links(
818
+ linux,
819
+ finder,
820
+ key_path="tool.kivy.linux.find_links",
821
+ project_root=project_root,
822
+ )
823
+ exclude = _parse_platform_exclude(linux, finder, key_path="tool.kivy.linux.exclude")
824
+
825
+ python_version = _parse_platform_python_version(
826
+ linux, key_path="tool.kivy.linux.python"
827
+ )
828
+ _check_requires_python_generic(
829
+ project,
830
+ python_version,
831
+ finder,
832
+ key_path="tool.kivy.linux.python.version",
833
+ )
834
+
835
+ icons = _parse_platform_icons(linux, key_path="tool.kivy.linux.icons")
836
+ desktop = _parse_desktop(linux, finder)
837
+ binaries = _parse_native_binaries(linux, "linux", finder)
838
+ build_settings = _parse_desktop_build_settings(
839
+ linux.get("build_settings"), "tool.kivy.linux.build_settings", finder
840
+ )
841
+
842
+ return LinuxConfig(
843
+ schema_version=schema_version,
844
+ app_id=app_id,
845
+ glibc_floor=glibc_floor,
846
+ archs=archs,
847
+ extra_index_urls=tuple(extra_index_urls),
848
+ find_links=tuple(find_links),
849
+ exclude=tuple(exclude),
850
+ python_version=python_version,
851
+ icons=icons,
852
+ desktop=desktop,
853
+ binaries=tuple(binaries),
854
+ build_settings=build_settings,
855
+ )
856
+
857
+
858
+ def _parse_linux_archs(linux: dict, finder: _LineFinder) -> tuple[str, ...]:
859
+ raw = linux.get("archs")
860
+ if raw is None:
861
+ return DEFAULT_LINUX_ARCHS
862
+ line = finder.line("archs")
863
+ if not isinstance(raw, list) or not all(isinstance(a, str) for a in raw):
864
+ raise ConfigError(
865
+ "[tool.kivy.linux].archs must be a list of strings",
866
+ key_path="tool.kivy.linux.archs",
867
+ line=line,
868
+ )
869
+ if not raw:
870
+ raise ConfigError(
871
+ "[tool.kivy.linux].archs must not be empty",
872
+ key_path="tool.kivy.linux.archs",
873
+ line=line,
874
+ hint='only "x86_64" is supported this phase.',
875
+ )
876
+ unknown = [a for a in raw if a not in VALID_LINUX_ARCHS]
877
+ if unknown:
878
+ valid = ", ".join(sorted(VALID_LINUX_ARCHS))
879
+ raise ConfigError(
880
+ f"unsupported Linux arch(es) {unknown} in [tool.kivy.linux].archs",
881
+ key_path="tool.kivy.linux.archs",
882
+ line=line,
883
+ hint=f"only {valid} is supported this phase (aarch64 is planned).",
884
+ )
885
+ seen: set[str] = set()
886
+ ordered: list[str] = []
887
+ for arch in raw:
888
+ if arch not in seen:
889
+ seen.add(arch)
890
+ ordered.append(arch)
891
+ return tuple(ordered)
892
+
893
+
894
+ def _parse_desktop(linux: dict, finder: _LineFinder) -> DesktopConfig:
895
+ desktop = linux.get("desktop")
896
+ if desktop is None:
897
+ return DesktopConfig()
898
+ if not isinstance(desktop, dict):
899
+ raise ConfigError(
900
+ "[tool.kivy.linux.desktop] must be a table",
901
+ key_path="tool.kivy.linux.desktop",
902
+ )
903
+ raw = desktop.get("categories")
904
+ if raw is None:
905
+ return DesktopConfig()
906
+ if not isinstance(raw, list) or not all(isinstance(c, str) and c for c in raw):
907
+ raise ConfigError(
908
+ "[tool.kivy.linux.desktop].categories must be a list of non-empty strings",
909
+ key_path="tool.kivy.linux.desktop.categories",
910
+ line=finder.line("categories"),
911
+ )
912
+ # Categories must be freedesktop *main* categories (linux-spec); reject typos
913
+ # here so an invalid value never ships in a .desktop that breaks menu/search
914
+ # integration (also enforced by desktop-file-validate at build/package time).
915
+ unknown = [c for c in raw if c not in FREEDESKTOP_MAIN_CATEGORIES]
916
+ if unknown:
917
+ valid = ", ".join(sorted(FREEDESKTOP_MAIN_CATEGORIES))
918
+ raise ConfigError(
919
+ f"invalid [tool.kivy.linux.desktop].categories value(s): "
920
+ f"{', '.join(unknown)}",
921
+ key_path="tool.kivy.linux.desktop.categories",
922
+ line=finder.line("categories"),
923
+ hint=f"use freedesktop main categories: {valid}.",
924
+ )
925
+ return DesktopConfig(categories=tuple(raw) or DEFAULT_DESKTOP_CATEGORIES)
926
+
927
+
928
+ # --------------------------------------------------------------------------- #
929
+ # [tool.kivy.windows]
930
+ # --------------------------------------------------------------------------- #
931
+ def _parse_windows(
932
+ raw: dict,
933
+ finder: _LineFinder,
934
+ project: ProjectMeta,
935
+ *,
936
+ project_root: Path | None = None,
937
+ ) -> WindowsConfig | None:
938
+ tool = raw.get("tool", {})
939
+ kivy = tool.get("kivy", {}) if isinstance(tool, dict) else {}
940
+ windows = kivy.get("windows") if isinstance(kivy, dict) else None
941
+ if windows is None:
942
+ return None
943
+ if not isinstance(windows, dict):
944
+ raise ConfigError(
945
+ "[tool.kivy.windows] must be a table", key_path="tool.kivy.windows"
946
+ )
947
+
948
+ schema_version = _parse_platform_schema_version(
949
+ windows,
950
+ finder,
951
+ key_path="tool.kivy.windows.schema_version",
952
+ supported=SUPPORTED_WINDOWS_SCHEMA_VERSION,
953
+ )
954
+
955
+ app_id = _validate_windows_app_id(windows.get("app_id"), finder)
956
+ archs = _parse_windows_archs(windows, finder)
957
+
958
+ extra_index_urls = windows.get("extra_index_urls", [])
959
+ if not isinstance(extra_index_urls, list) or not all(
960
+ isinstance(u, str) for u in extra_index_urls
961
+ ):
962
+ raise ConfigError(
963
+ "[tool.kivy.windows].extra_index_urls must be a list of strings",
964
+ key_path="tool.kivy.windows.extra_index_urls",
965
+ line=finder.line("extra_index_urls"),
966
+ )
967
+
968
+ find_links = _parse_platform_find_links(
969
+ windows,
970
+ finder,
971
+ key_path="tool.kivy.windows.find_links",
972
+ project_root=project_root,
973
+ )
974
+ exclude = _parse_platform_exclude(
975
+ windows, finder, key_path="tool.kivy.windows.exclude"
976
+ )
977
+
978
+ python_version = _parse_platform_python_version(
979
+ windows, key_path="tool.kivy.windows.python"
980
+ )
981
+ _check_requires_python_generic(
982
+ project,
983
+ python_version,
984
+ finder,
985
+ key_path="tool.kivy.windows.python.version",
986
+ )
987
+
988
+ icons = _parse_platform_icons(windows, key_path="tool.kivy.windows.icons")
989
+ signing = _parse_windows_signing(windows, finder)
990
+ binaries = _parse_native_binaries(windows, "windows", finder)
991
+ build_settings = _parse_desktop_build_settings(
992
+ windows.get("build_settings"), "tool.kivy.windows.build_settings", finder
993
+ )
994
+
995
+ return WindowsConfig(
996
+ schema_version=schema_version,
997
+ app_id=app_id,
998
+ archs=archs,
999
+ extra_index_urls=tuple(extra_index_urls),
1000
+ find_links=tuple(find_links),
1001
+ exclude=tuple(exclude),
1002
+ python_version=python_version,
1003
+ icons=icons,
1004
+ signing=signing,
1005
+ binaries=tuple(binaries),
1006
+ build_settings=build_settings,
1007
+ )
1008
+
1009
+
1010
+ def _validate_windows_app_id(app_id: object, finder: _LineFinder) -> str:
1011
+ # app_id is the Windows AppUserModelID. Enforce only Microsoft's *hard*
1012
+ # constraints — no spaces, ≤128 characters (windows-spec). Pascal-case /
1013
+ # period-delimited style is a doctor WARNING, not a config-time error, and
1014
+ # hyphens are allowed. This is a different identifier from the Linux
1015
+ # reverse-DNS app_id and the macOS bundle_id, so it does not mirror theirs.
1016
+ if not app_id or not isinstance(app_id, str):
1017
+ raise ConfigError(
1018
+ "missing required [tool.kivy.windows].app_id",
1019
+ key_path="tool.kivy.windows.app_id",
1020
+ hint='e.g. app_id = "Example.MyApp" (AppUserModelID).',
1021
+ )
1022
+ if " " in app_id:
1023
+ raise ConfigError(
1024
+ f"[tool.kivy.windows].app_id {app_id!r} must not contain spaces",
1025
+ key_path="tool.kivy.windows.app_id",
1026
+ line=finder.line("app_id"),
1027
+ hint="an AppUserModelID may not contain spaces; use a compact "
1028
+ 'identifier such as "Example.MyApp".',
1029
+ )
1030
+ if len(app_id) > WINDOWS_APP_ID_MAX_LENGTH:
1031
+ raise ConfigError(
1032
+ f"[tool.kivy.windows].app_id is {len(app_id)} characters; the "
1033
+ f"AppUserModelID maximum is {WINDOWS_APP_ID_MAX_LENGTH}",
1034
+ key_path="tool.kivy.windows.app_id",
1035
+ line=finder.line("app_id"),
1036
+ )
1037
+ return app_id
1038
+
1039
+
1040
+ def _parse_windows_archs(windows: dict, finder: _LineFinder) -> tuple[str, ...]:
1041
+ raw = windows.get("archs")
1042
+ if raw is None:
1043
+ return DEFAULT_WINDOWS_ARCHS
1044
+ line = finder.line("archs")
1045
+ if not isinstance(raw, list) or not all(isinstance(a, str) for a in raw):
1046
+ raise ConfigError(
1047
+ "[tool.kivy.windows].archs must be a list of strings",
1048
+ key_path="tool.kivy.windows.archs",
1049
+ line=line,
1050
+ )
1051
+ if not raw:
1052
+ raise ConfigError(
1053
+ "[tool.kivy.windows].archs must not be empty",
1054
+ key_path="tool.kivy.windows.archs",
1055
+ line=line,
1056
+ hint='only "amd64" is supported this phase.',
1057
+ )
1058
+ # arm64: the "win-arm64 is planned" hint + the amd64-only validation relax
1059
+ # automatically once VALID_WINDOWS_ARCHS gains "arm64". See arm64-windows.md §1.
1060
+ unknown = [a for a in raw if a not in VALID_WINDOWS_ARCHS]
1061
+ if unknown:
1062
+ valid = ", ".join(sorted(VALID_WINDOWS_ARCHS))
1063
+ raise ConfigError(
1064
+ f"unsupported Windows arch(es) {unknown} in [tool.kivy.windows].archs",
1065
+ key_path="tool.kivy.windows.archs",
1066
+ line=line,
1067
+ hint=f"only {valid} is supported this phase (win-arm64 is planned).",
1068
+ )
1069
+ seen: set[str] = set()
1070
+ ordered: list[str] = []
1071
+ for arch in raw:
1072
+ if arch not in seen:
1073
+ seen.add(arch)
1074
+ ordered.append(arch)
1075
+ return tuple(ordered)
1076
+
1077
+
1078
+ def _parse_windows_signing(windows: dict, finder: _LineFinder) -> WindowsSigningConfig:
1079
+ signing = windows.get("signing")
1080
+ if signing is None:
1081
+ return WindowsSigningConfig()
1082
+ if not isinstance(signing, dict):
1083
+ raise ConfigError(
1084
+ "[tool.kivy.windows.signing] must be a table",
1085
+ key_path="tool.kivy.windows.signing",
1086
+ )
1087
+ for key in ("thumbprint", "timestamp_url", "store_scope"):
1088
+ value = signing.get(key, "")
1089
+ if not isinstance(value, str):
1090
+ raise ConfigError(
1091
+ f"[tool.kivy.windows.signing].{key} must be a string",
1092
+ key_path=f"tool.kivy.windows.signing.{key}",
1093
+ line=finder.line(key),
1094
+ )
1095
+ store_scope = signing.get("store_scope", DEFAULT_WINDOWS_STORE_SCOPE)
1096
+ if store_scope not in VALID_WINDOWS_STORE_SCOPES:
1097
+ valid = ", ".join(sorted(VALID_WINDOWS_STORE_SCOPES))
1098
+ raise ConfigError(
1099
+ f"[tool.kivy.windows.signing].store_scope {store_scope!r} is invalid",
1100
+ key_path="tool.kivy.windows.signing.store_scope",
1101
+ line=finder.line("store_scope"),
1102
+ hint=f"valid values are: {valid}.",
1103
+ )
1104
+ return WindowsSigningConfig(
1105
+ thumbprint=signing.get("thumbprint", ""),
1106
+ timestamp_url=signing.get("timestamp_url", DEFAULT_WINDOWS_TIMESTAMP_URL),
1107
+ store_scope=store_scope,
1108
+ )
1109
+
1110
+
1111
+ # --------------------------------------------------------------------------- #
1112
+ # [tool.kivy.android] (android/01, validation rules 1-21)
1113
+ # --------------------------------------------------------------------------- #
1114
+
1115
+ _JAVA_IDENT_RE = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*$")
1116
+ _HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
1117
+
1118
+
1119
+ def _parse_android(
1120
+ raw: dict,
1121
+ finder: _LineFinder,
1122
+ project: ProjectMeta,
1123
+ *,
1124
+ project_root: Path | None = None,
1125
+ ) -> AndroidConfig | None:
1126
+ tool = raw.get("tool", {})
1127
+ kivy = tool.get("kivy", {}) if isinstance(tool, dict) else {}
1128
+ android = kivy.get("android") if isinstance(kivy, dict) else None
1129
+ if android is None:
1130
+ return None
1131
+ if not isinstance(android, dict):
1132
+ raise ConfigError(
1133
+ "[tool.kivy.android] must be a table", key_path="tool.kivy.android"
1134
+ )
1135
+
1136
+ schema_version = _parse_platform_schema_version(
1137
+ android,
1138
+ finder,
1139
+ key_path="tool.kivy.android.schema_version",
1140
+ supported=SUPPORTED_ANDROID_SCHEMA_VERSION,
1141
+ )
1142
+ package = _validate_android_package(android.get("package"), finder)
1143
+ build = _parse_android_build_counter(android, finder)
1144
+ version_code, version_code_auto = _parse_android_version_code(
1145
+ android, finder, project, build=build
1146
+ )
1147
+ min_sdk, target_sdk, compile_sdk = _parse_android_sdk_levels(android, finder)
1148
+ kivy_generation = _parse_android_kivy_generation(android, finder)
1149
+ abis = _parse_android_abis(android, finder)
1150
+
1151
+ extra_index_urls = android.get("extra_index_urls", [])
1152
+ if not isinstance(extra_index_urls, list) or not all(
1153
+ isinstance(u, str) for u in extra_index_urls
1154
+ ):
1155
+ raise ConfigError(
1156
+ "[tool.kivy.android].extra_index_urls must be a list of strings",
1157
+ key_path="tool.kivy.android.extra_index_urls",
1158
+ line=finder.line("extra_index_urls"),
1159
+ )
1160
+
1161
+ find_links = _parse_platform_find_links(
1162
+ android,
1163
+ finder,
1164
+ key_path="tool.kivy.android.find_links",
1165
+ project_root=project_root,
1166
+ )
1167
+ exclude = _parse_platform_exclude(
1168
+ android, finder, key_path="tool.kivy.android.exclude"
1169
+ )
1170
+
1171
+ base_theme = android.get("base_theme", DEFAULT_ANDROID_BASE_THEME)
1172
+ if not isinstance(base_theme, str) or not base_theme.strip():
1173
+ raise ConfigError(
1174
+ "[tool.kivy.android].base_theme must be a non-empty string",
1175
+ key_path="tool.kivy.android.base_theme",
1176
+ line=finder.line("base_theme"),
1177
+ )
1178
+
1179
+ python = _parse_android_python(android, finder)
1180
+ _check_requires_python_generic(
1181
+ project,
1182
+ python.version,
1183
+ finder,
1184
+ key_path="tool.kivy.android.python.version",
1185
+ )
1186
+
1187
+ permissions = _parse_android_permissions(android, finder)
1188
+ icons = _parse_android_icons(android, finder)
1189
+ splash = _parse_android_splash(android, finder)
1190
+ aars = _parse_android_archives(android, "aars", finder)
1191
+ jars = _parse_android_archives(android, "jars", finder)
1192
+ gradle = _parse_android_gradle(android, finder)
1193
+ include_files = _parse_android_include_files(
1194
+ android, finder, project_root=project_root
1195
+ )
1196
+ src = _parse_android_src(android, finder)
1197
+ services = _parse_android_services(android, finder)
1198
+ activities = _parse_android_activities(android, finder)
1199
+ intent_filters = _parse_android_intent_filters(android, finder)
1200
+ manifest = _parse_android_manifest(android, finder)
1201
+ signing = _parse_android_signing(android, finder)
1202
+ gradle_properties = _parse_android_gradle_properties(android, finder)
1203
+ build_settings = _parse_android_build_settings(android, finder)
1204
+
1205
+ return AndroidConfig(
1206
+ schema_version=schema_version,
1207
+ package=package,
1208
+ version_code=version_code,
1209
+ version_code_auto=version_code_auto,
1210
+ build=build,
1211
+ min_sdk=min_sdk,
1212
+ target_sdk=target_sdk,
1213
+ compile_sdk=compile_sdk,
1214
+ kivy_generation=kivy_generation,
1215
+ abis=abis,
1216
+ extra_index_urls=tuple(extra_index_urls),
1217
+ find_links=tuple(find_links),
1218
+ exclude=tuple(exclude),
1219
+ base_theme=base_theme,
1220
+ python=python,
1221
+ permissions=permissions,
1222
+ icons=icons,
1223
+ splash=splash,
1224
+ aars=tuple(aars),
1225
+ jars=tuple(jars),
1226
+ gradle=gradle,
1227
+ include_files=tuple(include_files),
1228
+ src=src,
1229
+ services=tuple(services),
1230
+ activities=tuple(activities),
1231
+ intent_filters=tuple(intent_filters),
1232
+ manifest=manifest,
1233
+ signing=signing,
1234
+ gradle_properties=gradle_properties,
1235
+ build_settings=build_settings,
1236
+ )
1237
+
1238
+
1239
+ def _validate_android_package(package: object, finder: _LineFinder) -> str:
1240
+ # Rule 4: the applicationId — >= 2 dot-separated Java-identifier segments.
1241
+ if not package or not isinstance(package, str):
1242
+ raise ConfigError(
1243
+ "missing required [tool.kivy.android].package",
1244
+ key_path="tool.kivy.android.package",
1245
+ hint='e.g. package = "org.example.myapp" (the Android applicationId).',
1246
+ )
1247
+ segments = package.split(".")
1248
+ if len(segments) < 2 or not all(_JAVA_IDENT_RE.match(s) for s in segments):
1249
+ raise ConfigError(
1250
+ f"[tool.kivy.android].package {package!r} is not a valid Android "
1251
+ "package name",
1252
+ key_path="tool.kivy.android.package",
1253
+ line=finder.line("package"),
1254
+ hint="use >= 2 dot-separated Java-identifier segments, e.g. "
1255
+ '"org.example.myapp".',
1256
+ )
1257
+ return package
1258
+
1259
+
1260
+ def _parse_android_build_counter(android: dict, finder: _LineFinder) -> int:
1261
+ build = android.get("build", 0)
1262
+ if isinstance(build, bool) or not isinstance(build, int) or not 0 <= build <= 99:
1263
+ raise ConfigError(
1264
+ "[tool.kivy.android].build must be an integer in 0-99",
1265
+ key_path="tool.kivy.android.build",
1266
+ line=finder.line("build"),
1267
+ hint='build is the re-upload counter used only when version_code = "auto".',
1268
+ )
1269
+ return build
1270
+
1271
+
1272
+ def _parse_android_version_code(
1273
+ android: dict, finder: _LineFinder, project: ProjectMeta, *, build: int
1274
+ ) -> tuple[int, bool]:
1275
+ # Rule 20: a positive integer used verbatim, or "auto" derived from
1276
+ # [project].version as MAJOR*1_000_000 + MINOR*10_000 + PATCH*100 + build.
1277
+ raw = android.get("version_code", 1)
1278
+ line = finder.line("version_code")
1279
+ if isinstance(raw, bool):
1280
+ raw = None # bool is an int subclass; force the type error below
1281
+ if isinstance(raw, int):
1282
+ if raw < 1:
1283
+ raise ConfigError(
1284
+ "[tool.kivy.android].version_code must be a positive integer",
1285
+ key_path="tool.kivy.android.version_code",
1286
+ line=line,
1287
+ )
1288
+ if raw > ANDROID_VERSION_CODE_CEILING:
1289
+ raise ConfigError(
1290
+ f"[tool.kivy.android].version_code {raw} exceeds Google Play's "
1291
+ f"ceiling of {ANDROID_VERSION_CODE_CEILING:,}",
1292
+ key_path="tool.kivy.android.version_code",
1293
+ line=line,
1294
+ )
1295
+ return raw, False
1296
+ if raw == "auto":
1297
+ try:
1298
+ version = Version(project.version)
1299
+ except InvalidVersion as exc:
1300
+ raise ConfigError(
1301
+ f"[project].version {project.version!r} is not a valid version; "
1302
+ 'version_code = "auto" cannot derive from it',
1303
+ key_path="tool.kivy.android.version_code",
1304
+ line=line,
1305
+ ) from exc
1306
+ if (
1307
+ version.is_prerelease
1308
+ or version.is_devrelease
1309
+ or version.is_postrelease
1310
+ or version.epoch != 0
1311
+ or version.local is not None
1312
+ ):
1313
+ raise ConfigError(
1314
+ f'version_code = "auto" requires a final MAJOR.MINOR.PATCH '
1315
+ f"[project].version; {project.version!r} is not one",
1316
+ key_path="tool.kivy.android.version_code",
1317
+ line=line,
1318
+ hint="pre-release/dev/post/local/epoch versions have no "
1319
+ "monotonic integer mapping; set an explicit integer "
1320
+ "version_code for those.",
1321
+ )
1322
+ release = version.release
1323
+ if len(release) > 3:
1324
+ raise ConfigError(
1325
+ f"[project].version {project.version!r} has a 4th numeric "
1326
+ 'segment; version_code = "auto" supports MAJOR.MINOR.PATCH only',
1327
+ key_path="tool.kivy.android.version_code",
1328
+ line=line,
1329
+ hint="express the extra increment via the `build` counter.",
1330
+ )
1331
+ major = release[0]
1332
+ minor = release[1] if len(release) > 1 else 0
1333
+ patch = release[2] if len(release) > 2 else 0
1334
+ if minor > 99 or patch > 99:
1335
+ raise ConfigError(
1336
+ f"MINOR and PATCH must each be <= 99 for "
1337
+ f'version_code = "auto"; got {project.version!r}',
1338
+ key_path="tool.kivy.android.version_code",
1339
+ line=line,
1340
+ )
1341
+ code = major * 1_000_000 + minor * 10_000 + patch * 100 + build
1342
+ if code > ANDROID_VERSION_CODE_CEILING:
1343
+ raise ConfigError(
1344
+ f"auto-derived version_code {code:,} exceeds Google Play's "
1345
+ f"ceiling of {ANDROID_VERSION_CODE_CEILING:,}",
1346
+ key_path="tool.kivy.android.version_code",
1347
+ line=line,
1348
+ )
1349
+ return code, True
1350
+ raise ConfigError(
1351
+ "[tool.kivy.android].version_code must be a positive integer or the "
1352
+ 'string "auto"',
1353
+ key_path="tool.kivy.android.version_code",
1354
+ line=line,
1355
+ )
1356
+
1357
+
1358
+ def _parse_android_sdk_levels(
1359
+ android: dict, finder: _LineFinder
1360
+ ) -> tuple[int, int, int]:
1361
+ # Rule 8: min_sdk >= 24; target_sdk >= min_sdk; compile_sdk >= target_sdk.
1362
+ def _level(key: str, default: int) -> int:
1363
+ value = android.get(key, default)
1364
+ if isinstance(value, bool) or not isinstance(value, int):
1365
+ raise ConfigError(
1366
+ f"[tool.kivy.android].{key} must be an integer",
1367
+ key_path=f"tool.kivy.android.{key}",
1368
+ line=finder.line(key),
1369
+ )
1370
+ return value
1371
+
1372
+ min_sdk = _level("min_sdk", DEFAULT_ANDROID_MIN_SDK)
1373
+ target_sdk = _level("target_sdk", DEFAULT_ANDROID_TARGET_SDK)
1374
+ compile_sdk = _level("compile_sdk", target_sdk)
1375
+ if min_sdk < ANDROID_MIN_SDK_FLOOR:
1376
+ raise ConfigError(
1377
+ f"[tool.kivy.android].min_sdk {min_sdk} is below the floor of "
1378
+ f"{ANDROID_MIN_SDK_FLOOR}",
1379
+ key_path="tool.kivy.android.min_sdk",
1380
+ line=finder.line("min_sdk"),
1381
+ hint="API 24 is the first level with RUNPATH and the python.org "
1382
+ "runtime / android wheel-tag floor (android/01 §min_sdk).",
1383
+ )
1384
+ if target_sdk < min_sdk:
1385
+ raise ConfigError(
1386
+ f"[tool.kivy.android].target_sdk {target_sdk} is below min_sdk {min_sdk}",
1387
+ key_path="tool.kivy.android.target_sdk",
1388
+ line=finder.line("target_sdk"),
1389
+ )
1390
+ if compile_sdk < target_sdk:
1391
+ raise ConfigError(
1392
+ f"[tool.kivy.android].compile_sdk {compile_sdk} is below target_sdk "
1393
+ f"{target_sdk}",
1394
+ key_path="tool.kivy.android.compile_sdk",
1395
+ line=finder.line("compile_sdk"),
1396
+ )
1397
+ return min_sdk, target_sdk, compile_sdk
1398
+
1399
+
1400
+ def _parse_android_kivy_generation(android: dict, finder: _LineFinder) -> int:
1401
+ # Rule 9.
1402
+ generation = android.get("kivy_generation", DEFAULT_ANDROID_KIVY_GENERATION)
1403
+ if isinstance(generation, bool) or generation not in VALID_ANDROID_KIVY_GENERATIONS:
1404
+ raise ConfigError(
1405
+ f"[tool.kivy.android].kivy_generation must be 2 or 3, got {generation!r}",
1406
+ key_path="tool.kivy.android.kivy_generation",
1407
+ line=finder.line("kivy_generation"),
1408
+ hint="kivy_generation = 2 targets Kivy 2.3.1 (SDL2); "
1409
+ "kivy_generation = 3 targets Kivy 3.0 (SDL3).",
1410
+ )
1411
+ return generation
1412
+
1413
+
1414
+ def _parse_android_abis(android: dict, finder: _LineFinder) -> tuple[str, ...]:
1415
+ # Rule 10: non-empty subset of the 64-bit ABI set.
1416
+ raw = android.get("abis")
1417
+ if raw is None:
1418
+ return DEFAULT_ANDROID_ABIS
1419
+ line = finder.line("abis")
1420
+ if not isinstance(raw, list) or not all(isinstance(a, str) for a in raw):
1421
+ raise ConfigError(
1422
+ "[tool.kivy.android].abis must be a list of strings",
1423
+ key_path="tool.kivy.android.abis",
1424
+ line=line,
1425
+ )
1426
+ if not raw:
1427
+ raise ConfigError(
1428
+ "[tool.kivy.android].abis must not be empty",
1429
+ key_path="tool.kivy.android.abis",
1430
+ line=line,
1431
+ )
1432
+ unknown = [a for a in raw if a not in VALID_ANDROID_ABIS]
1433
+ if unknown:
1434
+ thirty_two = [a for a in unknown if a in ("armeabi_v7a", "armeabi-v7a", "x86")]
1435
+ hint = (
1436
+ "the python.org Android runtime is 64-bit only, so 32-bit ABIs are "
1437
+ "rejected permanently (android/01 §ABIs)."
1438
+ if thirty_two
1439
+ else 'valid ABIs are "arm64_v8a" and "x86_64" (wheel-tag spelling).'
1440
+ )
1441
+ raise ConfigError(
1442
+ f"unsupported Android ABI(s) {unknown} in [tool.kivy.android].abis",
1443
+ key_path="tool.kivy.android.abis",
1444
+ line=line,
1445
+ hint=hint,
1446
+ )
1447
+ seen: set[str] = set()
1448
+ ordered: list[str] = []
1449
+ for abi in raw:
1450
+ if abi not in seen:
1451
+ seen.add(abi)
1452
+ ordered.append(abi)
1453
+ return tuple(ordered)
1454
+
1455
+
1456
+ def _parse_android_python(android: dict, finder: _LineFinder) -> AndroidPythonConfig:
1457
+ # Rule 11: [tool.kivy.android.python].version is required.
1458
+ version = _parse_platform_python_version(
1459
+ android, key_path="tool.kivy.android.python"
1460
+ )
1461
+ if version is None:
1462
+ raise ConfigError(
1463
+ "missing required [tool.kivy.android.python].version",
1464
+ key_path="tool.kivy.android.python.version",
1465
+ hint="pin the python.org Android embeddable-package version, "
1466
+ 'e.g. version = "3.14.6".',
1467
+ )
1468
+ return AndroidPythonConfig(version=version)
1469
+
1470
+
1471
+ def _validate_android_relpath(
1472
+ value: object, *, key_path: str, finder: _LineFinder, key: str
1473
+ ) -> str:
1474
+ """A repo-relative, non-escaping path field (icons, splash, src, ...)."""
1475
+ if not isinstance(value, str) or not value:
1476
+ raise ConfigError(
1477
+ f"{key_path} must be a non-empty string path",
1478
+ key_path=key_path,
1479
+ line=finder.line(key),
1480
+ )
1481
+ if _is_absolute_source(value):
1482
+ raise ConfigError(
1483
+ f"{key_path} must be repo-relative, not absolute: {value!r}",
1484
+ key_path=key_path,
1485
+ line=finder.line(key),
1486
+ )
1487
+ normalized = _posix_normpath(value)
1488
+ if normalized == ".." or normalized.startswith("../"):
1489
+ raise ConfigError(
1490
+ f"{key_path} escapes the project directory: {value!r}",
1491
+ key_path=key_path,
1492
+ line=finder.line(key),
1493
+ )
1494
+ return value
1495
+
1496
+
1497
+ def _parse_android_permissions(
1498
+ android: dict, finder: _LineFinder
1499
+ ) -> AndroidPermissions:
1500
+ table = android.get("permissions")
1501
+ if table is None:
1502
+ return AndroidPermissions()
1503
+ if not isinstance(table, dict):
1504
+ raise ConfigError(
1505
+ "[tool.kivy.android.permissions] must be a table",
1506
+ key_path="tool.kivy.android.permissions",
1507
+ )
1508
+ uses = table.get("uses", [])
1509
+ if not isinstance(uses, list) or not all(
1510
+ isinstance(p, str) and p.strip() for p in uses
1511
+ ):
1512
+ raise ConfigError(
1513
+ "[tool.kivy.android.permissions].uses must be a list of non-empty strings",
1514
+ key_path="tool.kivy.android.permissions.uses",
1515
+ line=finder.line("uses"),
1516
+ )
1517
+ features_raw = table.get("features", [])
1518
+ if not isinstance(features_raw, list):
1519
+ raise ConfigError(
1520
+ "[tool.kivy.android.permissions].features must be a list of tables",
1521
+ key_path="tool.kivy.android.permissions.features",
1522
+ line=finder.line("features"),
1523
+ )
1524
+ features: list[AndroidFeature] = []
1525
+ for i, entry in enumerate(features_raw):
1526
+ key_path = f"tool.kivy.android.permissions.features[{i}]"
1527
+ if not isinstance(entry, dict) or not isinstance(entry.get("name"), str):
1528
+ raise ConfigError(
1529
+ f"{key_path} must be a table with a string `name`",
1530
+ key_path=key_path,
1531
+ )
1532
+ required = entry.get("required", True)
1533
+ if not isinstance(required, bool):
1534
+ raise ConfigError(
1535
+ f"{key_path}.required must be a bool", key_path=f"{key_path}.required"
1536
+ )
1537
+ features.append(AndroidFeature(name=entry["name"], required=required))
1538
+ auto_features = table.get("auto_features", True)
1539
+ if not isinstance(auto_features, bool):
1540
+ raise ConfigError(
1541
+ "[tool.kivy.android.permissions].auto_features must be a bool",
1542
+ key_path="tool.kivy.android.permissions.auto_features",
1543
+ line=finder.line("auto_features"),
1544
+ )
1545
+ return AndroidPermissions(
1546
+ uses=tuple(uses), features=tuple(features), auto_features=auto_features
1547
+ )
1548
+
1549
+
1550
+ def _parse_android_icons(android: dict, finder: _LineFinder) -> AndroidIconConfig:
1551
+ table = android.get("icons")
1552
+ if table is None:
1553
+ return AndroidIconConfig()
1554
+ if not isinstance(table, dict):
1555
+ raise ConfigError(
1556
+ "[tool.kivy.android.icons] must be a table",
1557
+ key_path="tool.kivy.android.icons",
1558
+ )
1559
+ source = table.get("source")
1560
+ if source is not None:
1561
+ source = _validate_android_relpath(
1562
+ source,
1563
+ key_path="tool.kivy.android.icons.source",
1564
+ finder=finder,
1565
+ key="source",
1566
+ )
1567
+ background = table.get("background")
1568
+ if background is not None:
1569
+ if not isinstance(background, str) or not background:
1570
+ raise ConfigError(
1571
+ "[tool.kivy.android.icons].background must be a #rrggbb color "
1572
+ "or an image path",
1573
+ key_path="tool.kivy.android.icons.background",
1574
+ line=finder.line("background"),
1575
+ )
1576
+ if background.startswith("#") and not _HEX_COLOR_RE.match(background):
1577
+ raise ConfigError(
1578
+ f"[tool.kivy.android.icons].background {background!r} is not a "
1579
+ "#rrggbb color",
1580
+ key_path="tool.kivy.android.icons.background",
1581
+ line=finder.line("background"),
1582
+ )
1583
+ if not background.startswith("#"):
1584
+ background = _validate_android_relpath(
1585
+ background,
1586
+ key_path="tool.kivy.android.icons.background",
1587
+ finder=finder,
1588
+ key="background",
1589
+ )
1590
+ monochrome = table.get("monochrome")
1591
+ if monochrome is not None:
1592
+ monochrome = _validate_android_relpath(
1593
+ monochrome,
1594
+ key_path="tool.kivy.android.icons.monochrome",
1595
+ finder=finder,
1596
+ key="monochrome",
1597
+ )
1598
+ return AndroidIconConfig(
1599
+ source=source, background=background, monochrome=monochrome
1600
+ )
1601
+
1602
+
1603
+ def _parse_android_splash(android: dict, finder: _LineFinder) -> AndroidSplashConfig:
1604
+ # Rule 21 (splash half): types, #rrggbb colors, positive duration, relpaths.
1605
+ table = android.get("splash")
1606
+ if table is None:
1607
+ return AndroidSplashConfig()
1608
+ if not isinstance(table, dict):
1609
+ raise ConfigError(
1610
+ "[tool.kivy.android.splash] must be a table",
1611
+ key_path="tool.kivy.android.splash",
1612
+ )
1613
+ source = table.get("source")
1614
+ if source is not None:
1615
+ source = _validate_android_relpath(
1616
+ source,
1617
+ key_path="tool.kivy.android.splash.source",
1618
+ finder=finder,
1619
+ key="source",
1620
+ )
1621
+ branding = table.get("branding")
1622
+ if branding is not None:
1623
+ branding = _validate_android_relpath(
1624
+ branding,
1625
+ key_path="tool.kivy.android.splash.branding",
1626
+ finder=finder,
1627
+ key="branding",
1628
+ )
1629
+
1630
+ def _color(key: str) -> str | None:
1631
+ value = table.get(key)
1632
+ if value is None:
1633
+ return None
1634
+ if not isinstance(value, str) or not _HEX_COLOR_RE.match(value):
1635
+ raise ConfigError(
1636
+ f"[tool.kivy.android.splash].{key} must be a #rrggbb color",
1637
+ key_path=f"tool.kivy.android.splash.{key}",
1638
+ line=finder.line(key),
1639
+ )
1640
+ return value
1641
+
1642
+ background = _color("background")
1643
+ icon_background = _color("icon_background")
1644
+ duration = table.get("animation_duration")
1645
+ if duration is not None and (
1646
+ isinstance(duration, bool) or not isinstance(duration, int) or duration <= 0
1647
+ ):
1648
+ raise ConfigError(
1649
+ "[tool.kivy.android.splash].animation_duration must be a positive "
1650
+ "integer (milliseconds)",
1651
+ key_path="tool.kivy.android.splash.animation_duration",
1652
+ line=finder.line("animation_duration"),
1653
+ )
1654
+ return AndroidSplashConfig(
1655
+ source=source,
1656
+ background=background,
1657
+ icon_background=icon_background,
1658
+ animation_duration=duration,
1659
+ branding=branding,
1660
+ )
1661
+
1662
+
1663
+ def _parse_android_archives(
1664
+ android: dict, kind_key: str, finder: _LineFinder
1665
+ ) -> list[AndroidArchiveDep]:
1666
+ # Rule 14: [tool.kivy.android.native.aars] / .jars, explicit URL-or-relative
1667
+ # sources only (mirrors the iOS xcframework rule via the shared validator).
1668
+ native = android.get("native")
1669
+ if native is None:
1670
+ return []
1671
+ if not isinstance(native, dict):
1672
+ raise ConfigError(
1673
+ "[tool.kivy.android.native] must be a table",
1674
+ key_path="tool.kivy.android.native",
1675
+ )
1676
+ table = native.get(kind_key)
1677
+ if table is None:
1678
+ return []
1679
+ kind = kind_key.rstrip("s") # aars -> aar, jars -> jar
1680
+ key_root = f"tool.kivy.android.native.{kind_key}"
1681
+ if not isinstance(table, dict):
1682
+ raise ConfigError(f"[{key_root}] must be a table", key_path=key_root)
1683
+ deps: list[AndroidArchiveDep] = []
1684
+ for name, entry in table.items():
1685
+ key_path = f"{key_root}.{name}"
1686
+ if not isinstance(entry, dict):
1687
+ raise ConfigError(
1688
+ f"[{key_path}] must be an inline table", key_path=key_path
1689
+ )
1690
+ version = entry.get("version")
1691
+ source = entry.get("source")
1692
+ if not isinstance(version, str) or not version:
1693
+ raise ConfigError(
1694
+ f"{key_path}.version must be a non-empty string",
1695
+ key_path=f"{key_path}.version",
1696
+ )
1697
+ if not isinstance(source, str) or not source:
1698
+ raise ConfigError(
1699
+ f"{key_path}.source must be a non-empty string",
1700
+ key_path=f"{key_path}.source",
1701
+ )
1702
+ _validate_artifact_source(kind, name, source, f"{key_path}.source")
1703
+ deps.append(
1704
+ AndroidArchiveDep(name=name, version=version, source=source, kind=kind)
1705
+ )
1706
+ return deps
1707
+
1708
+
1709
+ def _parse_android_gradle(android: dict, finder: _LineFinder) -> AndroidGradleConfig:
1710
+ # Rule 13: fully-versioned group:artifact:version coordinates only.
1711
+ table = android.get("gradle")
1712
+ if table is None:
1713
+ return AndroidGradleConfig()
1714
+ if not isinstance(table, dict):
1715
+ raise ConfigError(
1716
+ "[tool.kivy.android.gradle] must be a table",
1717
+ key_path="tool.kivy.android.gradle",
1718
+ )
1719
+ deps = table.get("dependencies", [])
1720
+ if not isinstance(deps, list) or not all(isinstance(d, str) for d in deps):
1721
+ raise ConfigError(
1722
+ "[tool.kivy.android.gradle].dependencies must be a list of strings",
1723
+ key_path="tool.kivy.android.gradle.dependencies",
1724
+ line=finder.line("dependencies"),
1725
+ )
1726
+ for dep in deps:
1727
+ parts = dep.split(":")
1728
+ version = parts[2] if len(parts) == 3 else ""
1729
+ dynamic = (
1730
+ "+" in version
1731
+ or "*" in version
1732
+ or version.startswith(("[", "("))
1733
+ or version.lower().startswith("latest.")
1734
+ )
1735
+ if len(parts) != 3 or not all(parts) or dynamic:
1736
+ raise ConfigError(
1737
+ f"[tool.kivy.android.gradle].dependencies entry {dep!r} is not a "
1738
+ "fully-versioned group:artifact:version coordinate",
1739
+ key_path="tool.kivy.android.gradle.dependencies",
1740
+ line=finder.line("dependencies"),
1741
+ hint="dynamic versions (+, ranges, latest.*) are rejected for "
1742
+ "reproducibility.",
1743
+ )
1744
+ repos = table.get("repositories", [])
1745
+ if not isinstance(repos, list) or not all(isinstance(r, str) for r in repos):
1746
+ raise ConfigError(
1747
+ "[tool.kivy.android.gradle].repositories must be a list of strings",
1748
+ key_path="tool.kivy.android.gradle.repositories",
1749
+ line=finder.line("repositories"),
1750
+ )
1751
+ return AndroidGradleConfig(dependencies=tuple(deps), repositories=tuple(repos))
1752
+
1753
+
1754
+ def _parse_android_include_files(
1755
+ android: dict, finder: _LineFinder, *, project_root: Path | None
1756
+ ) -> list[AndroidIncludeFile]:
1757
+ # Rule 18: dest inside the generated project, sources repo-relative and
1758
+ # present; generated-file clobber protection is enforced at build time
1759
+ # against the generator's own manifest of emitted files.
1760
+ entries = android.get("include_files")
1761
+ if entries is None:
1762
+ return []
1763
+ if not isinstance(entries, list):
1764
+ raise ConfigError(
1765
+ "[[tool.kivy.android.include_files]] must be an array of tables",
1766
+ key_path="tool.kivy.android.include_files",
1767
+ )
1768
+ out: list[AndroidIncludeFile] = []
1769
+ for i, entry in enumerate(entries):
1770
+ key_path = f"tool.kivy.android.include_files[{i}]"
1771
+ if not isinstance(entry, dict):
1772
+ raise ConfigError(f"{key_path} must be a table", key_path=key_path)
1773
+ dest = entry.get("dest")
1774
+ if not isinstance(dest, str) or not dest:
1775
+ raise ConfigError(
1776
+ f"{key_path}.dest must be a non-empty string",
1777
+ key_path=f"{key_path}.dest",
1778
+ )
1779
+ if _is_absolute_source(dest) or _posix_normpath(dest).startswith(".."):
1780
+ raise ConfigError(
1781
+ f"{key_path}.dest must stay inside the generated project: {dest!r}",
1782
+ key_path=f"{key_path}.dest",
1783
+ )
1784
+ sources = entry.get("sources")
1785
+ if (
1786
+ not isinstance(sources, list)
1787
+ or not sources
1788
+ or not all(isinstance(s, str) and s for s in sources)
1789
+ ):
1790
+ raise ConfigError(
1791
+ f"{key_path}.sources must be a non-empty list of strings",
1792
+ key_path=f"{key_path}.sources",
1793
+ )
1794
+ for src in sources:
1795
+ if _is_absolute_source(src) or _posix_normpath(src).startswith(".."):
1796
+ raise ConfigError(
1797
+ f"{key_path}.sources entry escapes the project: {src!r}",
1798
+ key_path=f"{key_path}.sources",
1799
+ )
1800
+ if project_root is not None and not (project_root / src).exists():
1801
+ raise ConfigError(
1802
+ f"{key_path}.sources entry does not exist: {src!r}",
1803
+ key_path=f"{key_path}.sources",
1804
+ )
1805
+ out.append(AndroidIncludeFile(dest=dest, sources=tuple(sources)))
1806
+ return out
1807
+
1808
+
1809
+ def _parse_android_src(android: dict, finder: _LineFinder) -> AndroidSrcConfig:
1810
+ table = android.get("src")
1811
+ if table is None:
1812
+ return AndroidSrcConfig()
1813
+ if not isinstance(table, dict):
1814
+ raise ConfigError(
1815
+ "[tool.kivy.android.src] must be a table",
1816
+ key_path="tool.kivy.android.src",
1817
+ )
1818
+
1819
+ def _roots(key: str) -> tuple[str, ...]:
1820
+ raw = table.get(key, [])
1821
+ if not isinstance(raw, list) or not all(isinstance(p, str) for p in raw):
1822
+ raise ConfigError(
1823
+ f"[tool.kivy.android.src].{key} must be a list of strings",
1824
+ key_path=f"tool.kivy.android.src.{key}",
1825
+ line=finder.line(key),
1826
+ )
1827
+ for path in raw:
1828
+ _validate_android_relpath(
1829
+ path,
1830
+ key_path=f"tool.kivy.android.src.{key}",
1831
+ finder=finder,
1832
+ key=key,
1833
+ )
1834
+ return tuple(raw)
1835
+
1836
+ return AndroidSrcConfig(java=_roots("java"), kotlin=_roots("kotlin"))
1837
+
1838
+
1839
+ def _parse_android_services(android: dict, finder: _LineFinder) -> list[AndroidService]:
1840
+ entries = android.get("services")
1841
+ if entries is None:
1842
+ return []
1843
+ if not isinstance(entries, list):
1844
+ raise ConfigError(
1845
+ "[[tool.kivy.android.services]] must be an array of tables",
1846
+ key_path="tool.kivy.android.services",
1847
+ )
1848
+ out: list[AndroidService] = []
1849
+ for i, entry in enumerate(entries):
1850
+ key_path = f"tool.kivy.android.services[{i}]"
1851
+ if not isinstance(entry, dict):
1852
+ raise ConfigError(f"{key_path} must be a table", key_path=key_path)
1853
+ name = entry.get("name")
1854
+ if not isinstance(name, str) or not _JAVA_IDENT_RE.match(name or ""):
1855
+ raise ConfigError(
1856
+ f"{key_path}.name must be a Java identifier (the generated "
1857
+ "PythonService subclass name)",
1858
+ key_path=f"{key_path}.name",
1859
+ )
1860
+ entry_point = entry.get("entry_point")
1861
+ if (
1862
+ not isinstance(entry_point, str)
1863
+ or not entry_point
1864
+ or not all(
1865
+ part.isidentifier() and not keyword.iskeyword(part)
1866
+ for part in entry_point.split(".")
1867
+ )
1868
+ ):
1869
+ raise ConfigError(
1870
+ f"{key_path}.entry_point must be a (dotted) Python identifier",
1871
+ key_path=f"{key_path}.entry_point",
1872
+ )
1873
+ exported = entry.get("exported", False)
1874
+ foreground = entry.get("foreground", False)
1875
+ if not isinstance(exported, bool) or not isinstance(foreground, bool):
1876
+ raise ConfigError(
1877
+ f"{key_path}.exported / .foreground must be bools",
1878
+ key_path=key_path,
1879
+ )
1880
+ fstype = entry.get("foreground_service_type")
1881
+ notification_raw = entry.get("notification")
1882
+ notification: AndroidNotification | None = None
1883
+ if foreground:
1884
+ # Android 14+: a foreground service needs BOTH a type and a posted
1885
+ # notification (android/01 services).
1886
+ if fstype not in VALID_FOREGROUND_SERVICE_TYPES:
1887
+ valid = ", ".join(sorted(VALID_FOREGROUND_SERVICE_TYPES))
1888
+ raise ConfigError(
1889
+ f"{key_path}: foreground = true requires "
1890
+ f"foreground_service_type (one of: {valid})",
1891
+ key_path=f"{key_path}.foreground_service_type",
1892
+ )
1893
+ if not isinstance(notification_raw, dict):
1894
+ raise ConfigError(
1895
+ f"{key_path}: foreground = true requires a notification "
1896
+ "table (channel_id, channel_name, title, text)",
1897
+ key_path=f"{key_path}.notification",
1898
+ )
1899
+ for field_name in ("channel_id", "channel_name", "title", "text"):
1900
+ value = notification_raw.get(field_name)
1901
+ if not isinstance(value, str) or not value:
1902
+ raise ConfigError(
1903
+ f"{key_path}.notification.{field_name} must be a "
1904
+ "non-empty string",
1905
+ key_path=f"{key_path}.notification.{field_name}",
1906
+ )
1907
+ icon = notification_raw.get("icon")
1908
+ if icon is not None and not isinstance(icon, str):
1909
+ raise ConfigError(
1910
+ f"{key_path}.notification.icon must be a resource-name string",
1911
+ key_path=f"{key_path}.notification.icon",
1912
+ )
1913
+ notification = AndroidNotification(
1914
+ channel_id=notification_raw["channel_id"],
1915
+ channel_name=notification_raw["channel_name"],
1916
+ title=notification_raw["title"],
1917
+ text=notification_raw["text"],
1918
+ icon=icon,
1919
+ )
1920
+ elif fstype is not None or notification_raw is not None:
1921
+ raise ConfigError(
1922
+ f"{key_path}: foreground_service_type / notification are only "
1923
+ "meaningful with foreground = true",
1924
+ key_path=key_path,
1925
+ )
1926
+ out.append(
1927
+ AndroidService(
1928
+ name=name,
1929
+ entry_point=entry_point,
1930
+ exported=exported,
1931
+ foreground=foreground,
1932
+ foreground_service_type=fstype if foreground else None,
1933
+ notification=notification,
1934
+ )
1935
+ )
1936
+ return out
1937
+
1938
+
1939
+ def _parse_android_activities(
1940
+ android: dict, finder: _LineFinder
1941
+ ) -> list[AndroidActivity]:
1942
+ entries = android.get("activities")
1943
+ if entries is None:
1944
+ return []
1945
+ if not isinstance(entries, list):
1946
+ raise ConfigError(
1947
+ "[[tool.kivy.android.activities]] must be an array of tables",
1948
+ key_path="tool.kivy.android.activities",
1949
+ )
1950
+ out: list[AndroidActivity] = []
1951
+ for i, entry in enumerate(entries):
1952
+ key_path = f"tool.kivy.android.activities[{i}]"
1953
+ if not isinstance(entry, dict):
1954
+ raise ConfigError(f"{key_path} must be a table", key_path=key_path)
1955
+ name = entry.get("name")
1956
+ if not isinstance(name, str) or not all(
1957
+ _JAVA_IDENT_RE.match(s) for s in name.split(".")
1958
+ ):
1959
+ raise ConfigError(
1960
+ f"{key_path}.name must be a (qualified) Java class name",
1961
+ key_path=f"{key_path}.name",
1962
+ )
1963
+ exported = entry.get("exported", False)
1964
+ if not isinstance(exported, bool):
1965
+ raise ConfigError(
1966
+ f"{key_path}.exported must be a bool",
1967
+ key_path=f"{key_path}.exported",
1968
+ )
1969
+ out.append(AndroidActivity(name=name, exported=exported))
1970
+ return out
1971
+
1972
+
1973
+ def _parse_android_intent_filters(
1974
+ android: dict, finder: _LineFinder
1975
+ ) -> list[AndroidIntentFilter]:
1976
+ entries = android.get("intent_filters")
1977
+ if entries is None:
1978
+ return []
1979
+ if not isinstance(entries, list):
1980
+ raise ConfigError(
1981
+ "[[tool.kivy.android.intent_filters]] must be an array of tables",
1982
+ key_path="tool.kivy.android.intent_filters",
1983
+ )
1984
+ out: list[AndroidIntentFilter] = []
1985
+ for i, entry in enumerate(entries):
1986
+ key_path = f"tool.kivy.android.intent_filters[{i}]"
1987
+ if not isinstance(entry, dict):
1988
+ raise ConfigError(f"{key_path} must be a table", key_path=key_path)
1989
+ action = entry.get("action")
1990
+ if not isinstance(action, str) or not action:
1991
+ raise ConfigError(
1992
+ f"{key_path}.action must be a non-empty string",
1993
+ key_path=f"{key_path}.action",
1994
+ )
1995
+ categories = entry.get("categories", [])
1996
+ if not isinstance(categories, list) or not all(
1997
+ isinstance(c, str) and c for c in categories
1998
+ ):
1999
+ raise ConfigError(
2000
+ f"{key_path}.categories must be a list of non-empty strings",
2001
+ key_path=f"{key_path}.categories",
2002
+ )
2003
+ data_raw = entry.get("data", [])
2004
+ if not isinstance(data_raw, list):
2005
+ raise ConfigError(
2006
+ f"{key_path}.data must be a list of tables",
2007
+ key_path=f"{key_path}.data",
2008
+ )
2009
+ data: list[dict[str, str]] = []
2010
+ for j, item in enumerate(data_raw):
2011
+ if not isinstance(item, dict) or not all(
2012
+ isinstance(k, str) and isinstance(v, str) for k, v in item.items()
2013
+ ):
2014
+ raise ConfigError(
2015
+ f"{key_path}.data[{j}] must be a table of string attributes",
2016
+ key_path=f"{key_path}.data",
2017
+ )
2018
+ unknown = set(item) - VALID_INTENT_DATA_KEYS
2019
+ if unknown:
2020
+ valid = ", ".join(sorted(VALID_INTENT_DATA_KEYS))
2021
+ raise ConfigError(
2022
+ f"{key_path}.data[{j}] has unknown attribute(s) "
2023
+ f"{sorted(unknown)}; valid: {valid}",
2024
+ key_path=f"{key_path}.data",
2025
+ )
2026
+ data.append(dict(item))
2027
+ out.append(
2028
+ AndroidIntentFilter(
2029
+ action=action, categories=tuple(categories), data=tuple(data)
2030
+ )
2031
+ )
2032
+ return out
2033
+
2034
+
2035
+ def _parse_android_manifest(
2036
+ android: dict, finder: _LineFinder
2037
+ ) -> AndroidManifestConfig:
2038
+ # Rules 15 (managed-attr collisions) and 17 (raw-XML well-formedness).
2039
+ table = android.get("manifest")
2040
+ if table is None:
2041
+ return AndroidManifestConfig()
2042
+ if not isinstance(table, dict):
2043
+ raise ConfigError(
2044
+ "[tool.kivy.android.manifest] must be a table",
2045
+ key_path="tool.kivy.android.manifest",
2046
+ )
2047
+
2048
+ def _attrs(key: str, managed: frozenset[str]) -> dict[str, object]:
2049
+ raw = table.get(key, {})
2050
+ if not isinstance(raw, dict):
2051
+ raise ConfigError(
2052
+ f"[tool.kivy.android.manifest].{key} must be a table",
2053
+ key_path=f"tool.kivy.android.manifest.{key}",
2054
+ )
2055
+ collisions = sorted(set(raw) & managed)
2056
+ if collisions:
2057
+ raise ConfigError(
2058
+ f"[tool.kivy.android.manifest].{key} sets kivyforge-managed "
2059
+ f"attribute(s): {collisions}",
2060
+ key_path=f"tool.kivy.android.manifest.{key}",
2061
+ hint="these are written from the schema (android/01 §managed "
2062
+ "manifest keys); set the controlling field instead.",
2063
+ )
2064
+ return dict(raw)
2065
+
2066
+ application = _attrs("application", MANAGED_ANDROID_APPLICATION_ATTRS)
2067
+ activity = _attrs("activity", MANAGED_ANDROID_ACTIVITY_ATTRS)
2068
+
2069
+ placeholders_raw = table.get("placeholders", {})
2070
+ if not isinstance(placeholders_raw, dict) or not all(
2071
+ isinstance(k, str) and isinstance(v, str) for k, v in placeholders_raw.items()
2072
+ ):
2073
+ raise ConfigError(
2074
+ "[tool.kivy.android.manifest].placeholders must be a table of "
2075
+ "string -> string",
2076
+ key_path="tool.kivy.android.manifest.placeholders",
2077
+ )
2078
+
2079
+ allow_exported_raw = table.get("allow_exported", [])
2080
+ if not isinstance(allow_exported_raw, list) or not all(
2081
+ isinstance(v, str) and v.strip() for v in allow_exported_raw
2082
+ ):
2083
+ raise ConfigError(
2084
+ "[tool.kivy.android.manifest].allow_exported must be a list of "
2085
+ "non-empty component class names",
2086
+ key_path="tool.kivy.android.manifest.allow_exported",
2087
+ line=finder.line("allow_exported"),
2088
+ hint="each entry is a fully-qualified class as it appears in the "
2089
+ "merged manifest, e.g. 'com.vendor.sdk.TrampolineActivity'.",
2090
+ )
2091
+
2092
+ def _xml_fragment(key: str) -> str:
2093
+ fragment = table.get(key, "")
2094
+ if not isinstance(fragment, str):
2095
+ raise ConfigError(
2096
+ f"[tool.kivy.android.manifest].{key} must be a string",
2097
+ key_path=f"tool.kivy.android.manifest.{key}",
2098
+ )
2099
+ if fragment.strip():
2100
+ import xml.etree.ElementTree as ET
2101
+
2102
+ wrapped = (
2103
+ "<kivyforge xmlns:android="
2104
+ '"http://schemas.android.com/apk/res/android">'
2105
+ f"{fragment}</kivyforge>"
2106
+ )
2107
+ try:
2108
+ ET.fromstring(wrapped)
2109
+ except ET.ParseError as exc:
2110
+ raise ConfigError(
2111
+ f"[tool.kivy.android.manifest].{key} is not well-formed XML: {exc}",
2112
+ key_path=f"tool.kivy.android.manifest.{key}",
2113
+ line=finder.line(key),
2114
+ ) from exc
2115
+ return fragment
2116
+
2117
+ return AndroidManifestConfig(
2118
+ application=application,
2119
+ activity=activity,
2120
+ placeholders=dict(placeholders_raw),
2121
+ allow_exported=tuple(allow_exported_raw),
2122
+ extra_manifest_xml=_xml_fragment("extra_manifest_xml"),
2123
+ extra_application_xml=_xml_fragment("extra_application_xml"),
2124
+ extra_activity_xml=_xml_fragment("extra_activity_xml"),
2125
+ )
2126
+
2127
+
2128
+ def _parse_android_signing(android: dict, finder: _LineFinder) -> AndroidSigningConfig:
2129
+ signing = android.get("signing")
2130
+ if signing is None:
2131
+ return AndroidSigningConfig()
2132
+ if not isinstance(signing, dict):
2133
+ raise ConfigError(
2134
+ "[tool.kivy.android.signing] must be a table",
2135
+ key_path="tool.kivy.android.signing",
2136
+ )
2137
+ for key in ("keystore", "key_alias", "store_password_env", "key_password_env"):
2138
+ value = signing.get(key, "")
2139
+ if not isinstance(value, str):
2140
+ raise ConfigError(
2141
+ f"[tool.kivy.android.signing].{key} must be a string",
2142
+ key_path=f"tool.kivy.android.signing.{key}",
2143
+ line=finder.line(key),
2144
+ )
2145
+ toggles: dict[str, bool] = {}
2146
+ for key, default in (
2147
+ ("v1_signing", False),
2148
+ ("v2_signing", True),
2149
+ ("v3_signing", True),
2150
+ ("v4_signing", False),
2151
+ ):
2152
+ value = signing.get(key, default)
2153
+ if not isinstance(value, bool):
2154
+ raise ConfigError(
2155
+ f"[tool.kivy.android.signing].{key} must be a bool",
2156
+ key_path=f"tool.kivy.android.signing.{key}",
2157
+ line=finder.line(key),
2158
+ )
2159
+ toggles[key] = value
2160
+ return AndroidSigningConfig(
2161
+ keystore=signing.get("keystore", ""),
2162
+ key_alias=signing.get("key_alias", ""),
2163
+ store_password_env=signing.get(
2164
+ "store_password_env", DEFAULT_ANDROID_STORE_PASSWORD_ENV
2165
+ ),
2166
+ key_password_env=signing.get(
2167
+ "key_password_env", DEFAULT_ANDROID_KEY_PASSWORD_ENV
2168
+ ),
2169
+ **toggles,
2170
+ )
2171
+
2172
+
2173
+ def _parse_android_gradle_properties(
2174
+ android: dict, finder: _LineFinder
2175
+ ) -> dict[str, object]:
2176
+ table = android.get("gradle_properties", {})
2177
+ if not isinstance(table, dict):
2178
+ raise ConfigError(
2179
+ "[tool.kivy.android.gradle_properties] must be a table",
2180
+ key_path="tool.kivy.android.gradle_properties",
2181
+ )
2182
+ reserved = sorted(set(table) & RESERVED_ANDROID_GRADLE_PROPERTIES)
2183
+ if reserved:
2184
+ raise ConfigError(
2185
+ f"[tool.kivy.android.gradle_properties] sets kivyforge-reserved "
2186
+ f"key(s): {reserved}",
2187
+ key_path="tool.kivy.android.gradle_properties",
2188
+ hint="kivyforge manages these (android/01 §gradle_properties).",
2189
+ )
2190
+ return dict(table)
2191
+
2192
+
2193
+ def _parse_android_build_settings(
2194
+ android: dict, finder: _LineFinder
2195
+ ) -> AndroidBuildSettings:
2196
+ # Rule 19: bool-or-"release" tri-states plus the debug_symbols enum.
2197
+ table = android.get("build_settings")
2198
+ if table is None:
2199
+ return AndroidBuildSettings()
2200
+ if not isinstance(table, dict):
2201
+ raise ConfigError(
2202
+ "[tool.kivy.android.build_settings] must be a table",
2203
+ key_path="tool.kivy.android.build_settings",
2204
+ )
2205
+
2206
+ def _bool(key: str, default: bool) -> bool:
2207
+ value = table.get(key, default)
2208
+ if not isinstance(value, bool):
2209
+ raise ConfigError(
2210
+ f"[tool.kivy.android.build_settings].{key} must be a bool",
2211
+ key_path=f"tool.kivy.android.build_settings.{key}",
2212
+ line=finder.line(key),
2213
+ )
2214
+ return value
2215
+
2216
+ def _tri(key: str) -> bool | str:
2217
+ value = table.get(key, ANDROID_RELEASE_ONLY)
2218
+ if isinstance(value, bool) or value == ANDROID_RELEASE_ONLY:
2219
+ return value
2220
+ raise ConfigError(
2221
+ f"[tool.kivy.android.build_settings].{key} must be a bool or the "
2222
+ 'string "release"',
2223
+ key_path=f"tool.kivy.android.build_settings.{key}",
2224
+ line=finder.line(key),
2225
+ )
2226
+
2227
+ minify = _bool("minify", False)
2228
+ shrink_resources = _bool("shrink_resources", False)
2229
+ if shrink_resources and not minify:
2230
+ raise ConfigError(
2231
+ "[tool.kivy.android.build_settings].shrink_resources requires "
2232
+ "minify = true",
2233
+ key_path="tool.kivy.android.build_settings.shrink_resources",
2234
+ line=finder.line("shrink_resources"),
2235
+ )
2236
+ debug_symbols = table.get("debug_symbols", "symbol_table")
2237
+ if debug_symbols not in VALID_ANDROID_DEBUG_SYMBOLS:
2238
+ valid = ", ".join(sorted(VALID_ANDROID_DEBUG_SYMBOLS))
2239
+ raise ConfigError(
2240
+ f"[tool.kivy.android.build_settings].debug_symbols "
2241
+ f"{debug_symbols!r} is invalid",
2242
+ key_path="tool.kivy.android.build_settings.debug_symbols",
2243
+ line=finder.line("debug_symbols"),
2244
+ hint=f"valid values are: {valid}.",
2245
+ )
2246
+ return AndroidBuildSettings(
2247
+ minify=minify,
2248
+ shrink_resources=shrink_resources,
2249
+ multidex=_bool("multidex", True),
2250
+ byte_compile=_tri("byte_compile"),
2251
+ strip_source=_tri("strip_source"),
2252
+ strip_native_libs=_tri("strip_native_libs"),
2253
+ debug_symbols=debug_symbols,
2254
+ )
2255
+
2256
+
2257
+ def _parse_platform_schema_version(
2258
+ table: dict, finder: _LineFinder, *, key_path: str, supported: int
2259
+ ) -> int:
2260
+ # Each platform overlay carries its *own* schema_version and evolves
2261
+ # independently (spec 01), so the ceiling is the caller's platform constant
2262
+ # — never another platform's (e.g. Linux must not be gated by the macOS max).
2263
+ schema_version = table.get("schema_version")
2264
+ if schema_version is None:
2265
+ raise ConfigError(
2266
+ f"missing required [{key_path.rsplit('.', 1)[0]}].schema_version",
2267
+ key_path=key_path,
2268
+ )
2269
+ if not isinstance(schema_version, int) or isinstance(schema_version, bool):
2270
+ raise ConfigError(
2271
+ "schema_version must be an integer",
2272
+ key_path=key_path,
2273
+ line=finder.line("schema_version"),
2274
+ )
2275
+ if schema_version > supported:
2276
+ raise ConfigError(
2277
+ f"schema_version {schema_version} is newer than this kivyforge "
2278
+ f"understands (max {supported})",
2279
+ key_path=key_path,
2280
+ line=finder.line("schema_version"),
2281
+ hint="upgrade kivyforge.",
2282
+ )
2283
+ if schema_version < 1:
2284
+ raise ConfigError(
2285
+ f"unsupported schema_version {schema_version}",
2286
+ key_path=key_path,
2287
+ line=finder.line("schema_version"),
2288
+ )
2289
+ return schema_version
2290
+
2291
+
2292
+ def _parse_platform_python_version(table: dict, *, key_path: str) -> str:
2293
+ python = table.get("python")
2294
+ if python is None:
2295
+ raise ConfigError(
2296
+ f"missing required [{key_path}] table with a 'version'",
2297
+ key_path=f"{key_path}.version",
2298
+ hint=f'add:\n [{key_path}]\n version = "3.15.0"',
2299
+ )
2300
+ if not isinstance(python, dict):
2301
+ raise ConfigError(f"[{key_path}] must be a table", key_path=key_path)
2302
+ version = python.get("version")
2303
+ if version is None:
2304
+ raise ConfigError(
2305
+ f"missing required [{key_path}].version", key_path=f"{key_path}.version"
2306
+ )
2307
+ if not isinstance(version, str) or not version.strip():
2308
+ raise ConfigError(
2309
+ f"[{key_path}].version must be a non-empty string",
2310
+ key_path=f"{key_path}.version",
2311
+ )
2312
+ return version
2313
+
2314
+
2315
+ def _parse_platform_icons(table: dict, *, key_path: str) -> IconConfig:
2316
+ icons = table.get("icons")
2317
+ if icons is None:
2318
+ return IconConfig()
2319
+ if not isinstance(icons, dict):
2320
+ raise ConfigError(f"[{key_path}] must be a table", key_path=key_path)
2321
+ return IconConfig(source=icons.get("source"))
2322
+
2323
+
2324
+ def _parse_platform_find_links(
2325
+ table: dict, finder: _LineFinder, *, key_path: str, project_root: Path | None
2326
+ ) -> list[str]:
2327
+ raw = table.get("find_links", [])
2328
+ if not isinstance(raw, list) or not all(isinstance(p, str) for p in raw):
2329
+ raise ConfigError(
2330
+ f"[{key_path}] must be a list of strings",
2331
+ key_path=key_path,
2332
+ line=finder.line("find_links"),
2333
+ )
2334
+ out: list[str] = []
2335
+ for path in raw:
2336
+ if _is_absolute_source(path) or _posix_normpath(path) == "..":
2337
+ raise ConfigError(
2338
+ "find_links entries must be repo-relative paths",
2339
+ key_path=key_path,
2340
+ line=finder.line("find_links"),
2341
+ )
2342
+ normalized = _posix_normpath(path)
2343
+ if project_root is not None:
2344
+ _validate_find_link_scope(
2345
+ project_root, normalized, finder, key_path=key_path
2346
+ )
2347
+ out.append(normalized)
2348
+ return out
2349
+
2350
+
2351
+ def _parse_platform_exclude(
2352
+ table: dict, finder: _LineFinder, *, key_path: str
2353
+ ) -> list[str]:
2354
+ raw = table.get("exclude", [])
2355
+ if not isinstance(raw, list) or not all(isinstance(p, str) for p in raw):
2356
+ raise ConfigError(
2357
+ f"[{key_path}] must be a list of package-name strings",
2358
+ key_path=key_path,
2359
+ line=finder.line("exclude"),
2360
+ )
2361
+ return [str(p) for p in raw]
2362
+
2363
+
2364
+ def _check_requires_python_generic(
2365
+ project: ProjectMeta,
2366
+ python_version: str | None,
2367
+ finder: _LineFinder,
2368
+ *,
2369
+ key_path: str,
2370
+ ) -> None:
2371
+ if not project.requires_python or not python_version:
2372
+ return
2373
+ try:
2374
+ spec = SpecifierSet(project.requires_python)
2375
+ except InvalidSpecifier:
2376
+ raise ConfigError(
2377
+ f"[project].requires-python is not a valid specifier: "
2378
+ f"{project.requires_python!r}",
2379
+ key_path="project.requires-python",
2380
+ ) from None
2381
+ try:
2382
+ ver = Version(python_version)
2383
+ except InvalidVersion:
2384
+ raise ConfigError(
2385
+ f"[{key_path}] is not a valid version: {python_version!r}",
2386
+ key_path=key_path,
2387
+ ) from None
2388
+ if not spec.contains(ver, prereleases=True):
2389
+ hint = "align requires-python with the platform Python version."
2390
+ if ver.is_prerelease:
2391
+ hint = (
2392
+ f"pre-release runtimes such as {python_version} need an explicit "
2393
+ f'floor (e.g. requires-python = ">={python_version}").'
2394
+ )
2395
+ raise ConfigError(
2396
+ f"[project].requires-python ({project.requires_python}) excludes the "
2397
+ f"selected Python version {python_version}",
2398
+ key_path=key_path,
2399
+ line=finder.line("requires-python"),
2400
+ hint=hint,
2401
+ )
2402
+
2403
+
2404
+ def _parse_simulator_archs(ios: dict, finder: _LineFinder) -> tuple[str, ...]:
2405
+ """``[tool.kivy.ios].simulator_archs`` — which simulator slices to pin.
2406
+
2407
+ arm64 only: the x86_64 simulator slice had value solely on an Intel Mac
2408
+ host, and macOS 27 stops installing on Intel hardware. The key stays for
2409
+ schema stability, but there is only one valid value.
2410
+ """
2411
+ raw = ios.get("simulator_archs")
2412
+ if raw is None:
2413
+ return DEFAULT_SIMULATOR_ARCHS
2414
+ line = finder.line("simulator_archs")
2415
+ if not isinstance(raw, list) or not all(isinstance(a, str) for a in raw):
2416
+ raise ConfigError(
2417
+ "[tool.kivy.ios].simulator_archs must be a list of strings",
2418
+ key_path="tool.kivy.ios.simulator_archs",
2419
+ line=line,
2420
+ )
2421
+ if not raw:
2422
+ raise ConfigError(
2423
+ "[tool.kivy.ios].simulator_archs must not be empty",
2424
+ key_path="tool.kivy.ios.simulator_archs",
2425
+ line=line,
2426
+ hint='"arm64" is the only supported simulator arch.',
2427
+ )
2428
+ if "x86_64" in raw:
2429
+ raise ConfigError(
2430
+ "[tool.kivy.ios].simulator_archs no longer supports x86_64; kivyforge "
2431
+ "pins the arm64 simulator slice only",
2432
+ key_path="tool.kivy.ios.simulator_archs",
2433
+ line=line,
2434
+ hint=(
2435
+ 'set simulator_archs = ["arm64"] (or drop the key — it is the '
2436
+ "default). The x86_64 simulator slice only ran on an Intel Mac "
2437
+ "host, and macOS 27 stops installing on Intel hardware."
2438
+ ),
2439
+ )
2440
+ unknown = [a for a in raw if a not in VALID_SIMULATOR_ARCHS]
2441
+ if unknown:
2442
+ valid = ", ".join(sorted(VALID_SIMULATOR_ARCHS))
2443
+ raise ConfigError(
2444
+ f"unknown simulator arch(es) {unknown} in [tool.kivy.ios].simulator_archs",
2445
+ key_path="tool.kivy.ios.simulator_archs",
2446
+ line=line,
2447
+ hint=f"valid values are: {valid}.",
2448
+ )
2449
+ # De-dupe while preserving declared order (stable, deterministic slices).
2450
+ seen: set[str] = set()
2451
+ ordered: list[str] = []
2452
+ for arch in raw:
2453
+ if arch not in seen:
2454
+ seen.add(arch)
2455
+ ordered.append(arch)
2456
+ return tuple(ordered)
2457
+
2458
+
2459
+ def _parse_python_version(ios: dict) -> str:
2460
+ # Rule: [tool.kivy.ios.python].version is required (spec 01). Without it,
2461
+ # `kivyforge lock` would silently fall back to a hidden default Python
2462
+ # version rather than building against the version the project declares.
2463
+ python = ios.get("python")
2464
+ if python is None:
2465
+ raise ConfigError(
2466
+ "missing required [tool.kivy.ios.python] table with a 'version'",
2467
+ key_path="tool.kivy.ios.python.version",
2468
+ hint='add:\n [tool.kivy.ios.python]\n version = "3.15.0"\n'
2469
+ " (the Python.xcframework version to build against).",
2470
+ )
2471
+ if not isinstance(python, dict):
2472
+ raise ConfigError(
2473
+ "[tool.kivy.ios.python] must be a table", key_path="tool.kivy.ios.python"
2474
+ )
2475
+ version = python.get("version")
2476
+ if version is None:
2477
+ raise ConfigError(
2478
+ "missing required [tool.kivy.ios.python].version",
2479
+ key_path="tool.kivy.ios.python.version",
2480
+ )
2481
+ if not isinstance(version, str) or not version.strip():
2482
+ raise ConfigError(
2483
+ "[tool.kivy.ios.python].version must be a non-empty string",
2484
+ key_path="tool.kivy.ios.python.version",
2485
+ )
2486
+ return version
2487
+
2488
+
2489
+ def _check_requires_python(
2490
+ project: ProjectMeta, python_version: str | None, finder: _LineFinder
2491
+ ) -> None:
2492
+ # Rule 10: requires-python must not exclude the selected python version.
2493
+ if not project.requires_python or not python_version:
2494
+ return
2495
+ try:
2496
+ spec = SpecifierSet(project.requires_python)
2497
+ except InvalidSpecifier:
2498
+ raise ConfigError(
2499
+ f"[project].requires-python is not a valid specifier: "
2500
+ f"{project.requires_python!r}",
2501
+ key_path="project.requires-python",
2502
+ ) from None
2503
+ try:
2504
+ ver = Version(python_version)
2505
+ except InvalidVersion:
2506
+ raise ConfigError(
2507
+ f"[tool.kivy.ios.python].version is not a valid version: "
2508
+ f"{python_version!r}",
2509
+ key_path="tool.kivy.ios.python.version",
2510
+ ) from None
2511
+ if not spec.contains(ver, prereleases=True):
2512
+ hint = "align requires-python with the iOS Python version."
2513
+ if ver.is_prerelease:
2514
+ hint = (
2515
+ f"pre-release runtimes such as {python_version} need an explicit "
2516
+ f'floor (e.g. requires-python = ">={python_version}"), not '
2517
+ f'">=3.15" alone.'
2518
+ )
2519
+ raise ConfigError(
2520
+ f"[project].requires-python ({project.requires_python}) excludes the "
2521
+ f"selected Python.xcframework version {python_version}",
2522
+ key_path="tool.kivy.ios.python.version",
2523
+ line=finder.line("requires-python"),
2524
+ hint=hint,
2525
+ )
2526
+
2527
+
2528
+ def _parse_find_links(
2529
+ ios: dict, finder: _LineFinder, *, project_root: Path | None = None
2530
+ ) -> list[str]:
2531
+ raw = ios.get("find_links", [])
2532
+ if not isinstance(raw, list) or not all(isinstance(p, str) for p in raw):
2533
+ raise ConfigError(
2534
+ "[tool.kivy.ios].find_links must be a list of strings",
2535
+ key_path="tool.kivy.ios.find_links",
2536
+ line=finder.line("find_links"),
2537
+ )
2538
+ out: list[str] = []
2539
+ for path in raw:
2540
+ if _is_absolute_source(path) or _posix_normpath(path) == "..":
2541
+ raise ConfigError(
2542
+ "find_links entries must be repo-relative paths",
2543
+ key_path="tool.kivy.ios.find_links",
2544
+ line=finder.line("find_links"),
2545
+ )
2546
+ normalized = _posix_normpath(path)
2547
+ if project_root is not None:
2548
+ _validate_find_link_scope(project_root, normalized, finder)
2549
+ out.append(normalized)
2550
+ return out
2551
+
2552
+
2553
+ def _repo_root(start: Path) -> Path | None:
2554
+ """Nearest ancestor (inclusive) containing a ``.git`` marker, else None."""
2555
+ for candidate in (start, *start.parents):
2556
+ if (candidate / ".git").exists():
2557
+ return candidate
2558
+ return None
2559
+
2560
+
2561
+ def _validate_find_link_scope(
2562
+ project_root: Path,
2563
+ entry: str,
2564
+ finder: _LineFinder,
2565
+ *,
2566
+ key_path: str = "tool.kivy.ios.find_links",
2567
+ ) -> None:
2568
+ """Constrain find_links to repo-relative locations.
2569
+
2570
+ Permitted: inside the project, a sibling under the same parent (e.g.
2571
+ ``../wheels``), or — when the project lives in a git checkout — anywhere
2572
+ within that repository (e.g. a shared ``examples/wheels/`` reached from a
2573
+ nested ``examples/<group>/<app>/`` via ``../../wheels``). This keeps wheels
2574
+ inside the tree while supporting one shared wheelhouse for grouped examples.
2575
+ ``key_path`` names the offending overlay so the error points at the right
2576
+ platform (find_links is a shared iOS/macOS/Linux surface).
2577
+ """
2578
+ resolved = (project_root / entry).resolve()
2579
+ root = project_root.resolve()
2580
+ allowed = [root, root.parent]
2581
+ repo = _repo_root(root)
2582
+ if repo is not None:
2583
+ allowed.append(repo)
2584
+ for base in allowed:
2585
+ if resolved == base or base in resolved.parents:
2586
+ return
2587
+ raise ConfigError(
2588
+ "find_links entries must stay within the project directory, a sibling "
2589
+ "directory under the same parent, or the enclosing repository",
2590
+ key_path=key_path,
2591
+ line=finder.line("find_links"),
2592
+ hint='e.g. "wheels" inside the project, "../wheels" for a sibling dir, '
2593
+ 'or "../../wheels" for a shared examples wheelhouse.',
2594
+ )
2595
+
2596
+
2597
+ def _parse_exclude(ios: dict, finder: _LineFinder) -> list[str]:
2598
+ raw = ios.get("exclude", [])
2599
+ if not isinstance(raw, list) or not all(isinstance(p, str) for p in raw):
2600
+ raise ConfigError(
2601
+ "[tool.kivy.ios].exclude must be a list of package-name strings",
2602
+ key_path="tool.kivy.ios.exclude",
2603
+ line=finder.line("exclude"),
2604
+ )
2605
+ return [str(p) for p in raw]
2606
+
2607
+
2608
+ def _parse_icons(ios: dict) -> IconConfig:
2609
+ icons = ios.get("icons")
2610
+ if icons is None:
2611
+ return IconConfig()
2612
+ if not isinstance(icons, dict):
2613
+ raise ConfigError(
2614
+ "[tool.kivy.ios.icons] must be a table", key_path="tool.kivy.ios.icons"
2615
+ )
2616
+ return IconConfig(source=icons.get("source"))
2617
+
2618
+
2619
+ def _parse_splash(ios: dict) -> SplashConfig:
2620
+ splash = ios.get("splash")
2621
+ if splash is None:
2622
+ return SplashConfig()
2623
+ if not isinstance(splash, dict):
2624
+ raise ConfigError(
2625
+ "[tool.kivy.ios.splash] must be a table", key_path="tool.kivy.ios.splash"
2626
+ )
2627
+ return SplashConfig(
2628
+ source=splash.get("source"), background=splash.get("background")
2629
+ )
2630
+
2631
+
2632
+ def _parse_xcframeworks(ios: dict, finder: _LineFinder) -> list[XcframeworkDep]:
2633
+ native = ios.get("native")
2634
+ if not isinstance(native, dict):
2635
+ return []
2636
+ table = native.get("xcframeworks")
2637
+ if table is None:
2638
+ return []
2639
+ if not isinstance(table, dict):
2640
+ raise ConfigError(
2641
+ "[tool.kivy.ios.native.xcframeworks] must be a table",
2642
+ key_path="tool.kivy.ios.native.xcframeworks",
2643
+ )
2644
+ out: list[XcframeworkDep] = []
2645
+ for name, entry in table.items():
2646
+ key_path = f"tool.kivy.ios.native.xcframeworks.{name}"
2647
+ if not isinstance(entry, dict):
2648
+ raise ConfigError(
2649
+ f"xcframework {name!r} must be an inline table with version + source",
2650
+ key_path=key_path,
2651
+ )
2652
+ version = entry.get("version")
2653
+ source = entry.get("source")
2654
+ if not isinstance(version, str) or not version:
2655
+ raise ConfigError(
2656
+ f"xcframework {name!r} requires a string 'version'",
2657
+ key_path=key_path,
2658
+ )
2659
+ if not isinstance(source, str) or not source:
2660
+ raise ConfigError(
2661
+ f"xcframework {name!r} requires an explicit 'source' (URL or "
2662
+ f"repo-relative path)",
2663
+ key_path=key_path,
2664
+ )
2665
+ _validate_artifact_source("xcframework", name, source, key_path)
2666
+ out.append(
2667
+ XcframeworkDep(
2668
+ name=name,
2669
+ version=version,
2670
+ source=source,
2671
+ link=_require_bool(
2672
+ entry.get("link", True),
2673
+ key_path=f"{key_path}.link",
2674
+ field="link",
2675
+ finder=finder,
2676
+ ),
2677
+ embed=_require_bool(
2678
+ entry.get("embed", True),
2679
+ key_path=f"{key_path}.embed",
2680
+ field="embed",
2681
+ finder=finder,
2682
+ ),
2683
+ )
2684
+ )
2685
+ return out
2686
+
2687
+
2688
+ def _validate_artifact_source(kind: str, name: str, source: str, key_path: str) -> None:
2689
+ # A URL is fetched as-is; anything else is a repo-relative path that must
2690
+ # stay inside the project (mirrors find_links / swift path rules, spec 01).
2691
+ # Shared by xcframeworks (iOS) and native binaries (desktop).
2692
+ if source.startswith(("http://", "https://")):
2693
+ return
2694
+ if _is_absolute_source(source):
2695
+ raise ConfigError(
2696
+ f"{kind} {name!r} source must not be an absolute path",
2697
+ key_path=key_path,
2698
+ )
2699
+ parts = PurePosixPath(_posix_normpath(source)).parts
2700
+ if parts and parts[0] == "..":
2701
+ raise ConfigError(
2702
+ f"{kind} {name!r} source must not escape the project directory",
2703
+ key_path=key_path,
2704
+ hint="vendor the artifact under the project (or a sibling dir) and use "
2705
+ "a repo-relative path, or give a direct https:// URL.",
2706
+ )
2707
+
2708
+
2709
+ def _parse_native_binaries(
2710
+ overlay: dict, platform: str, finder: _LineFinder
2711
+ ) -> list[NativeBinaryDep]:
2712
+ """Parse ``[tool.kivy.<platform>.native.binaries]`` into ``NativeBinaryDep``s.
2713
+
2714
+ Shared across the desktop backends; the ``platform`` string only shapes the
2715
+ ``key_path`` used in diagnostics.
2716
+ """
2717
+ native = overlay.get("native")
2718
+ if not isinstance(native, dict):
2719
+ return []
2720
+ table = native.get("binaries")
2721
+ if table is None:
2722
+ return []
2723
+ base = f"tool.kivy.{platform}.native.binaries"
2724
+ if not isinstance(table, dict):
2725
+ raise ConfigError(f"[{base}] must be a table", key_path=base)
2726
+ out: list[NativeBinaryDep] = []
2727
+ for name, entry in table.items():
2728
+ key_path = f"{base}.{name}"
2729
+ if not isinstance(entry, dict):
2730
+ raise ConfigError(
2731
+ f"native binary {name!r} must be an inline table with version + source",
2732
+ key_path=key_path,
2733
+ )
2734
+ version = entry.get("version")
2735
+ source = entry.get("source")
2736
+ if not isinstance(version, str) or not version:
2737
+ raise ConfigError(
2738
+ f"native binary {name!r} requires a string 'version'",
2739
+ key_path=key_path,
2740
+ )
2741
+ if not isinstance(source, str) or not source:
2742
+ raise ConfigError(
2743
+ f"native binary {name!r} requires an explicit 'source' (URL or "
2744
+ f"repo-relative path)",
2745
+ key_path=key_path,
2746
+ )
2747
+ _validate_artifact_source("native binary", name, source, key_path)
2748
+ out.append(NativeBinaryDep(name=name, version=version, source=source))
2749
+ return out
2750
+
2751
+
2752
+ def _parse_desktop_build_settings(
2753
+ table: dict | None, base: str, finder: _LineFinder
2754
+ ) -> DesktopBuildSettings:
2755
+ """Parse a ``byte_compile``/``strip_source`` tri-state table at *base*.
2756
+
2757
+ Shared by every backend with this release tri-state and no Android-style
2758
+ minify/R8/multidex: Windows/Linux/macOS at ``<platform>.build_settings``,
2759
+ and iOS at ``ios.python.build_settings`` (a nested path, since
2760
+ ``ios.build_settings`` is already the Xcode passthrough table). *base*
2761
+ only shapes the ``key_path`` used in diagnostics.
2762
+ """
2763
+ if table is None:
2764
+ return DesktopBuildSettings()
2765
+ if not isinstance(table, dict):
2766
+ raise ConfigError(f"[{base}] must be a table", key_path=base)
2767
+
2768
+ def _tri(key: str) -> bool | str:
2769
+ value = table.get(key, DESKTOP_RELEASE_ONLY)
2770
+ if isinstance(value, bool) or value == DESKTOP_RELEASE_ONLY:
2771
+ return value
2772
+ raise ConfigError(
2773
+ f'[{base}].{key} must be a bool or the string "release"',
2774
+ key_path=f"{base}.{key}",
2775
+ line=finder.line(key),
2776
+ )
2777
+
2778
+ return DesktopBuildSettings(
2779
+ byte_compile=_tri("byte_compile"),
2780
+ strip_source=_tri("strip_source"),
2781
+ )
2782
+
2783
+
2784
+ def _parse_ios_python_build_settings(
2785
+ ios: dict, finder: _LineFinder
2786
+ ) -> DesktopBuildSettings:
2787
+ """Parse ``[tool.kivy.ios.python.build_settings]``.
2788
+
2789
+ Nested under ``python`` (not directly under ``ios``) because
2790
+ ``ios.build_settings`` already names the free-form Xcode passthrough
2791
+ table (``[tool.kivy.ios.xcode.build_settings]``'s dataclass attribute);
2792
+ ``[tool.kivy.ios.python]`` is otherwise the natural home for anything
2793
+ about the shipped Python payload, and it is already a required table
2794
+ (see ``_parse_python_version``), so it is always present by this point.
2795
+ """
2796
+ python = ios.get("python")
2797
+ table = python.get("build_settings") if isinstance(python, dict) else None
2798
+ return _parse_desktop_build_settings(
2799
+ table, "tool.kivy.ios.python.build_settings", finder
2800
+ )
2801
+
2802
+
2803
+ def _parse_swift_packages(ios: dict, finder: _LineFinder) -> list[SwiftPackageDep]:
2804
+ native = ios.get("native")
2805
+ if not isinstance(native, dict):
2806
+ return []
2807
+ table = native.get("swift_packages")
2808
+ if table is None:
2809
+ return []
2810
+ if not isinstance(table, dict):
2811
+ raise ConfigError(
2812
+ "[tool.kivy.ios.native.swift_packages] must be a table",
2813
+ key_path="tool.kivy.ios.native.swift_packages",
2814
+ )
2815
+ out: list[SwiftPackageDep] = []
2816
+ for name, entry in table.items():
2817
+ key_path = f"tool.kivy.ios.native.swift_packages.{name}"
2818
+ if not isinstance(entry, dict):
2819
+ raise ConfigError(
2820
+ f"swift package {name!r} must be an inline table",
2821
+ key_path=key_path,
2822
+ )
2823
+ url = entry.get("url")
2824
+ path = entry.get("path")
2825
+ if url is not None and (not isinstance(url, str) or not url):
2826
+ raise ConfigError(
2827
+ f"swift package {name!r} 'url' must be a non-empty string",
2828
+ key_path=key_path,
2829
+ )
2830
+ # Rule: exactly one of url / path identifies the package source.
2831
+ if bool(url) == bool(path):
2832
+ raise ConfigError(
2833
+ f"swift package {name!r} must set exactly one of 'url' or 'path'",
2834
+ key_path=key_path,
2835
+ hint="a remote package needs 'url' + 'requirement'; a local one "
2836
+ "needs a repo-relative 'path'.",
2837
+ )
2838
+ requirement: dict[str, object] | None = None
2839
+ if url:
2840
+ requirement = _parse_swift_requirement(
2841
+ name, entry.get("requirement"), key_path
2842
+ )
2843
+ else:
2844
+ path = _validate_swift_path(name, path, key_path)
2845
+ products = entry.get("products")
2846
+ if (
2847
+ not isinstance(products, list)
2848
+ or not products
2849
+ or not all(isinstance(p, str) and p for p in products)
2850
+ ):
2851
+ raise ConfigError(
2852
+ f"swift package {name!r} requires a non-empty 'products' list of "
2853
+ f"strings (the SPM library product(s) to link)",
2854
+ key_path=key_path,
2855
+ )
2856
+ out.append(
2857
+ SwiftPackageDep(
2858
+ name=name,
2859
+ products=tuple(products),
2860
+ url=url or None,
2861
+ path=path or None,
2862
+ requirement=requirement,
2863
+ link=_require_bool(
2864
+ entry.get("link", True),
2865
+ key_path=f"{key_path}.link",
2866
+ field="link",
2867
+ finder=finder,
2868
+ ),
2869
+ embed=_require_bool(
2870
+ entry.get("embed", True),
2871
+ key_path=f"{key_path}.embed",
2872
+ field="embed",
2873
+ finder=finder,
2874
+ ),
2875
+ )
2876
+ )
2877
+ return out
2878
+
2879
+
2880
+ def _parse_swift_requirement(
2881
+ name: str, requirement: object, key_path: str
2882
+ ) -> dict[str, object]:
2883
+ valid = ", ".join(sorted(VALID_SWIFT_REQUIREMENT_KINDS))
2884
+ if not isinstance(requirement, dict) or len(requirement) != 1:
2885
+ raise ConfigError(
2886
+ f"remote swift package {name!r} needs a 'requirement' with exactly one "
2887
+ f"version rule",
2888
+ key_path=key_path,
2889
+ hint=f"valid rules: {valid}.",
2890
+ )
2891
+ ((kind, value),) = requirement.items()
2892
+ if kind not in VALID_SWIFT_REQUIREMENT_KINDS:
2893
+ raise ConfigError(
2894
+ f"swift package {name!r} has unknown requirement rule {kind!r}",
2895
+ key_path=key_path,
2896
+ hint=f"valid rules: {valid}.",
2897
+ )
2898
+ if kind == "range":
2899
+ if (
2900
+ not isinstance(value, list)
2901
+ or len(value) != 2
2902
+ or not all(isinstance(v, str) and v for v in value)
2903
+ ):
2904
+ raise ConfigError(
2905
+ f"swift package {name!r} 'range' must be two version strings "
2906
+ f"[lower, upper)",
2907
+ key_path=key_path,
2908
+ )
2909
+ elif not isinstance(value, str) or not value:
2910
+ raise ConfigError(
2911
+ f"swift package {name!r} requirement {kind!r} must be a non-empty string",
2912
+ key_path=key_path,
2913
+ )
2914
+ return dict(requirement)
2915
+
2916
+
2917
+ def _validate_swift_path(name: str, path: object, key_path: str) -> str:
2918
+ if not isinstance(path, str) or not path:
2919
+ raise ConfigError(
2920
+ f"swift package {name!r} 'path' must be a non-empty string",
2921
+ key_path=key_path,
2922
+ )
2923
+ if _is_absolute_source(path):
2924
+ raise ConfigError(
2925
+ f"swift package {name!r} 'path' must be relative, not an absolute path",
2926
+ key_path=key_path,
2927
+ )
2928
+ parts = PurePosixPath(_posix_normpath(path)).parts
2929
+ if parts and parts[0] == "..":
2930
+ raise ConfigError(
2931
+ f"swift package {name!r} 'path' must not escape the project directory",
2932
+ key_path=key_path,
2933
+ )
2934
+ return path
2935
+
2936
+
2937
+ def _require_bool(
2938
+ value: object, *, key_path: str, field: str, finder: _LineFinder
2939
+ ) -> bool:
2940
+ """Reject non-boolean TOML values. ``bool("false")`` is ``True``, so coercing
2941
+ with ``bool()`` would silently turn a stray string/int into the wrong flag.
2942
+ """
2943
+ if not isinstance(value, bool):
2944
+ raise ConfigError(
2945
+ f"[{key_path}] must be a boolean (true/false), not {type(value).__name__}",
2946
+ key_path=key_path,
2947
+ line=finder.line(field),
2948
+ )
2949
+ return value
2950
+
2951
+
2952
+ def _parse_signing(ios: dict, finder: _LineFinder) -> SigningConfig:
2953
+ signing = ios.get("signing")
2954
+ if signing is None:
2955
+ return SigningConfig()
2956
+ if not isinstance(signing, dict):
2957
+ raise ConfigError(
2958
+ "[tool.kivy.ios.signing] must be a table",
2959
+ key_path="tool.kivy.ios.signing",
2960
+ )
2961
+ return SigningConfig(
2962
+ team_id=signing.get("team_id", ""),
2963
+ identity=signing.get("identity", "Apple Development"),
2964
+ provisioning_profile=signing.get("provisioning_profile", ""),
2965
+ auto_signing=_require_bool(
2966
+ signing.get("auto_signing", True),
2967
+ key_path="tool.kivy.ios.signing.auto_signing",
2968
+ field="auto_signing",
2969
+ finder=finder,
2970
+ ),
2971
+ upload_symbols=_require_bool(
2972
+ signing.get("upload_symbols", True),
2973
+ key_path="tool.kivy.ios.signing.upload_symbols",
2974
+ field="upload_symbols",
2975
+ finder=finder,
2976
+ ),
2977
+ )
2978
+
2979
+
2980
+ def _parse_info_plist(ios: dict, finder: _LineFinder) -> dict[str, object]:
2981
+ info = ios.get("info_plist")
2982
+ if info is None:
2983
+ return {}
2984
+ if not isinstance(info, dict):
2985
+ raise ConfigError(
2986
+ "[tool.kivy.ios.info_plist] must be a table",
2987
+ key_path="tool.kivy.ios.info_plist",
2988
+ )
2989
+ conflicts = sorted(set(info) & MANAGED_INFO_PLIST_KEYS)
2990
+ if conflicts:
2991
+ raise ConfigError(
2992
+ f"[tool.kivy.ios.info_plist] sets kivyforge-managed key(s): "
2993
+ f"{', '.join(conflicts)}",
2994
+ key_path="tool.kivy.ios.info_plist",
2995
+ line=finder.line("info_plist"),
2996
+ hint="set these via their dedicated schema fields instead.",
2997
+ )
2998
+ return dict(info)
2999
+
3000
+
3001
+ def _parse_build_settings(ios: dict, finder: _LineFinder) -> dict[str, str]:
3002
+ # Rule 8: reserved keys under xcode.build_settings are rejected.
3003
+ xcode = ios.get("xcode")
3004
+ if not isinstance(xcode, dict):
3005
+ return {}
3006
+ settings = xcode.get("build_settings")
3007
+ if settings is None:
3008
+ return {}
3009
+ if not isinstance(settings, dict):
3010
+ raise ConfigError(
3011
+ "[tool.kivy.ios.xcode.build_settings] must be a table",
3012
+ key_path="tool.kivy.ios.xcode.build_settings",
3013
+ )
3014
+ conflicts = sorted(set(settings) & RESERVED_BUILD_SETTINGS)
3015
+ if conflicts:
3016
+ raise ConfigError(
3017
+ f"[tool.kivy.ios.xcode.build_settings] sets toolchain-reserved key(s): "
3018
+ f"{', '.join(conflicts)}",
3019
+ key_path="tool.kivy.ios.xcode.build_settings",
3020
+ line=finder.line("build_settings"),
3021
+ hint="these are managed by the toolchain and cannot be overridden.",
3022
+ )
3023
+ # Xcode build settings are strings (spec 01); reject non-strings rather than
3024
+ # silently str()-coercing a TOML int/bool/list into a surprising value.
3025
+ result: dict[str, str] = {}
3026
+ for key, value in settings.items():
3027
+ if not isinstance(value, str):
3028
+ raise ConfigError(
3029
+ f"[tool.kivy.ios.xcode.build_settings].{key} must be a string, not "
3030
+ f"{type(value).__name__} (Xcode build settings are strings; quote "
3031
+ f'the value, e.g. {key} = "NO")',
3032
+ key_path=f"tool.kivy.ios.xcode.build_settings.{key}",
3033
+ line=finder.line(key),
3034
+ )
3035
+ result[key] = value
3036
+ return result
3037
+
3038
+
3039
+ def _parse_privacy(ios: dict) -> str | None:
3040
+ privacy = ios.get("privacy_manifest")
3041
+ if privacy is None:
3042
+ return None
3043
+ if not isinstance(privacy, dict):
3044
+ raise ConfigError(
3045
+ "[tool.kivy.ios.privacy_manifest] must be a table",
3046
+ key_path="tool.kivy.ios.privacy_manifest",
3047
+ )
3048
+ return privacy.get("source")
3049
+
3050
+
3051
+ def _parse_entitlements(ios: dict) -> dict[str, object]:
3052
+ ent = ios.get("entitlements")
3053
+ if ent is None:
3054
+ return {}
3055
+ if not isinstance(ent, dict):
3056
+ raise ConfigError(
3057
+ "[tool.kivy.ios.entitlements] must be a table",
3058
+ key_path="tool.kivy.ios.entitlements",
3059
+ )
3060
+ return dict(ent)
3061
+
3062
+
3063
+ class _LineFinder:
3064
+ """Best-effort source-line lookup for a bare key name.
3065
+
3066
+ tomllib does not expose per-key positions for semantically-valid TOML, so
3067
+ we scan the raw text for the first ``key =`` (or ``[table]``) occurrence.
3068
+ Returns None when not found; callers fall back to the dotted key path.
3069
+ """
3070
+
3071
+ def __init__(self, text: str) -> None:
3072
+ self._lines = text.splitlines()
3073
+
3074
+ def line(self, key: str) -> int | None:
3075
+ key_re = re.compile(rf"^\s*(\[*\s*){re.escape(key)}\b")
3076
+ for i, line in enumerate(self._lines, start=1):
3077
+ if key_re.search(line):
3078
+ return i
3079
+ return None