build-python-release.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. #!/usr/bin/env python3
  2. """Stage and build one Python wheel at the repository version."""
  3. from __future__ import annotations
  4. import argparse
  5. import email
  6. import json
  7. import os
  8. import re
  9. import shutil
  10. import stat
  11. import subprocess
  12. import tempfile
  13. import zipfile
  14. from pathlib import Path
  15. ROOT = Path(__file__).resolve().parents[1]
  16. PLATFORMS = {
  17. "linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"),
  18. "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"),
  19. "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"),
  20. }
  21. def main() -> None:
  22. parser = argparse.ArgumentParser(description=__doc__)
  23. parser.add_argument("--package", choices=("sdk", "runtime"), required=True)
  24. parser.add_argument(
  25. "--tag",
  26. help="optional python-vX.Y.Z release tag; it must match package.json",
  27. )
  28. parser.add_argument("--output-dir", type=Path, required=True)
  29. parser.add_argument("--platform", choices=tuple(PLATFORMS))
  30. parser.add_argument("--runtime-exe", type=Path)
  31. args = parser.parse_args()
  32. version = repository_version()
  33. validate_release_tag(args.tag, version)
  34. if args.package == "runtime" and (args.platform is None or args.runtime_exe is None):
  35. parser.error("runtime builds require --platform and --runtime-exe")
  36. if args.package == "sdk" and (args.platform is not None or args.runtime_exe is not None):
  37. parser.error("SDK builds do not accept --platform or --runtime-exe")
  38. output_dir = args.output_dir.resolve()
  39. output_dir.mkdir(parents=True, exist_ok=True)
  40. with tempfile.TemporaryDirectory(prefix="dsh-python-release-") as temporary:
  41. staging = Path(temporary) / args.package
  42. if args.package == "sdk":
  43. stage_sdk(staging, version)
  44. environment = None
  45. expected = output_dir / f"deepseek_harness-{version}-py3-none-any.whl"
  46. else:
  47. platform_tag, executable_name = PLATFORMS[args.platform]
  48. stage_runtime(staging, version, args.runtime_exe.resolve(), executable_name)
  49. environment = {"DSH_RUNTIME_PLATFORM_TAG": platform_tag}
  50. expected = output_dir / f"deepseek_harness_runtime_bin-{version}-py3-none-{platform_tag}.whl"
  51. command = ["uv", "build", "--wheel", "--out-dir", str(output_dir), str(staging)]
  52. subprocess.run(command, cwd=ROOT, env=None if environment is None else {**os.environ, **environment}, check=True)
  53. if not expected.is_file():
  54. raise RuntimeError(f"build did not produce expected wheel: {expected}")
  55. verify_wheel(expected, args.package, version, None if args.platform is None else PLATFORMS[args.platform])
  56. print(expected)
  57. def repository_version(root: Path = ROOT) -> str:
  58. package_json = root / "package.json"
  59. try:
  60. payload = json.loads(package_json.read_text())
  61. except (OSError, json.JSONDecodeError) as error:
  62. raise ValueError(f"could not read repository version from {package_json}") from error
  63. version = payload.get("version") if isinstance(payload, dict) else None
  64. if not isinstance(version, str) or re.fullmatch(r"\d+\.\d+\.\d+", version) is None:
  65. raise ValueError(
  66. f"{package_json} version must be stable X.Y.Z, got {version!r}"
  67. )
  68. return version
  69. def validate_release_tag(tag: str | None, version: str) -> None:
  70. if tag is None:
  71. return
  72. expected = f"python-v{version}"
  73. if tag != expected:
  74. raise ValueError(
  75. f"release tag must match repository version: expected {expected!r}, got {tag!r}"
  76. )
  77. def copy_package(source: Path, destination: Path) -> None:
  78. shutil.copytree(
  79. source,
  80. destination,
  81. ignore=shutil.ignore_patterns(
  82. ".venv",
  83. ".pytest_cache",
  84. "__pycache__",
  85. "*.pyc",
  86. "dist",
  87. "node_modules",
  88. "dsh-jsonrpc-agent-pkg-*",
  89. ),
  90. )
  91. def rewrite_version(pyproject: Path, version: str) -> None:
  92. text, count = re.subn(
  93. r'^version = "[^"]+"$',
  94. f'version = "{version}"',
  95. pyproject.read_text(),
  96. count=1,
  97. flags=re.MULTILINE,
  98. )
  99. if count != 1:
  100. raise RuntimeError(f"could not rewrite version in {pyproject}")
  101. pyproject.write_text(text)
  102. def stage_sdk(destination: Path, version: str) -> None:
  103. copy_package(ROOT / "python" / "sdk", destination)
  104. pyproject = destination / "pyproject.toml"
  105. rewrite_version(pyproject, version)
  106. text, count = re.subn(
  107. r'"deepseek-harness-runtime-bin==[^"]+"',
  108. f'"deepseek-harness-runtime-bin=={version}"',
  109. pyproject.read_text(),
  110. count=1,
  111. )
  112. if count != 1:
  113. raise RuntimeError("SDK must contain exactly one runtime dependency pin")
  114. pyproject.write_text(text)
  115. def stage_runtime(destination: Path, version: str, executable: Path, executable_name: str) -> None:
  116. if not executable.is_file():
  117. raise FileNotFoundError(f"runtime executable does not exist: {executable}")
  118. if executable.stat().st_mode & stat.S_IXUSR == 0:
  119. raise PermissionError(f"runtime executable is not executable: {executable}")
  120. copy_package(ROOT / "python" / "sdk-runtime", destination)
  121. rewrite_version(destination / "pyproject.toml", version)
  122. runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime"
  123. runtime_dir.mkdir(parents=True, exist_ok=True)
  124. destination_executable = runtime_dir / executable_name
  125. shutil.copyfile(executable, destination_executable)
  126. destination_executable.chmod(executable.stat().st_mode & 0o777)
  127. def verify_wheel(
  128. wheel: Path,
  129. package: str,
  130. version: str,
  131. platform: tuple[str, str] | None,
  132. ) -> None:
  133. expected_tag = "py3-none-any" if platform is None else f"py3-none-{platform[0]}"
  134. with zipfile.ZipFile(wheel) as archive:
  135. wheel_metadata_path = next(name for name in archive.namelist() if name.endswith(".dist-info/WHEEL"))
  136. metadata_path = next(name for name in archive.namelist() if name.endswith(".dist-info/METADATA"))
  137. wheel_metadata = email.message_from_bytes(archive.read(wheel_metadata_path))
  138. metadata = email.message_from_bytes(archive.read(metadata_path))
  139. if wheel_metadata.get_all("Tag") != [expected_tag]:
  140. raise RuntimeError(f"{wheel} has wrong WHEEL tags: {wheel_metadata.get_all('Tag')}")
  141. if metadata.get("Version") != version:
  142. raise RuntimeError(f"{wheel} has version {metadata.get('Version')}, expected {version}")
  143. executables = [name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name]
  144. if package == "runtime":
  145. assert platform is not None
  146. if len(executables) != 1 or not executables[0].endswith(f"/runtime/{platform[1]}"):
  147. raise RuntimeError(f"{wheel} must contain exactly {platform[1]}, found {executables}")
  148. mode = archive.getinfo(executables[0]).external_attr >> 16
  149. if mode & stat.S_IXUSR == 0:
  150. raise RuntimeError(f"{wheel} runtime executable lost its executable bit")
  151. elif executables:
  152. raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {executables}")
  153. if package == "sdk":
  154. requirements = metadata.get_all("Requires-Dist") or []
  155. expected_requirement = f"deepseek-harness-runtime-bin=={version}"
  156. if expected_requirement not in requirements:
  157. raise RuntimeError(f"{wheel} does not pin {expected_requirement}; found {requirements}")
  158. if __name__ == "__main__":
  159. main()