kivyforge 3.0.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. kivyforge/__init__.py +9 -0
  2. kivyforge/artifacts/__init__.py +41 -0
  3. kivyforge/artifacts/cache.py +81 -0
  4. kivyforge/artifacts/collect.py +354 -0
  5. kivyforge/artifacts/download.py +125 -0
  6. kivyforge/artifacts/frameworks.py +240 -0
  7. kivyforge/artifacts/native_stage_util.py +346 -0
  8. kivyforge/artifacts/runtime.py +65 -0
  9. kivyforge/artifacts/verify.py +45 -0
  10. kivyforge/artifacts/wheels.py +89 -0
  11. kivyforge/bundle/__init__.py +1 -0
  12. kivyforge/bundle/pycompile.py +161 -0
  13. kivyforge/cli/__init__.py +52 -0
  14. kivyforge/cli/_common.py +62 -0
  15. kivyforge/cli/_platform.py +84 -0
  16. kivyforge/cli/build.py +111 -0
  17. kivyforge/cli/clean.py +135 -0
  18. kivyforge/cli/doctor.py +52 -0
  19. kivyforge/cli/init.py +612 -0
  20. kivyforge/cli/init_writer.py +716 -0
  21. kivyforge/cli/lock.py +261 -0
  22. kivyforge/cli/open_cmd.py +15 -0
  23. kivyforge/cli/package.py +134 -0
  24. kivyforge/cli/run.py +162 -0
  25. kivyforge/cli/status.py +20 -0
  26. kivyforge/cli/upgrade.py +314 -0
  27. kivyforge/config/__init__.py +46 -0
  28. kivyforge/config/errors.py +43 -0
  29. kivyforge/config/icons.py +60 -0
  30. kivyforge/config/loader.py +3079 -0
  31. kivyforge/config/model.py +817 -0
  32. kivyforge/doctor/__init__.py +19 -0
  33. kivyforge/doctor/checks_common.py +95 -0
  34. kivyforge/doctor/probe.py +357 -0
  35. kivyforge/doctor/result.py +41 -0
  36. kivyforge/lock/__init__.py +42 -0
  37. kivyforge/lock/_pip_shim.py +63 -0
  38. kivyforge/lock/find_links.py +182 -0
  39. kivyforge/lock/model.py +69 -0
  40. kivyforge/lock/pep751.py +111 -0
  41. kivyforge/lock/reader.py +30 -0
  42. kivyforge/lock/resolver.py +124 -0
  43. kivyforge/lock/wheelruntime/__init__.py +70 -0
  44. kivyforge/lock/wheelruntime/builder.py +280 -0
  45. kivyforge/lock/wheelruntime/model.py +94 -0
  46. kivyforge/lock/wheelruntime/native_binaries.py +127 -0
  47. kivyforge/lock/wheelruntime/pbs_github.py +133 -0
  48. kivyforge/lock/wheelruntime/profile.py +104 -0
  49. kivyforge/lock/wheelruntime/resolver.py +248 -0
  50. kivyforge/lock/wheelruntime/runtime.py +161 -0
  51. kivyforge/lock/wheelruntime/serialize.py +245 -0
  52. kivyforge/platforms/__init__.py +114 -0
  53. kivyforge/platforms/android/__init__.py +140 -0
  54. kivyforge/platforms/android/adb.py +215 -0
  55. kivyforge/platforms/android/bootstrap/__init__.py +7 -0
  56. kivyforge/platforms/android/bootstrap/contract.py +166 -0
  57. kivyforge/platforms/android/bootstrap/render.py +244 -0
  58. kivyforge/platforms/android/bootstrap/templates/androidtest/KivyforgeContractTest.java +76 -0
  59. kivyforge/platforms/android/bootstrap/templates/bootstrap_py/_kivy_bootstrap.py +50 -0
  60. kivyforge/platforms/android/bootstrap/templates/bootstrap_py/_kivyforge_bootstrap.py +54 -0
  61. kivyforge/platforms/android/bootstrap/templates/bootstrap_py/_kivyforge_selftest.py +97 -0
  62. kivyforge/platforms/android/bootstrap/templates/cpp/CMakeLists.txt +20 -0
  63. kivyforge/platforms/android/bootstrap/templates/cpp/main.c +150 -0
  64. kivyforge/platforms/android/bootstrap/templates/java/org/jnius/NativeInvocationHandler.java +48 -0
  65. kivyforge/platforms/android/bootstrap/templates/java/org/kivy/android/PythonActivity.java +75 -0
  66. kivyforge/platforms/android/bootstrap/templates/java/org/kivy/android/PythonBundle.java +132 -0
  67. kivyforge/platforms/android/bootstrap/templates/java/org/kivy/android/PythonService.java +189 -0
  68. kivyforge/platforms/android/bootstrap/templates/java/org/renpy/android/Hardware.java +276 -0
  69. kivyforge/platforms/android/bootstrap/templates/sdl2/LICENSE-SDL.txt +18 -0
  70. kivyforge/platforms/android/bootstrap/templates/sdl2/SDL_REVISION.txt +3 -0
  71. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDevice.java +22 -0
  72. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDeviceBLESteamController.java +650 -0
  73. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDeviceManager.java +698 -0
  74. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/HIDDeviceUSB.java +309 -0
  75. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDL.java +90 -0
  76. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLActivity.java +2120 -0
  77. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLAudioManager.java +514 -0
  78. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLControllerManager.java +856 -0
  79. kivyforge/platforms/android/bootstrap/templates/sdl2/org/libsdl/app/SDLSurface.java +405 -0
  80. kivyforge/platforms/android/bootstrap/templates/sdl3/LICENSE-SDL.txt +18 -0
  81. kivyforge/platforms/android/bootstrap/templates/sdl3/SDL_REVISION.txt +3 -0
  82. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDevice.java +21 -0
  83. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDeviceBLESteamController.java +829 -0
  84. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDeviceManager.java +698 -0
  85. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/HIDDeviceUSB.java +354 -0
  86. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDL.java +90 -0
  87. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLActivity.java +2240 -0
  88. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLAudioManager.java +126 -0
  89. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLControllerManager.java +1010 -0
  90. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLDummyEdit.java +66 -0
  91. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLInputConnection.java +136 -0
  92. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLSensorManager.java +73 -0
  93. kivyforge/platforms/android/bootstrap/templates/sdl3/org/libsdl/app/SDLSurface.java +464 -0
  94. kivyforge/platforms/android/cli.py +1023 -0
  95. kivyforge/platforms/android/doctor.py +899 -0
  96. kivyforge/platforms/android/elf.py +148 -0
  97. kivyforge/platforms/android/generate/__init__.py +1 -0
  98. kivyforge/platforms/android/generate/manifest.py +261 -0
  99. kivyforge/platforms/android/generate/project.py +462 -0
  100. kivyforge/platforms/android/generate/services.py +187 -0
  101. kivyforge/platforms/android/gradle_wrapper/NOTICE +3 -0
  102. kivyforge/platforms/android/gradle_wrapper/SHA256SUMS +1 -0
  103. kivyforge/platforms/android/gradle_wrapper/gradle-wrapper.jar +0 -0
  104. kivyforge/platforms/android/gradle_wrapper/gradlew +164 -0
  105. kivyforge/platforms/android/gradle_wrapper/gradlew.bat +90 -0
  106. kivyforge/platforms/android/gradlew.py +60 -0
  107. kivyforge/platforms/android/icons.py +237 -0
  108. kivyforge/platforms/android/lock/__init__.py +1 -0
  109. kivyforge/platforms/android/lock/archives.py +109 -0
  110. kivyforge/platforms/android/lock/builder.py +387 -0
  111. kivyforge/platforms/android/lock/markers.py +71 -0
  112. kivyforge/platforms/android/lock/maven.py +252 -0
  113. kivyforge/platforms/android/lock/model.py +113 -0
  114. kivyforge/platforms/android/lock/python_meta.py +199 -0
  115. kivyforge/platforms/android/lock/reader.py +206 -0
  116. kivyforge/platforms/android/lock/resolver.py +276 -0
  117. kivyforge/platforms/android/lock/writer.py +121 -0
  118. kivyforge/platforms/android/policy.py +214 -0
  119. kivyforge/platforms/android/signing.py +145 -0
  120. kivyforge/platforms/android/smoke.py +43 -0
  121. kivyforge/platforms/android/splash.py +293 -0
  122. kivyforge/platforms/android/stage/__init__.py +1 -0
  123. kivyforge/platforms/android/stage/bundle.py +295 -0
  124. kivyforge/platforms/android/stage/jnilibs.py +186 -0
  125. kivyforge/platforms/android/stage/runtime.py +61 -0
  126. kivyforge/platforms/android/stage/wheels.py +106 -0
  127. kivyforge/platforms/android/toolchain.py +29 -0
  128. kivyforge/platforms/base.py +157 -0
  129. kivyforge/platforms/ios/__init__.py +129 -0
  130. kivyforge/platforms/ios/assets.py +193 -0
  131. kivyforge/platforms/ios/buildsettings.py +128 -0
  132. kivyforge/platforms/ios/cli.py +543 -0
  133. kivyforge/platforms/ios/doctor.py +410 -0
  134. kivyforge/platforms/ios/entitlements.py +34 -0
  135. kivyforge/platforms/ios/generator.py +318 -0
  136. kivyforge/platforms/ios/lock/__init__.py +71 -0
  137. kivyforge/platforms/ios/lock/builder.py +393 -0
  138. kivyforge/platforms/ios/lock/markers.py +69 -0
  139. kivyforge/platforms/ios/lock/model.py +95 -0
  140. kivyforge/platforms/ios/lock/python_meta.py +187 -0
  141. kivyforge/platforms/ios/lock/reader.py +191 -0
  142. kivyforge/platforms/ios/lock/resolver.py +304 -0
  143. kivyforge/platforms/ios/lock/spm.py +217 -0
  144. kivyforge/platforms/ios/lock/writer.py +114 -0
  145. kivyforge/platforms/ios/lock/xcframework.py +188 -0
  146. kivyforge/platforms/ios/materialize.py +59 -0
  147. kivyforge/platforms/ios/plist.py +91 -0
  148. kivyforge/platforms/ios/privacy.py +39 -0
  149. kivyforge/platforms/ios/skeleton.py +165 -0
  150. kivyforge/platforms/ios/sources.py +63 -0
  151. kivyforge/platforms/ios/staging.py +225 -0
  152. kivyforge/platforms/ios/swift_packages.py +324 -0
  153. kivyforge/platforms/ios/templates/kivyforge_bootstrap.h +34 -0
  154. kivyforge/platforms/ios/templates/kivyforge_bootstrap.m +146 -0
  155. kivyforge/platforms/ios/templates/main.m +15 -0
  156. kivyforge/platforms/ios/templates/main_config.h.in +12 -0
  157. kivyforge/platforms/ios/xcframeworks.py +194 -0
  158. kivyforge/platforms/ios/xcode/__init__.py +76 -0
  159. kivyforge/platforms/ios/xcode/commands.py +296 -0
  160. kivyforge/platforms/ios/xcode/runner.py +316 -0
  161. kivyforge/platforms/linux/__init__.py +120 -0
  162. kivyforge/platforms/linux/appimage.py +169 -0
  163. kivyforge/platforms/linux/bundle.py +235 -0
  164. kivyforge/platforms/linux/cli.py +232 -0
  165. kivyforge/platforms/linux/desktop.py +71 -0
  166. kivyforge/platforms/linux/doctor.py +438 -0
  167. kivyforge/platforms/linux/elftools.py +95 -0
  168. kivyforge/platforms/linux/icons.py +129 -0
  169. kivyforge/platforms/linux/launcher.py +107 -0
  170. kivyforge/platforms/linux/lock/__init__.py +126 -0
  171. kivyforge/platforms/linux/lock/profile.py +254 -0
  172. kivyforge/platforms/linux/lock/runtime.py +75 -0
  173. kivyforge/platforms/linux/native_stage.py +47 -0
  174. kivyforge/platforms/linux/runtime_stage.py +107 -0
  175. kivyforge/platforms/linux/wheels_stage.py +103 -0
  176. kivyforge/platforms/macos/__init__.py +121 -0
  177. kivyforge/platforms/macos/bundle.py +255 -0
  178. kivyforge/platforms/macos/cli.py +269 -0
  179. kivyforge/platforms/macos/doctor.py +480 -0
  180. kivyforge/platforms/macos/icns.py +75 -0
  181. kivyforge/platforms/macos/launcher.py +118 -0
  182. kivyforge/platforms/macos/lock/__init__.py +112 -0
  183. kivyforge/platforms/macos/lock/profile.py +119 -0
  184. kivyforge/platforms/macos/lock/runtime.py +58 -0
  185. kivyforge/platforms/macos/machotools.py +138 -0
  186. kivyforge/platforms/macos/native_stage.py +47 -0
  187. kivyforge/platforms/macos/notarize.py +143 -0
  188. kivyforge/platforms/macos/plist.py +37 -0
  189. kivyforge/platforms/macos/runtime_stage.py +110 -0
  190. kivyforge/platforms/macos/signing.py +78 -0
  191. kivyforge/platforms/macos/wheels_stage.py +108 -0
  192. kivyforge/platforms/windows/__init__.py +133 -0
  193. kivyforge/platforms/windows/assets.py +76 -0
  194. kivyforge/platforms/windows/bundle.py +298 -0
  195. kivyforge/platforms/windows/cli.py +314 -0
  196. kivyforge/platforms/windows/doctor.py +563 -0
  197. kivyforge/platforms/windows/fsswap.py +87 -0
  198. kivyforge/platforms/windows/icons.py +64 -0
  199. kivyforge/platforms/windows/launcher/__init__.py +193 -0
  200. kivyforge/platforms/windows/launcher/build_launcher.py +507 -0
  201. kivyforge/platforms/windows/launcher/launcher.c +379 -0
  202. kivyforge/platforms/windows/lock/__init__.py +112 -0
  203. kivyforge/platforms/windows/lock/profile.py +101 -0
  204. kivyforge/platforms/windows/lock/runtime.py +64 -0
  205. kivyforge/platforms/windows/naming.py +52 -0
  206. kivyforge/platforms/windows/native_stage.py +66 -0
  207. kivyforge/platforms/windows/petools.py +98 -0
  208. kivyforge/platforms/windows/rcedit.py +116 -0
  209. kivyforge/platforms/windows/runtime_stage.py +221 -0
  210. kivyforge/platforms/windows/signing.py +168 -0
  211. kivyforge/platforms/windows/vendor/LICENSE.rcedit +29 -0
  212. kivyforge/platforms/windows/vendor/NOTICE +17 -0
  213. kivyforge/platforms/windows/vendor/SHA256SUMS +4 -0
  214. kivyforge/platforms/windows/vendor/TOOLSET.txt +4 -0
  215. kivyforge/platforms/windows/vendor/__init__.py +12 -0
  216. kivyforge/platforms/windows/vendor/fetch_rcedit.py +97 -0
  217. kivyforge/platforms/windows/vendor/launcher-amd64.exe +0 -0
  218. kivyforge/platforms/windows/vendor/rcedit-x64.exe +0 -0
  219. kivyforge/platforms/windows/wheels_stage.py +139 -0
  220. kivyforge-3.0.0.dev0.dist-info/METADATA +609 -0
  221. kivyforge-3.0.0.dev0.dist-info/RECORD +225 -0
  222. kivyforge-3.0.0.dev0.dist-info/WHEEL +5 -0
  223. kivyforge-3.0.0.dev0.dist-info/entry_points.txt +3 -0
  224. kivyforge-3.0.0.dev0.dist-info/licenses/LICENSE +21 -0
  225. kivyforge-3.0.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,240 @@
