Path: blob/main/resources/bump_version.py
469 views
#!/usr/bin/env python31"""2Command for bumping version and generating documentation.34Usage: python bump_version.py [major|minor|patch] [--summary "Release notes"]56Examples:7python bump_version.py patch8python bump_version.py minor --summary "* Added new feature X\n* Fixed bug Y"9python bump_version.py major --summary "Breaking changes:\n* Removed deprecated API"1011Note: Release notes should be in reStructuredText format.1213"""14from __future__ import annotations1516import argparse17import datetime18import os19import re20import subprocess21import sys22from pathlib import Path232425def get_current_version() -> str:26"""Get the current version from setup.cfg."""27setup_cfg_path = Path(__file__).parent.parent / 'setup.cfg'28with open(setup_cfg_path, 'r') as f:29content = f.read()3031match = re.search(r'^version\s*=\s*(.+)$', content, re.MULTILINE)32if not match:33raise ValueError('Could not find version in setup.cfg')3435return match.group(1).strip()363738def bump_version(current_version: str, bump_type: str) -> str:39"""Bump the version number based on the bump type."""40parts = current_version.split('.')41major = int(parts[0])42minor = int(parts[1])43patch = int(parts[2])4445if bump_type == 'major':46major += 147minor = 048patch = 049elif bump_type == 'minor':50minor += 151patch = 052elif bump_type == 'patch':53patch += 154else:55raise ValueError(f'Invalid bump type: {bump_type}')5657return f'{major}.{minor}.{patch}'585960def update_version_in_file(file_path: Path, old_version: str, new_version: str) -> None:61"""Update version in a file."""62with open(file_path, 'r') as f:63content = f.read()6465# For setup.cfg66if file_path.name == 'setup.cfg':67pattern = r'^(version\s*=\s*)' + re.escape(old_version) + r'$'68replacement = r'\g<1>' + new_version69content = re.sub(pattern, replacement, content, flags=re.MULTILINE)7071# For __init__.py72elif file_path.name == '__init__.py':73pattern = r"^(__version__\s*=\s*['\"])" + re.escape(old_version) + r"(['\"])$"74replacement = r'\g<1>' + new_version + r'\g<2>'75content = re.sub(pattern, replacement, content, flags=re.MULTILINE)7677with open(file_path, 'w') as f:78f.write(content)798081def get_git_log_since_last_release(current_version: str) -> str:82"""Get git commits since the last release."""83# Get the tag for the current version (which should be the last release)84try:85# Try to find the last tag that matches a version pattern86result = subprocess.run(87['git', 'tag', '-l', '--sort=-version:refname', 'v*'],88capture_output=True,89text=True,90check=True,91)92tags = result.stdout.strip().split('\n')9394# Find the tag matching the current version or the most recent tag95current_tag = f'v{current_version}'96if current_tag in tags:97last_tag = current_tag98elif tags:99last_tag = tags[0]100else:101# If no tags found, get all commits102last_tag = None103except subprocess.CalledProcessError:104last_tag = None105106# Get commits since last tag107if last_tag:108cmd = ['git', 'log', f'{last_tag}..HEAD', '--oneline', '--no-merges']109else:110# Get last 20 commits if no tag found111cmd = ['git', 'log', '--oneline', '--no-merges', '-20']112113result = subprocess.run(cmd, capture_output=True, text=True, check=True)114return result.stdout.strip()115116117def summarize_changes(git_log: str) -> str:118"""Summarize the git log into categories."""119lines = git_log.split('\n')120if not lines or not lines[0]:121return 'No changes since last release.'122123features = []124fixes = []125other = []126127for line in lines:128if not line:129continue130131# Remove commit hash132parts = line.split(' ', 1)133if len(parts) > 1:134message = parts[1]135else:136continue137138# Categorize based on commit message139lower_msg = message.lower()140if any(word in lower_msg for word in ['add', 'feat', 'feature', 'implement', 'new']):141features.append(message)142elif any(word in lower_msg for word in ['fix', 'bug', 'patch', 'correct', 'resolve']):143fixes.append(message)144else:145other.append(message)146147summary = []148149if features:150summary.append('**New Features:**')151for feat in features[:5]: # Limit to 5 most recent152summary.append(f'* {feat}')153if len(features) > 5:154summary.append(f'* ...and {len(features) - 5} more features')155summary.append('')156157if fixes:158summary.append('**Bug Fixes:**')159for fix in fixes[:5]: # Limit to 5 most recent160summary.append(f'* {fix}')161if len(fixes) > 5:162summary.append(f'* ...and {len(fixes) - 5} more fixes')163summary.append('')164165if other:166summary.append('**Other Changes:**')167for change in other[:3]: # Limit to 3 most recent168summary.append(f'* {change}')169if len(other) > 3:170summary.append(f'* ...and {len(other) - 3} more changes')171172return '\n'.join(summary) if summary else '* Various improvements and updates'173174175def update_whatsnew(new_version: str, summary: str) -> None:176"""Update the whatsnew.rst file with the new release."""177whatsnew_path = Path(__file__).parent.parent / 'docs' / 'src' / 'whatsnew.rst'178179with open(whatsnew_path, 'r') as f:180content = f.read()181182# Find the position after the note section183note_end = content.find('\n\nv')184if note_end == -1:185# If no versions found, add after the document description186note_end = content.find('changes to the API.\n') + len('changes to the API.\n')187188# Create new release section189today = datetime.date.today()190date_str = today.strftime('%B %d, %Y').replace(' 0', ' ') # Remove leading zero191192new_section = f'\n\nv{new_version} - {date_str}\n'193new_section += '-' * (len(new_section) - 3) + '\n'194new_section += summary.strip()195196# Insert the new section197content = content[:note_end] + new_section + content[note_end:]198199with open(whatsnew_path, 'w') as f:200f.write(content)201202203def build_docs() -> None:204"""Build the documentation."""205docs_src_path = Path(__file__).parent.parent / 'docs' / 'src'206207# Change to docs/src directory208original_dir = os.getcwd()209os.chdir(docs_src_path)210211try:212# Run make html213result = subprocess.run(['make', 'html'], capture_output=True, text=True)214if result.returncode != 0:215print('Error building documentation:')216print(result.stderr)217sys.exit(1)218print('Documentation built successfully')219finally:220# Change back to original directory221os.chdir(original_dir)222223224def stage_files() -> None:225"""Stage all modified files for commit."""226# Stage version files227subprocess.run(['git', 'add', 'setup.cfg'], check=True)228subprocess.run(['git', 'add', 'singlestoredb/__init__.py'], check=True)229subprocess.run(['git', 'add', 'docs/src/whatsnew.rst'], check=True)230231# Stage any generated documentation files232subprocess.run(['git', 'add', 'docs/'], check=True)233234print('\nAll modified files have been staged for commit.')235print("You can now commit with: git commit -m 'Bump version to X.Y.Z'")236237238def main() -> None:239parser = argparse.ArgumentParser(240description='Bump version and generate documentation',241formatter_class=argparse.RawDescriptionHelpFormatter,242epilog='''Examples:243%(prog)s patch244%(prog)s minor --summary "* Added new feature X\\n* Fixed bug Y"245%(prog)s major --summary "Breaking changes:\\n* Removed deprecated API"''',246)247parser.add_argument(248'bump_type',249choices=['major', 'minor', 'patch'],250help='Type of version bump',251)252parser.add_argument(253'--summary',254default=None,255help='Optional summary for the release notes (supports reStructuredText and \\n for newlines)',256)257258args = parser.parse_args()259260# Get current version261current_version = get_current_version()262print(f'Current version: {current_version}')263264# Calculate new version265new_version = bump_version(current_version, args.bump_type)266print(f'New version: {new_version}')267268# Update version in files269print('\nUpdating version in files...')270update_version_in_file(Path(__file__).parent.parent / 'setup.cfg', current_version, new_version)271update_version_in_file(272Path(__file__).parent.parent / 'singlestoredb' / '__init__.py',273current_version,274new_version,275)276277# Get summary - either from argument or from git history278if args.summary:279print('\nUsing provided summary...')280# Replace literal \n with actual newlines281summary = args.summary.replace('\\n', '\n')282else:283print('\nAnalyzing git history...')284git_log = get_git_log_since_last_release(current_version)285summary = summarize_changes(git_log)286287# Update whatsnew.rst288print('\nUpdating whatsnew.rst...')289update_whatsnew(new_version, summary)290291# Build documentation292print('\nBuilding documentation...')293build_docs()294295# Stage files296print('\nStaging files for commit...')297stage_files()298299print(f'\n✅ Version bumped from {current_version} to {new_version}')300print('✅ Documentation updated and built')301print('✅ Files staged for commit')302303304if __name__ == '__main__':305main()306307308