|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Generate PEX platform files for multi-platform Python zipapp builds.""" |
| 3 | + |
| 4 | +from __future__ import annotations |
| 5 | + |
| 6 | +import json |
| 7 | +import shutil |
| 8 | +import subprocess |
| 9 | +import sys |
| 10 | +import tempfile |
| 11 | +import time |
| 12 | +from dataclasses import dataclass |
| 13 | +from pathlib import Path |
| 14 | +from typing import Callable, TypeVar |
| 15 | + |
| 16 | +# Configuration |
| 17 | +PYTHON_VERSIONS = ("3.10", "3.11", "3.12", "3.13", "3.14") |
| 18 | +MAX_RETRIES = 3 |
| 19 | +RETRY_DELAY_SECONDS = 5 |
| 20 | + |
| 21 | +# Stable ABI (abi3) wheels built for older Python work on newer versions. |
| 22 | +# Most packages build abi3 wheels against 3.7-3.10 for maximum compatibility. |
| 23 | +ABI3_PREFIXES = ("cp310-abi3-", "cp39-abi3-", "cp38-abi3-", "cp37-abi3-") |
| 24 | + |
| 25 | +T = TypeVar("T") |
| 26 | + |
| 27 | + |
| 28 | +@dataclass(frozen=True) |
| 29 | +class Platform: |
| 30 | + """Platform configuration for generating PEX platform files.""" |
| 31 | + |
| 32 | + name: str |
| 33 | + docker_platform: str |
| 34 | + docker_image: str |
| 35 | + use_alpine_shell: bool = False |
| 36 | + |
| 37 | + @property |
| 38 | + def shell(self) -> list[str]: |
| 39 | + return ["sh", "-c"] if self.use_alpine_shell else ["bash", "-c"] |
| 40 | + |
| 41 | + |
| 42 | +def get_platforms(py_version: str) -> list[Platform]: |
| 43 | + """Return platform configurations for a Python version.""" |
| 44 | + return [ |
| 45 | + Platform("linux-x86_64", "linux/amd64", f"python:{py_version}-slim"), |
| 46 | + Platform("linux-aarch64", "linux/arm64", f"python:{py_version}-slim"), |
| 47 | + Platform( |
| 48 | + "linux-x86_64-musl", |
| 49 | + "linux/amd64", |
| 50 | + f"python:{py_version}-alpine", |
| 51 | + use_alpine_shell=True, |
| 52 | + ), |
| 53 | + Platform( |
| 54 | + "linux-aarch64-musl", |
| 55 | + "linux/arm64", |
| 56 | + f"python:{py_version}-alpine", |
| 57 | + use_alpine_shell=True, |
| 58 | + ), |
| 59 | + ] |
| 60 | + |
| 61 | + |
| 62 | +def retry( |
| 63 | + max_attempts: int = MAX_RETRIES, |
| 64 | +) -> Callable[[Callable[[], T]], Callable[[], T]]: |
| 65 | + """Decorator for retrying functions with exponential backoff.""" |
| 66 | + |
| 67 | + def decorator(func: Callable[[], T]) -> Callable[[], T]: |
| 68 | + def wrapper() -> T: |
| 69 | + last_error: Exception | None = None |
| 70 | + for attempt in range(1, max_attempts + 1): |
| 71 | + try: |
| 72 | + return func() |
| 73 | + except subprocess.CalledProcessError as e: |
| 74 | + last_error = e |
| 75 | + if attempt < max_attempts: |
| 76 | + delay = RETRY_DELAY_SECONDS * attempt |
| 77 | + print( |
| 78 | + f" ⚠ Attempt {attempt} failed, retrying in {delay}s...", |
| 79 | + file=sys.stderr, |
| 80 | + ) |
| 81 | + time.sleep(delay) |
| 82 | + raise last_error # type: ignore[misc] |
| 83 | + |
| 84 | + return wrapper |
| 85 | + |
| 86 | + return decorator |
| 87 | + |
| 88 | + |
| 89 | +def is_valid(file_path: Path) -> bool: |
| 90 | + """Check if platform file exists with required JSON structure.""" |
| 91 | + if not file_path.exists(): |
| 92 | + return False |
| 93 | + try: |
| 94 | + data = json.loads(file_path.read_text()) |
| 95 | + return "marker_environment" in data and "compatible_tags" in data |
| 96 | + except (json.JSONDecodeError, OSError): |
| 97 | + return False |
| 98 | + |
| 99 | + |
| 100 | +def filter_tags(tags: list[str], py_minor: str) -> list[str]: |
| 101 | + """Keep only necessary wheel tags for the target Python version.""" |
| 102 | + return [ |
| 103 | + tag |
| 104 | + for tag in tags |
| 105 | + if tag.startswith((f"cp{py_minor}-", f"py{py_minor}-", "py3-none-")) |
| 106 | + or tag.startswith(ABI3_PREFIXES) |
| 107 | + ] |
| 108 | + |
| 109 | + |
| 110 | +def write_platform_json(path: Path, data: dict, py_minor: str) -> None: |
| 111 | + """Write platform JSON with filtered tags and consistent key order.""" |
| 112 | + if "compatible_tags" in data: |
| 113 | + data["compatible_tags"] = filter_tags(data["compatible_tags"], py_minor) |
| 114 | + |
| 115 | + output = { |
| 116 | + k: data[k] for k in ("marker_environment", "compatible_tags") if k in data |
| 117 | + } |
| 118 | + path.write_text(json.dumps(output, indent=2) + "\n") |
| 119 | + |
| 120 | + |
| 121 | +def run_docker(platform: Platform, command: str) -> str: |
| 122 | + """Execute command in Docker container and return stdout.""" |
| 123 | + result = subprocess.run( |
| 124 | + [ |
| 125 | + "docker", |
| 126 | + "run", |
| 127 | + "--rm", |
| 128 | + "--platform", |
| 129 | + platform.docker_platform, |
| 130 | + platform.docker_image, |
| 131 | + *platform.shell, |
| 132 | + command, |
| 133 | + ], |
| 134 | + capture_output=True, |
| 135 | + text=True, |
| 136 | + check=True, |
| 137 | + ) |
| 138 | + return result.stdout |
| 139 | + |
| 140 | + |
| 141 | +def generate_docker( |
| 142 | + platform: Platform, py_version: str, py_minor: str, output_dir: Path |
| 143 | +) -> bool: |
| 144 | + """Generate platform file using Docker.""" |
| 145 | + output_file = output_dir / f"{platform.name}-py{py_minor}.json" |
| 146 | + print(f" - {platform.name}-py{py_minor}.json") |
| 147 | + |
| 148 | + if is_valid(output_file): |
| 149 | + print(" ✓ Already exists") |
| 150 | + return True |
| 151 | + |
| 152 | + try: |
| 153 | + |
| 154 | + @retry() |
| 155 | + def fetch() -> str: |
| 156 | + return run_docker( |
| 157 | + platform, |
| 158 | + "pip install -q pex && pex3 interpreter inspect --markers --tags --indent 4", |
| 159 | + ) |
| 160 | + |
| 161 | + output_file.write_text(fetch()) |
| 162 | + write_platform_json(output_file, json.loads(output_file.read_text()), py_minor) |
| 163 | + print(" ✓ Generated") |
| 164 | + return True |
| 165 | + except subprocess.CalledProcessError as e: |
| 166 | + output_file.unlink(missing_ok=True) |
| 167 | + print(f" ✗ Failed: {e.stderr.strip() if e.stderr else e}", file=sys.stderr) |
| 168 | + return False |
| 169 | + |
| 170 | + |
| 171 | +def generate_macos(py_version: str, py_minor: str, output_dir: Path) -> bool: |
| 172 | + """Generate macOS platform file using local Python.""" |
| 173 | + output_file = output_dir / f"macos-arm64-py{py_minor}.json" |
| 174 | + print(f" - macos-arm64-py{py_minor}.json") |
| 175 | + |
| 176 | + if is_valid(output_file): |
| 177 | + print(" ✓ Already exists") |
| 178 | + return True |
| 179 | + |
| 180 | + python_exe = shutil.which(f"python{py_version}") |
| 181 | + if not python_exe: |
| 182 | + print(f" ⚠ python{py_version} not available") |
| 183 | + return False |
| 184 | + |
| 185 | + with tempfile.TemporaryDirectory() as venv_dir: |
| 186 | + venv = Path(venv_dir) |
| 187 | + try: |
| 188 | + subprocess.run( |
| 189 | + [python_exe, "-m", "venv", str(venv)], check=True, capture_output=True |
| 190 | + ) |
| 191 | + subprocess.run( |
| 192 | + [str(venv / "bin/pip"), "install", "-q", "pex"], |
| 193 | + check=True, |
| 194 | + capture_output=True, |
| 195 | + ) |
| 196 | + result = subprocess.run( |
| 197 | + [ |
| 198 | + str(venv / "bin/pex3"), |
| 199 | + "interpreter", |
| 200 | + "inspect", |
| 201 | + "--markers", |
| 202 | + "--tags", |
| 203 | + "--indent", |
| 204 | + "4", |
| 205 | + ], |
| 206 | + capture_output=True, |
| 207 | + text=True, |
| 208 | + check=True, |
| 209 | + ) |
| 210 | + output_file.write_text(result.stdout) |
| 211 | + write_platform_json( |
| 212 | + output_file, json.loads(output_file.read_text()), py_minor |
| 213 | + ) |
| 214 | + print(" ✓ Generated") |
| 215 | + return True |
| 216 | + except subprocess.CalledProcessError as e: |
| 217 | + output_file.unlink(missing_ok=True) |
| 218 | + print( |
| 219 | + f" ✗ Failed: {e.stderr.strip() if e.stderr else e}", file=sys.stderr |
| 220 | + ) |
| 221 | + return False |
| 222 | + |
| 223 | + |
| 224 | +def generate_windows(py_version: str, py_minor: str, output_dir: Path) -> bool: |
| 225 | + """Generate Windows platform file from template.""" |
| 226 | + output_file = output_dir / f"windows-x86_64-py{py_minor}.json" |
| 227 | + print(f" - windows-x86_64-py{py_minor}.json") |
| 228 | + |
| 229 | + if is_valid(output_file): |
| 230 | + print(" ✓ Already exists") |
| 231 | + return True |
| 232 | + |
| 233 | + data = { |
| 234 | + "marker_environment": { |
| 235 | + "implementation_name": "cpython", |
| 236 | + "implementation_version": f"{py_version}.0", |
| 237 | + "os_name": "nt", |
| 238 | + "platform_machine": "AMD64", |
| 239 | + "platform_python_implementation": "CPython", |
| 240 | + "platform_release": "", |
| 241 | + "platform_system": "Windows", |
| 242 | + "platform_version": "", |
| 243 | + "python_full_version": f"{py_version}.0", |
| 244 | + "python_version": py_version, |
| 245 | + "sys_platform": "win32", |
| 246 | + }, |
| 247 | + "compatible_tags": [ |
| 248 | + f"cp{py_minor}-cp{py_minor}-win_amd64", |
| 249 | + f"cp{py_minor}-abi3-win_amd64", |
| 250 | + f"cp{py_minor}-none-win_amd64", |
| 251 | + "cp310-abi3-win_amd64", |
| 252 | + "cp39-abi3-win_amd64", |
| 253 | + "cp38-abi3-win_amd64", |
| 254 | + "cp37-abi3-win_amd64", |
| 255 | + f"py{py_minor}-none-win_amd64", |
| 256 | + "py3-none-win_amd64", |
| 257 | + f"cp{py_minor}-none-any", |
| 258 | + f"py{py_minor}-none-any", |
| 259 | + "py3-none-any", |
| 260 | + ], |
| 261 | + } |
| 262 | + output_file.write_text(json.dumps(data, indent=2) + "\n") |
| 263 | + print(" ✓ Generated") |
| 264 | + return True |
| 265 | + |
| 266 | + |
| 267 | +def main() -> int: |
| 268 | + """Generate platform files for all Python versions.""" |
| 269 | + output_dir = Path(__file__).parent |
| 270 | + |
| 271 | + print("PEX Platform Generator") |
| 272 | + print("=" * 22) |
| 273 | + print(f"Python versions: {', '.join(PYTHON_VERSIONS)}\n") |
| 274 | + |
| 275 | + total, failed = 0, 0 |
| 276 | + |
| 277 | + for py_version in PYTHON_VERSIONS: |
| 278 | + py_minor = py_version.replace(".", "") |
| 279 | + print(f"Python {py_version}:") |
| 280 | + |
| 281 | + for platform in get_platforms(py_version): |
| 282 | + total += 1 |
| 283 | + if not generate_docker(platform, py_version, py_minor, output_dir): |
| 284 | + failed += 1 |
| 285 | + |
| 286 | + total += 1 |
| 287 | + if not generate_macos(py_version, py_minor, output_dir): |
| 288 | + failed += 1 |
| 289 | + |
| 290 | + total += 1 |
| 291 | + if not generate_windows(py_version, py_minor, output_dir): |
| 292 | + failed += 1 |
| 293 | + |
| 294 | + print() |
| 295 | + |
| 296 | + print(f"Summary\n{'=' * 7}") |
| 297 | + print(f"Total: {total} | Failed: {failed}") |
| 298 | + print( |
| 299 | + "✓ All platform files ready" |
| 300 | + if failed == 0 |
| 301 | + else "⚠ Some files failed (re-run to retry)" |
| 302 | + ) |
| 303 | + |
| 304 | + return 0 if failed == 0 else 1 |
| 305 | + |
| 306 | + |
| 307 | +if __name__ == "__main__": |
| 308 | + sys.exit(main()) |
0 commit comments