1
+ """Populate ``<app>-ios/Frameworks/`` from wheels and xcframework archives (spec 03/06).
2
+
3
+ Two sources land in ``Frameworks/``:
4
+
5
+ 1. **Wheel-embedded xcframeworks** — a wheel may ship a ``<pkg>.frameworks/``
6
+ directory containing one or more ``<name>.xcframework`` trees (for the
7
+ canonical Kivy app, ANGLE + the SDL3 family ride inside the kivy wheel).
8
+ ``kivyforge build`` walks installed wheels and copies each one out.
9
+ 2. **Standalone native xcframework archives** — ``[[tool.kivyforge.xcframeworks]]``
10
+ entries, extracted from a zip/tar.gz (honoring ``archive_member`` when the
11
+ archive holds more than one xcframework).
12
+
13
+ A duplicate-framework policy (spec 06) guards against two sources providing the
14
+ same ``<name>.xcframework``: identical content (same tree hash) is deduplicated
15
+ silently, while a basename collision with differing content fails the build,
16
+ naming both providers and their hashes.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import plistlib
23
+ import shutil
24
+ import tarfile
25
+ import zipfile
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+
30
+ class FrameworkConflict(Exception):
31
+ """Two artifacts provide the same ``<name>.xcframework`` with differing content."""
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class PlacedFramework:
36
+ """A framework staged into ``Frameworks/``, with provenance for conflict checks."""
37
+
38
+ path: Path
39
+ origin: str
40
+ tree_hash: str
41
+
42
+
43
+ def copy_wheel_frameworks(
44
+ pip_deps: Path,
45
+ frameworks_dir: Path,
46
+ *,
47
+ existing: dict[str, PlacedFramework] | None = None,
48
+ ) -> list[str]:
49
+ """Copy every ``*.xcframework`` found under any ``*.frameworks/`` dir.
50
+
51
+ Returns the names placed during this call. Two artifacts that share a
52
+ framework basename are deduplicated when their content trees hash equal, and
53
+ raise ``FrameworkConflict`` (naming both providers) when they differ.
54
+
55
+ ``existing`` is a shared registry threaded across the multiple pip-deps
56
+ slices (and into native xcframework extraction) so cross-source duplicates
57
+ are reconciled against everything already staged.
58
+
59
+ After copying, wheel staging dirs (``kivy.frameworks``, ``.frameworks``, …)
60
+ are removed from ``pip-deps/`` so Copy Bundle Resources does not ship
61
+ duplicate xcframework trees inside the app bundle.
62
+ """
63
+ frameworks_dir.mkdir(parents=True, exist_ok=True)
64
+ registry = existing if existing is not None else {}
65
+ placed: set[str] = set()
66
+ embedded_dirs: list[Path] = []
67
+ for frameworks_subdir in sorted(pip_deps.glob("*.frameworks")):
68
+ if not frameworks_subdir.is_dir():
69
+ continue
70
+ embedded_dirs.append(frameworks_subdir)
71
+ for xc in sorted(frameworks_subdir.glob("*.xcframework")):
72
+ _place(xc, frameworks_dir, registry, origin=str(frameworks_subdir.name))
73
+ placed.add(xc.name)
74
+ for subdir in embedded_dirs:
75
+ shutil.rmtree(subdir)
76
+ return sorted(placed)
77
+
78
+
79
+ def extract_xcframework_archive(
80
+ archive: Path,
81
+ frameworks_dir: Path,
82
+ *,
83
+ name: str,
84
+ archive_format: str = "zip",
85
+ archive_member: str | None = None,
86
+ existing: dict[str, PlacedFramework] | None = None,
87
+ ) -> Path:
88
+ """Extract a native ``.xcframework`` from an archive into ``Frameworks/``.
89
+
90
+ ``archive_member`` names the exact ``.xcframework`` directory inside the
91
+ archive when auto-detection (single top-level ``.xcframework``) is
92
+ insufficient. Returns the destination path. ``existing`` is the shared
93
+ registry used to dedupe identical duplicates and reject conflicting ones.
94
+ """
95
+ frameworks_dir.mkdir(parents=True, exist_ok=True)
96
+ registry = existing if existing is not None else {}
97
+ import tempfile
98
+
99
+ with tempfile.TemporaryDirectory(prefix="kivy-xc-") as tmp:
100
+ tmp_dir = Path(tmp)
101
+ _unpack(archive, tmp_dir, archive_format)
102
+ xc_dir = _locate_xcframework(tmp_dir, archive_member, name=name)
103
+ return _place(xc_dir, frameworks_dir, registry, origin=archive.name)
104
+
105
+
106
+ def read_xcframework_slices(xcframework: Path) -> tuple[str, ...]:
107
+ """Return the slice identifiers declared in an ``.xcframework`` root plist.
108
+
109
+ These are the ``LibraryIdentifier`` values from ``Info.plist``'s
110
+ ``AvailableLibraries`` (e.g. ``ios-arm64``, ``ios-arm64_x86_64-simulator``),
111
+ returned sorted for a deterministic lockfile. ``kivyforge lock`` records them
112
+ so the lock advertises which device/simulator slices an artifact ships.
113
+ """
114
+ info = xcframework / "Info.plist"
115
+ try:
116
+ with open(info, "rb") as fh:
117
+ data = plistlib.load(fh)
118
+ except OSError as exc:
119
+ raise FileNotFoundError(
120
+ f"{xcframework.name}: missing or unreadable Info.plist ({exc})."
121
+ ) from exc
122
+ libraries = data.get("AvailableLibraries", [])
123
+ identifiers = [
124
+ lib["LibraryIdentifier"]
125
+ for lib in libraries
126
+ if isinstance(lib, dict) and lib.get("LibraryIdentifier")
127
+ ]
128
+ return tuple(sorted(identifiers))
129
+
130
+
131
+ def _unpack(archive: Path, dest: Path, archive_format: str) -> None:
132
+ if archive_format == "zip":
133
+ with zipfile.ZipFile(archive) as zf:
134
+ zf.extractall(dest)
135
+ elif archive_format in ("tar.gz", "tgz", "targz"):
136
+ with tarfile.open(archive, "r:gz") as tf:
137
+ # filter="data" rejects path traversal, absolute paths, and
138
+ # unsafe special files (Python 3.12+ / PEP 706).
139
+ tf.extractall(dest, filter="data")
140
+ else:
141
+ raise ValueError(f"unsupported archive_format {archive_format!r} (zip|tar.gz)")
142
+
143
+
144
+ def _locate_xcframework(root: Path, archive_member: str | None, *, name: str) -> Path:
145
+ if archive_member:
146
+ candidate = root / archive_member
147
+ if not candidate.is_dir():
148
+ raise FileNotFoundError(
149
+ f"{name}: archive_member {archive_member!r} not found in archive."
150
+ )
151
+ return candidate
152
+ matches = [p for p in root.rglob("*.xcframework") if p.is_dir()]
153
+ if not matches:
154
+ raise FileNotFoundError(f"{name}: no .xcframework found in archive.")
155
+ # Auto-detect only considers the shallowest depth (the "top level").
156
+ min_depth = min(len(p.relative_to(root).parts) for p in matches)
157
+ top = [p for p in matches if len(p.relative_to(root).parts) == min_depth]
158
+ if len(top) == 1:
159
+ return top[0]
160
+ raise FrameworkConflict(
161
+ f"{name}: archive contains multiple .xcframework directories "
162
+ f"({', '.join(sorted(p.name for p in top))}); set archive_member to pick one."
163
+ )
164
+
165
+
166
+ def _place(
167
+ src: Path,
168
+ frameworks_dir: Path,
169
+ registry: dict[str, PlacedFramework],
170
+ *,
171
+ origin: str,
172
+ ) -> Path:
173
+ """Stage ``src`` into ``Frameworks/`` applying the duplicate policy (spec 06).
174
+
175
+ Identical content (matching tree hash) already staged under the same
176
+ basename is deduplicated silently; differing content raises
177
+ ``FrameworkConflict`` naming both providers and their hashes.
178
+ """
179
+ dest = frameworks_dir / src.name
180
+ new_hash = _tree_hash(src)
181
+ prior = registry.get(src.name)
182
+ if prior is not None:
183
+ if prior.tree_hash == new_hash:
184
+ # Same artifact from two sources (or the same wheel across slices):
185
+ # keep the copy already staged, emit nothing.
186
+ return prior.path
187
+ raise FrameworkConflict(
188
+ f"{src.name} is provided by two sources with different contents:\n"
189
+ f" - {prior.origin} (sha256 {prior.tree_hash})\n"
190
+ f" - {origin} (sha256 {new_hash})\n"
191
+ " Pin a single provider, or remove the duplicate. kivyforge does not "
192
+ "pick a winner: a version mismatch can link yet crash at runtime."
193
+ )
194
+ if dest.exists():
195
+ shutil.rmtree(dest)
196
+ shutil.copytree(src, dest)
197
+ _normalize_framework_plists(dest)
198
+ registry[src.name] = PlacedFramework(path=dest, origin=origin, tree_hash=new_hash)
199
+ return dest
200
+
201
+
202
+ def _tree_hash(root: Path) -> str:
203
+ """Deterministic SHA-256 over a directory tree's structure and file contents.
204
+
205
+ Walks entries in sorted relative-path order, framing each with a type tag
206
+ (file/dir/symlink), its relative path, and — for files — a length-prefixed
207
+ stream of contents, so two trees hash equal iff their layout and bytes match.
208
+ """
209
+ h = hashlib.sha256()
210
+ for path in sorted(root.rglob("*"), key=lambda p: p.relative_to(root).as_posix()):
211
+ rel = path.relative_to(root).as_posix().encode("utf-8")
212
+ if path.is_symlink():
213
+ target = path.readlink().as_posix().encode("utf-8")
214
+ h.update(b"L\0" + rel + b"\0" + target + b"\0")
215
+ elif path.is_dir():
216
+ h.update(b"D\0" + rel + b"\0")
217
+ elif path.is_file():
218
+ size = path.stat().st_size
219
+ h.update(b"F\0" + rel + b"\0" + str(size).encode("ascii") + b"\0")
220
+ with open(path, "rb") as fh:
221
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
222
+ h.update(chunk)
223
+ return h.hexdigest()
224
+
225
+
226
+ def _normalize_framework_plists(xcframework: Path) -> None:
227
+ """Ensure each embedded ``.framework`` has a root ``Info.plist``.
228
+
229
+ Some upstream wheels (e.g. KivyThorVG) ship the plist only under
230
+ ``Resources/``; Xcode's bundle validation requires it at the framework root.
231
+ """
232
+ for fw in xcframework.rglob("*.framework"):
233
+ if not fw.is_dir():
234
+ continue
235
+ root_plist = fw / "Info.plist"
236
+ if root_plist.is_file():
237
+ continue
238
+ resources_plist = fw / "Resources" / "Info.plist"
239
+ if resources_plist.is_file():
240
+ shutil.copy2(resources_plist, root_plist)
@@ -0,0 +1,346 @@
1
+ """Platform-neutral native-binary staging mechanics (shared by desktop backends).
2
+
3
+ Each desktop backend declares a ``[tool.kivy.<platform>.native.binaries]``
4
+ channel whose entries are fetched + SHA-256-verified from the platform lock and
5
+ staged into the bundle's ``bin`` directory — a single native file copied in with
6
+ its exec bit set, or a ``.zip`` / ``.tar.gz`` / ``.tgz`` of several extracted
7
+ with a path-traversal guard. ``bin/`` is a single flat namespace, so two entries
8
+ staging to the same path is a hard error (a silent clobber otherwise).
9
+
10
+ This module owns the mechanics so the security-sensitive ``_safe_extract``
11
+ (zip path-traversal) and the ``tarfile`` ``filter="data"`` extractor live in
12
+ exactly one audited place. It raises the neutral
13
+ :class:`NativeStageError`; each backend's thin wrapper translates that into its
14
+ own bundle-error type (the ``NativeBinaryResolverError`` →
15
+ ``WheelRuntimeBuildError`` pattern used in the lock builder).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import shutil
21
+ import stat
22
+ import sys
23
+ import tarfile
24
+ import tempfile
25
+ import zipfile
26
+ from pathlib import Path
27
+ from typing import TYPE_CHECKING
28
+
29
+ from kivyforge.artifacts.cache import ArtifactCache
30
+ from kivyforge.artifacts.download import DownloadError, fetch_artifact
31
+ from kivyforge.artifacts.verify import HashMismatch
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Callable, Iterable
35
+
36
+ from kivyforge.lock.wheelruntime.model import LockedNativeBinary
37
+
38
+ # Reserved DOS device names (case-insensitive), illegal as a path component on
39
+ # Windows even with an extension (``NUL.dll`` is still reserved).
40
+ _WINDOWS_RESERVED = frozenset(
41
+ {
42
+ "CON",
43
+ "PRN",
44
+ "AUX",
45
+ "NUL",
46
+ *(f"COM{i}" for i in range(1, 10)),
47
+ *(f"LPT{i}" for i in range(1, 10)),
48
+ }
49
+ )
50
+
51
+
52
+ class NativeStageError(Exception):
53
+ """A native-binary staging failure (translated to the caller's bundle error)."""
54
+
55
+
56
+ def stage_binaries(
57
+ binaries: tuple[LockedNativeBinary, ...],
58
+ parent_dir: Path,
59
+ *,
60
+ project_root: Path,
61
+ cache: ArtifactCache | None = None,
62
+ no_cache: bool = False,
63
+ bin_label: str,
64
+ casefold: bool = False,
65
+ reject_windows_names: bool = False,
66
+ validate: Callable[[Path], None] | None = None,
67
+ ) -> None:
68
+ """Stage every pinned native binary into ``parent_dir/bin``.
69
+
70
+ ``bin/`` is created under *parent_dir* only when *binaries* is non-empty
71
+ (no empty ``bin/``). *bin_label* is the human-readable path used in the
72
+ collision message (e.g. ``"Contents/Resources/bin"`` or ``"usr/bin"``).
73
+
74
+ Windows-facing options (defaults keep macOS/Linux behavior unchanged):
75
+ *casefold* keys the collision check case-insensitively (so ``SDK.dll`` and
76
+ ``sdk.dll`` collide on a case-insensitive filesystem); *reject_windows_names*
77
+ rejects members that are reserved DOS device names, contain ``:`` (alternate
78
+ data streams), or end in a dot/space; *validate* is called on each staged
79
+ file (e.g. a PE architecture check).
80
+ """
81
+ if not binaries:
82
+ return
83
+ cache = cache or ArtifactCache()
84
+ bin_dir = parent_dir / "bin"
85
+ bin_dir.mkdir(parents=True, exist_ok=True)
86
+ # bin/ is a single flat namespace (helpers run by name off PATH; libraries
87
+ # load by path/soname), so two entries must never stage a file to the same
88
+ # path — the second would silently clobber the first. Track every claimed
89
+ # path and fail loudly instead.
90
+ claimed: dict[str, str] = {}
91
+ for entry in binaries:
92
+ _stage_one(
93
+ entry,
94
+ bin_dir,
95
+ claimed,
96
+ project_root=project_root,
97
+ cache=cache,
98
+ no_cache=no_cache,
99
+ bin_label=bin_label,
100
+ casefold=casefold,
101
+ reject_windows_names=reject_windows_names,
102
+ validate=validate,
103
+ )
104
+
105
+
106
+ def _stage_one(
107
+ entry: LockedNativeBinary,
108
+ bin_dir: Path,
109
+ claimed: dict[str, str],
110
+ *,
111
+ project_root: Path,
112
+ cache: ArtifactCache,
113
+ no_cache: bool,
114
+ bin_label: str,
115
+ casefold: bool,
116
+ reject_windows_names: bool,
117
+ validate: Callable[[Path], None] | None,
118
+ ) -> None:
119
+ source = entry.url or entry.path or ""
120
+ # Stage single files under their *source* basename (copied "as-is") so a
121
+ # library keeps its extension for by-path/by-name loads.
122
+ filename = source.rsplit("/", 1)[-1] or entry.name
123
+ fetched = _fetch(entry, filename, project_root, cache, no_cache)
124
+ lowered = source.lower()
125
+ if lowered.endswith(".zip"):
126
+ _extract_zip(
127
+ fetched,
128
+ bin_dir,
129
+ claimed,
130
+ entry.name,
131
+ bin_label,
132
+ casefold=casefold,
133
+ reject_windows_names=reject_windows_names,
134
+ validate=validate,
135
+ )
136
+ elif lowered.endswith((".tar.gz", ".tgz")):
137
+ _extract_tar(
138
+ fetched,
139
+ bin_dir,
140
+ claimed,
141
+ entry.name,
142
+ bin_label,
143
+ casefold=casefold,
144
+ reject_windows_names=reject_windows_names,
145
+ validate=validate,
146
+ )
147
+ else:
148
+ rel = Path(filename)
149
+ if reject_windows_names:
150
+ _reject_windows_unsafe(rel, entry.name, bin_label)
151
+ _claim(claimed, rel, entry.name, bin_label, casefold=casefold)
152
+ dest = bin_dir / rel
153
+ shutil.copy2(fetched, dest)
154
+ _make_executable(dest)
155
+ if validate is not None:
156
+ validate(dest)
157
+
158
+
159
+ def _fetch(
160
+ entry: LockedNativeBinary,
161
+ filename: str,
162
+ project_root: Path,
163
+ cache: ArtifactCache,
164
+ no_cache: bool,
165
+ ) -> Path:
166
+ try:
167
+ return fetch_artifact(
168
+ name=entry.name,
169
+ sha256=entry.sha256,
170
+ filename=filename,
171
+ url=entry.url,
172
+ path=entry.path,
173
+ project_root=project_root,
174
+ cache=cache,
175
+ no_cache=no_cache,
176
+ )
177
+ except (DownloadError, HashMismatch) as exc:
178
+ raise NativeStageError(str(exc)) from exc
179
+
180
+
181
+ def _extract_zip(
182
+ archive: Path,
183
+ bin_dir: Path,
184
+ claimed: dict[str, str],
185
+ entry_name: str,
186
+ bin_label: str,
187
+ *,
188
+ casefold: bool = False,
189
+ reject_windows_names: bool = False,
190
+ validate: Callable[[Path], None] | None = None,
191
+ ) -> None:
192
+ with tempfile.TemporaryDirectory(prefix="kivy-nb-stage-") as tmp:
193
+ staged = Path(tmp)
194
+ with zipfile.ZipFile(archive) as zf:
195
+ if reject_windows_names:
196
+ _reject_windows_unsafe_names(zf.namelist(), entry_name, bin_label)
197
+ _safe_extract(zf, staged)
198
+ for src in sorted(staged.rglob("*")):
199
+ if not src.is_file():
200
+ continue
201
+ rel = src.relative_to(staged)
202
+ _claim(claimed, rel, entry_name, bin_label, casefold=casefold)
203
+ dest = bin_dir / rel
204
+ dest.parent.mkdir(parents=True, exist_ok=True)
205
+ shutil.copy2(src, dest)
206
+ _make_executable(dest)
207
+ if validate is not None:
208
+ validate(dest)
209
+
210
+
211
+ def _extract_tar(
212
+ archive: Path,
213
+ bin_dir: Path,
214
+ claimed: dict[str, str],
215
+ entry_name: str,
216
+ bin_label: str,
217
+ *,
218
+ casefold: bool = False,
219
+ reject_windows_names: bool = False,
220
+ validate: Callable[[Path], None] | None = None,
221
+ ) -> None:
222
+ """Extract a ``.tar.gz`` / ``.tgz`` into ``bin_dir`` with the same guards as zip.
223
+
224
+ ``.tar.gz`` is the dominant distribution format for Linux SDKs; without this
225
+ a tarball ``source`` would land in ``bin/`` as one opaque, exec-bit'd file.
226
+ Uses ``tarfile``'s hardened ``filter="data"`` extractor — the tar analog of
227
+ :func:`_safe_extract` — which strips symlinks/hardlinks/device nodes and
228
+ rejects absolute/escaping members (more important for tar than zip, since
229
+ tar *restores* symlinks and special files).
230
+ """
231
+ with tempfile.TemporaryDirectory(prefix="kivy-nb-stage-") as tmp:
232
+ staged = Path(tmp)
233
+ try:
234
+ with tarfile.open(archive, "r:*") as tf:
235
+ if reject_windows_names:
236
+ _reject_windows_unsafe_names(tf.getnames(), entry_name, bin_label)
237
+ tf.extractall(staged, filter="data") # noqa: S202 — hardened filter
238
+ except tarfile.FilterError as exc:
239
+ raise NativeStageError(
240
+ f"unsafe path in native-binary archive: {exc}"
241
+ ) from exc
242
+ except tarfile.TarError as exc:
243
+ raise NativeStageError(
244
+ f"could not read native-binary archive {archive.name}: {exc}"
245
+ ) from exc
246
+ for src in sorted(staged.rglob("*")):
247
+ # The "data" filter drops symlinks/special files; copy only regular
248
+ # files (a lingering symlink is skipped rather than dereferenced).
249
+ if src.is_symlink() or not src.is_file():
250
+ continue
251
+ rel = src.relative_to(staged)
252
+ _claim(claimed, rel, entry_name, bin_label, casefold=casefold)
253
+ dest = bin_dir / rel
254
+ dest.parent.mkdir(parents=True, exist_ok=True)
255
+ shutil.copy2(src, dest)
256
+ _make_executable(dest)
257
+ if validate is not None:
258
+ validate(dest)
259
+
260
+
261
+ def _claim(
262
+ claimed: dict[str, str],
263
+ rel: Path,
264
+ entry_name: str,
265
+ bin_label: str,
266
+ *,
267
+ casefold: bool = False,
268
+ ) -> None:
269
+ """Reserve ``bin/<rel>`` for *entry_name*; raise if already taken.
270
+
271
+ Prevents one native-binary entry from silently overwriting another (two
272
+ single files with the same basename, two zips sharing a member path, or a
273
+ single file colliding with a zip member). When *casefold* is set the key is
274
+ lowercased, so ``SDK.dll`` and ``sdk.dll`` collide on a case-insensitive
275
+ target filesystem (Windows).
276
+ """
277
+ key = rel.as_posix()
278
+ if casefold:
279
+ key = key.lower()
280
+ prev = claimed.get(key)
281
+ if prev is not None:
282
+ owners = entry_name if prev == entry_name else f"{prev!r} and {entry_name!r}"
283
+ raise NativeStageError(
284
+ f"native binaries stage colliding path bin/{key} "
285
+ f"({'duplicate inside ' + owners if prev == entry_name else owners}).\n"
286
+ f" Every native binary must stage to a unique path in "
287
+ f"{bin_label}. Rename the artifact (or its source), or "
288
+ f"drop the duplicate, so no two entries write the same file."
289
+ )
290
+ claimed[key] = entry_name
291
+
292
+
293
+ def _make_executable(path: Path) -> None:
294
+ # NTFS has no POSIX exec bit; "executable" is determined by extension/loader.
295
+ # Skip explicitly on Windows so the POSIX chmod is a documented no-op there.
296
+ if sys.platform == "win32":
297
+ return
298
+ mode = path.stat().st_mode
299
+ path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
300
+
301
+
302
+ def _reject_windows_unsafe_names(
303
+ names: Iterable[str], entry_name: str, bin_label: str
304
+ ) -> None:
305
+ """Reject any archive member illegal on Windows (reserved / ADS / trailing)."""
306
+ for name in names:
307
+ _reject_windows_unsafe(Path(name.replace("\\", "/")), entry_name, bin_label)
308
+
309
+
310
+ def _reject_windows_unsafe(rel: Path, entry_name: str, bin_label: str) -> None:
311
+ """Raise if any component of *rel* is illegal on a Windows filesystem.
312
+
313
+ Reserved DOS device names (``NUL``, ``COM1`` ...), alternate data streams
314
+ (a ``:`` in a component), and trailing dots/spaces (which Windows silently
315
+ strips, changing the staged name) are all rejected loudly rather than
316
+ producing a surprising or unusable ``bin/`` layout.
317
+ """
318
+ for part in rel.parts:
319
+ if not part or part in (".", ".."):
320
+ continue
321
+ if ":" in part:
322
+ raise NativeStageError(
323
+ f"native binary {entry_name!r} stages a path with an alternate "
324
+ f"data stream (':') in {bin_label}/{rel.as_posix()!r}; rename it."
325
+ )
326
+ if part != part.rstrip(" ."):
327
+ raise NativeStageError(
328
+ f"native binary {entry_name!r} stages a component ending in a dot "
329
+ f"or space ({part!r}) in {bin_label}; Windows would silently strip "
330
+ "it. Rename the artifact."
331
+ )
332
+ stem = part.split(".", 1)[0].upper()
333
+ if stem in _WINDOWS_RESERVED:
334
+ raise NativeStageError(
335
+ f"native binary {entry_name!r} stages a reserved Windows device "
336
+ f"name ({part!r}) in {bin_label}; rename the artifact."
337
+ )
338
+
339
+
340
+ def _safe_extract(zf: zipfile.ZipFile, target: Path) -> None:
341
+ base = target.resolve()
342
+ for member in zf.namelist():
343
+ resolved = (target / member).resolve()
344
+ if not (resolved == base or base in resolved.parents):
345
+ raise NativeStageError(f"unsafe path in native-binary archive: {member!r}")
346
+ zf.extractall(target) # noqa: S202 — members validated just above
@@ -0,0 +1,65 @@
1
+ """Pluggable Python-runtime adapter (plan cross-cutting decision).
2
+
3
+ ``PythonOrgRuntime`` provides the canonical iOS Python xcframework from
4
+ python.org (3.15+). The ``PythonRuntime`` Protocol keeps the interface
5
+ stable if a second runtime is ever needed.
6
+
7
+ See specs 02/03/06 for the canonical target details.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Protocol
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class RuntimeArtifact:
18
+ version: str
19
+ url: str
20
+ # Archive container; python.org ships a gzipped tarball.
21
+ archive_format: str = "tar.gz"
22
+
23
+
24
+ class PythonRuntime(Protocol):
25
+ name: str
26
+
27
+ def xcframework_artifact(self, version: str) -> RuntimeArtifact:
28
+ """The downloadable Python.xcframework archive for ``version``."""
29
+ ...
30
+
31
+ def build_python_invocation(
32
+ self, *, xcframework: str, app: str, pip_deps: str
33
+ ) -> str:
34
+ """The 'Build Python' Run Script body that wires .so -> .framework."""
35
+ ...
36
+
37
+
38
+ class PythonOrgRuntime:
39
+ """Canonical runtime: python.org Python.xcframework + ``install_python``."""
40
+
41
+ name = "python.org"
42
+
43
+ URL_TEMPLATE = (
44
+ "https://www.python.org/ftp/python/{version}/"
45
+ "python-{version}-iOS-XCframework.tar.gz"
46
+ )
47
+
48
+ def xcframework_artifact(self, version: str) -> RuntimeArtifact:
49
+ return RuntimeArtifact(
50
+ version=version, url=self.URL_TEMPLATE.format(version=version)
51
+ )
52
+
53
+ def build_python_invocation(
54
+ self, *, xcframework: str, app: str, pip_deps: str
55
+ ) -> str:
56
+ # python.org ships `install_python` inside the xcframework; it walks
57
+ # `app/` + `pip-deps/`, converts each `.so` to a per-module framework,
58
+ # and leaves a `.fwork` marker (Python 3.15 iOS docs §7.2.2 step 7).
59
+ return f"install_python {xcframework} {app} {pip_deps}"
60
+
61
+
62
+ def get_runtime(name: str = "python.org", **kwargs) -> PythonRuntime:
63
+ if name in ("python.org", "pythonorg", "python_org"):
64
+ return PythonOrgRuntime()
65
+ raise ValueError(f"unknown python runtime {name!r} (expected python.org)")
@@ -0,0 +1,45 @@
1
+ """SHA-256 verification for downloaded/vendored artifacts (spec 03)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from pathlib import Path
7
+
8
+ _CHUNK = 1 << 20 # 1 MiB
9
+
10
+
11
+ class HashMismatch(Exception):
12
+ """Raised when an artifact's SHA-256 does not match the lockfile pin."""
13
+
14
+ def __init__(self, *, name: str, source: str, expected: str, actual: str) -> None:
15
+ self.name = name
16
+ self.source = source
17
+ self.expected = expected
18
+ self.actual = actual
19
+ super().__init__(
20
+ f"SHA-256 mismatch for {name}\n"
21
+ f" source: {source}\n"
22
+ f" expected: {expected}\n"
23
+ f" actual: {actual}\n"
24
+ f" The artifact may have been tampered with, or the source registry "
25
+ f"replaced it. Re-run `kivyforge lock`, and report the source if this "
26
+ f"persists."
27
+ )
28
+
29
+
30
+ def sha256_file(path: str | Path) -> str:
31
+ digest = hashlib.sha256()
32
+ with open(path, "rb") as f:
33
+ for chunk in iter(lambda: f.read(_CHUNK), b""):
34
+ digest.update(chunk)
35
+ return digest.hexdigest()
36
+
37
+
38
+ def sha256_bytes(data: bytes) -> str:
39
+ return hashlib.sha256(data).hexdigest()
40
+
41
+
42
+ def verify_file(path: str | Path, expected: str, *, name: str, source: str) -> None:
43
+ actual = sha256_file(path)
44
+ if actual != expected:
45
+ raise HashMismatch(name=name, source=source, expected=expected, actual=actual)