Files
wehub-resource-sync 91e75e620b
CI: cua-driver distro-compat matrix / Resolve release version (push) Waiting to run
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Blocked by required conditions
CI: cua-driver distro-compat matrix / Distro compat summary (push) Blocked by required conditions
CI: Nix Linux Rust source / Nix / compositor build (push) Waiting to run
CI: Nix Linux Rust source / Nix / driver package (push) Waiting to run
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Waiting to run
CI: Rust Linux unit / Rust Linux unit and compile (push) Waiting to run
CI: Rust Windows unit / Rust Windows unit and compile (push) Waiting to run
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Waiting to run
CD: Docs MCP Server / build (linux/amd64) (push) Waiting to run
CD: Docs MCP Server / build (linux/arm64) (push) Waiting to run
CD: Docs MCP Server / merge (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:03:19 +08:00

352 lines
12 KiB
Python

#!/usr/bin/env python3
"""Build script to download platform-specific cua-driver binary and create wheel.
This script:
1. Detects the current platform
2. Downloads the appropriate cua-driver-rs binary from GitHub releases
3. Places it in src/cua_driver/bin/
4. Builds the wheel with hatchling
Usage:
python build_wheel.py [--version VERSION]
"""
import argparse
import hashlib
import os
import platform
import shutil
import subprocess
import sys
import tarfile
import urllib.request
import zipfile
from pathlib import Path
def get_default_version() -> str:
"""Read the wrapper package version from pyproject.toml."""
pyproject = Path(__file__).parent / "pyproject.toml"
for line in pyproject.read_text().splitlines():
line = line.strip()
if line.startswith("version = "):
return line.split('"', 2)[1]
raise RuntimeError(f"Could not read project version from {pyproject}")
def get_platform_info(arch_override: str = None):
"""Determine platform and architecture for binary selection.
Args:
arch_override: Optional architecture override (e.g., 'arm64', 'x86_64', 'universal')
"""
system = platform.system().lower()
if arch_override:
# Normalize arch_override to handle common aliases/casing
arch_lower = arch_override.lower()
if arch_lower in ("x86_64", "amd64", "x64"):
arch = "x86_64"
elif arch_lower in ("arm64", "aarch64"):
arch = "arm64"
elif arch_lower == "universal":
arch = "universal"
else:
raise ValueError(f"Unsupported architecture override: {arch_override}")
else:
machine = platform.machine().lower()
# Normalize architecture names
if machine in ("x86_64", "amd64"):
arch = "x86_64"
elif machine in ("arm64", "aarch64"):
arch = "arm64"
else:
raise ValueError(f"Unsupported architecture: {machine}")
# Map to cua-driver-rs release naming
if system == "darwin":
# macOS uses universal binary
return "darwin", "universal"
elif system == "linux":
return "linux", arch
elif system == "windows":
return "windows", arch
else:
raise ValueError(f"Unsupported platform: {system}")
def get_wheel_tag(platform_name: str, arch: str) -> str:
"""Return the Python wheel tag for the bundled binary target."""
if platform_name == "darwin":
return "py3-none-macosx_11_0_universal2"
if platform_name == "linux":
# Rust Linux release binaries are built in debian:11, whose glibc floor is 2.31.
if arch == "x86_64":
return "py3-none-manylinux_2_31_x86_64"
if arch == "arm64":
return "py3-none-manylinux_2_31_aarch64"
if platform_name == "windows":
if arch == "x86_64":
return "py3-none-win_amd64"
if arch == "arm64":
return "py3-none-win_arm64"
raise ValueError(f"Unsupported wheel target: {platform_name}-{arch}")
def get_release_url(version: str, platform_name: str, arch: str) -> tuple[str, list[str]]:
"""Get the GitHub release URL and binary names for the platform.
Returns:
Tuple of (download_url, list_of_binary_names_in_archive)
"""
base_url = f"https://github.com/trycua/cua/releases/download/cua-driver-rs-v{version}"
if platform_name == "darwin":
# Universal binary tarball
filename = f"cua-driver-rs-{version}-darwin-universal-binary.tar.gz"
binary_names = ["cua-driver"]
elif platform_name == "linux":
filename = f"cua-driver-rs-{version}-linux-{arch}-binary.tar.gz"
binary_names = ["cua-driver"]
elif platform_name == "windows":
filename = f"cua-driver-rs-{version}-windows-{arch}-binary.zip"
# Windows includes both main executable and UIAccess worker
binary_names = ["cua-driver.exe", "cua-driver-uia.exe"]
else:
raise ValueError(f"Unknown platform: {platform_name}")
return f"{base_url}/{filename}", binary_names
def verify_sha256(file_path: Path, expected_sha256: str) -> None:
"""Verify file matches expected SHA256 hash."""
h = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
h.update(chunk)
actual = h.hexdigest()
if actual != expected_sha256:
raise ValueError(
f"SHA256 mismatch for {file_path.name}: expected {expected_sha256}, got {actual}"
)
def get_expected_sha256(version: str, archive_name: str) -> str:
"""Fetch and parse checksums.txt from GitHub release."""
checksums_url = f"https://github.com/trycua/cua/releases/download/cua-driver-rs-v{version}/checksums.txt"
print(f"Fetching checksums from {checksums_url}...")
try:
with urllib.request.urlopen(checksums_url) as response:
content = response.read().decode("utf-8")
for line in content.splitlines():
line = line.strip()
# Skip empty lines, comments, and headers
if not line or line.startswith("#") or "Checksums" in line or line.startswith("```"):
continue
# Parse "SHA256 filename" format
parts = line.split()
if len(parts) >= 2:
sha, name = parts[0], parts[-1]
if name == archive_name:
return sha
raise ValueError(f"SHA256 for {archive_name} not found in checksums.txt")
except Exception as e:
raise RuntimeError(f"Failed to fetch or parse checksums: {e}")
def download_file(url: str, dest: Path, expected_sha256: str) -> None:
"""Download a file with progress indication and SHA256 verification."""
print(f"Downloading {url}...")
try:
with urllib.request.urlopen(url) as response:
total_size = int(response.headers.get("content-length", 0))
dest.parent.mkdir(parents=True, exist_ok=True)
with open(dest, "wb") as f:
downloaded = 0
block_size = 8192
while True:
chunk = response.read(block_size)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
if total_size > 0:
percent = (downloaded / total_size) * 100
print(f" {percent:.1f}% ({downloaded}/{total_size} bytes)", end="\r")
print(f"\nDownloaded to {dest}")
# Verify SHA256
print("Verifying SHA256 checksum...")
verify_sha256(dest, expected_sha256)
print("[OK] Checksum verified")
except Exception as e:
if dest.exists():
dest.unlink()
raise RuntimeError(f"Failed to download {url}: {e}")
def extract_binaries(archive_path: Path, binary_names: list[str], dest_dir: Path) -> list[Path]:
"""Extract the binaries from the archive.
Args:
archive_path: Path to the archive file
binary_names: List of binary names to extract
dest_dir: Destination directory
Returns:
List of paths to extracted binaries
"""
dest_dir.mkdir(parents=True, exist_ok=True)
extracted_paths = []
if archive_path.suffix == ".zip":
with zipfile.ZipFile(archive_path, "r") as zf:
for binary_name in binary_names:
dest_path = dest_dir / binary_name
# Find the binary in the zip (exact match or ends with /<binary_name>)
for name in zf.namelist():
# Match exact name or path ending with /binary_name
if name == binary_name or name.endswith(f"/{binary_name}"):
with zf.open(name) as src, open(dest_path, "wb") as dst:
shutil.copyfileobj(src, dst)
extracted_paths.append(dest_path)
print(f"Extracted binary to {dest_path}")
break
else:
raise ValueError(f"Binary {binary_name} not found in {archive_path}")
else:
# .tar.gz
with tarfile.open(archive_path, "r:gz") as tf:
for binary_name in binary_names:
# The -binary tarballs have the binary at the root
tf.extract(binary_name, dest_dir)
dest_path = dest_dir / binary_name
extracted_paths.append(dest_path)
print(f"Extracted binary to {dest_path}")
# Make executable on Unix
if sys.platform != "win32":
for path in extracted_paths:
os.chmod(path, 0o755)
return extracted_paths
def build_wheel(package_dir: Path, wheel_tag: str, target_arch: str = None) -> None:
"""Build the wheel using hatchling.
Args:
package_dir: Directory containing pyproject.toml
wheel_tag: Platform-specific wheel tag to write.
target_arch: Target architecture for cross-compilation (x86_64, arm64)
"""
print("\nBuilding wheel...")
env = os.environ.copy()
env["CUA_DRIVER_WHEEL_TAG"] = wheel_tag
# Set platform tag override for cross-compilation
if target_arch:
system = platform.system().lower()
if system == "windows":
# Override wheel platform tag for Windows cross-compilation
if target_arch == "arm64":
env["_PYTHON_HOST_PLATFORM"] = "win-arm64"
elif target_arch == "x86_64":
env["_PYTHON_HOST_PLATFORM"] = "win-amd64"
# Linux and macOS don't need overrides for our use case
# (macOS uses universal binary, Linux builds on native arch)
subprocess.run(
[sys.executable, "-m", "build", "--wheel"],
cwd=package_dir,
env=env,
check=True,
)
print("Wheel built successfully!")
def main():
parser = argparse.ArgumentParser(description="Build cua-driver Python wheel with bundled binary")
parser.add_argument(
"--version",
help="cua-driver-rs version to download (default: pyproject.toml version)",
)
parser.add_argument(
"--arch",
help="Architecture override (e.g., 'arm64', 'x86_64', 'universal')",
)
parser.add_argument(
"--skip-download",
action="store_true",
help="Skip download and use existing binary in bin/ (for local testing)",
)
args = parser.parse_args()
version = args.version or get_default_version()
# Determine paths
script_dir = Path(__file__).parent
bin_dir = script_dir / "src" / "cua_driver" / "bin"
download_dir = script_dir / "downloads"
platform_name, arch = get_platform_info(args.arch)
wheel_tag = get_wheel_tag(platform_name, arch)
print(f"Wheel tag: {wheel_tag}")
if not args.skip_download:
# Get platform info and download URL
print(f"Building for {platform_name}-{arch}")
url, binary_names = get_release_url(version, platform_name, arch)
archive_name = url.split("/")[-1]
archive_path = download_dir / archive_name
# Get expected SHA256 from checksums.txt
expected_sha256 = get_expected_sha256(version, archive_name)
# Download the release archive (or verify cached)
if not archive_path.exists():
download_file(url, archive_path, expected_sha256)
else:
print(f"Using cached archive: {archive_path}")
# Verify cached archive too
print("Verifying cached archive SHA256...")
verify_sha256(archive_path, expected_sha256)
print("[OK] Cached archive checksum verified")
# Extract binaries
extract_binaries(archive_path, binary_names, bin_dir)
else:
print("Skipping download (using existing binary)")
# Verify main binary exists
expected_binary = "cua-driver.exe" if sys.platform == "win32" else "cua-driver"
binary_path = bin_dir / expected_binary
if not binary_path.exists():
raise FileNotFoundError(
f"Binary not found at {binary_path}. "
f"Run without --skip-download or place binary manually."
)
print(f"\nBinary ready at: {binary_path}")
print(f"Binary size: {binary_path.stat().st_size / 1024 / 1024:.2f} MB")
# List all binaries in bin directory
print(f"\nAll binaries in {bin_dir}:")
for binary in bin_dir.iterdir():
if binary.is_file():
print(f" - {binary.name} ({binary.stat().st_size / 1024 / 1024:.2f} MB)")
# Build the wheel (pass target arch for cross-compilation)
build_wheel(script_dir, wheel_tag, target_arch=arch)
if __name__ == "__main__":
main()