chore: vendor sglang v0.5.10 snapshot

This commit is contained in:
2026-04-24 12:29:36 +00:00
parent 78f0d15221
commit bded08301f
4308 changed files with 1200894 additions and 2 deletions

View File

@@ -0,0 +1,96 @@
# Release Scripts
This directory contains scripts to automate version bumping for SGLang releases.
## Scripts
### `bump_sglang_version.py`
Updates SGLang version across all relevant files following the pattern from [PR #10468](https://github.com/sgl-project/sglang/pull/10468).
**Usage:**
```bash
python scripts/release/bump_sglang_version.py 0.5.3rc0
```
**Files updated:**
- `Makefile`
- `benchmark/deepseek_v3/README.md`
- `docker/rocm.Dockerfile`
- `docs/get_started/install.md`
- `docs/platforms/amd_gpu.md`
- `docs/platforms/ascend_npu.md`
- `python/pyproject.toml`
- `python/pyproject_other.toml`
- `python/pyproject_npu.toml`
- `python/sglang/version.py`
### `bump_kernel_version.py`
Updates the `sglang-kernel` release version across all relevant files following the pattern from [PR #10732](https://github.com/sgl-project/sglang/pull/10732).
**Usage:**
```bash
python scripts/release/bump_kernel_version.py 0.4.0
```
**Files updated:**
- `sgl-kernel/pyproject.toml`
- `sgl-kernel/pyproject_cpu.toml`
- `sgl-kernel/pyproject_rocm.toml`
- `sgl-kernel/pyproject_musa.toml`
- `sgl-kernel/python/sgl_kernel/version.py`
## Manual Testing Instructions
### Test SGLang Version Bump
1. **Run the script:**
```bash
python scripts/release/bump_sglang_version.py 0.5.4rc0
```
2. **Verify changes with git diff:**
```bash
git diff
```
3. **Check specific files contain the new version:**
```bash
grep -r "0.5.4rc0" python/sglang/version.py
grep -r "0.5.4rc0" python/pyproject.toml
grep -r "0.5.4rc0" docs/get_started/install.md
```
4. **Reset changes (if testing):**
```bash
git checkout .
```
### Test Kernel Version Bump
1. **Run the script:**
```bash
python scripts/release/bump_kernel_version.py 0.4.0
```
2. **Verify changes with git diff:**
```bash
git diff
```
3. **Check specific files contain the new version:**
```bash
grep -r "0.4.0" sgl-kernel/python/sgl_kernel/version.py
grep -r "0.4.0" sgl-kernel/pyproject.toml
```
4. **Reset changes (if testing):**
```bash
git checkout .
```
## Version Format Validation
- **SGLang versions:** `X.Y.Z` or `X.Y.ZrcN` (e.g., `0.5.3` or `0.5.3rc0`)
- **Kernel versions:** `X.Y.Z` (e.g., `0.4.0`)
The scripts will validate the version format and exit with an error if invalid.

View File

@@ -0,0 +1,148 @@
#!/usr/bin/env python3
import argparse
import re
import sys
from pathlib import Path
from utils import compare_versions, get_repo_root, normalize_version, validate_version
FILES_TO_UPDATE = [
Path("python/pyproject.toml"),
Path("docker/Dockerfile"),
Path("python/sglang/srt/entrypoints/engine.py"),
Path("python/sglang/srt/utils/common.py"),
]
def read_current_flashinfer_version(repo_root: Path) -> str:
"""Read the current flashinfer version from python/pyproject.toml."""
pyproject = repo_root / "python" / "pyproject.toml"
content = pyproject.read_text()
match = re.search(
r"flashinfer_python==(\d+\.\d+\.\d+(?:rc\d+|\.post\d+)?)", content
)
if not match:
raise ValueError(f"Could not find flashinfer_python version in {pyproject}")
return match.group(1)
def replace_flashinfer_version(
file_path: Path, old_version: str, new_version: str
) -> bool:
if not file_path.exists():
print(f"Warning: {file_path} does not exist, skipping")
return False
content = file_path.read_text()
new_content = content
name = file_path.name
if name == "pyproject.toml":
new_content = new_content.replace(
f"flashinfer_python=={old_version}", f"flashinfer_python=={new_version}"
)
new_content = new_content.replace(
f"flashinfer_cubin=={old_version}", f"flashinfer_cubin=={new_version}"
)
elif name == "Dockerfile":
new_content = re.sub(
rf"(ARG FLASHINFER_VERSION=){re.escape(old_version)}",
rf"\g<1>{new_version}",
new_content,
)
elif name == "engine.py":
new_content = re.sub(
r'(assert_pkg_version\(\s*"flashinfer_python",\s*)"'
+ re.escape(old_version)
+ r'"',
r'\g<1>"' + new_version + '"',
new_content,
flags=re.DOTALL,
)
elif name == "common.py":
new_content = new_content.replace(
f'e.g., "{old_version}"',
f'e.g., "{new_version}"',
)
if content == new_content:
print(f"No changes needed in {file_path}")
return False
file_path.write_text(new_content)
print(f"✓ Updated {file_path}")
return True
def main():
parser = argparse.ArgumentParser(
description="Bump flashinfer version across all relevant files"
)
parser.add_argument(
"new_version",
help="New version (e.g., 0.6.4, 0.6.4rc0, or 0.6.4.post1)",
)
args = parser.parse_args()
new_version = normalize_version(args.new_version)
if not validate_version(new_version):
print(f"Error: Invalid version format: {new_version}")
print("Expected format: X.Y.Z, X.Y.ZrcN, or X.Y.Z.postN")
print("Examples: 0.6.4, 0.6.4rc0, 0.6.4.post1")
sys.exit(1)
repo_root = get_repo_root()
old_version = read_current_flashinfer_version(repo_root)
print(f"Current flashinfer version: {old_version}")
print(f"New flashinfer version: {new_version}")
print()
comparison = compare_versions(new_version, old_version)
if comparison == 0:
print("Error: New version is the same as current version")
sys.exit(1)
elif comparison < 0:
print(
f"Error: New version ({new_version}) is older than current version ({old_version})"
)
print("Version must be greater than the current version")
sys.exit(1)
updated_count = 0
for file_rel in FILES_TO_UPDATE:
file_abs = repo_root / file_rel
if replace_flashinfer_version(file_abs, old_version, new_version):
updated_count += 1
print()
print(f"Successfully updated {updated_count} file(s)")
print(f"Flashinfer version bumped from {old_version} to {new_version}")
print("\nValidating version updates...")
failed_files = []
for file_rel in FILES_TO_UPDATE:
file_abs = repo_root / file_rel
if not file_abs.exists():
print(f"Warning: File {file_rel} does not exist, skipping validation.")
continue
content = file_abs.read_text()
if new_version not in content:
failed_files.append(file_rel)
print(f"{file_rel} does not contain version {new_version}")
else:
print(f"{file_rel} validated")
if failed_files:
print(f"\nError: {len(failed_files)} file(s) were not updated correctly:")
for file_rel in failed_files:
print(f" - {file_rel}")
sys.exit(1)
print("\nAll files validated successfully!")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env python3
import argparse
from pathlib import Path
from utils import bump_version
def main():
parser = argparse.ArgumentParser(
description="Bump sgl-kernel version across all relevant files"
)
parser.add_argument(
"new_version",
help="New version (e.g., 0.3.12, 0.3.11rc0, or 0.3.11.post1)",
)
args = parser.parse_args()
version_file = Path("sgl-kernel/python/sgl_kernel/version.py")
files_to_update = [
Path("sgl-kernel/pyproject.toml"),
Path("sgl-kernel/pyproject_cpu.toml"),
Path("sgl-kernel/pyproject_rocm.toml"),
Path("sgl-kernel/pyproject_musa.toml"),
Path("sgl-kernel/python/sgl_kernel/version.py"),
]
bump_version(args.new_version, version_file, files_to_update)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env python3
"""
Bump sglang-kernel version in SGLang files to match the version in sgl-kernel/pyproject.toml.
Updates:
- python/pyproject.toml
- python/sglang/srt/entrypoints/engine.py
- docker/Dockerfile
"""
import re
import sys
from pathlib import Path
try:
import tomllib # Python 3.11+
except ImportError:
import tomli as tomllib # Fallback for older Python versions
def get_kernel_version_from_source() -> str:
"""Extract version from sgl-kernel/pyproject.toml"""
pyproject_path = Path("sgl-kernel/pyproject.toml")
if not pyproject_path.exists():
print(f"Error: {pyproject_path} not found")
sys.exit(1)
with open(pyproject_path, "rb") as f:
data = tomllib.load(f)
version = data.get("project", {}).get("version")
if not version:
print("Error: Could not find version in sgl-kernel/pyproject.toml")
sys.exit(1)
return version
def update_python_pyproject(new_version: str) -> bool:
"""Update sglang-kernel version in python/pyproject.toml"""
pyproject_path = Path("python/pyproject.toml")
if not pyproject_path.exists():
print(f"Error: {pyproject_path} not found")
sys.exit(1)
content = pyproject_path.read_text()
# Replace "sglang-kernel==x.x.x" with new version
new_content = re.sub(
r'"sglang-kernel==[^"]+"',
f'"sglang-kernel=={new_version}"',
content,
)
if content == new_content:
print("No changes needed in python/pyproject.toml")
return False
pyproject_path.write_text(new_content)
print(f"✓ Updated python/pyproject.toml to version {new_version}")
return True
def update_engine_py(new_version: str) -> bool:
"""Update sglang-kernel version in python/sglang/srt/entrypoints/engine.py"""
engine_path = Path("python/sglang/srt/entrypoints/engine.py")
if not engine_path.exists():
print(f"Error: {engine_path} not found")
sys.exit(1)
content = engine_path.read_text()
# Replace version in assert_pkg_version("sglang-kernel", "version", ...)
new_content = re.sub(
r'(assert_pkg_version\s*\(\s*"sglang-kernel"\s*,\s*)"[^"]+"',
rf'\1"{new_version}"',
content,
)
if content == new_content:
print("No changes needed in engine.py")
return False
engine_path.write_text(new_content)
print(f"✓ Updated engine.py to version {new_version}")
return True
def update_dockerfile(new_version: str) -> bool:
"""Update SGL_KERNEL_VERSION in docker/Dockerfile"""
dockerfile_path = Path("docker/Dockerfile")
if not dockerfile_path.exists():
print(f"Error: {dockerfile_path} not found")
sys.exit(1)
content = dockerfile_path.read_text()
# Replace ARG SGL_KERNEL_VERSION=x.x.x with new version
new_content = re.sub(
r"^(ARG\s+SGL_KERNEL_VERSION=)(.+)$",
rf"\g<1>{new_version}",
content,
flags=re.MULTILINE,
)
if content == new_content:
print("No changes needed in Dockerfile")
return False
dockerfile_path.write_text(new_content)
print(f"✓ Updated Dockerfile to version {new_version}")
return True
def main():
kernel_version = get_kernel_version_from_source()
print(f"Bumping sglang-kernel version to: {kernel_version}\n")
updated_files = []
if update_python_pyproject(kernel_version):
updated_files.append("python/pyproject.toml")
if update_engine_py(kernel_version):
updated_files.append("python/sglang/srt/entrypoints/engine.py")
if update_dockerfile(kernel_version):
updated_files.append("docker/Dockerfile")
print()
if updated_files:
print(f"✓ Successfully updated {len(updated_files)} file(s):")
for file in updated_files:
print(f" - {file}")
else:
print("✓ All files already have the correct version")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""
Check if sglang-kernel version from sgl-kernel/pyproject.toml matches the versions
used in SGLang files (python/pyproject.toml, engine.py, and Dockerfile).
Sets GitHub Actions output variables to indicate if sync is needed.
"""
import os
import re
import sys
from pathlib import Path
try:
import tomllib # Python 3.11+
except ImportError:
import tomli as tomllib # Fallback for older Python versions
def get_kernel_version_from_source() -> str:
"""Extract version from sgl-kernel/pyproject.toml (line 11)"""
pyproject_path = Path("sgl-kernel/pyproject.toml")
if not pyproject_path.exists():
print(f"Error: {pyproject_path} not found")
sys.exit(1)
with open(pyproject_path, "rb") as f:
data = tomllib.load(f)
version = data.get("project", {}).get("version")
if not version:
print("Error: Could not find version in sgl-kernel/pyproject.toml")
sys.exit(1)
return version
def get_kernel_version_from_python_pyproject() -> str:
"""Extract sglang-kernel version from python/pyproject.toml"""
pyproject_path = Path("python/pyproject.toml")
if not pyproject_path.exists():
print(f"Error: {pyproject_path} not found")
sys.exit(1)
content = pyproject_path.read_text()
# Match "sglang-kernel==x.x.x"
match = re.search(r'"sglang-kernel==([^"]+)"', content)
if not match:
print("Error: Could not find sglang-kernel version in python/pyproject.toml")
sys.exit(1)
return match.group(1)
def get_kernel_version_from_engine() -> str:
"""Extract sglang-kernel version from python/sglang/srt/entrypoints/engine.py"""
engine_path = Path("python/sglang/srt/entrypoints/engine.py")
if not engine_path.exists():
print(f"Error: {engine_path} not found")
sys.exit(1)
content = engine_path.read_text()
# Find the assert_pkg_version call for sglang-kernel
# Look for the pattern: assert_pkg_version("sglang-kernel", "version", ...)
match = re.search(
r'assert_pkg_version\s*\(\s*"sglang-kernel"\s*,\s*"([^"]+)"', content
)
if not match:
print("Error: Could not find sglang-kernel version in engine.py")
sys.exit(1)
return match.group(1)
def get_kernel_version_from_dockerfile() -> str:
"""Extract SGL_KERNEL_VERSION from docker/Dockerfile"""
dockerfile_path = Path("docker/Dockerfile")
if not dockerfile_path.exists():
print(f"Error: {dockerfile_path} not found")
sys.exit(1)
content = dockerfile_path.read_text()
# Match ARG SGL_KERNEL_VERSION=x.x.x
match = re.search(r"^ARG\s+SGL_KERNEL_VERSION=(.+)$", content, re.MULTILINE)
if not match:
print("Error: Could not find SGL_KERNEL_VERSION in Dockerfile")
sys.exit(1)
return match.group(1).strip()
def main():
kernel_version = get_kernel_version_from_source()
pyproject_version = get_kernel_version_from_python_pyproject()
engine_version = get_kernel_version_from_engine()
dockerfile_version = get_kernel_version_from_dockerfile()
print(f"Kernel version in sgl-kernel/pyproject.toml: {kernel_version}")
print(
f"SGLang kernel dependency version in python/pyproject.toml: {pyproject_version}"
)
print(f"SGLang kernel dependency version in engine.py: {engine_version}")
print(f"Kernel version in Dockerfile: {dockerfile_version}")
# Check if any version differs from the source
needs_sync = (
kernel_version != pyproject_version
or kernel_version != engine_version
or kernel_version != dockerfile_version
)
# Set GitHub Actions output
github_output = os.getenv("GITHUB_OUTPUT")
if github_output:
with open(github_output, "a") as f:
f.write(f"needs_sync={'true' if needs_sync else 'false'}\n")
f.write(f"kernel_version={kernel_version}\n")
if needs_sync:
print(f"\n✓ Sync needed to version: {kernel_version}")
mismatches = []
if kernel_version != pyproject_version:
mismatches.append(
f" - python/pyproject.toml: {pyproject_version}{kernel_version}"
)
if kernel_version != engine_version:
mismatches.append(f" - engine.py: {engine_version}{kernel_version}")
if kernel_version != dockerfile_version:
mismatches.append(
f" - Dockerfile: {dockerfile_version}{kernel_version}"
)
print("Changes needed:")
for mismatch in mismatches:
print(mismatch)
sys.exit(0)
else:
print("\n✓ All versions are in sync, no action needed")
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,72 @@
#!/bin/bash
set -e
# Script to commit version bump changes and create a pull request
# Usage: commit_and_pr.sh <version_type> <new_version> <branch_name>
#
# Arguments:
# version_type: "SGLang" or "sgl-kernel"
# new_version: The new version number
# branch_name: The git branch name to push to
VERSION_TYPE="$1"
NEW_VERSION="$2"
BRANCH_NAME="$3"
if [ -z "$VERSION_TYPE" ] || [ -z "$NEW_VERSION" ] || [ -z "$BRANCH_NAME" ]; then
echo "Error: Missing required arguments"
echo "Usage: $0 <version_type> <new_version> <branch_name>"
exit 1
fi
# Get changed files and format them
echo "Getting changed files..."
FILES_LIST=$(git diff --name-only | sed 's/^/- /')
COMMIT_FILES=$(git diff --name-only | sed 's/^/ - /')
# Commit changes
echo "Committing changes..."
git add -A
git commit -m "chore: bump ${VERSION_TYPE} version to ${NEW_VERSION}
This commit updates the ${VERSION_TYPE} version across all relevant files:
${COMMIT_FILES}
🤖 Generated with GitHub Actions"
# Push changes
echo "Pushing to ${BRANCH_NAME}..."
git push origin "${BRANCH_NAME}"
# Create pull request
echo "Creating pull request..."
PR_URL=$(gh pr create \
--title "chore: bump ${VERSION_TYPE} version to ${NEW_VERSION}" \
--body "## Summary
This PR bumps the ${VERSION_TYPE} version to \`${NEW_VERSION}\` across all relevant files.
## Files Updated
${FILES_LIST}
🤖 Generated with GitHub Actions" \
--base main \
--head "${BRANCH_NAME}")
echo "✓ Pull request created successfully"
# Add GitHub Actions job summary
if [ -n "$GITHUB_STEP_SUMMARY" ]; then
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
## ✅ Version Bump Complete
**Version Type:** ${VERSION_TYPE}
**New Version:** \`${NEW_VERSION}\`
### 📝 Pull Request Created
${PR_URL}
### 📦 Files Updated
${FILES_LIST}
EOF
fi

View File

@@ -0,0 +1,81 @@
#!/bin/bash
set -e
# Script to commit kernel version bump changes to SGLang and create a pull request
# Usage: commit_and_pr_kernel_to_sglang.sh <kernel_version> <branch_name>
#
# Arguments:
# kernel_version: The kernel version being synced
# branch_name: The git branch name to push to
KERNEL_VERSION="$1"
BRANCH_NAME="$2"
if [ -z "$KERNEL_VERSION" ] || [ -z "$BRANCH_NAME" ]; then
echo "Error: Missing required arguments"
echo "Usage: $0 <kernel_version> <branch_name>"
exit 1
fi
# Get changed files and format them
echo "Getting changed files..."
FILES_LIST=$(git diff --name-only | sed 's/^/- /')
COMMIT_FILES=$(git diff --name-only | sed 's/^/ - /')
# Commit changes
echo "Committing changes..."
git add -A
git commit -m "chore: bump sglang-kernel version to ${KERNEL_VERSION} in SGLang
This commit updates the sglang-kernel version across SGLang files to match
the version defined in sgl-kernel/pyproject.toml.
Files updated:
${COMMIT_FILES}
🤖 Generated with GitHub Actions"
# Push changes
echo "Pushing to ${BRANCH_NAME}..."
git push origin "${BRANCH_NAME}"
# Create pull request
echo "Creating pull request..."
PR_URL=$(gh pr create \
--title "chore: bump sglang-kernel version to ${KERNEL_VERSION}" \
--body "## Summary
This PR bumps the \`sglang-kernel\` version to \`${KERNEL_VERSION}\` across SGLang files to match the version defined in \`sgl-kernel/pyproject.toml\`.
**Kernel Version:** \`${KERNEL_VERSION}\`
## Files Updated
${FILES_LIST}
## Context
The kernel version in \`sgl-kernel/pyproject.toml\` has been updated. This PR ensures that all SGLang files referencing the \`sglang-kernel\` dependency are updated accordingly:
- \`python/pyproject.toml\` - dependency specification
- \`python/sglang/srt/entrypoints/engine.py\` - version check
- \`docker/Dockerfile\` - Docker build argument
🤖 Generated with GitHub Actions" \
--base main \
--head "${BRANCH_NAME}")
echo "✓ Pull request created successfully"
# Add GitHub Actions job summary
if [ -n "$GITHUB_STEP_SUMMARY" ]; then
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
## ✅ Kernel Version Bump Complete
**Kernel Version:** \`${KERNEL_VERSION}\`
### 📝 Pull Request Created
${PR_URL}
### 📦 Files Updated
${FILES_LIST}
EOF
fi

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env python3
import unittest
from pathlib import Path
from utils import compare_versions, normalize_version, parse_version, validate_version
class TestVersionUtils(unittest.TestCase):
def test_normalize_version(self):
"""Test version normalization removes 'v' prefix."""
self.assertEqual(normalize_version("v0.5.3"), "0.5.3")
self.assertEqual(normalize_version("0.5.3"), "0.5.3")
self.assertEqual(normalize_version("v0.5.3rc0"), "0.5.3rc0")
self.assertEqual(normalize_version("0.5.3.post1"), "0.5.3.post1")
def test_validate_version(self):
"""Test version format validation."""
# Valid formats
self.assertTrue(validate_version("0.5.3"))
self.assertTrue(validate_version("0.5.3rc0"))
self.assertTrue(validate_version("0.5.3rc1"))
self.assertTrue(validate_version("0.5.3rc999"))
self.assertTrue(validate_version("0.5.3.post1"))
self.assertTrue(validate_version("0.5.3.post10"))
self.assertTrue(validate_version("1.2.3"))
self.assertTrue(validate_version("10.20.30"))
# Invalid formats
self.assertFalse(validate_version("0.5"))
self.assertFalse(validate_version("0.5.3."))
self.assertFalse(validate_version("0.5.3rc"))
self.assertFalse(validate_version("0.5.3post1"))
self.assertFalse(validate_version("0.5.3-rc0"))
self.assertFalse(validate_version("v0.5.3"))
self.assertFalse(validate_version("0.5.3beta1"))
self.assertFalse(validate_version("0.5.3.rc0"))
def test_parse_version_stable(self):
"""Test parsing stable version."""
self.assertEqual(parse_version("0.5.3"), (0, 5, 3, 0, 0))
self.assertEqual(parse_version("1.2.3"), (1, 2, 3, 0, 0))
self.assertEqual(parse_version("10.20.30"), (10, 20, 30, 0, 0))
def test_parse_version_rc(self):
"""Test parsing release candidate versions."""
self.assertEqual(parse_version("0.5.3rc0"), (0, 5, 3, -1000, 0))
self.assertEqual(parse_version("0.5.3rc1"), (0, 5, 3, -999, 0))
self.assertEqual(parse_version("0.5.3rc2"), (0, 5, 3, -998, 0))
self.assertEqual(parse_version("0.5.3rc10"), (0, 5, 3, -990, 0))
def test_parse_version_post(self):
"""Test parsing post-release versions."""
self.assertEqual(parse_version("0.5.3.post1"), (0, 5, 3, 0, 1))
self.assertEqual(parse_version("0.5.3.post2"), (0, 5, 3, 0, 2))
self.assertEqual(parse_version("0.5.3.post10"), (0, 5, 3, 0, 10))
def test_parse_version_invalid(self):
"""Test parsing invalid versions raises error."""
with self.assertRaises(ValueError):
parse_version("0.5")
with self.assertRaises(ValueError):
parse_version("invalid")
with self.assertRaises(ValueError):
parse_version("v0.5.3")
def test_compare_versions_equal(self):
"""Test comparing equal versions."""
self.assertEqual(compare_versions("0.5.3", "0.5.3"), 0)
self.assertEqual(compare_versions("0.5.3rc0", "0.5.3rc0"), 0)
self.assertEqual(compare_versions("0.5.3.post1", "0.5.3.post1"), 0)
def test_compare_versions_rc_ordering(self):
"""Test release candidate ordering: rc0 < rc1 < rc2 < stable."""
# rc0 < rc1
self.assertEqual(compare_versions("0.5.3rc0", "0.5.3rc1"), -1)
self.assertEqual(compare_versions("0.5.3rc1", "0.5.3rc0"), 1)
# rc1 < rc2
self.assertEqual(compare_versions("0.5.3rc1", "0.5.3rc2"), -1)
self.assertEqual(compare_versions("0.5.3rc2", "0.5.3rc1"), 1)
# rc < stable
self.assertEqual(compare_versions("0.5.3rc0", "0.5.3"), -1)
self.assertEqual(compare_versions("0.5.3rc1", "0.5.3"), -1)
self.assertEqual(compare_versions("0.5.3", "0.5.3rc0"), 1)
def test_compare_versions_post_ordering(self):
"""Test post-release ordering: stable < post1 < post2."""
# stable < post1
self.assertEqual(compare_versions("0.5.3", "0.5.3.post1"), -1)
self.assertEqual(compare_versions("0.5.3.post1", "0.5.3"), 1)
# post1 < post2
self.assertEqual(compare_versions("0.5.3.post1", "0.5.3.post2"), -1)
self.assertEqual(compare_versions("0.5.3.post2", "0.5.3.post1"), 1)
def test_compare_versions_full_ordering(self):
"""Test complete version ordering: rc < stable < post."""
# rc < stable < post
self.assertEqual(compare_versions("0.5.3rc0", "0.5.3"), -1)
self.assertEqual(compare_versions("0.5.3", "0.5.3.post1"), -1)
self.assertEqual(compare_versions("0.5.3rc0", "0.5.3.post1"), -1)
# Verify transitivity: rc0 < rc1 < stable < post1 < post2
versions = [
"0.5.3rc0",
"0.5.3rc1",
"0.5.3",
"0.5.3.post1",
"0.5.3.post2",
]
for i in range(len(versions) - 1):
self.assertEqual(
compare_versions(versions[i], versions[i + 1]),
-1,
f"{versions[i]} should be less than {versions[i + 1]}",
)
def test_compare_versions_different_patch(self):
"""Test comparing versions with different patch numbers."""
# 0.5.3 < 0.5.4
self.assertEqual(compare_versions("0.5.3", "0.5.4"), -1)
self.assertEqual(compare_versions("0.5.4", "0.5.3"), 1)
# rc of higher patch > stable of lower patch
self.assertEqual(compare_versions("0.5.4rc0", "0.5.3"), 1)
self.assertEqual(compare_versions("0.5.3.post1", "0.5.4rc0"), -1)
def test_compare_versions_different_minor(self):
"""Test comparing versions with different minor numbers."""
self.assertEqual(compare_versions("0.4.9", "0.5.0"), -1)
self.assertEqual(compare_versions("0.5.0", "0.4.9"), 1)
def test_compare_versions_different_major(self):
"""Test comparing versions with different major numbers."""
self.assertEqual(compare_versions("0.9.9", "1.0.0"), -1)
self.assertEqual(compare_versions("1.0.0", "0.9.9"), 1)
def test_real_world_scenarios(self):
"""Test real-world version bump scenarios."""
# Scenario 1: RC progression
self.assertEqual(compare_versions("0.5.3rc0", "0.5.3rc1"), -1)
# Scenario 2: RC to stable release
self.assertEqual(compare_versions("0.5.3rc2", "0.5.3"), -1)
# Scenario 3: Stable to post-release hotfix
self.assertEqual(compare_versions("0.5.3", "0.5.3.post1"), -1)
# Scenario 4: Post-release to next RC
self.assertEqual(compare_versions("0.5.3.post1", "0.5.4rc0"), -1)
# Scenario 5: Next stable version
self.assertEqual(compare_versions("0.5.3", "0.5.4"), -1)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,220 @@
import re
import sys
from pathlib import Path
from typing import List, Tuple
try:
import tomllib # Python 3.11+
except ImportError:
import tomli as tomllib # Fallback for older Python versions
def normalize_version(version: str) -> str:
"""Remove 'v' prefix from version string if present."""
return version.lstrip("v")
def validate_version(version: str) -> bool:
"""Validate version format: X.Y.Z, X.Y.Zrc0, or X.Y.Z.post1"""
pattern = r"^\d+\.\d+\.\d+(rc\d+|\.post\d+)?$"
return bool(re.match(pattern, version))
def parse_version(version: str) -> Tuple[int, int, int, int, int]:
"""
Parse version string into comparable components.
Returns: (major, minor, patch, pre_release, post_release)
- pre_release: -1000 + rc_number for rcN, 0 for stable (rc0 < rc1 < stable)
- post_release: N for .postN, 0 otherwise
The pre_release field uses negative numbers to ensure RC versions come before
stable versions when tuples are compared. Python compares tuples element by
element, so (0, 5, 3, -1000, 0) < (0, 5, 3, 0, 0) ensures rc0 < stable.
Examples:
- "0.5.3rc0" → (0, 5, 3, -1000, 0) # rc0 comes before stable
- "0.5.3rc1" → (0, 5, 3, -999, 0) # rc1 comes after rc0
- "0.5.3" → (0, 5, 3, 0, 0) # stable version
- "0.5.3.post1" → (0, 5, 3, 0, 1) # post comes after stable
"""
# Match version components
match = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:rc(\d+)|\.post(\d+))?$", version)
if not match:
raise ValueError(f"Invalid version format: {version}")
major, minor, patch, rc, post = match.groups()
major, minor, patch = int(major), int(minor), int(patch)
if rc is not None:
# RC version: pre_release = -1000 + rc_number (ensures rc0 < rc1 < ... < stable)
return (major, minor, patch, -1000 + int(rc), 0)
elif post is not None:
# Post version: post_release = N
return (major, minor, patch, 0, int(post))
else:
# Stable version
return (major, minor, patch, 0, 0)
def compare_versions(v1: str, v2: str) -> int:
"""
Compare two version strings following PEP 440 ordering.
Returns:
- -1 if v1 < v2
- 0 if v1 == v2
- 1 if v1 > v2
Version ordering: X.Y.ZrcN < X.Y.Z < X.Y.Z.postN < X.Y.(Z+1)
"""
parsed_v1 = parse_version(v1)
parsed_v2 = parse_version(v2)
if parsed_v1 < parsed_v2:
return -1
elif parsed_v1 > parsed_v2:
return 1
else:
return 0
def get_repo_root() -> Path:
return Path(__file__).parent.parent.parent
def read_current_version(version_file: Path) -> str:
content = version_file.read_text()
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
if not match:
raise ValueError(f"Could not find version in {version_file}")
return match.group(1)
def replace_in_file(file_path: Path, old_version: str, new_version: str) -> bool:
if not file_path.exists():
print(f"Warning: {file_path} does not exist, skipping")
return False
content = file_path.read_text()
# For TOML files, parse and update only the [project] version field
if file_path.suffix == ".toml":
try:
# Parse TOML to verify structure
toml_data = tomllib.loads(content)
# Check if [project] section exists and has version field
if "project" not in toml_data or "version" not in toml_data["project"]:
print(
f"Warning: {file_path} does not have [project] version field, skipping"
)
return False
# Use regex to replace only the version field in [project] section
# This pattern matches the version field that comes after [project]
# and before any other section marker
pattern = r'(\[project\].*?version\s*=\s*)["\']([^"\']+)["\']'
new_content = re.sub(
pattern, rf'\g<1>"{new_version}"', content, flags=re.DOTALL
)
except Exception as e:
print(f"Warning: Failed to parse {file_path} as TOML: {e}")
print("Falling back to simple string replacement")
new_content = content.replace(old_version, new_version)
else:
# For non-TOML files, use simple string replacement
new_content = content.replace(old_version, new_version)
if content == new_content:
print(f"No changes needed in {file_path}")
return False
file_path.write_text(new_content)
print(f"✓ Updated {file_path}")
return True
def bump_version(
new_version: str,
version_file: Path,
files_to_update: List[Path],
) -> None:
# Normalize version (remove 'v' prefix if present)
new_version = normalize_version(new_version)
if not validate_version(new_version):
print(f"Error: Invalid version format: {new_version}")
print("Expected format: X.Y.Z, X.Y.ZrcN, or X.Y.Z.postN")
print("Examples: 0.5.4, 0.5.3rc0, 0.5.3.post1")
sys.exit(1)
repo_root = get_repo_root()
version_file_abs = repo_root / version_file
if not version_file_abs.exists():
print(f"Error: Version file {version_file_abs} does not exist")
sys.exit(1)
old_version = read_current_version(version_file_abs)
print(f"Current version: {old_version}")
print(f"New version: {new_version}")
print()
# Compare versions
comparison = compare_versions(new_version, old_version)
if comparison == 0:
print("Error: New version is the same as current version")
sys.exit(1)
elif comparison < 0:
print(
f"Error: New version ({new_version}) is older than current version ({old_version})"
)
print("Version must be greater than the current version")
sys.exit(1)
updated_count = 0
for file_rel in files_to_update:
file_abs = repo_root / file_rel
if replace_in_file(file_abs, old_version, new_version):
updated_count += 1
print()
print(f"Successfully updated {updated_count} file(s)")
print(f"Version bumped from {old_version} to {new_version}")
# Validate that all files now contain the new version
print("\nValidating version updates...")
failed_files = []
for file_rel in files_to_update:
file_abs = repo_root / file_rel
if not file_abs.exists():
print(f"Warning: File {file_rel} does not exist, skipping validation.")
continue
content = file_abs.read_text()
# For TOML files, use regex to specifically check the version field
if file_abs.suffix == ".toml":
# Match version field with optional quotes
pattern = r'version\s*=\s*["\']?' + re.escape(new_version) + r'["\']?'
if not re.search(pattern, content):
failed_files.append(file_rel)
print(f"{file_rel} does not contain version {new_version}")
else:
print(f"{file_rel} validated")
else:
# For non-TOML files, use simple string search
if new_version not in content:
failed_files.append(file_rel)
print(f"{file_rel} does not contain version {new_version}")
else:
print(f"{file_rel} validated")
if failed_files:
print(f"\nError: {len(failed_files)} file(s) were not updated correctly:")
for file_rel in failed_files:
print(f" - {file_rel}")
sys.exit(1)
print("\nAll files validated successfully!")