check-macos-deployment-target.py 3.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. #!/usr/bin/env python3
  2. """Reject runtime executables that require newer macOS than their wheel tag."""
  3. from __future__ import annotations
  4. import argparse
  5. import re
  6. import runpy
  7. import subprocess
  8. from pathlib import Path
  9. ROOT = Path(__file__).resolve().parents[1]
  10. RELEASE = runpy.run_path(str(ROOT / "scripts" / "build-python-release.py"))
  11. MACOS_PLATFORM_TAG = RELEASE["PLATFORMS"]["macos-arm64"][0]
  12. def parse_version(value: str) -> tuple[int, ...]:
  13. """Parse a dot-separated numeric deployment version."""
  14. if re.fullmatch(r"\d+(?:\.\d+)*", value) is None:
  15. raise ValueError(f"invalid macOS deployment version: {value!r}")
  16. return tuple(int(part) for part in value.split("."))
  17. def claimed_version(platform_tag: str) -> tuple[int, ...]:
  18. """Return the minimum macOS version encoded by a wheel platform tag."""
  19. match = re.fullmatch(r"macosx_(\d+)_(\d+)_arm64", platform_tag)
  20. if match is None:
  21. raise ValueError(f"unsupported macOS wheel platform tag: {platform_tag!r}")
  22. return int(match.group(1)), int(match.group(2))
  23. def parse_otool_deployment_target(output: str) -> tuple[int, ...]:
  24. """Return the newest deployment target from one or more Mach-O slices."""
  25. versions = [
  26. parse_version(match.group(1))
  27. for match in re.finditer(r"^\s*minos\s+(\d+(?:\.\d+)*)\s*$", output, re.MULTILINE)
  28. ]
  29. if not versions:
  30. raise ValueError("otool output contains no LC_BUILD_VERSION deployment target")
  31. return max(versions)
  32. def deployment_target(executable: Path) -> tuple[int, ...]:
  33. """Read one Mach-O executable's deployment target with ``otool``."""
  34. if not executable.is_file():
  35. raise FileNotFoundError(f"runtime executable does not exist: {executable}")
  36. result = subprocess.run(
  37. ["otool", "-l", str(executable)],
  38. check=True,
  39. capture_output=True,
  40. text=True,
  41. )
  42. try:
  43. return parse_otool_deployment_target(result.stdout)
  44. except ValueError as error:
  45. raise ValueError(f"{executable}: {error}") from error
  46. def ensure_compatible(
  47. executable: Path, actual: tuple[int, ...], platform_tag: str
  48. ) -> None:
  49. """Reject an executable whose deployment target exceeds its wheel claim."""
  50. claimed = claimed_version(platform_tag)
  51. width = max(len(actual), len(claimed))
  52. padded_actual = actual + (0,) * (width - len(actual))
  53. padded_claimed = claimed + (0,) * (width - len(claimed))
  54. if padded_actual > padded_claimed:
  55. rendered = ".".join(str(part) for part in actual)
  56. raise RuntimeError(
  57. f"{executable} requires macOS {rendered} but the wheel claims {platform_tag}"
  58. )
  59. def validate_deployment_targets(
  60. executables: list[Path], platform_tag: str = MACOS_PLATFORM_TAG
  61. ) -> list[tuple[Path, tuple[int, ...]]]:
  62. """Validate every executable and return its measured deployment target."""
  63. measured = [(executable, deployment_target(executable)) for executable in executables]
  64. for executable, actual in measured:
  65. ensure_compatible(executable, actual, platform_tag)
  66. return measured
  67. def main() -> None:
  68. parser = argparse.ArgumentParser(description=__doc__)
  69. parser.add_argument("executables", type=Path, nargs="+")
  70. args = parser.parse_args()
  71. for executable, version in validate_deployment_targets(args.executables):
  72. rendered = ".".join(str(part) for part in version)
  73. print(f"{executable}: macOS {rendered} <= {MACOS_PLATFORM_TAG}")
  74. if __name__ == "__main__":
  75. main()