Skip to content

Commit a644d27

Browse files
author
Leo Vasanko
committed
Add tools/release.py script for automatic release process.
1 parent 4445c25 commit a644d27

1 file changed

Lines changed: 296 additions & 0 deletions

File tree

tools/release.py

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
#!/usr/bin/env python3
2+
"""Build wheels for all supported Python versions using uv."""
3+
4+
import subprocess
5+
import sys
6+
import tomllib
7+
from pathlib import Path
8+
import shutil
9+
from packaging.specifiers import SpecifierSet
10+
from packaging.version import Version
11+
12+
# Import generate module from same directory
13+
sys.path.insert(0, str(Path(__file__).parent))
14+
import generate
15+
16+
17+
def get_python_versions():
18+
"""Get supported Python versions."""
19+
pyproject_toml = Path(__file__).parent.parent / "pyproject.toml"
20+
data = tomllib.loads(pyproject_toml.read_text(encoding="utf-8"))
21+
spec = SpecifierSet(data["project"]["requires-python"])
22+
# Generate versions that match the specifier (up to Python 3.14)
23+
return [f"3.{minor}" for minor in range(10, 15) if f"3.{minor}" in spec]
24+
25+
26+
def get_version_from_scm():
27+
"""Get version from setuptools-scm (git tags)."""
28+
try:
29+
result = subprocess.run(
30+
["uv", "run", "-m", "setuptools_scm"],
31+
capture_output=True,
32+
text=True,
33+
check=True,
34+
cwd=Path(__file__).parent.parent,
35+
)
36+
return result.stdout.strip()
37+
except subprocess.CalledProcessError as e:
38+
print(f"✗ Error getting version from setuptools-scm: {e}", file=sys.stderr)
39+
return None
40+
41+
42+
def is_release_version(version):
43+
"""Check if version is a clean release (no dev/post/local identifiers)."""
44+
# A release version is just x.y.z with optional alpha/beta/rc suffixes
45+
# No +local or .devN or .postN
46+
if not version:
47+
return False
48+
return not any(marker in version for marker in ["+", ".dev", ".post"])
49+
50+
51+
def get_next_version(current_version):
52+
"""Get the next release version from a dev version."""
53+
# Parse base version (strips dev/local parts)
54+
try:
55+
v = Version(current_version)
56+
return f"{v.major}.{v.minor}.{v.micro}"
57+
except Exception:
58+
return current_version
59+
60+
61+
def is_working_copy_clean():
62+
"""Check if git working copy is clean."""
63+
result = subprocess.run(
64+
["git", "status", "--porcelain"], capture_output=True, text=True
65+
)
66+
return result.returncode == 0 and not result.stdout.strip()
67+
68+
69+
def make_release_message(version):
70+
"""Generate message for making a release."""
71+
next_version = get_next_version(version)
72+
is_clean = is_working_copy_clean()
73+
74+
msg = "\n⚠️ This is not a clean release version; upload to PyPI skipped.\n\n"
75+
msg += f"To create a release (e.g. {next_version}) and upload to PyPI:\n"
76+
77+
if not is_clean:
78+
msg += " 1. Add and commit changes on the working copy\n"
79+
msg += f" 2. Tag the commit: git tag v{next_version}\n"
80+
msg += " 3. Run this script again\n"
81+
msg += f" 4. Push the tag: git push origin v{next_version}\n"
82+
else:
83+
msg += f" 1. Tag the current commit: git tag v{next_version}\n"
84+
msg += " 2. Run this script again\n"
85+
msg += f" 3. Push the tag: git push origin v{next_version}\n"
86+
87+
msg += (
88+
f"\nIf the build didn't work, delete the tag with git tag -d v{next_version}\n"
89+
)
90+
return msg
91+
92+
93+
PYTHON_VERSIONS = get_python_versions()
94+
95+
96+
def run_command(cmd, description):
97+
"""Run a command and handle errors."""
98+
print(f"\n{'=' * 70}")
99+
print(f"{description}")
100+
print(f"{'=' * 70}")
101+
print(f">>> {' '.join(cmd)}")
102+
try:
103+
subprocess.run(cmd, check=True)
104+
print(f"✓ {description} completed successfully")
105+
return True
106+
except subprocess.CalledProcessError as e:
107+
print(f"✗ {description} failed with exit code {e.returncode}", file=sys.stderr)
108+
return False
109+
110+
111+
def normalize_line_endings(repo_root: Path):
112+
"""Normalize all text files to LF line endings."""
113+
# Patterns for files to normalize
114+
patterns = [
115+
"pyaegis/**/*.py",
116+
"pyaegis/**/*.h",
117+
"tests/**/*.py",
118+
"tools/**/*.py",
119+
"*.py",
120+
"*.md",
121+
"*.txt",
122+
"*.toml",
123+
"*.in",
124+
]
125+
for pattern in patterns:
126+
for file_path in repo_root.glob(pattern):
127+
if file_path.is_file():
128+
content = file_path.read_bytes()
129+
if b"\r\n" in content:
130+
content = content.replace(b"\r\n", b"\n")
131+
file_path.write_bytes(content)
132+
133+
134+
def main():
135+
"""Build wheels for all supported Python versions."""
136+
repo_root = Path(__file__).parent.parent
137+
dist_dir = repo_root / "dist"
138+
139+
# Generate CFFI definitions and Python modules
140+
print(f"\n{'=' * 70}")
141+
print("Code generation from C headers (tools/generate.py)")
142+
print(f"{'=' * 70}")
143+
if generate.main() != 0:
144+
print("✗ Code generation failed", file=sys.stderr)
145+
return 1
146+
147+
# Run ruff to check and fix any issues
148+
if not run_command(
149+
["uv", "run", "ruff", "check", "--fix", "."], "Running ruff check --fix"
150+
):
151+
print("✗ Ruff check failed", file=sys.stderr)
152+
return 1
153+
154+
# Run ruff format
155+
if not run_command(["uv", "run", "ruff", "format", "."], "Running ruff format"):
156+
print("✗ Ruff format failed", file=sys.stderr)
157+
return 1
158+
159+
# Normalize all line endings to LF (important for consistent builds)
160+
normalize_line_endings(repo_root)
161+
162+
# Get version from git repo
163+
version = get_version_from_scm()
164+
if not version:
165+
return 1
166+
is_release = is_release_version(version)
167+
168+
# Main header for the packaging process
169+
print(f"\n{'=' * 70}")
170+
print(
171+
f"Packaging pyaegis-{version}"
172+
+ (" for release" if is_release else " (not release)")
173+
)
174+
print(f"Building wheels for Python versions: {', '.join(PYTHON_VERSIONS)}")
175+
print(f"Output directory: {dist_dir}", end=" ")
176+
177+
# Clean dist directory
178+
if dist_dir.exists():
179+
print("(wiped)")
180+
shutil.rmtree(dist_dir)
181+
else:
182+
print("(created)")
183+
print(f"{'=' * 70}")
184+
185+
# Build source distribution first
186+
if not run_command(
187+
["uv", "build", "--sdist", "--quiet"], "Building source distribution"
188+
):
189+
print("✗ Source distribution build failed", file=sys.stderr)
190+
return 1
191+
192+
failed_builds = []
193+
successful_wheels = []
194+
195+
for py_version in PYTHON_VERSIONS:
196+
# Build wheel
197+
description = f"Building wheel for Python {py_version}"
198+
cmd = ["uv", "build", "--python", py_version, "--wheel", "--quiet"]
199+
200+
if not run_command(cmd, description):
201+
failed_builds.append(py_version)
202+
continue
203+
204+
# Find the wheel for this version
205+
wheel_pattern = f"pyaegis-*-cp{py_version.replace('.', '')}-*.whl"
206+
wheels = list(dist_dir.glob(wheel_pattern))
207+
if not wheels:
208+
print(f"✗ Could not find wheel for Python {py_version}", file=sys.stderr)
209+
failed_builds.append(py_version)
210+
continue
211+
212+
wheel = wheels[0]
213+
214+
# Test the wheel with pytest (use --isolated to avoid .venv conflicts)
215+
test_cmd = [
216+
"uv",
217+
"run",
218+
"--isolated",
219+
"--python",
220+
py_version,
221+
"--with",
222+
str(wheel),
223+
"--with",
224+
"pytest",
225+
"pytest",
226+
]
227+
if not run_command(
228+
test_cmd, f"Testing wheel for Python {py_version} with pytest"
229+
):
230+
print(f"✗ Tests failed for Python {py_version}", file=sys.stderr)
231+
failed_builds.append(py_version)
232+
continue
233+
234+
# Run benchmark (use --isolated to avoid .venv conflicts)
235+
bench_cmd = [
236+
"uv",
237+
"run",
238+
"--isolated",
239+
"--python",
240+
py_version,
241+
"--with",
242+
str(wheel),
243+
"-m",
244+
"pyaegis.benchmark",
245+
]
246+
if not run_command(bench_cmd, f"Running benchmark for Python {py_version}"):
247+
print(f"✗ Benchmark failed for Python {py_version}", file=sys.stderr)
248+
failed_builds.append(py_version)
249+
continue
250+
251+
successful_wheels.append(wheel)
252+
253+
# Summary
254+
print(f"\n{'=' * 70}")
255+
print("BUILD SUMMARY")
256+
print(f"{'=' * 70}")
257+
print(
258+
f"Successful builds: sdist and {len(successful_wheels)}/{len(PYTHON_VERSIONS)} wheels"
259+
)
260+
261+
if failed_builds:
262+
print(f"\nFailed builds: {len(failed_builds)}")
263+
for version in failed_builds:
264+
print(f" ✗ Python {version}")
265+
266+
if not successful_wheels:
267+
print("\n✗ No successful wheels to upload")
268+
return 1
269+
270+
# List files to upload
271+
sdist = list(dist_dir.glob("*.tar.gz"))
272+
upload_files = sdist + successful_wheels
273+
274+
for file in upload_files:
275+
print(f" - {file.name}")
276+
277+
# Only upload if this is a clean release version
278+
if not is_release:
279+
print(make_release_message(version))
280+
return 0
281+
282+
# Upload with twine
283+
upload_cmd = ["uvx", "twine", "upload"] + [str(f) for f in upload_files]
284+
if not run_command(upload_cmd, "Uploading to PyPI with twine"):
285+
print("\n✗ Upload failed")
286+
return 1
287+
288+
print(f"\n{'=' * 70}")
289+
print("All builds and upload completed successfully!")
290+
print(f"{'=' * 70}")
291+
print()
292+
return 0
293+
294+
295+
if __name__ == "__main__":
296+
sys.exit(main())

0 commit comments

Comments
 (0)