96 lines
3.4 KiB
Python
96 lines
3.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Reject runtime executables that require newer macOS than their wheel tag."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import runpy
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
RELEASE = runpy.run_path(str(ROOT / "scripts" / "build-python-release.py"))
|
|
MACOS_PLATFORM_TAG = RELEASE["PLATFORMS"]["macos-arm64"][0]
|
|
|
|
|
|
def parse_version(value: str) -> tuple[int, ...]:
|
|
"""Parse a dot-separated numeric deployment version."""
|
|
if re.fullmatch(r"\d+(?:\.\d+)*", value) is None:
|
|
raise ValueError(f"invalid macOS deployment version: {value!r}")
|
|
return tuple(int(part) for part in value.split("."))
|
|
|
|
|
|
def claimed_version(platform_tag: str) -> tuple[int, ...]:
|
|
"""Return the minimum macOS version encoded by a wheel platform tag."""
|
|
match = re.fullmatch(r"macosx_(\d+)_(\d+)_arm64", platform_tag)
|
|
if match is None:
|
|
raise ValueError(f"unsupported macOS wheel platform tag: {platform_tag!r}")
|
|
return int(match.group(1)), int(match.group(2))
|
|
|
|
|
|
def parse_otool_deployment_target(output: str) -> tuple[int, ...]:
|
|
"""Return the newest deployment target from one or more Mach-O slices."""
|
|
versions = [
|
|
parse_version(match.group(1))
|
|
for match in re.finditer(r"^\s*minos\s+(\d+(?:\.\d+)*)\s*$", output, re.MULTILINE)
|
|
]
|
|
if not versions:
|
|
raise ValueError("otool output contains no LC_BUILD_VERSION deployment target")
|
|
return max(versions)
|
|
|
|
|
|
def deployment_target(executable: Path) -> tuple[int, ...]:
|
|
"""Read one Mach-O executable's deployment target with ``otool``."""
|
|
if not executable.is_file():
|
|
raise FileNotFoundError(f"runtime executable does not exist: {executable}")
|
|
result = subprocess.run(
|
|
["otool", "-l", str(executable)],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
try:
|
|
return parse_otool_deployment_target(result.stdout)
|
|
except ValueError as error:
|
|
raise ValueError(f"{executable}: {error}") from error
|
|
|
|
|
|
def ensure_compatible(
|
|
executable: Path, actual: tuple[int, ...], platform_tag: str
|
|
) -> None:
|
|
"""Reject an executable whose deployment target exceeds its wheel claim."""
|
|
claimed = claimed_version(platform_tag)
|
|
width = max(len(actual), len(claimed))
|
|
padded_actual = actual + (0,) * (width - len(actual))
|
|
padded_claimed = claimed + (0,) * (width - len(claimed))
|
|
if padded_actual > padded_claimed:
|
|
rendered = ".".join(str(part) for part in actual)
|
|
raise RuntimeError(
|
|
f"{executable} requires macOS {rendered} but the wheel claims {platform_tag}"
|
|
)
|
|
|
|
|
|
def validate_deployment_targets(
|
|
executables: list[Path], platform_tag: str = MACOS_PLATFORM_TAG
|
|
) -> list[tuple[Path, tuple[int, ...]]]:
|
|
"""Validate every executable and return its measured deployment target."""
|
|
measured = [(executable, deployment_target(executable)) for executable in executables]
|
|
for executable, actual in measured:
|
|
ensure_compatible(executable, actual, platform_tag)
|
|
return measured
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("executables", type=Path, nargs="+")
|
|
args = parser.parse_args()
|
|
for executable, version in validate_deployment_targets(args.executables):
|
|
rendered = ".".join(str(part) for part in version)
|
|
print(f"{executable}: macOS {rendered} <= {MACOS_PLATFORM_TAG}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|