Files
2026-07-13 13:17:40 +08:00

249 lines
7.2 KiB
Python
Executable File

#!/usr/bin/env python3
# /// script
# requires-python = ">=3.9"
# dependencies = []
# ///
"""
Build Ray manylinux wheels locally using raymake.
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from ci.build.build_common import (
BuildError,
detect_host_arch,
find_ray_root,
get_git_commit,
log,
parse_file,
)
# Configuration Constants
SUPPORTED_PYTHON_VERSIONS = ("3.10", "3.11", "3.12", "3.13")
RAYMAKE_SPEC = "ci/docker/ray-wheel.wanda.yaml"
RAYCI_REGISTRY = "cr.ray.io/rayproject"
@dataclass(frozen=True)
class BuildConfig:
python_version: str
output_dir: Path
ray_root: Path
hosttype: str
arch_suffix: str
raymake_version: str
manylinux_version: str
commit: str
no_java: bool = False
no_dashboard: bool = False
@property
def build_env(self) -> dict[str, str]:
java_image = (
"scratch"
if self.no_java
else f"{RAYCI_REGISTRY}/ray-java-build{self.arch_suffix}"
)
dashboard_image = (
"scratch"
if self.no_dashboard
else f"{RAYCI_REGISTRY}/ray-dashboard{self.arch_suffix}"
)
return {
"PYTHON_VERSION": self.python_version,
"MANYLINUX_VERSION": self.manylinux_version,
"HOSTTYPE": self.hosttype,
"ARCH_SUFFIX": self.arch_suffix,
"JDK_SUFFIX": "" if self.no_java else "-jdk",
"BUILDKITE_COMMIT": self.commit,
"IS_LOCAL_BUILD": "true",
"RAY_JAVA_IMAGE": java_image,
"RAY_DASHBOARD_IMAGE": dashboard_image,
}
@classmethod
def from_env(
cls,
python_version: str,
output_dir: str,
*,
no_java: bool = False,
no_dashboard: bool = False,
) -> BuildConfig:
root = find_ray_root()
host, suffix = cls._detect_platform()
rayciversion_path = root / ".rayciversion"
if not rayciversion_path.exists():
raise BuildError(f"Missing {rayciversion_path}")
raymake_version = rayciversion_path.read_text().strip()
manylinux_version = parse_file(
root / "rayci.env", r'MANYLINUX_VERSION=["\']?([^"\'\s]+)'
)
commit = get_git_commit(root)
return cls(
python_version=python_version,
output_dir=Path(output_dir).resolve(),
ray_root=root,
hosttype=host,
arch_suffix=suffix,
raymake_version=raymake_version,
manylinux_version=manylinux_version,
commit=commit,
no_java=no_java,
no_dashboard=no_dashboard,
)
@staticmethod
def _detect_platform() -> tuple[str, str]:
"""Return (hosttype, arch_suffix) for current platform."""
arch = detect_host_arch()
suffix = "" if arch == "x86_64" else f"-{arch}"
return arch, suffix
class WheelBuilder:
def __init__(self, config: BuildConfig):
self.config = config
def build(self) -> list[Path]:
if not shutil.which("raymake"):
raise BuildError("raymake not found. Run via ./build-wheel.sh")
excluded = []
if self.config.no_java:
excluded.append("java")
if self.config.no_dashboard:
excluded.append("dashboard")
log.info("Build configuration:")
summary = {
"Python": self.config.python_version,
"Arch": self.config.hosttype,
"Commit": self.config.commit,
"Raymake": self.config.raymake_version,
"Manylinux": self.config.manylinux_version,
"Excluded": ", ".join(excluded) if excluded else "none",
"Ray Root": self.config.ray_root,
"Output Dir": self.config.output_dir,
}
print("-" * 50)
for k, v in summary.items():
print(f"{k:<12}: {v}")
print("-" * 50)
cmd = [
"raymake",
"--artifacts_dir",
str(self.config.output_dir),
str(self.config.ray_root / RAYMAKE_SPEC),
]
log.info(f"Running raymake: {RAYMAKE_SPEC}")
log.info(f"Build environment: {self.config.build_env}")
proc = subprocess.Popen(cmd, env={**os.environ, **self.config.build_env})
try:
if proc.wait() != 0:
raise BuildError("raymake failed")
except KeyboardInterrupt:
# We don't need to do much here; the child received the SIGINT too.
# Just wait a moment for it to exit to avoid the "No such process" error.
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
proc.kill()
raise
return list(self.config.output_dir.glob("*.whl"))
def main():
# Override the program name to match the root script name
parser = argparse.ArgumentParser(
prog="./build-wheel.sh",
description="Build Ray wheel using a manylinux base image.",
epilog="Example: ./build-wheel.sh 3.13",
)
parser.add_argument(
"python_version",
choices=SUPPORTED_PYTHON_VERSIONS,
metavar="PYTHON_VERSION",
help="Target Python version: %(choices)s",
)
parser.add_argument(
"output_dir",
nargs="?",
default=".whl",
help="Directory to store wheels (default: .whl)",
)
parser.add_argument(
"--no-java",
action="store_true",
default=False,
help="Build wheel without Java JARs",
)
parser.add_argument(
"--no-dashboard",
action="store_true",
default=False,
help="Build wheel without the dashboard",
)
if len(sys.argv) == 1:
parser.print_help()
sys.exit(0)
args = parser.parse_args()
try:
builder = WheelBuilder(
BuildConfig.from_env(
args.python_version,
args.output_dir,
no_java=args.no_java,
no_dashboard=args.no_dashboard,
)
)
output_dir = builder.config.output_dir
if output_dir.exists():
if not output_dir.is_dir():
raise BuildError(
f"Output path '{output_dir}' exists and is not a directory."
)
existing_wheels = list(output_dir.glob("*.whl"))
if existing_wheels:
ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
backup_dir = output_dir / f"old_{ts}"
backup_dir.mkdir()
for whl in existing_wheels:
whl.rename(backup_dir / whl.name)
log.info(f"Moved existing wheels to {backup_dir}")
output_dir.mkdir(parents=True, exist_ok=True)
wheels = builder.build()
log.info(f"Success! Wheels saved to {output_dir}")
log.info("To install, run:")
for w in wheels:
print(f" pip install {w}")
except BuildError as e:
log.error(e)
sys.exit(1)
except KeyboardInterrupt:
sys.exit(130)
if __name__ == "__main__":
main()