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.
- kivyforge/__init__.py +9 -0
- kivyforge/artifacts/__init__.py +41 -0
- kivyforge/artifacts/cache.py +81 -0
- kivyforge/artifacts/collect.py +354 -0
- kivyforge/artifacts/download.py +125 -0
- kivyforge/artifacts/frameworks.py +240 -0
- kivyforge/artifacts/native_stage_util.py +346 -0
- kivyforge/artifacts/runtime.py +65 -0
- kivyforge/artifacts/verify.py +45 -0
- kivyforge/artifacts/wheels.py +89 -0
- kivyforge/bundle/__init__.py +1 -0
- kivyforge/bundle/pycompile.py +161 -0
- kivyforge/cli/__init__.py +52 -0
- kivyforge/cli/_common.py +62 -0
- kivyforge/cli/_platform.py +84 -0
- kivyforge/cli/build.py +111 -0
- kivyforge/cli/clean.py +135 -0
- kivyforge/cli/doctor.py +52 -0
- kivyforge/cli/init.py +612 -0
- kivyforge/cli/init_writer.py +716 -0
- kivyforge/cli/lock.py +261 -0
- kivyforge/cli/open_cmd.py +15 -0
- kivyforge/cli/package.py +134 -0
- kivyforge/cli/run.py +162 -0
- kivyforge/cli/status.py +20 -0
- kivyforge/cli/upgrade.py +314 -0
- kivyforge/config/__init__.py +46 -0
- kivyforge/config/errors.py +43 -0
- kivyforge/config/icons.py +60 -0
- kivyforge/config/loader.py +3079 -0
- kivyforge/config/model.py +817 -0
- kivyforge/doctor/__init__.py +19 -0
- kivyforge/doctor/checks_common.py +95 -0
- kivyforge/doctor/probe.py +357 -0
- kivyforge/doctor/result.py +41 -0
- kivyforge/lock/__init__.py +42 -0
- kivyforge/lock/_pip_shim.py +63 -0
- kivyforge/lock/find_links.py +182 -0
- kivyforge/lock/model.py +69 -0
- kivyforge/lock/pep751.py +111 -0
- kivyforge/lock/reader.py +30 -0
- kivyforge/lock/resolver.py +124 -0
- kivyforge/lock/wheelruntime/__init__.py +70 -0
- kivyforge/lock/wheelruntime/builder.py +280 -0
- kivyforge/lock/wheelruntime/model.py +94 -0
- kivyforge/lock/wheelruntime/native_binaries.py +127 -0
- kivyforge/lock/wheelruntime/pbs_github.py +133 -0
- kivyforge/lock/wheelruntime/profile.py +104 -0
- kivyforge/lock/wheelruntime/resolver.py +248 -0
- kivyforge/lock/wheelruntime/runtime.py +161 -0
- kivyforge/lock/wheelruntime/serialize.py +245 -0
- kivyforge/platforms/__init__.py +114 -0
- kivyforge/platforms/android/__init__.py +140 -0
- kivyforge/platforms/android/adb.py +215 -0
- kivyforge/platforms/android/bootstrap/__init__.py +7 -0
- kivyforge/platforms/android/bootstrap/contract.py +166 -0
- kivyforge/platforms/android/bootstrap/render.py +244 -0
- kivyforge/platforms/android/bootstrap/templates/androidtest/KivyforgeContractTest.java +76 -0
- kivyforge/platforms/android/bootstrap/templates/bootstrap_py/_kivy_bootstrap.py +50 -0
- kivyforge/platforms/android/bootstrap/templates/bootstrap_py/_kivyforge_bootstrap.py +54 -0
- kivyforge/platforms/android/bootstrap/templates/bootstrap_py/_kivyforge_selftest.py +97 -0
- kivyforge/platforms/android/bootstrap/templates/cpp/CMakeLists.txt +20 -0
- kivyforge/platforms/android/bootstrap/templates/cpp/main.c +150 -0
- kivyforge/platforms/android/bootstrap/templates/java/org/jnius/NativeInvocationHandler.java +48 -0
- kivyforge/platforms/android/bootstrap/templates/java/org/kivy/android/PythonActivity.java +75 -0
- kivyforge/platforms/android/bootstrap/templates/java/org/kivy/android/PythonBundle.java +132 -0
- kivyforge/platforms/android/bootstrap/templates/java/org/kivy/android/PythonService.java +189 -0
- kivyforge/platforms/android/bootstrap/templates/java/org/renpy/android/Hardware.java +276 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/LICENSE-SDL.txt +18 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/SDL_REVISION.txt +3 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDevice.java +22 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDeviceBLESteamController.java +650 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDeviceManager.java +698 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDeviceUSB.java +309 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDL.java +90 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLActivity.java +2120 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLAudioManager.java +514 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLControllerManager.java +856 -0
- kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLSurface.java +405 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/LICENSE-SDL.txt +18 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/SDL_REVISION.txt +3 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDevice.java +21 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDeviceBLESteamController.java +829 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDeviceManager.java +698 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDeviceUSB.java +354 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDL.java +90 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLActivity.java +2240 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLAudioManager.java +126 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLControllerManager.java +1010 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLDummyEdit.java +66 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLInputConnection.java +136 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLSensorManager.java +73 -0
- kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLSurface.java +464 -0
- kivyforge/platforms/android/cli.py +1023 -0
- kivyforge/platforms/android/doctor.py +899 -0
- kivyforge/platforms/android/elf.py +148 -0
- kivyforge/platforms/android/generate/__init__.py +1 -0
- kivyforge/platforms/android/generate/manifest.py +261 -0
- kivyforge/platforms/android/generate/project.py +462 -0
- kivyforge/platforms/android/generate/services.py +187 -0
- kivyforge/platforms/android/gradle_wrapper/NOTICE +3 -0
- kivyforge/platforms/android/gradle_wrapper/SHA256SUMS +1 -0
- kivyforge/platforms/android/gradle_wrapper/gradle-wrapper.jar +0 -0
- kivyforge/platforms/android/gradle_wrapper/gradlew +164 -0
- kivyforge/platforms/android/gradle_wrapper/gradlew.bat +90 -0
- kivyforge/platforms/android/gradlew.py +60 -0
- kivyforge/platforms/android/icons.py +237 -0
- kivyforge/platforms/android/lock/__init__.py +1 -0
- kivyforge/platforms/android/lock/archives.py +109 -0
- kivyforge/platforms/android/lock/builder.py +387 -0
- kivyforge/platforms/android/lock/markers.py +71 -0
- kivyforge/platforms/android/lock/maven.py +252 -0
- kivyforge/platforms/android/lock/model.py +113 -0
- kivyforge/platforms/android/lock/python_meta.py +199 -0
- kivyforge/platforms/android/lock/reader.py +206 -0
- kivyforge/platforms/android/lock/resolver.py +276 -0
- kivyforge/platforms/android/lock/writer.py +121 -0
- kivyforge/platforms/android/policy.py +214 -0
- kivyforge/platforms/android/signing.py +145 -0
- kivyforge/platforms/android/smoke.py +43 -0
- kivyforge/platforms/android/splash.py +293 -0
- kivyforge/platforms/android/stage/__init__.py +1 -0
- kivyforge/platforms/android/stage/bundle.py +295 -0
- kivyforge/platforms/android/stage/jnilibs.py +186 -0
- kivyforge/platforms/android/stage/runtime.py +61 -0
- kivyforge/platforms/android/stage/wheels.py +106 -0
- kivyforge/platforms/android/toolchain.py +29 -0
- kivyforge/platforms/base.py +157 -0
- kivyforge/platforms/ios/__init__.py +129 -0
- kivyforge/platforms/ios/assets.py +193 -0
- kivyforge/platforms/ios/buildsettings.py +128 -0
- kivyforge/platforms/ios/cli.py +543 -0
- kivyforge/platforms/ios/doctor.py +410 -0
- kivyforge/platforms/ios/entitlements.py +34 -0
- kivyforge/platforms/ios/generator.py +318 -0
- kivyforge/platforms/ios/lock/__init__.py +71 -0
- kivyforge/platforms/ios/lock/builder.py +393 -0
- kivyforge/platforms/ios/lock/markers.py +69 -0
- kivyforge/platforms/ios/lock/model.py +95 -0
- kivyforge/platforms/ios/lock/python_meta.py +187 -0
- kivyforge/platforms/ios/lock/reader.py +191 -0
- kivyforge/platforms/ios/lock/resolver.py +304 -0
- kivyforge/platforms/ios/lock/spm.py +217 -0
- kivyforge/platforms/ios/lock/writer.py +114 -0
- kivyforge/platforms/ios/lock/xcframework.py +188 -0
- kivyforge/platforms/ios/materialize.py +59 -0
- kivyforge/platforms/ios/plist.py +91 -0
- kivyforge/platforms/ios/privacy.py +39 -0
- kivyforge/platforms/ios/skeleton.py +165 -0
- kivyforge/platforms/ios/sources.py +63 -0
- kivyforge/platforms/ios/staging.py +225 -0
- kivyforge/platforms/ios/swift_packages.py +324 -0
- kivyforge/platforms/ios/templates/kivyforge_bootstrap.h +34 -0
- kivyforge/platforms/ios/templates/kivyforge_bootstrap.m +146 -0
- kivyforge/platforms/ios/templates/main.m +15 -0
- kivyforge/platforms/ios/templates/main_config.h.in +12 -0
- kivyforge/platforms/ios/xcframeworks.py +194 -0
- kivyforge/platforms/ios/xcode/__init__.py +76 -0
- kivyforge/platforms/ios/xcode/commands.py +296 -0
- kivyforge/platforms/ios/xcode/runner.py +316 -0
- kivyforge/platforms/linux/__init__.py +120 -0
- kivyforge/platforms/linux/appimage.py +169 -0
- kivyforge/platforms/linux/bundle.py +235 -0
- kivyforge/platforms/linux/cli.py +232 -0
- kivyforge/platforms/linux/desktop.py +71 -0
- kivyforge/platforms/linux/doctor.py +438 -0
- kivyforge/platforms/linux/elftools.py +95 -0
- kivyforge/platforms/linux/icons.py +129 -0
- kivyforge/platforms/linux/launcher.py +107 -0
- kivyforge/platforms/linux/lock/__init__.py +126 -0
- kivyforge/platforms/linux/lock/profile.py +254 -0
- kivyforge/platforms/linux/lock/runtime.py +75 -0
- kivyforge/platforms/linux/native_stage.py +47 -0
- kivyforge/platforms/linux/runtime_stage.py +107 -0
- kivyforge/platforms/linux/wheels_stage.py +103 -0
- kivyforge/platforms/macos/__init__.py +121 -0
- kivyforge/platforms/macos/bundle.py +255 -0
- kivyforge/platforms/macos/cli.py +269 -0
- kivyforge/platforms/macos/doctor.py +480 -0
- kivyforge/platforms/macos/icns.py +75 -0
- kivyforge/platforms/macos/launcher.py +118 -0
- kivyforge/platforms/macos/lock/__init__.py +112 -0
- kivyforge/platforms/macos/lock/profile.py +119 -0
- kivyforge/platforms/macos/lock/runtime.py +58 -0
- kivyforge/platforms/macos/machotools.py +138 -0
- kivyforge/platforms/macos/native_stage.py +47 -0
- kivyforge/platforms/macos/notarize.py +143 -0
- kivyforge/platforms/macos/plist.py +37 -0
- kivyforge/platforms/macos/runtime_stage.py +110 -0
- kivyforge/platforms/macos/signing.py +78 -0
- kivyforge/platforms/macos/wheels_stage.py +108 -0
- kivyforge/platforms/windows/__init__.py +133 -0
- kivyforge/platforms/windows/assets.py +76 -0
- kivyforge/platforms/windows/bundle.py +298 -0
- kivyforge/platforms/windows/cli.py +314 -0
- kivyforge/platforms/windows/doctor.py +563 -0
- kivyforge/platforms/windows/fsswap.py +87 -0
- kivyforge/platforms/windows/icons.py +64 -0
- kivyforge/platforms/windows/launcher/__init__.py +193 -0
- kivyforge/platforms/windows/launcher/build_launcher.py +507 -0
- kivyforge/platforms/windows/launcher/launcher.c +379 -0
- kivyforge/platforms/windows/lock/__init__.py +112 -0
- kivyforge/platforms/windows/lock/profile.py +101 -0
- kivyforge/platforms/windows/lock/runtime.py +64 -0
- kivyforge/platforms/windows/naming.py +52 -0
- kivyforge/platforms/windows/native_stage.py +66 -0
- kivyforge/platforms/windows/petools.py +98 -0
- kivyforge/platforms/windows/rcedit.py +116 -0
- kivyforge/platforms/windows/runtime_stage.py +221 -0
- kivyforge/platforms/windows/signing.py +168 -0
- kivyforge/platforms/windows/vendor/LICENSE.rcedit +29 -0
- kivyforge/platforms/windows/vendor/NOTICE +17 -0
- kivyforge/platforms/windows/vendor/SHA256SUMS +4 -0
- kivyforge/platforms/windows/vendor/TOOLSET.txt +4 -0
- kivyforge/platforms/windows/vendor/__init__.py +12 -0
- kivyforge/platforms/windows/vendor/fetch_rcedit.py +97 -0
- kivyforge/platforms/windows/vendor/launcher-amd64.exe +0 -0
- kivyforge/platforms/windows/vendor/rcedit-x64.exe +0 -0
- kivyforge/platforms/windows/wheels_stage.py +139 -0
- kivyforge-3.0.0.dev0.dist-info/METADATA +609 -0
- kivyforge-3.0.0.dev0.dist-info/RECORD +225 -0
- kivyforge-3.0.0.dev0.dist-info/WHEEL +5 -0
- kivyforge-3.0.0.dev0.dist-info/entry_points.txt +3 -0
- kivyforge-3.0.0.dev0.dist-info/licenses/LICENSE +21 -0
- 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
|