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,563 @@
|
|
|
1
|
+
r"""Windows-specific doctor checks (windows-spec).
|
|
2
|
+
|
|
3
|
+
Reuses the platform-neutral checks (kivyforge version, app source dir) and adds
|
|
4
|
+
the Windows environment (host OS, long-path support) and project checks (app_id
|
|
5
|
+
style, ``win_amd64`` arch coverage, icon, native-binary sources / collisions /
|
|
6
|
+
arch, reachable hosts, resource assets, signtool + signing certificate,
|
|
7
|
+
find_links) over the ``pylock.windows.toml`` model.
|
|
8
|
+
|
|
9
|
+
Every check returns an explicit PASS / WARN / FAIL / SKIP; ``doctor`` exits
|
|
10
|
+
non-zero only on FAIL. Signing checks SKIP unless ``[tool.kivy.windows.signing]``
|
|
11
|
+
is configured, and the certificate lookup targets the **same** ``store_scope``
|
|
12
|
+
store that ``signtool`` will use, so the two never disagree.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from urllib.parse import urlparse
|
|
20
|
+
|
|
21
|
+
from kivyforge.config import ConfigError, load_config
|
|
22
|
+
from kivyforge.config.icons import APP_ICON_SIZE, icon_source_problem
|
|
23
|
+
from kivyforge.config.model import (
|
|
24
|
+
WINDOWS_APP_ID_MAX_LENGTH,
|
|
25
|
+
Config,
|
|
26
|
+
)
|
|
27
|
+
from kivyforge.doctor import checks_common as C
|
|
28
|
+
from kivyforge.doctor.probe import Probe, RealProbe
|
|
29
|
+
from kivyforge.doctor.result import CheckResult, Status
|
|
30
|
+
from kivyforge.lock.find_links import find_links_doctor_detail
|
|
31
|
+
from kivyforge.lock.reader import LockError
|
|
32
|
+
|
|
33
|
+
from .bundle import bundle_dir_name, onedir_path
|
|
34
|
+
from .lock import WindowsLockfile, load
|
|
35
|
+
from .naming import _RESERVED
|
|
36
|
+
from .petools import PeArchError, verify_pe_arch
|
|
37
|
+
|
|
38
|
+
# A well-formed AppUserModelID *style* (Microsoft's recommendation): one or more
|
|
39
|
+
# PascalCase segments joined by periods, e.g. ``Acme.MyApp`` or
|
|
40
|
+
# ``Contoso.Suite.Editor``. Only a WARN — the loader already enforces the hard
|
|
41
|
+
# constraints (no spaces, <=128 chars).
|
|
42
|
+
_APP_ID_STYLE = re.compile(r"^[A-Z][A-Za-z0-9]*(\.[A-Z][A-Za-z0-9]*)+$")
|
|
43
|
+
|
|
44
|
+
_ARCHIVE_SUFFIXES = (".zip", ".tar.gz", ".tgz")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---- environment checks -------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def check_windows_host(probe: Probe) -> CheckResult:
|
|
51
|
+
host = probe.host_system()
|
|
52
|
+
if host != "Windows":
|
|
53
|
+
return CheckResult(
|
|
54
|
+
"Host is Windows",
|
|
55
|
+
Status.FAIL,
|
|
56
|
+
f"host is {host!r}",
|
|
57
|
+
hint="the onedir backend stages an MSVC amd64 "
|
|
58
|
+
"python-build-standalone runtime and uses Windows-only tools "
|
|
59
|
+
"(rcedit, signtool); build on a Windows host.",
|
|
60
|
+
)
|
|
61
|
+
return CheckResult("Host is Windows", Status.PASS, "Windows")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def check_long_path_support(probe: Probe) -> CheckResult:
|
|
65
|
+
enabled = probe.long_paths_enabled()
|
|
66
|
+
if enabled is None:
|
|
67
|
+
return CheckResult(
|
|
68
|
+
"Long-path support",
|
|
69
|
+
Status.SKIP,
|
|
70
|
+
"could not read LongPathsEnabled",
|
|
71
|
+
)
|
|
72
|
+
if not enabled:
|
|
73
|
+
return CheckResult(
|
|
74
|
+
"Long-path support",
|
|
75
|
+
Status.WARN,
|
|
76
|
+
"LongPathsEnabled is off",
|
|
77
|
+
hint="a full runtime prefix under build/windows can exceed the legacy "
|
|
78
|
+
"260-char MAX_PATH. Enable it: set HKLM\\SYSTEM\\CurrentControlSet\\"
|
|
79
|
+
"Control\\FileSystem\\LongPathsEnabled=1 (admin), then reboot.",
|
|
80
|
+
)
|
|
81
|
+
return CheckResult("Long-path support", Status.PASS, "LongPathsEnabled=1")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---- project checks -----------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check_windows_app_id(config: Config) -> CheckResult:
|
|
88
|
+
app_id = config.windows_required.app_id
|
|
89
|
+
if " " in app_id or len(app_id) > WINDOWS_APP_ID_MAX_LENGTH:
|
|
90
|
+
return CheckResult(
|
|
91
|
+
"AppUserModelID",
|
|
92
|
+
Status.FAIL,
|
|
93
|
+
f"{app_id!r} violates the AppUserModelID constraints",
|
|
94
|
+
hint="the AppUserModelID must contain no spaces and be at most "
|
|
95
|
+
f"{WINDOWS_APP_ID_MAX_LENGTH} characters.",
|
|
96
|
+
)
|
|
97
|
+
if not _APP_ID_STYLE.match(app_id):
|
|
98
|
+
return CheckResult(
|
|
99
|
+
"AppUserModelID",
|
|
100
|
+
Status.WARN,
|
|
101
|
+
f"{app_id!r} is not the recommended CompanyName.ProductName style",
|
|
102
|
+
hint="Microsoft recommends a PascalCase, period-delimited "
|
|
103
|
+
"AppUserModelID such as 'Acme.MyApp' so taskbar grouping and jump "
|
|
104
|
+
"lists behave; it still works as-is.",
|
|
105
|
+
)
|
|
106
|
+
return CheckResult("AppUserModelID", Status.PASS, app_id)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def check_windows_arch_coverage(
|
|
110
|
+
config: Config, lock: WindowsLockfile | None
|
|
111
|
+
) -> CheckResult:
|
|
112
|
+
archs = config.windows_required.archs
|
|
113
|
+
if lock is None:
|
|
114
|
+
return CheckResult(
|
|
115
|
+
"Architecture coverage", Status.SKIP, "no pylock.windows.toml"
|
|
116
|
+
)
|
|
117
|
+
runtime_archs = {a.arch for a in lock.python_runtime.artifacts}
|
|
118
|
+
missing_runtime = [a for a in archs if a not in runtime_archs]
|
|
119
|
+
if missing_runtime:
|
|
120
|
+
return CheckResult(
|
|
121
|
+
"Architecture coverage",
|
|
122
|
+
Status.FAIL,
|
|
123
|
+
f"runtime missing arch(es): {', '.join(missing_runtime)}",
|
|
124
|
+
hint="re-run `kivyforge lock -p windows` after setting archs.",
|
|
125
|
+
)
|
|
126
|
+
# arm64: this hardcodes win_amd64; generalize to each configured arch's tag
|
|
127
|
+
# (windows_platform_tag(arch)) so arm64 coverage is checked too. The native
|
|
128
|
+
# arch check (check_windows_native_arch) already loops per arch and needs no
|
|
129
|
+
# change. See arm64-windows.md §8.
|
|
130
|
+
for pkg in lock.packages:
|
|
131
|
+
if any(w.is_pure_python for w in pkg.wheels):
|
|
132
|
+
continue
|
|
133
|
+
tags = {w.platform_tag for w in pkg.wheels}
|
|
134
|
+
if "win_amd64" not in tags:
|
|
135
|
+
return CheckResult(
|
|
136
|
+
"Architecture coverage",
|
|
137
|
+
Status.FAIL,
|
|
138
|
+
f"{pkg.name} has no win_amd64 wheel",
|
|
139
|
+
hint="a compiled dependency must publish a win_amd64 wheel; supply "
|
|
140
|
+
"it via extra_index_urls/find_links and re-lock, or drop the dep "
|
|
141
|
+
"on Windows.",
|
|
142
|
+
)
|
|
143
|
+
return CheckResult(
|
|
144
|
+
"Architecture coverage", Status.PASS, f"all deps cover {', '.join(archs)}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def check_windows_app_icon(config: Config, project_root: Path) -> CheckResult:
|
|
149
|
+
source = config.windows_required.icons.source
|
|
150
|
+
if not source:
|
|
151
|
+
return CheckResult("App icon", Status.SKIP, "not configured")
|
|
152
|
+
problem = icon_source_problem((project_root / source).resolve())
|
|
153
|
+
if problem:
|
|
154
|
+
return CheckResult("App icon", Status.FAIL, f"{source} invalid", hint=problem)
|
|
155
|
+
return CheckResult("App icon", Status.PASS, f"{APP_ICON_SIZE}x{APP_ICON_SIZE} PNG")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def check_windows_native_sources(config: Config, project_root: Path) -> CheckResult:
|
|
159
|
+
declared = config.windows_required.binaries
|
|
160
|
+
if not declared:
|
|
161
|
+
return CheckResult("Native binaries: sources", Status.SKIP, "not configured")
|
|
162
|
+
root = project_root.resolve()
|
|
163
|
+
missing = [
|
|
164
|
+
dep.source
|
|
165
|
+
for dep in declared
|
|
166
|
+
if not dep.source.startswith(("http://", "https://"))
|
|
167
|
+
and not (root / dep.source).is_file()
|
|
168
|
+
]
|
|
169
|
+
if missing:
|
|
170
|
+
return CheckResult(
|
|
171
|
+
"Native binaries: sources",
|
|
172
|
+
Status.FAIL,
|
|
173
|
+
f"vendored source(s) not found: {', '.join(missing)}",
|
|
174
|
+
hint="vendor the artifact under the project and re-run "
|
|
175
|
+
"`kivyforge lock -p windows` (URL sources are checked by the hosts "
|
|
176
|
+
"reachability check).",
|
|
177
|
+
)
|
|
178
|
+
return CheckResult(
|
|
179
|
+
"Native binaries: sources",
|
|
180
|
+
Status.PASS,
|
|
181
|
+
f"{len(declared)} declared; sources present",
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def check_windows_native_collision(config: Config) -> CheckResult:
|
|
186
|
+
"""Case-insensitive + reserved-name preflight for single-file sources.
|
|
187
|
+
|
|
188
|
+
Windows is case-insensitive, so ``SDK.dll`` and ``sdk.dll`` would land on
|
|
189
|
+
the same ``bin\\`` path; reserved device names (``NUL.dll``) are illegal.
|
|
190
|
+
Archive-member collisions need extraction and are caught (loudly) at build.
|
|
191
|
+
"""
|
|
192
|
+
declared = config.windows_required.binaries
|
|
193
|
+
if not declared:
|
|
194
|
+
return CheckResult("Native binaries: collision", Status.SKIP, "not configured")
|
|
195
|
+
staged: dict[str, str] = {}
|
|
196
|
+
for dep in declared:
|
|
197
|
+
if dep.source.lower().endswith(_ARCHIVE_SUFFIXES):
|
|
198
|
+
continue
|
|
199
|
+
base = dep.source.rsplit("/", 1)[-1] or dep.name
|
|
200
|
+
stem = base.split(".", 1)[0].upper()
|
|
201
|
+
if stem in _RESERVED:
|
|
202
|
+
return CheckResult(
|
|
203
|
+
"Native binaries: collision",
|
|
204
|
+
Status.FAIL,
|
|
205
|
+
f"{dep.name!r} stages reserved device name {base!r}",
|
|
206
|
+
hint="rename the artifact; Windows forbids CON/PRN/AUX/NUL/"
|
|
207
|
+
"COM1-9/LPT1-9 even with an extension.",
|
|
208
|
+
)
|
|
209
|
+
key = base.lower()
|
|
210
|
+
prev = staged.get(key)
|
|
211
|
+
if prev is not None:
|
|
212
|
+
return CheckResult(
|
|
213
|
+
"Native binaries: collision",
|
|
214
|
+
Status.FAIL,
|
|
215
|
+
f"{prev!r} and {dep.name!r} both stage bin/{base} (case-insensitive)",
|
|
216
|
+
hint="two native binaries would overwrite each other in bin\\; "
|
|
217
|
+
"rename one so each stages to a case-insensitively unique name.",
|
|
218
|
+
)
|
|
219
|
+
staged[key] = dep.name
|
|
220
|
+
return CheckResult(
|
|
221
|
+
"Native binaries: collision", Status.PASS, "no case/reserved-name collisions"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def check_windows_native_arch(config: Config, project_root: Path) -> CheckResult:
|
|
226
|
+
declared = config.windows_required.binaries
|
|
227
|
+
if not declared:
|
|
228
|
+
return CheckResult("Native binaries: arch", Status.SKIP, "not configured")
|
|
229
|
+
bin_dir = (
|
|
230
|
+
project_root.resolve() / "build" / "windows" / bundle_dir_name(config) / "bin"
|
|
231
|
+
)
|
|
232
|
+
if not bin_dir.is_dir():
|
|
233
|
+
return CheckResult(
|
|
234
|
+
"Native binaries: arch", Status.SKIP, "not built (run `kivyforge build`)"
|
|
235
|
+
)
|
|
236
|
+
archs = config.windows_required.archs
|
|
237
|
+
problems: list[str] = []
|
|
238
|
+
pe_count = 0
|
|
239
|
+
for path in sorted(bin_dir.rglob("*")):
|
|
240
|
+
if not path.is_file():
|
|
241
|
+
continue
|
|
242
|
+
for arch in archs:
|
|
243
|
+
try:
|
|
244
|
+
verify_pe_arch(path, arch)
|
|
245
|
+
except PeArchError as exc:
|
|
246
|
+
problems.append(str(exc).splitlines()[0])
|
|
247
|
+
pe_count += 1
|
|
248
|
+
if problems:
|
|
249
|
+
return CheckResult(
|
|
250
|
+
"Native binaries: arch",
|
|
251
|
+
Status.FAIL,
|
|
252
|
+
"; ".join(problems),
|
|
253
|
+
hint=f"rebuild the native binaries for {', '.join(archs)} and re-lock.",
|
|
254
|
+
)
|
|
255
|
+
return CheckResult(
|
|
256
|
+
"Native binaries: arch", Status.PASS, f"{pe_count} staged file(s) match"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def check_windows_output_lock(
|
|
261
|
+
config: Config, project_root: Path, probe: Probe
|
|
262
|
+
) -> CheckResult:
|
|
263
|
+
"""Pre-flight the exact rename ``build``/``package`` performs on the onedir.
|
|
264
|
+
|
|
265
|
+
A running instance of the app, or an Explorer/terminal window sitting in the
|
|
266
|
+
output folder, holds the tree open so the assemble step can't replace it
|
|
267
|
+
(``WinError 5``). Catch that before a full build is wasted. SKIP when nothing
|
|
268
|
+
is built yet; WARN (not FAIL) since it is transient, user-fixable state.
|
|
269
|
+
"""
|
|
270
|
+
onedir = onedir_path(config, project_root)
|
|
271
|
+
try:
|
|
272
|
+
locked = probe.build_output_locked(onedir)
|
|
273
|
+
except OSError as exc:
|
|
274
|
+
return CheckResult(
|
|
275
|
+
"Build output not locked",
|
|
276
|
+
Status.FAIL,
|
|
277
|
+
"could not restore the build output after probing its lock",
|
|
278
|
+
hint=str(exc),
|
|
279
|
+
)
|
|
280
|
+
if locked is None:
|
|
281
|
+
return CheckResult("Build output not locked", Status.SKIP, "not built")
|
|
282
|
+
if locked:
|
|
283
|
+
return CheckResult(
|
|
284
|
+
"Build output not locked",
|
|
285
|
+
Status.WARN,
|
|
286
|
+
f"{onedir.name!r} is held open by another process",
|
|
287
|
+
hint="a running instance of the app, or an Explorer/terminal window "
|
|
288
|
+
"in build\\windows, blocks build/package from replacing the folder "
|
|
289
|
+
"(WinError 5); quit the app / close the window before building.",
|
|
290
|
+
)
|
|
291
|
+
return CheckResult("Build output not locked", Status.PASS, "replaceable")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def check_windows_build_volume(
|
|
295
|
+
config: Config, project_root: Path, probe: Probe
|
|
296
|
+
) -> CheckResult:
|
|
297
|
+
"""Advise on the build volume's filesystem (Dev Drive vs NTFS).
|
|
298
|
+
|
|
299
|
+
Never FAILs/WARNs — a normal NTFS setup is healthy. It surfaces the sanctioned
|
|
300
|
+
mitigation for repeated rename-lock churn (a trusted ReFS Dev Drive, where
|
|
301
|
+
Defender scans asynchronously) in the always-shown detail rather than as noise.
|
|
302
|
+
"""
|
|
303
|
+
onedir = onedir_path(config, project_root)
|
|
304
|
+
target = onedir if onedir.exists() else project_root
|
|
305
|
+
fs = probe.filesystem_type(target)
|
|
306
|
+
if fs is None:
|
|
307
|
+
return CheckResult("Build volume", Status.SKIP, "filesystem unknown")
|
|
308
|
+
if fs.upper() == "REFS":
|
|
309
|
+
return CheckResult(
|
|
310
|
+
"Build volume", Status.PASS, f"{fs} (Dev Drive — async Defender scanning)"
|
|
311
|
+
)
|
|
312
|
+
return CheckResult(
|
|
313
|
+
"Build volume",
|
|
314
|
+
Status.PASS,
|
|
315
|
+
f"{fs}; for repeated build-lock churn a Windows 11 Dev Drive (ReFS) "
|
|
316
|
+
"enables Defender performance mode (async scanning)",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def check_windows_resource_assets(config: Config, project_root: Path) -> CheckResult:
|
|
321
|
+
"""The inputs the launcher resource-patch needs are present.
|
|
322
|
+
|
|
323
|
+
The patch always writes ProductName / ProductVersion (from project metadata,
|
|
324
|
+
always present) and, when configured, an icon (validated by the icon check).
|
|
325
|
+
"""
|
|
326
|
+
version = config.project.version
|
|
327
|
+
source = config.windows_required.icons.source
|
|
328
|
+
if source and icon_source_problem((project_root / source).resolve()):
|
|
329
|
+
return CheckResult(
|
|
330
|
+
"Resource assets",
|
|
331
|
+
Status.WARN,
|
|
332
|
+
f"icon {source!r} is invalid; the launcher will ship without one",
|
|
333
|
+
hint="fix the icon source (see the App icon check) so the launcher "
|
|
334
|
+
"carries it.",
|
|
335
|
+
)
|
|
336
|
+
icon_note = "with icon" if source else "no icon (default)"
|
|
337
|
+
return CheckResult(
|
|
338
|
+
"Resource assets", Status.PASS, f"version {version}, {icon_note}"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def check_windows_signtool(probe: Probe, config: Config) -> CheckResult:
|
|
343
|
+
signing = config.windows_required.signing
|
|
344
|
+
if not signing.configured:
|
|
345
|
+
return CheckResult("signtool available", Status.SKIP, "signing not configured")
|
|
346
|
+
if not probe.has_signtool():
|
|
347
|
+
return CheckResult(
|
|
348
|
+
"signtool available",
|
|
349
|
+
Status.FAIL,
|
|
350
|
+
"signtool not found on PATH",
|
|
351
|
+
hint="install the Windows SDK (signtool ships with it) and ensure it is "
|
|
352
|
+
"on PATH, or run packaging from a Developer Command Prompt.",
|
|
353
|
+
)
|
|
354
|
+
return CheckResult("signtool available", Status.PASS, "found")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def check_windows_signing_cert(probe: Probe, config: Config) -> CheckResult:
|
|
358
|
+
signing = config.windows_required.signing
|
|
359
|
+
if not signing.configured:
|
|
360
|
+
return CheckResult("Signing certificate", Status.SKIP, "signing not configured")
|
|
361
|
+
want = signing.thumbprint.replace(" ", "").upper()
|
|
362
|
+
scope = signing.store_scope
|
|
363
|
+
thumbprints = probe.code_signing_thumbprints(scope)
|
|
364
|
+
matches = [t for t in thumbprints if t == want]
|
|
365
|
+
store = "LocalMachine" if scope == "machine" else "CurrentUser"
|
|
366
|
+
if not matches:
|
|
367
|
+
return CheckResult(
|
|
368
|
+
"Signing certificate",
|
|
369
|
+
Status.FAIL,
|
|
370
|
+
f"no code-signing cert with thumbprint {want} in Cert:\\{store}\\My",
|
|
371
|
+
hint="import the code-signing certificate into the configured "
|
|
372
|
+
f"store_scope ({scope}), or fix [tool.kivy.windows.signing].thumbprint.",
|
|
373
|
+
)
|
|
374
|
+
if len(matches) > 1:
|
|
375
|
+
return CheckResult(
|
|
376
|
+
"Signing certificate",
|
|
377
|
+
Status.FAIL,
|
|
378
|
+
f"{len(matches)} certs share thumbprint {want} in Cert:\\{store}\\My",
|
|
379
|
+
hint="remove the duplicate certificate so signtool selects unambiguously.",
|
|
380
|
+
)
|
|
381
|
+
return CheckResult("Signing certificate", Status.PASS, f"{want} in {scope} store")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def check_windows_find_links(config: Config, project_root: Path) -> CheckResult:
|
|
385
|
+
entries = config.windows_required.find_links
|
|
386
|
+
if not entries:
|
|
387
|
+
return CheckResult("find_links directories", Status.SKIP, "not configured")
|
|
388
|
+
root = project_root.resolve()
|
|
389
|
+
problems: list[tuple[str, str | None]] = []
|
|
390
|
+
warnings: list[tuple[str, str | None]] = []
|
|
391
|
+
ok: list[str] = []
|
|
392
|
+
for entry in entries:
|
|
393
|
+
path = (root / entry).resolve()
|
|
394
|
+
detail, hint = find_links_doctor_detail(root, entry, path, platform="windows")
|
|
395
|
+
if not path.is_dir():
|
|
396
|
+
problems.append((detail, hint))
|
|
397
|
+
elif not any(path.glob("*.whl")):
|
|
398
|
+
warnings.append((detail, hint))
|
|
399
|
+
else:
|
|
400
|
+
ok.append(detail)
|
|
401
|
+
if problems:
|
|
402
|
+
return CheckResult(
|
|
403
|
+
"find_links directories",
|
|
404
|
+
Status.FAIL,
|
|
405
|
+
"; ".join(d for d, _ in problems),
|
|
406
|
+
hint=next((h for _, h in problems if h), ""),
|
|
407
|
+
)
|
|
408
|
+
if warnings:
|
|
409
|
+
return CheckResult(
|
|
410
|
+
"find_links directories",
|
|
411
|
+
Status.WARN,
|
|
412
|
+
"; ".join(d for d, _ in warnings),
|
|
413
|
+
hint=next((h for _, h in warnings if h), ""),
|
|
414
|
+
)
|
|
415
|
+
return CheckResult("find_links directories", Status.PASS, "; ".join(ok))
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def check_windows_hosts_reachable(
|
|
419
|
+
probe: Probe,
|
|
420
|
+
config: Config,
|
|
421
|
+
lock: WindowsLockfile | None,
|
|
422
|
+
*,
|
|
423
|
+
offline: bool = False,
|
|
424
|
+
) -> CheckResult:
|
|
425
|
+
if offline:
|
|
426
|
+
return CheckResult("Required hosts reachable", Status.SKIP, "offline")
|
|
427
|
+
# (host, port) pairs: the port comes from each URL's scheme so the HTTP
|
|
428
|
+
# RFC-3161 timestamp server is probed on 80, not the wheels' HTTPS 443.
|
|
429
|
+
hosts: set[tuple[str, int]] = set()
|
|
430
|
+
if lock is not None:
|
|
431
|
+
for pkg in lock.packages:
|
|
432
|
+
for wheel in pkg.wheels:
|
|
433
|
+
if wheel.url:
|
|
434
|
+
_add_host(hosts, wheel.url)
|
|
435
|
+
for art in lock.python_runtime.artifacts:
|
|
436
|
+
if art.url:
|
|
437
|
+
_add_host(hosts, art.url)
|
|
438
|
+
for nb in lock.native_binaries:
|
|
439
|
+
if nb.url:
|
|
440
|
+
_add_host(hosts, nb.url)
|
|
441
|
+
for url in config.windows_required.extra_index_urls:
|
|
442
|
+
_add_host(hosts, url)
|
|
443
|
+
# The RFC-3161 timestamp server is fetched during signing, so its host must
|
|
444
|
+
# be reachable when signing is configured.
|
|
445
|
+
if config.windows_required.signing.configured:
|
|
446
|
+
_add_host(hosts, config.windows_required.signing.timestamp_url)
|
|
447
|
+
if not hosts:
|
|
448
|
+
return CheckResult(
|
|
449
|
+
"Required hosts reachable", Status.PASS, "all artifacts vendored"
|
|
450
|
+
)
|
|
451
|
+
unreachable = sorted(
|
|
452
|
+
host for host, port in hosts if not probe.tcp_reachable(host, port)
|
|
453
|
+
)
|
|
454
|
+
if unreachable:
|
|
455
|
+
return CheckResult(
|
|
456
|
+
"Required hosts reachable",
|
|
457
|
+
Status.FAIL,
|
|
458
|
+
f"unreachable: {', '.join(unreachable)}",
|
|
459
|
+
hint="check your network/proxy; these hosts serve pinned wheels, the "
|
|
460
|
+
"runtime, native binaries, and the signing timestamp server.",
|
|
461
|
+
)
|
|
462
|
+
return CheckResult(
|
|
463
|
+
"Required hosts reachable",
|
|
464
|
+
Status.PASS,
|
|
465
|
+
", ".join(sorted({host for host, _ in hosts})),
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _add_host(hosts: set[tuple[str, int]], url: str) -> None:
|
|
470
|
+
parsed = urlparse(url)
|
|
471
|
+
host = parsed.hostname
|
|
472
|
+
if host:
|
|
473
|
+
port = parsed.port or (80 if parsed.scheme == "http" else 443)
|
|
474
|
+
hosts.add((host, port))
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
_PROJECT_CHECK_NAMES = (
|
|
478
|
+
"App source directory",
|
|
479
|
+
"AppUserModelID",
|
|
480
|
+
"Architecture coverage",
|
|
481
|
+
"App icon",
|
|
482
|
+
"Native binaries: sources",
|
|
483
|
+
"Native binaries: collision",
|
|
484
|
+
"Native binaries: arch",
|
|
485
|
+
"Build output not locked",
|
|
486
|
+
"Build volume",
|
|
487
|
+
"Resource assets",
|
|
488
|
+
"find_links directories",
|
|
489
|
+
"signtool available",
|
|
490
|
+
"Signing certificate",
|
|
491
|
+
"Required hosts reachable",
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def run_windows_checks(
|
|
496
|
+
probe: Probe,
|
|
497
|
+
*,
|
|
498
|
+
kivyforge_version: str,
|
|
499
|
+
config: Config | None = None,
|
|
500
|
+
project_root: Path | None = None,
|
|
501
|
+
lock: WindowsLockfile | None = None,
|
|
502
|
+
offline: bool = False,
|
|
503
|
+
) -> list[CheckResult]:
|
|
504
|
+
"""Run Windows doctor checks (windows-spec). Project checks SKIP without config."""
|
|
505
|
+
project_root = project_root or Path.cwd()
|
|
506
|
+
results = [
|
|
507
|
+
check_windows_host(probe),
|
|
508
|
+
check_long_path_support(probe),
|
|
509
|
+
C.check_kivyforge_version(probe, kivyforge_version, offline=offline),
|
|
510
|
+
]
|
|
511
|
+
if config is None:
|
|
512
|
+
for name in _PROJECT_CHECK_NAMES:
|
|
513
|
+
results.append(CheckResult(name, Status.SKIP, C.SKIP_NOTE))
|
|
514
|
+
return results
|
|
515
|
+
|
|
516
|
+
results += [
|
|
517
|
+
C.check_app_dir(config, project_root),
|
|
518
|
+
check_windows_app_id(config),
|
|
519
|
+
check_windows_arch_coverage(config, lock),
|
|
520
|
+
check_windows_app_icon(config, project_root),
|
|
521
|
+
check_windows_native_sources(config, project_root),
|
|
522
|
+
check_windows_native_collision(config),
|
|
523
|
+
check_windows_native_arch(config, project_root),
|
|
524
|
+
check_windows_output_lock(config, project_root, probe),
|
|
525
|
+
check_windows_build_volume(config, project_root, probe),
|
|
526
|
+
check_windows_resource_assets(config, project_root),
|
|
527
|
+
check_windows_find_links(config, project_root),
|
|
528
|
+
check_windows_signtool(probe, config),
|
|
529
|
+
check_windows_signing_cert(probe, config),
|
|
530
|
+
check_windows_hosts_reachable(probe, config, lock, offline=offline),
|
|
531
|
+
]
|
|
532
|
+
return results
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def windows_doctor(
|
|
536
|
+
cwd: Path, *, kivyforge_version: str, offline: bool
|
|
537
|
+
) -> list[CheckResult]:
|
|
538
|
+
"""Load the Windows project + lock (if present) and run the Windows check set."""
|
|
539
|
+
pyproject = cwd / "pyproject.toml"
|
|
540
|
+
config = None
|
|
541
|
+
lock = None
|
|
542
|
+
parse_results: list[CheckResult] = []
|
|
543
|
+
if pyproject.is_file():
|
|
544
|
+
try:
|
|
545
|
+
config = load_config(pyproject, require_ios=False, require_windows=True)
|
|
546
|
+
except ConfigError as exc:
|
|
547
|
+
parse_results.append(
|
|
548
|
+
CheckResult("pyproject.toml", Status.FAIL, exc.format())
|
|
549
|
+
)
|
|
550
|
+
lockfile = cwd / "pylock.windows.toml"
|
|
551
|
+
if lockfile.is_file():
|
|
552
|
+
try:
|
|
553
|
+
lock = load(lockfile)
|
|
554
|
+
except LockError as exc:
|
|
555
|
+
parse_results.append(C.lock_parse_fail("windows", exc))
|
|
556
|
+
return parse_results + run_windows_checks(
|
|
557
|
+
RealProbe(),
|
|
558
|
+
kivyforge_version=kivyforge_version,
|
|
559
|
+
config=config,
|
|
560
|
+
project_root=cwd,
|
|
561
|
+
lock=lock,
|
|
562
|
+
offline=offline,
|
|
563
|
+
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Windows-safe in-place tree publishing (windows-spec).
|
|
2
|
+
|
|
3
|
+
Windows cannot reliably rename a freshly-written directory: the antivirus
|
|
4
|
+
real-time scanner holds new executables/DLLs open (without share-delete), and a
|
|
5
|
+
cold *first-sight* scan (cloud lookup) can exceed any sane retry window — the
|
|
6
|
+
whole extracted CPython + wheels tree is thousands of new files. So we never
|
|
7
|
+
rename a freshly written tree. Instead callers write it **directly into its
|
|
8
|
+
final location** and only ever rename the *previous* (long-since-scanned) tree
|
|
9
|
+
aside, which renames instantly, restoring it if the write fails so a broken
|
|
10
|
+
operation never destroys a working artifact.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def rename_with_retry(
|
|
22
|
+
src: Path, dst: Path, *, timeout: float = 30.0, initial_delay: float = 0.1
|
|
23
|
+
) -> None:
|
|
24
|
+
"""``os.replace(src, dst)`` retrying transient Windows sharing locks.
|
|
25
|
+
|
|
26
|
+
Retries until *timeout* seconds elapse, backing off from *initial_delay* up
|
|
27
|
+
to a 1s cap. Re-raises the last ``PermissionError`` if the lock never clears.
|
|
28
|
+
"""
|
|
29
|
+
deadline = time.monotonic() + timeout
|
|
30
|
+
delay = initial_delay
|
|
31
|
+
while True:
|
|
32
|
+
try:
|
|
33
|
+
os.replace(src, dst)
|
|
34
|
+
return
|
|
35
|
+
except PermissionError:
|
|
36
|
+
if time.monotonic() >= deadline:
|
|
37
|
+
raise
|
|
38
|
+
time.sleep(delay)
|
|
39
|
+
delay = min(delay * 1.5, 1.0)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def reserve_previous(target: Path, *, timeout: float = 30.0) -> Path | None:
|
|
43
|
+
"""Move an existing *target* aside so a fresh one can be written in place.
|
|
44
|
+
|
|
45
|
+
Returns the trash path (to restore on failure / delete on success), or
|
|
46
|
+
``None`` when there was no previous tree. Renaming the old tree is fast — it
|
|
47
|
+
was scanned long ago — but we still retry briefly in case a just-built tree's
|
|
48
|
+
files are momentarily held.
|
|
49
|
+
"""
|
|
50
|
+
if not target.exists():
|
|
51
|
+
return None
|
|
52
|
+
trash = target.with_name(f".{target.name}.old-{os.getpid()}")
|
|
53
|
+
try:
|
|
54
|
+
rename_with_retry(target, trash, timeout=timeout)
|
|
55
|
+
except PermissionError as exc:
|
|
56
|
+
# A persistent lock (past the retry window) is almost always a human
|
|
57
|
+
# holding the tree open rather than a transient scanner touch, so point
|
|
58
|
+
# at the usual culprits instead of surfacing a bare WinError 5.
|
|
59
|
+
from . import WindowsBundleError
|
|
60
|
+
|
|
61
|
+
raise WindowsBundleError(
|
|
62
|
+
f"could not replace the existing output at {target}: a file inside "
|
|
63
|
+
"it is still open in another process.\n"
|
|
64
|
+
" Close any Explorer or terminal window sitting in that folder and "
|
|
65
|
+
"quit any running copy of the app, then re-run the command.\n"
|
|
66
|
+
" If it persists, delete the folder manually and retry."
|
|
67
|
+
) from exc
|
|
68
|
+
return trash
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def restore_previous(trash: Path | None, target: Path) -> None:
|
|
72
|
+
"""Move a reserved previous tree back into place after a failed write."""
|
|
73
|
+
if trash is None:
|
|
74
|
+
return
|
|
75
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
76
|
+
try:
|
|
77
|
+
rename_with_retry(trash, target)
|
|
78
|
+
except OSError:
|
|
79
|
+
# Best-effort restore: leave the trash for manual recovery rather than
|
|
80
|
+
# masking the original failure with a rename error.
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def discard_reserved(trash: Path | None) -> None:
|
|
85
|
+
"""Delete a reserved previous tree after a successful write (no-op if None)."""
|
|
86
|
+
if trash is not None:
|
|
87
|
+
shutil.rmtree(trash, ignore_errors=True)
|