Skip to content

Commit cd4566f

Browse files
include generation and readme script for platforms
1 parent 7a7fafe commit cd4566f

4 files changed

Lines changed: 397 additions & 41 deletions

File tree

.github/.platforms/README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# PEX Platform Files
2+
3+
Platform JSON files for building universal Python zipapps that work across operating systems, architectures, and Python versions.
4+
5+
## Supported Platforms
6+
7+
- Linux x86_64 (glibc) - Debian, Ubuntu, RHEL, CentOS
8+
- Linux ARM64 (glibc) - ARM-based Linux servers
9+
- Linux x86_64 (musl) - Alpine Linux
10+
- Linux ARM64 (musl) - Alpine Linux ARM
11+
- macOS ARM64 - Apple Silicon
12+
- Windows x86_64 - Windows 10/11
13+
14+
**Python versions:** 3.10, 3.11, 3.12, 3.13, 3.14
15+
16+
## When to Regenerate
17+
18+
Regenerate platform files when:
19+
- Adding support for new Python versions
20+
- Dependencies change (especially packages with C extensions)
21+
- Build failures on specific platforms
22+
23+
**Note:** The script skips existing valid files. When dependencies change, delete the existing platform files first:
24+
```bash
25+
rm .github/.platforms/*.json
26+
.github/.platforms/generate_platforms.py
27+
```
28+
29+
## How to Regenerate
30+
31+
**Requirements:**
32+
- Docker
33+
- Internet connection
34+
- Python 3.10-3.14 (for macOS platform files only)
35+
36+
**Run:**
37+
```bash
38+
.github/.platforms/generate_platforms.py
39+
```
40+
41+
The script skips existing files and retries failures automatically.
42+
43+
**Force regeneration:**
44+
```bash
45+
rm .github/.platforms/*.json
46+
.github/.platforms/generate_platforms.py
47+
```
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
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

Comments
 (0)