Build updated for upstream libaegis 0.9.0 simplified API with matching changes on Python side. Set lower MacOS build target (11.0). Build abi3 version for all supported Python versions, reducing the number of wheels. Other build cleanup.

This commit is contained in:
Leo Vasanko
2026-01-10 23:39:20 +00:00
parent ddd0331771
commit 998d523127
12 changed files with 389 additions and 567 deletions
+46 -78
View File
@@ -1,100 +1,68 @@
"""Custom build backend that builds libaegis with Zig before building the Python package."""
import os
import platform
import shutil
import subprocess
import sys
from pathlib import Path
from setuptools import build_meta as _orig
from setuptools import build_meta
__all__ = [
"build_sdist",
"build_wheel",
"build_editable",
"get_requires_for_build_sdist",
"get_requires_for_build_wheel",
"prepare_metadata_for_build_wheel",
]
_MACOS_TARGET = "11.0"
_prepared = False
def _check_zig_available():
"""Check if Zig is installed and available."""
def _prepare():
"""Prepare the build environment and build libaegis."""
global _prepared
if _prepared:
return
_prepared = True
# Set macOS deployment target
if sys.platform == "darwin" and "MACOSX_DEPLOYMENT_TARGET" not in os.environ:
os.environ["MACOSX_DEPLOYMENT_TARGET"] = _MACOS_TARGET
# Check Zig is available
if shutil.which("zig") is None:
raise RuntimeError(
"\n" + "=" * 70 + "\n"
"ERROR: Zig compiler not found!\n"
"\n"
"Building aeg requires the Zig compiler to build the libaegis\n"
"static library. Please install Zig before building this package.\n"
"\n"
"Installation instructions:\n"
" - Visit: https://ziglang.org/download/\n"
" - Or use a package manager:\n"
" * macOS: brew install zig\n"
" * Linux: See https://github.com/ziglang/zig/wiki/Install-Zig-from-a-Package-Manager\n"
" * Windows: choco install zig or scoop install zig\n"
"\n"
"After installing Zig, please try building again.\n" + "=" * 70 + "\n"
"Zig compiler not found. Install from https://ziglang.org/download/"
)
def _build_libaegis():
"""Build libaegis static library with Zig."""
# Check Zig availability first
_check_zig_available()
# Build libaegis
libaegis_dir = Path(__file__).parent.parent / "libaegis"
if not libaegis_dir.exists():
raise FileNotFoundError(
f"libaegis directory not found at {libaegis_dir}. "
"Cannot build static library."
)
print("Building libaegis static library with Zig...")
try:
subprocess.run(
["zig", "build", "-Drelease"],
cwd=libaegis_dir,
check=True,
capture_output=False,
)
print("Successfully built libaegis static library")
except subprocess.CalledProcessError as e:
print(
f"\nError: Zig build failed with exit code {e.returncode}\n"
f"Command: {' '.join(e.cmd)}\n",
file=sys.stderr,
)
raise
cmd = ["zig", "build", "-Drelease"]
if sys.platform == "darwin":
arch = {"arm64": "aarch64", "x86_64": "x86_64"}.get(platform.machine())
if arch:
cmd.append(f"-Dtarget={arch}-macos.{_MACOS_TARGET}")
subprocess.run(cmd, cwd=libaegis_dir, check=True)
# Expose all the standard build backend hooks
def get_requires_for_build_wheel(config_settings=None):
"""Return build requirements and ensure libaegis is built first."""
_build_libaegis()
return _orig.get_requires_for_build_wheel(config_settings)
def get_requires_for_build_sdist(config_settings=None):
"""Return build requirements for sdist and ensure libaegis is built first."""
_build_libaegis()
return _orig.get_requires_for_build_sdist(config_settings)
_orig_prepare_metadata_for_build_wheel = _orig.prepare_metadata_for_build_wheel
_orig_build_sdist = _orig.build_sdist
def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None):
"""Prepare metadata and ensure libaegis is built (some frontends call this early)."""
_build_libaegis()
return _orig_prepare_metadata_for_build_wheel(metadata_directory, config_settings)
def build_sdist(sdist_directory, config_settings=None):
"""Build sdist, building libaegis first so the sdist can include built artifacts if needed."""
_build_libaegis()
return _orig_build_sdist(sdist_directory, config_settings)
build_sdist = build_meta.build_sdist
get_requires_for_build_sdist = build_meta.get_requires_for_build_sdist
get_requires_for_build_wheel = build_meta.get_requires_for_build_wheel
prepare_metadata_for_build_wheel = build_meta.prepare_metadata_for_build_wheel
# Wheel build hooks - need libaegis built first
def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
"""Build wheel with libaegis built first."""
_build_libaegis()
return _orig.build_wheel(wheel_directory, config_settings, metadata_directory)
_prepare()
return build_meta.build_wheel(wheel_directory, config_settings, metadata_directory)
def build_editable(wheel_directory, config_settings=None, metadata_directory=None):
"""Build editable install with libaegis built first."""
_build_libaegis()
return _orig.build_editable(wheel_directory, config_settings, metadata_directory)
_prepare()
return build_meta.build_editable(
wheel_directory, config_settings, metadata_directory
)
+258 -133
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env -S uv run
"""Build wheels for all supported Python versions using uv."""
import os
import platform
import shutil
import subprocess
@@ -13,19 +14,34 @@ from packaging.version import Version
sys.path.insert(0, str(Path(__file__).parent))
import generate
PYTHON_VERSIONS = [
# Minimum macOS deployment target for compatibility
MACOS_DEPLOYMENT_TARGET = "11.0"
# ABI3 wheel: built once, works for all GIL-enabled Python versions
# We use a recent Python to build since it doesn't affect the wheel compatibility
ABI3_BUILD_VERSION = "3.14+gil"
# All GIL-enabled Python versions covered by the ABI3 wheel
ABI3_COVERED_VERSIONS = [
"3.10",
"3.11",
"3.12",
"3.13",
"3.14",
"3.13+gil",
"3.14+gil",
"3.15+gil",
]
# Non-ABI3 wheels: each needs its own build (free-threaded and PyPy)
NON_ABI3_VERSIONS = [
"3.14t",
"3.15",
"3.15t",
"pypy3.10",
"pypy3.11",
]
# All versions for testing and benchmarking
ALL_PYTHON_VERSIONS = ABI3_COVERED_VERSIONS + NON_ABI3_VERSIONS
def get_version_from_scm():
"""Get version from setuptools-scm (git tags)."""
@@ -94,21 +110,29 @@ def make_release_message(version):
return msg
def run_command(cmd, description):
"""Run a command and handle errors."""
print(f"\n{'=' * 70}")
print(f"{description}")
print(f"{'=' * 70}")
def run_command(cmd, description=None, env=None):
"""Run a command and handle errors. If description is None, only print the command."""
if description:
print(f"\n{'=' * 70}")
print(f"{description}")
print(f"{'=' * 70}")
print(f">>> {' '.join(cmd)}")
try:
subprocess.run(cmd, check=True)
print(f"{description} completed successfully")
subprocess.run(cmd, check=True, env=env)
return True
except subprocess.CalledProcessError as e:
print(f"{description} failed with exit code {e.returncode}", file=sys.stderr)
print(f"Command failed with exit code {e.returncode}", file=sys.stderr)
return False
def get_build_env():
"""Get environment variables for building wheels."""
env = os.environ.copy()
if platform.system() == "Darwin":
env["MACOSX_DEPLOYMENT_TARGET"] = MACOS_DEPLOYMENT_TARGET
return env
def normalize_line_endings(repo_root: Path):
"""Normalize all text files to LF line endings."""
# Patterns for files to normalize
@@ -132,6 +156,160 @@ def normalize_line_endings(repo_root: Path):
file_path.write_bytes(content)
def get_wheel_pattern(py_version: str, abi3: bool = False) -> str:
"""Get the glob pattern for finding a wheel file."""
if abi3:
# ABI3 wheels always use cp310-abi3 tag (minimum supported version)
# regardless of which Python version was used to build
return "aeg-*-cp310-abi3-*.whl"
elif py_version.startswith("pypy"):
# PyPy wheels use pp3XX format
return f"aeg-*-pp{py_version.replace('pypy', '').replace('.', '')}-*.whl"
elif py_version.endswith("t"):
# Free-threaded Python wheels use cpXXX-cpXXXt format (e.g., cp314-cp314t)
base_version = py_version.replace(".", "").replace("t", "")
return f"aeg-*-cp{base_version}-cp{base_version}t-*.whl"
else:
# Regular CPython wheels use cpXXX-cpXXX format
# Strip +gil suffix used to force non-free-threaded build
base_version = py_version.replace(".", "").replace("+gil", "")
return f"aeg-*-cp{base_version}-cp{base_version}-*.whl"
def build_abi3_wheel(dist_dir: Path, py_version: str) -> Path | None:
"""Build the ABI3 wheel using the specified Python version."""
cmd = ["uv", "build", "--python", py_version, "--wheel", "--quiet"]
if not run_command(cmd, env=get_build_env()):
return None
# Find the ABI3 wheel (always tagged cp310-abi3 regardless of build Python version)
wheel_pattern = get_wheel_pattern(py_version, abi3=True)
wheels = list(dist_dir.glob(wheel_pattern))
if not wheels:
print(f"✗ Could not find ABI3 wheel matching {wheel_pattern}", file=sys.stderr)
return None
wheel = wheels[0]
# Repair wheel with auditwheel for manylinux compatibility (Linux only)
if platform.system() == "Linux":
wheel = repair_wheel_linux(dist_dir, wheel, py_version, abi3=True)
if not wheel:
return None
return wheel
def build_wheel_for_version(dist_dir: Path, py_version: str) -> Path | None:
"""Build a wheel for a specific Python version (non-ABI3)."""
cmd = ["uv", "build", "--python", py_version, "--wheel", "--quiet"]
if not run_command(cmd, env=get_build_env()):
return None
# Find the wheel for this version
wheel_pattern = get_wheel_pattern(py_version, abi3=False)
wheels = list(dist_dir.glob(wheel_pattern))
if not wheels:
print(f"✗ Could not find wheel for Python {py_version}", file=sys.stderr)
return None
wheel = wheels[0]
# Repair wheel with auditwheel for manylinux compatibility (Linux only)
if platform.system() == "Linux":
wheel = repair_wheel_linux(dist_dir, wheel, py_version, abi3=False)
if not wheel:
return None
return wheel
def repair_wheel_linux(
dist_dir: Path, wheel: Path, py_version: str, abi3: bool
) -> Path | None:
"""Repair a wheel with auditwheel for manylinux compatibility (Linux only)."""
repair_cmd = [
"uv",
"run",
"auditwheel",
"repair",
str(wheel),
"-w",
str(dist_dir),
]
if not run_command(repair_cmd):
return None
# Find the repaired wheel (it will have a different name)
wheel_pattern = get_wheel_pattern(py_version, abi3=abi3)
all_wheels = list(dist_dir.glob(wheel_pattern))
repaired_wheels = [w for w in all_wheels if "linux_x86_64" not in str(w)]
if not repaired_wheels:
print(
f"✗ Could not find repaired (manylinux) wheel for Python {py_version}",
file=sys.stderr,
)
return None
repaired_wheel = repaired_wheels[0]
# Remove the unrepaired linux_x86_64 wheels
for w in all_wheels:
if "linux_x86_64" in str(w):
w.unlink()
return repaired_wheel
def test_wheel(wheel: Path, py_version: str) -> bool:
"""Test a wheel with pytest."""
# --isolated: avoid .venv conflicts
# --no-project: don't build from source in current directory, use the wheel
# --refresh-package: force uv to not use cached old versions
test_cmd = [
"uv",
"run",
"--isolated",
"--no-project",
"--refresh-package",
"aeg",
"--python",
py_version,
"--with",
str(wheel),
"--with",
"pytest",
"pytest",
"tests/",
]
return run_command(test_cmd)
def run_benchmark(wheel: Path, py_version: str) -> bool:
"""Run benchmark for a wheel."""
# --isolated: avoid .venv conflicts
# --no-project: don't build from source in current directory, use the wheel
# --refresh-package: force uv to not use cached old versions
bench_cmd = [
"uv",
"run",
"--isolated",
"--no-project",
"--refresh-package",
"aeg",
"--python",
py_version,
"--with",
str(wheel),
"-m",
"aeg.benchmark",
]
return run_command(bench_cmd)
return True
def main():
"""Build wheels for all supported Python versions."""
repo_root = Path(__file__).parent.parent
@@ -146,14 +324,15 @@ def main():
return 1
# Run ruff to check and fix any issues
if not run_command(
["uv", "run", "ruff", "check", "--fix", "."], "Running ruff check --fix"
):
print(f"\n{'=' * 70}")
print("Linting and formatting")
print(f"{'=' * 70}")
if not run_command(["uv", "run", "ruff", "check", "--fix", "."]):
print("✗ Ruff check failed", file=sys.stderr)
return 1
# Run ruff format
if not run_command(["uv", "run", "ruff", "format", "."], "Running ruff format"):
if not run_command(["uv", "run", "ruff", "format", "."]):
print("✗ Ruff format failed", file=sys.stderr)
return 1
@@ -172,7 +351,11 @@ def main():
f"Packaging aeg-{version}"
+ (" for release" if is_release else " (not release)")
)
print(f"Building wheels for Python versions: {', '.join(PYTHON_VERSIONS)}")
print(f"Building: 1 ABI3 wheel (for Python {', '.join(ABI3_COVERED_VERSIONS)})")
print(
f" + {len(NON_ABI3_VERSIONS)} non-ABI3 wheels ({', '.join(NON_ABI3_VERSIONS)})"
)
print(f"Testing/benchmarking: {len(ALL_PYTHON_VERSIONS)} Python versions")
print(f"Output directory: {dist_dir}", end=" ")
# Clean dist directory
@@ -181,149 +364,88 @@ def main():
shutil.rmtree(dist_dir)
else:
print("(created)")
# Clean build directory to remove stale CFFI-generated C code and .so files
build_dir = repo_root / "build"
if build_dir.exists():
print(f"Cleaning build directory: {build_dir}")
shutil.rmtree(build_dir)
# Build distributions
print(f"\n{'=' * 70}")
print("Building distributions")
print(f"{'=' * 70}")
# Build source distribution first
if not run_command(
["uv", "build", "--sdist", "--quiet"], "Building source distribution"
):
if not run_command(["uv", "build", "--sdist", "--quiet"], env=get_build_env()):
print("✗ Source distribution build failed", file=sys.stderr)
return 1
failed_builds = []
failed_tests = []
successful_wheels = []
wheel_for_version = {} # Map Python version to wheel path
for py_version in PYTHON_VERSIONS:
# Build wheel
description = f"Building wheel for Python {py_version}"
cmd = ["uv", "build", "--python", py_version, "--wheel", "--quiet"]
# Build ABI3 wheel (once, works for all GIL-enabled versions)
abi3_wheel = build_abi3_wheel(dist_dir, ABI3_BUILD_VERSION)
if abi3_wheel:
successful_wheels.append(abi3_wheel)
# This wheel works for all ABI3-covered versions
for py_version in ABI3_COVERED_VERSIONS:
wheel_for_version[py_version] = abi3_wheel
else:
failed_builds.append(f"abi3 (built with {ABI3_BUILD_VERSION})")
if not run_command(cmd, description):
failed_builds.append(py_version)
continue
# Find the wheel for this version
if py_version.startswith("pypy"):
# PyPy wheels use pp3XX format
wheel_pattern = (
f"aeg-*-pp{py_version.replace('pypy', '').replace('.', '')}-*.whl"
)
elif py_version.endswith("t"):
# Free-threaded Python wheels use cpXXX-cpXXXt format (e.g., cp314-cp314t)
base_version = py_version.replace(".", "").replace("t", "")
wheel_pattern = f"aeg-*-cp{base_version}-cp{base_version}t-*.whl"
# Build non-ABI3 wheels (free-threaded and PyPy)
for py_version in NON_ABI3_VERSIONS:
wheel = build_wheel_for_version(dist_dir, py_version)
if wheel:
successful_wheels.append(wheel)
wheel_for_version[py_version] = wheel
else:
# Regular CPython wheels use cpXXX-cpXXX format
base_version = py_version.replace(".", "")
wheel_pattern = f"aeg-*-cp{base_version}-cp{base_version}-*.whl"
wheels = list(dist_dir.glob(wheel_pattern))
if not wheels:
print(f"✗ Could not find wheel for Python {py_version}", file=sys.stderr)
failed_builds.append(py_version)
# Test and benchmark each Python version with its appropriate wheel
print(f"\n{'=' * 70}")
print("Testing and benchmarking")
print(f"{'=' * 70}")
for py_version in ALL_PYTHON_VERSIONS:
wheel = wheel_for_version.get(py_version)
if not wheel:
# No wheel available for this version (build failed)
continue
wheel = wheels[0]
# Repair wheel with auditwheel for manylinux compatibility (Linux only)
if platform.system() == "Linux":
repair_cmd = [
"uv",
"run",
"auditwheel",
"repair",
str(wheel),
"-w",
str(dist_dir),
]
if not run_command(
repair_cmd, f"Repairing wheel for Python {py_version} with auditwheel"
):
print(
f"✗ Auditwheel repair failed for Python {py_version}",
file=sys.stderr,
)
failed_builds.append(py_version)
continue
# Find the repaired wheel (it will have a different name)
# Use same pattern logic as above for free-threaded vs regular builds
if py_version.startswith("pypy"):
repair_pattern = (
f"aeg-*-pp{py_version.replace('pypy', '').replace('.', '')}-*.whl"
)
elif py_version.endswith("t"):
base_version = py_version.replace(".", "").replace("t", "")
repair_pattern = f"aeg-*-cp{base_version}-cp{base_version}t-*.whl"
else:
base_version = py_version.replace(".", "")
repair_pattern = f"aeg-*-cp{base_version}-cp{base_version}-*.whl"
all_wheels = list(dist_dir.glob(repair_pattern))
repaired_wheels = [w for w in all_wheels if "linux_x86_64" not in str(w)]
if not repaired_wheels:
print(
f"✗ Could not find repaired (manylinux) wheel for Python {py_version}",
file=sys.stderr,
)
failed_builds.append(py_version)
continue
wheel = repaired_wheels[0] # Use the repaired wheel for testing
# Remove the unrepaired linux_x86_64 wheels
for w in all_wheels:
if "linux_x86_64" in str(w):
w.unlink()
# Test the wheel with pytest (use --isolated to avoid .venv conflicts)
test_cmd = [
"uv",
"run",
"--isolated",
"--python",
py_version,
"--with",
str(wheel),
"--with",
"pytest",
"pytest",
]
if not run_command(
test_cmd, f"Testing wheel for Python {py_version} with pytest"
):
print(f"✗ Tests failed for Python {py_version}", file=sys.stderr)
failed_builds.append(py_version)
# Test the wheel with pytest
if not test_wheel(wheel, py_version):
failed_tests.append(py_version)
continue
# Run benchmark (use --isolated to avoid .venv conflicts)
bench_cmd = [
"uv",
"run",
"--isolated",
"--python",
py_version,
"--with",
str(wheel),
"-m",
"aeg.benchmark",
]
if not run_command(bench_cmd, f"Running benchmark for Python {py_version}"):
print(f"✗ Benchmark failed for Python {py_version}", file=sys.stderr)
failed_builds.append(py_version)
# Run benchmark
if not run_benchmark(wheel, py_version):
failed_tests.append(py_version)
continue
successful_wheels.append(wheel)
# Summary
print(f"\n{'=' * 70}")
print("BUILD SUMMARY")
print(f"{'=' * 70}")
print(
f"Successful builds: sdist and {len(successful_wheels)}/{len(PYTHON_VERSIONS)} wheels"
f"Successful builds: sdist and {len(successful_wheels)} wheels "
f"(1 abi3 + {len(NON_ABI3_VERSIONS)} non-abi3)"
)
print(
f"Tests/benchmarks passed: {len(ALL_PYTHON_VERSIONS) - len(failed_tests) - len(failed_builds)}/{len(ALL_PYTHON_VERSIONS)} Python versions"
)
if failed_builds:
print(f"\nFailed builds: {len(failed_builds)}")
for failed_version in failed_builds:
print(f"{failed_version}")
if failed_tests:
print(f"\nFailed tests/benchmarks: {len(failed_tests)}")
for failed_version in failed_tests:
print(f" ✗ Python {failed_version}")
if not successful_wheels:
@@ -356,4 +478,7 @@ def main():
if __name__ == "__main__":
sys.exit(main())
try:
sys.exit(main())
except KeyboardInterrupt:
sys.exit(1)