import argparse
import subprocess
import sys
import re
from pathlib import Path
from datetime import datetime
import json
class ReleasePreparation:
def __init__(self, root_dir: Path):
self.root_dir = root_dir
self.cargo_toml = root_dir / "Cargo.toml"
self.pyproject_toml = root_dir / "pyproject.toml"
self.changelog = root_dir / "CHANGELOG.md"
self.errors = []
def run_command(self, cmd: list, check=True, capture_output=True) -> subprocess.CompletedProcess:
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(
cmd,
check=check,
capture_output=capture_output,
text=True,
cwd=self.root_dir
)
return result
def get_current_version(self) -> str:
with open(self.cargo_toml, 'r') as f:
content = f.read()
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
if not match:
raise ValueError("Could not find version in Cargo.toml")
return match.group(1)
def bump_version(self, version: str, bump_type: str) -> str:
parts = version.split('.')
if len(parts) != 3:
raise ValueError(f"Invalid version format: {version}")
major, minor, patch = map(int, parts)
if bump_type == 'major':
return f"{major + 1}.0.0"
elif bump_type == 'minor':
return f"{major}.{minor + 1}.0"
elif bump_type == 'patch':
return f"{major}.{minor}.{patch + 1}"
else:
raise ValueError(f"Invalid bump type: {bump_type}")
def validate_code_quality(self) -> bool:
print("\nRunning code quality checks...")
checks = [
("Cargo format check", ["cargo", "fmt", "--all", "--", "--check"]),
("Cargo clippy", ["cargo", "clippy", "--all-features", "--all-targets", "--", "-D", "warnings"]),
("Cargo test", ["cargo", "test", "--all-features"]),
("Cargo doc", ["cargo", "doc", "--no-deps", "--all-features"]),
]
all_passed = True
for name, cmd in checks:
print(f"\n > {name}...")
try:
self.run_command(cmd)
print(f" [OK] {name} passed")
except subprocess.CalledProcessError as e:
print(f" [ERROR] {name} failed")
self.errors.append(f"{name} failed: {e}")
all_passed = False
return all_passed
def validate_python_package(self) -> bool:
print("\nValidating Python package...")
try:
self.run_command(["python", "-m", "pip", "show", "maturin"], check=False)
print(" > Building Python wheel...")
result = self.run_command(
["maturin", "build", "--release", "-o", "dist"],
cwd=self.root_dir / "market-data-source-python",
check=False
)
if result.returncode == 0:
print(" [OK] Python package build successful")
return True
else:
print(" [WARNING] Python package build failed (non-critical)")
return True
except Exception as e:
print(f" [WARNING] Could not validate Python package: {e}")
return True
def validate_version_consistency(self) -> bool:
print("\nChecking version consistency...")
sync_script = self.root_dir / "scripts" / "sync-version.py"
if sync_script.exists():
try:
self.run_command(["python", str(sync_script), "--check"])
print(" [OK] Versions are consistent")
return True
except subprocess.CalledProcessError:
print(" [ERROR] Version mismatch detected")
self.errors.append("Version mismatch between Cargo.toml and pyproject.toml")
return False
else:
print(" [WARNING] Version sync script not found, skipping check")
return True
def check_git_status(self) -> bool:
print("\nChecking git status...")
result = self.run_command(["git", "status", "--porcelain"], check=False)
if result.stdout.strip():
print(" [WARNING] Uncommitted changes detected:")
print(result.stdout)
return False
print(" [OK] Working directory is clean")
return True
def update_changelog(self, new_version: str, dry_run: bool = False) -> bool:
print(f"\nUpdating CHANGELOG.md for version {new_version}...")
if not self.changelog.exists():
print(" [WARNING] CHANGELOG.md not found")
return True
with open(self.changelog, 'r') as f:
content = f.read()
if f"## [{new_version}]" in content:
print(f" [INFO] Version {new_version} already in CHANGELOG.md")
return True
today = datetime.now().strftime("%Y-%m-%d")
new_section = f"\n## [{new_version}] - {today}\n\n### Added\n\n### Changed\n\n### Fixed\n\n"
lines = content.split('\n')
insert_index = 0
for i, line in enumerate(lines):
if line.startswith('## '):
insert_index = i
break
if not dry_run:
lines.insert(insert_index, new_section)
with open(self.changelog, 'w') as f:
f.write('\n'.join(lines))
print(f" [OK] Added section for version {new_version}")
else:
print(f" [DRY-RUN] Would add section for version {new_version}")
return True
def create_git_tag(self, version: str, dry_run: bool = False) -> bool:
tag = f"v{version}"
print(f"\nCreating git tag {tag}...")
result = self.run_command(["git", "tag", "-l", tag], check=False)
if result.stdout.strip():
print(f" [WARNING] Tag {tag} already exists")
return False
if not dry_run:
message = f"Release version {version}"
self.run_command(["git", "tag", "-a", tag, "-m", message])
print(f" [OK] Created tag {tag}")
else:
print(f" [DRY-RUN] Would create tag {tag}")
return True
def prepare_release(self, version: str = None, bump_type: str = None, dry_run: bool = False):
print("Starting release preparation...\n")
current_version = self.get_current_version()
print(f"Current version: {current_version}")
if version:
new_version = version
elif bump_type:
new_version = self.bump_version(current_version, bump_type)
else:
new_version = current_version
print(f"Preparing release for version: {new_version}\n")
if not self.validate_code_quality():
print("\n[ERROR] Code quality checks failed. Please fix issues before releasing.")
return False
if not self.validate_python_package():
print("\n[WARNING] Python package validation had issues (continuing)")
if new_version != current_version:
print(f"\nUpdating version from {current_version} to {new_version}...")
if not dry_run:
sync_script = self.root_dir / "scripts" / "sync-version.py"
if sync_script.exists():
self.run_command(["python", str(sync_script), "--set-version", new_version])
print(f" [OK] Updated version to {new_version}")
else:
print(" [ERROR] Version sync script not found")
return False
else:
print(f" [DRY-RUN] Would update version to {new_version}")
if not self.validate_version_consistency():
print("\n[ERROR] Version consistency check failed")
return False
self.update_changelog(new_version, dry_run)
if not dry_run and not self.check_git_status():
print("\n[WARNING] Uncommitted changes detected. Please commit changes before creating tag.")
print("Suggested commit message:")
print(f' git add -A && git commit -m "chore: prepare release v{new_version}"')
return False
self.create_git_tag(new_version, dry_run)
print("\n" + "="*50)
print("[OK] Release preparation complete!")
print(f"Version: {new_version}")
if dry_run:
print("\n[DRY-RUN] This was a dry run. No changes were made.")
else:
print("\nNext steps:")
print(f" 1. Review and update CHANGELOG.md with actual changes")
print(f" 2. Commit any remaining changes")
print(f" 3. Push the tag to trigger release workflow:")
print(f" git push origin v{new_version}")
print(f" 4. Monitor the GitHub Actions workflow")
return True
def main():
parser = argparse.ArgumentParser(
description='Prepare a new release for market-data-source'
)
parser.add_argument(
'--version',
type=str,
help='Set a specific version for the release'
)
parser.add_argument(
'--bump',
choices=['major', 'minor', 'patch'],
help='Bump the version (major, minor, or patch)'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Run in dry-run mode without making changes'
)
parser.add_argument(
'--skip-tests',
action='store_true',
help='Skip running tests (not recommended)'
)
parser.add_argument(
'--root',
type=Path,
default=Path(__file__).parent.parent,
help='Root directory of the project'
)
args = parser.parse_args()
if args.version and args.bump:
print("Error: Cannot specify both --version and --bump")
sys.exit(1)
prep = ReleasePreparation(args.root)
try:
success = prep.prepare_release(
version=args.version,
bump_type=args.bump,
dry_run=args.dry_run
)
if not success:
sys.exit(1)
except Exception as e:
print(f"\n[ERROR] Error during release preparation: {e}")
sys.exit(1)
if __name__ == '__main__':
main()