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,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)
|