build-python-release.py 7.6 KB

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