build-python-release.py 6.7 KB

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