check-macos-deployment-target.py 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  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_PLATFORMS = {
  12. name: details[0]
  13. for name, details in RELEASE["PLATFORMS"].items()
  14. if name.startswith("macos-")
  15. }
  16. def parse_version(value: str) -> tuple[int, ...]:
  17. """Parse a dot-separated numeric deployment version."""
  18. if re.fullmatch(r"\d+(?:\.\d+)*", value) is None:
  19. raise ValueError(f"invalid macOS deployment version: {value!r}")
  20. return tuple(int(part) for part in value.split("."))
  21. def claimed_version(platform_tag: str) -> tuple[int, ...]:
  22. """Return the minimum macOS version encoded by a wheel platform tag."""
  23. match = re.fullmatch(r"macosx_(\d+)_(\d+)_(?:arm64|x86_64)", platform_tag)
  24. if match is None:
  25. raise ValueError(f"unsupported macOS wheel platform tag: {platform_tag!r}")
  26. return int(match.group(1)), int(match.group(2))
  27. def parse_otool_deployment_target(output: str) -> tuple[int, ...]:
  28. """Return the newest deployment target from one or more Mach-O slices."""
  29. versions: list[tuple[int, ...]] = []
  30. command: str | None = None
  31. for line in output.splitlines():
  32. stripped = line.strip()
  33. if re.fullmatch(r"Load command \d+", stripped):
  34. command = None
  35. elif stripped == "cmd LC_BUILD_VERSION":
  36. command = "build"
  37. elif stripped == "cmd LC_VERSION_MIN_MACOSX":
  38. command = "minimum"
  39. elif command == "build" and (match := re.fullmatch(r"minos\s+(\d+(?:\.\d+)*)", stripped)):
  40. versions.append(parse_version(match.group(1)))
  41. elif command == "minimum" and (match := re.fullmatch(r"version\s+(\d+(?:\.\d+)*)", stripped)):
  42. versions.append(parse_version(match.group(1)))
  43. if not versions:
  44. raise ValueError("otool output contains no macOS deployment target load command")
  45. return max(versions)
  46. def deployment_target(executable: Path) -> tuple[int, ...]:
  47. """Read one Mach-O executable's deployment target with ``otool``."""
  48. if not executable.is_file():
  49. raise FileNotFoundError(f"runtime executable does not exist: {executable}")
  50. result = subprocess.run(
  51. ["otool", "-l", str(executable)],
  52. check=True,
  53. capture_output=True,
  54. text=True,
  55. )
  56. try:
  57. return parse_otool_deployment_target(result.stdout)
  58. except ValueError as error:
  59. raise ValueError(f"{executable}: {error}") from error
  60. def ensure_compatible(
  61. executable: Path, actual: tuple[int, ...], platform_tag: str
  62. ) -> None:
  63. """Reject an executable whose deployment target exceeds its wheel claim."""
  64. claimed = claimed_version(platform_tag)
  65. width = max(len(actual), len(claimed))
  66. padded_actual = actual + (0,) * (width - len(actual))
  67. padded_claimed = claimed + (0,) * (width - len(claimed))
  68. if padded_actual > padded_claimed:
  69. rendered = ".".join(str(part) for part in actual)
  70. raise RuntimeError(
  71. f"{executable} requires macOS {rendered} but the wheel claims {platform_tag}"
  72. )
  73. def validate_deployment_targets(
  74. executables: list[Path], platform_tag: str
  75. ) -> list[tuple[Path, tuple[int, ...]]]:
  76. """Validate every executable and return its measured deployment target."""
  77. measured = [(executable, deployment_target(executable)) for executable in executables]
  78. for executable, actual in measured:
  79. ensure_compatible(executable, actual, platform_tag)
  80. return measured
  81. def main() -> None:
  82. parser = argparse.ArgumentParser(description=__doc__)
  83. parser.add_argument("--platform", choices=tuple(MACOS_PLATFORMS), required=True)
  84. parser.add_argument("executables", type=Path, nargs="+")
  85. args = parser.parse_args()
  86. platform_tag = MACOS_PLATFORMS[args.platform]
  87. for executable, version in validate_deployment_targets(args.executables, platform_tag):
  88. rendered = ".".join(str(part) for part in version)
  89. print(f"{executable}: macOS {rendered} <= {platform_tag}")
  90. if __name__ == "__main__":
  91. main()