release.py previously ran the code generator, 'ruff check --fix', 'ruff format', and line-ending normalization before building. Any drift rewrote tracked files, dirtying the checkout and turning the release into a dev version (the v0.10.6 PyPI 400). Generation and linting are now validation-only: - tools/generate.py gains a --check mode that reports stale generated files without writing (comparing Python content in ruff-formatted form, matching what write + format would produce) - release.py runs generate --check, ruff check (no --fix), and ruff format --check, failing with remediation hints - normalize_line_endings() is removed; .gitattributes keeps checkouts LF
494 lines
16 KiB
Python
Executable File
494 lines
16 KiB
Python
Executable File
#!/usr/bin/env -S uv run
|
|
"""Build wheels for all supported Python versions using uv."""
|
|
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from packaging.version import Version
|
|
|
|
# Import generate module from same directory
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
import generate
|
|
|
|
# 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.12",
|
|
"3.13+gil",
|
|
"3.14+gil",
|
|
"3.15+gil",
|
|
]
|
|
|
|
# Non-ABI3 wheels: each needs its own build (free-threaded)
|
|
NON_ABI3_VERSIONS = [
|
|
"3.14t",
|
|
"3.15t",
|
|
]
|
|
|
|
# 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)."""
|
|
try:
|
|
result = subprocess.run(
|
|
["uv", "run", "-m", "setuptools_scm"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
cwd=Path(__file__).parent.parent,
|
|
)
|
|
return result.stdout.strip()
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"✗ Error getting version from setuptools-scm: {e}", file=sys.stderr)
|
|
return None
|
|
|
|
|
|
def is_release_version(version):
|
|
"""Check if version is a clean release (no dev/post/local identifiers)."""
|
|
# A release version is just x.y.z with optional alpha/beta/rc suffixes
|
|
# No +local or .devN or .postN
|
|
if not version:
|
|
return False
|
|
return not any(marker in version for marker in ["+", ".dev", ".post"])
|
|
|
|
|
|
def get_next_version(current_version):
|
|
"""Get the next release version from a dev version."""
|
|
# Parse base version (strips dev/local parts)
|
|
try:
|
|
v = Version(current_version)
|
|
return f"{v.major}.{v.minor}.{v.micro}"
|
|
except Exception:
|
|
return current_version
|
|
|
|
|
|
def is_working_copy_clean():
|
|
"""Check if git working copy is clean."""
|
|
result = subprocess.run(
|
|
["git", "status", "--porcelain"], capture_output=True, text=True, check=False
|
|
)
|
|
return result.returncode == 0 and not result.stdout.strip()
|
|
|
|
|
|
def make_release_message(version):
|
|
"""Generate message for making a release."""
|
|
next_version = get_next_version(version)
|
|
is_clean = is_working_copy_clean()
|
|
|
|
msg = "\n⚠️ This is not a clean release version; upload to PyPI skipped.\n\n"
|
|
msg += f"To create a release (e.g. {next_version}) and upload to PyPI:\n"
|
|
|
|
if not is_clean:
|
|
msg += " 1. Add and commit changes on the working copy\n"
|
|
msg += f" 2. Tag the commit: git tag v{next_version}\n"
|
|
msg += " 3. Run this script again\n"
|
|
msg += f" 4. Push the tag: git push origin v{next_version}\n"
|
|
else:
|
|
msg += f" 1. Tag the current commit: git tag v{next_version}\n"
|
|
msg += " 2. Run this script again\n"
|
|
msg += f" 3. Push the tag: git push origin v{next_version}\n"
|
|
|
|
msg += (
|
|
f"\nIf the build didn't work, delete the tag with git tag -d v{next_version}\n"
|
|
)
|
|
return msg
|
|
|
|
|
|
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, env=env)
|
|
return True
|
|
except subprocess.CalledProcessError as e:
|
|
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 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 cp312-abi3 tag (minimum supported version)
|
|
# regardless of which Python version was used to build
|
|
return "aeg-*-cp312-abi3-*.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 cp312-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."""
|
|
# Windows CI consoles default to cp1252; emit ✓/✗/⚠ as UTF-8
|
|
for stream in (sys.stdout, sys.stderr):
|
|
if hasattr(stream, "reconfigure"):
|
|
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
|
|
# CI matrix jobs build per-platform wheels but never upload; a separate
|
|
# publish job uploads everything once all platforms have succeeded.
|
|
no_upload = "--no-upload" in sys.argv
|
|
|
|
repo_root = Path(__file__).parent.parent
|
|
dist_dir = repo_root / "dist"
|
|
|
|
# The repository must already contain up-to-date generated code and
|
|
# lint-clean sources: a release build never writes to tracked files.
|
|
# Line endings are normalized to LF on checkout via .gitattributes.
|
|
print(f"\n{'=' * 70}")
|
|
print("Validating generated files (tools/generate.py --check)")
|
|
print(f"{'=' * 70}")
|
|
if generate.main(check=True) != 0:
|
|
return 1
|
|
|
|
print(f"\n{'=' * 70}")
|
|
print("Lint and format validation")
|
|
print(f"{'=' * 70}")
|
|
if not run_command(["uv", "run", "ruff", "check", "."]):
|
|
print("✗ Ruff check failed; fix and commit before releasing", file=sys.stderr)
|
|
return 1
|
|
|
|
if not run_command(["uv", "run", "ruff", "format", "--check", "."]):
|
|
print(
|
|
"✗ Ruff format check failed; run 'uv run ruff format .' and commit",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
# Get version from git repo
|
|
version = get_version_from_scm()
|
|
if not version:
|
|
return 1
|
|
is_release = is_release_version(version)
|
|
|
|
# On a tagged commit the version must be a clean release (e.g. 0.10.6).
|
|
# A dev/dirty version means the checkout itself is dirty (PyPI rejects
|
|
# such versions), so fail before building.
|
|
head_tags = subprocess.run(
|
|
["git", "tag", "--points-at", "HEAD", "--list", "v*"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
).stdout.split()
|
|
if head_tags and not is_release:
|
|
print(
|
|
f"✗ HEAD is tagged ({', '.join(head_tags)}) but the version resolved "
|
|
f"to {version}, not a clean release.",
|
|
file=sys.stderr,
|
|
)
|
|
dirty = subprocess.run(
|
|
["git", "status", "--porcelain"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
).stdout
|
|
if dirty.strip():
|
|
print("Working copy is dirty:", file=sys.stderr)
|
|
print(dirty, file=sys.stderr, end="")
|
|
print("Commit the changes, re-tag, and run again.", file=sys.stderr)
|
|
return 1
|
|
|
|
# Main header for the packaging process
|
|
print(f"\n{'=' * 70}")
|
|
print(
|
|
f"Packaging aeg-{version}"
|
|
+ (" for release" if is_release else " (not release)")
|
|
)
|
|
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
|
|
if dist_dir.exists():
|
|
print("(wiped)")
|
|
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"], 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
|
|
|
|
# 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})")
|
|
|
|
# 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:
|
|
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
|
|
|
|
# Test the wheel with pytest
|
|
if not test_wheel(wheel, py_version):
|
|
failed_tests.append(py_version)
|
|
continue
|
|
|
|
# Run benchmark
|
|
if not run_benchmark(wheel, py_version):
|
|
failed_tests.append(py_version)
|
|
continue
|
|
|
|
# Summary
|
|
print(f"\n{'=' * 70}")
|
|
print("BUILD SUMMARY")
|
|
print(f"{'=' * 70}")
|
|
print(
|
|
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:
|
|
print("\n✗ No successful wheels to upload")
|
|
return 1
|
|
|
|
# List files to upload
|
|
sdist = list(dist_dir.glob("*.tar.gz"))
|
|
upload_files = sdist + successful_wheels
|
|
|
|
for file in upload_files:
|
|
print(f" - {file.name}")
|
|
|
|
# Only upload if this is a clean release version
|
|
if no_upload:
|
|
print("\n--no-upload given; upload to PyPI skipped.")
|
|
return 0
|
|
|
|
if not is_release:
|
|
print(make_release_message(version))
|
|
return 0
|
|
|
|
# Upload with twine
|
|
upload_cmd = ["uvx", "twine", "upload"] + [str(f) for f in upload_files]
|
|
if not run_command(upload_cmd, "Uploading to PyPI with twine"):
|
|
print("\n✗ Upload failed")
|
|
return 1
|
|
|
|
print(f"\n{'=' * 70}")
|
|
print("All builds and upload completed successfully!")
|
|
print(f"{'=' * 70}")
|
|
print()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main())
|
|
except KeyboardInterrupt:
|
|
sys.exit(1)
|