Files
zoitechat/tools/prepare-windows-runtimes.py
T

150 lines
6.9 KiB
Python

#!/usr/bin/env python3
"""Prepare current Windows scripting dependencies and matching installer metadata."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.request
from urllib.parse import urlsplit
import xml.etree.ElementTree as ET
import zipfile
RELEASES_URL = "https://strawberryperl.com/releases.json"
RELEASE_ROOT = "https://github.com/StrawberryPerl/Perl-Dist-Strawberry/releases/download/"
MSBUILD_NS = "http://schemas.microsoft.com/developer/msbuild/2003"
def select_perl(releases):
"""Compare versions, not publication dates: older branches get newer releases too."""
candidates = []
for release in releases:
version = release.get("version", "")
if not re.fullmatch(r"5\.\d+\.\d+\.\d+", version):
continue
parts = tuple(map(int, version.split(".")))
if parts[1] % 2 or release.get("archname") != "MSWin32-x64-multi-thread":
continue
if not all(kind in release.get("edition", {}) for kind in ("portable", "msi")):
continue
candidates.append((parts, release))
if not candidates:
raise ValueError("No stable x64 Strawberry Perl release with portable ZIP and MSI")
release = max(candidates, key=lambda item: item[0])[1]
for kind, suffix in (("portable", "portable.zip"), ("msi", "msi")):
package = release["edition"][kind]
expected_name = f"strawberry-perl-{release['version']}-64bit-{suffix}"
if kind == "msi":
expected_name = f"strawberry-perl-{release['version']}-64bit.msi"
url = package.get("url", "")
if not url.startswith(RELEASE_ROOT) or urlsplit(url).path.rsplit("/", 1)[-1] != expected_name:
raise ValueError(f"Unexpected Strawberry Perl {kind} URL: {url}")
if not re.fullmatch(r"[0-9a-fA-F]{64}", package.get("sha256", "")):
raise ValueError(f"Missing SHA-256 for Strawberry Perl {kind}")
return release
def download(url, destination):
for attempt in range(4):
try:
request = urllib.request.Request(url, headers={"User-Agent": "ZoiteChat-build"})
with urllib.request.urlopen(request, timeout=120) as response, destination.open("wb") as output:
while chunk := response.read(1024 * 1024):
output.write(chunk)
return
except (OSError, TimeoutError):
if attempt == 3:
raise
time.sleep(2 ** attempt)
def write_metadata(repo, perl_root, release, python_version):
parts = release["version"].split(".")
perl_lib = f"perl{parts[0]}{parts[1]}"
ET.register_namespace("", MSBUILD_NS)
project = ET.Element(f"{{{MSBUILD_NS}}}Project")
group = ET.SubElement(project, "PropertyGroup")
ET.SubElement(group, "PerlPath").text = str(perl_root)
ET.SubElement(group, "PerlLib").text = perl_lib
ET.ElementTree(project).write(repo / "win32/runtimes.generated.props", encoding="utf-8", xml_declaration=True)
# Only numeric versions and validated upstream URLs enter the Inno source.
definitions = {
"PythonRuntimeVersion": python_version,
"PythonRuntimeURL": f"https://www.python.org/ftp/python/{python_version}/python-{python_version}-amd64.exe",
"PerlRuntimeVersion": release["version"],
"PerlRuntimeURL": release["edition"]["msi"]["url"],
"PerlRuntimeDLL": f"{perl_lib}.dll",
}
(repo / "win32/installer/runtimes.generated.iss").write_text(
"// Generated by tools/prepare-windows-runtimes.py\n"
+ "".join(f'#define {key} "{value}"\n' for key, value in definitions.items()),
encoding="utf-8",
)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--deps-root", type=Path, default=Path("C:/gtk-build"))
args = parser.parse_args()
if sys.platform != "win32" or sys.version_info[:2] != (3, 14) or sys.version_info[:3] < (3, 14, 8) or sys.version_info.releaselevel != "final":
parser.error("Run with stable x64 Python 3.14.8 or newer within the 3.14 branch")
if sys.maxsize <= 2 ** 32:
parser.error("The Windows build requires x64 Python")
repo = Path(__file__).resolve().parents[1]
# Do not let an interrupted refresh leave metadata from an older dependency.
for relative in ("win32/runtimes.generated.props", "win32/installer/runtimes.generated.iss"):
(repo / relative).unlink(missing_ok=True)
args.deps_root.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(dir=args.deps_root) as temporary:
temporary = Path(temporary)
metadata = temporary / "releases.json"
download(RELEASES_URL, metadata)
release = select_perl(json.loads(metadata.read_text(encoding="utf-8")))
archive = temporary / "perl.zip"
download(release["edition"]["portable"]["url"], archive)
with archive.open("rb") as source:
digest = hashlib.file_digest(source, "sha256").hexdigest()
if digest.lower() != release["edition"]["portable"]["sha256"].lower():
raise ValueError("Strawberry Perl portable ZIP SHA-256 mismatch")
# Keep the portable distribution intact, including its companion C DLLs.
target = args.deps_root / f"strawberry-perl-{release['version']}" / "x64"
extracted = temporary / "extracted"
with zipfile.ZipFile(archive) as package:
package.extractall(extracted)
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
shutil.rmtree(target)
shutil.move(extracted, target)
perl_root = target / "perl"
environment = os.environ.copy()
paths = [str(perl_root / "bin"), str(target / "c/bin")]
environment["PATH"] = os.pathsep.join(paths + [environment.get("PATH", "")])
perl_lib = "perl" + "".join(release["version"].split(".")[:2])
for required in (perl_root / "bin/perl.exe", perl_root / f"bin/{perl_lib}.dll", perl_root / "lib/CORE/perl.h"):
if not required.is_file():
raise FileNotFoundError(f"Strawberry Perl is missing {required}")
actual = subprocess.check_output(
[str(perl_root / "bin/perl.exe"), "-MConfig", "-e", 'print "$Config{version}|$Config{archname}"'],
env=environment, text=True,
).strip()
expected = ".".join(release["version"].split(".")[:3]) + "|MSWin32-x64-multi-thread"
if actual != expected:
raise ValueError(f"Unexpected Perl runtime: {actual}; expected {expected}")
python_version = ".".join(map(str, sys.version_info[:3]))
write_metadata(repo, perl_root, release, python_version)
if os.environ.get("GITHUB_PATH"):
with open(os.environ["GITHUB_PATH"], "a", encoding="utf-8") as output:
output.write("\n".join(paths) + "\n")
print(f"Windows runtimes: Python {python_version}; Strawberry Perl {release['version']}")
if __name__ == "__main__":
main()