Path: blob/main/resources/bump_version.py
798 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 sys22import tempfile23import time24import webbrowser25from pathlib import Path262728def status(message: str) -> None:29"""Show status messages to indicate progress."""30print(f'š {message}', file=sys.stderr)313233def step(step_num: int, total_steps: int, message: str) -> None:34"""Show a numbered step with progress."""35print(f'š Step {step_num}/{total_steps}: {message}', file=sys.stderr)363738def get_current_version() -> str:39"""Get the current version from pyproject.toml."""40pyproject_path = Path(__file__).parent.parent / 'pyproject.toml'41with open(pyproject_path, 'r') as f:42content = f.read()4344match = re.search(r'^version\s*=\s*["\'](.+)["\']$', content, re.MULTILINE)45if not match:46raise ValueError('Could not find version in pyproject.toml')4748return match.group(1).strip()495051def bump_version(current_version: str, bump_type: str) -> str:52"""Bump the version number based on the bump type."""53parts = current_version.split('.')54major = int(parts[0])55minor = int(parts[1])56patch = int(parts[2])5758if bump_type == 'major':59major += 160minor = 061patch = 062elif bump_type == 'minor':63minor += 164patch = 065elif bump_type == 'patch':66patch += 167else:68raise ValueError(f'Invalid bump type: {bump_type}')6970return f'{major}.{minor}.{patch}'717273def update_version_in_file(file_path: Path, old_version: str, new_version: str) -> None:74"""Update version in a file."""75with open(file_path, 'r') as f:76content = f.read()7778# For pyproject.toml79if file_path.name == 'pyproject.toml':80pattern = r'^(version\s*=\s*["\'])' + re.escape(old_version) + r'(["\'])$'81replacement = r'\g<1>' + new_version + r'\g<2>'82content = re.sub(pattern, replacement, content, flags=re.MULTILINE)8384# For __init__.py85elif file_path.name == '__init__.py':86pattern = r"^(__version__\s*=\s*['\"])" + re.escape(old_version) + r"(['\"])$"87replacement = r'\g<1>' + new_version + r'\g<2>'88content = re.sub(pattern, replacement, content, flags=re.MULTILINE)8990with open(file_path, 'w') as f:91f.write(content)929394def get_git_log_since_last_release(current_version: str) -> str:95"""Get git commits since the last release."""96# Get the tag for the current version (which should be the last release)97try:98# Try to find the last tag that matches a version pattern99result = subprocess.run(100['git', 'tag', '-l', '--sort=-version:refname', 'v*'],101capture_output=True,102text=True,103check=True,104)105tags = result.stdout.strip().split('\n')106107# Find the tag matching the current version or the most recent tag108current_tag = f'v{current_version}'109if current_tag in tags:110last_tag = current_tag111elif tags:112last_tag = tags[0]113else:114# If no tags found, get all commits115last_tag = None116except subprocess.CalledProcessError:117last_tag = None118119# Get commits since last tag120if last_tag:121cmd = ['git', 'log', f'{last_tag}..HEAD', '--oneline', '--no-merges']122else:123# Get last 20 commits if no tag found124cmd = ['git', 'log', '--oneline', '--no-merges', '-20']125126result = subprocess.run(cmd, capture_output=True, text=True, check=True)127return result.stdout.strip()128129130def summarize_changes(git_log: str) -> str:131"""Summarize the git log into categories."""132lines = git_log.split('\n')133if not lines or not lines[0]:134return 'No changes since last release.'135136features = []137fixes = []138other = []139140for line in lines:141if not line:142continue143144# Remove commit hash145parts = line.split(' ', 1)146if len(parts) > 1:147message = parts[1]148else:149continue150151# Categorize based on commit message152lower_msg = message.lower()153if any(word in lower_msg for word in ['add', 'feat', 'feature', 'implement', 'new']):154features.append(message)155elif any(word in lower_msg for word in ['fix', 'bug', 'patch', 'correct', 'resolve']):156fixes.append(message)157else:158other.append(message)159160summary = []161162if features:163# summary.append('**New Features:**')164for feat in features[:5]: # Limit to 5 most recent165summary.append(f'* {feat}')166if len(features) > 5:167summary.append(f'* ...and {len(features) - 5} more features')168# summary.append('')169170if fixes:171# summary.append('**Bug Fixes:**')172for fix in fixes[:5]: # Limit to 5 most recent173summary.append(f'* {fix}')174if len(fixes) > 5:175summary.append(f'* ...and {len(fixes) - 5} more fixes')176# summary.append('')177178if other:179# summary.append('**Other Changes:**')180for change in other[:3]: # Limit to 3 most recent181summary.append(f'* {change}')182if len(other) > 3:183summary.append(f'* ...and {len(other) - 3} more changes')184185return '\n'.join(summary) if summary else '* Various improvements and updates'186187188def edit_content(content: str, description: str = 'content') -> str | None:189"""Open the default editor to edit content and return the edited result.190191Args:192content: The initial content to edit193description: Description of what's being edited (for messages)194195Returns:196The edited content, or None if the user cancelled (empty content)197"""198# Get the editor from environment variables199editor = os.environ.get('EDITOR') or os.environ.get('VISUAL') or 'vi'200201# Create a temporary file with the content202with tempfile.NamedTemporaryFile(mode='w+', suffix='.txt', delete=False) as tmp_file:203tmp_file.write(content)204tmp_file.flush()205tmp_path = tmp_file.name206207try:208print(f'\nOpening editor to edit {description}...')209print(f'Editor: {editor}')210print('Save and exit to continue, or clear all content to cancel.')211212# Open the editor213result = subprocess.run([editor, tmp_path])214215if result.returncode != 0:216print(f'Editor exited with non-zero status: {result.returncode}')217return None218219# Read the edited content220with open(tmp_path, 'r') as f:221edited_content = f.read().strip()222223if not edited_content:224print('Content is empty - cancelling operation.')225return None226227return edited_content228229finally:230# Clean up the temporary file231try:232os.unlink(tmp_path)233except OSError:234pass235236237def prompt_yes_no(question: str, default: bool = True) -> bool:238"""Prompt user for yes/no input with a default value.239240Args:241question: The question to ask the user242default: Default value (True for yes, False for no)243244Returns:245True for yes, False for no246"""247prompt_suffix = '[Y/n]' if default else '[y/N]'248prompt = f'{question} {prompt_suffix}: '249250while True:251response = input(prompt).strip().lower()252253if not response:254return default255256if response in ('y', 'yes'):257return True258elif response in ('n', 'no'):259return False260else:261print('Please answer y or n')262263264def execute_commit_and_push(new_version: str) -> bool:265"""Execute git commit and push commands.266267Args:268new_version: The new version being released269270Returns:271True if successful, False otherwise272"""273commit_msg = f'Prepare for v{new_version} release'274275try:276# Execute git commit277status(f'š Committing changes: {commit_msg}')278result = subprocess.run(279['git', 'commit', '-m', commit_msg],280capture_output=True,281text=True,282)283284if result.returncode != 0:285print(f'ā Error committing changes: {result.stderr}', file=sys.stderr)286return False287288status('ā Changes committed successfully')289290# Execute git push291status('š Pushing to remote repository...')292result = subprocess.run(293['git', 'push'],294capture_output=True,295text=True,296)297298if result.returncode != 0:299print(f'ā Error pushing changes: {result.stderr}', file=sys.stderr)300return False301302status('ā Changes pushed successfully')303return True304305except Exception as e:306print(f'ā Unexpected error: {e}', file=sys.stderr)307return False308309310def open_actions_page() -> None:311"""Open the GitHub Actions page in the default web browser."""312actions_url = 'https://github.com/singlestore-labs/singlestoredb-python/actions'313status(f'š Opening GitHub Actions page: {actions_url}')314315try:316webbrowser.open(actions_url)317status('ā Browser opened successfully')318except Exception as e:319print(f'ā ļø Could not open browser: {e}', file=sys.stderr)320print(f' Please visit: {actions_url}', file=sys.stderr)321322323def prepare_whatsnew_content(new_version: str, summary: str) -> str:324"""Prepare the content for the new release section."""325today = datetime.date.today()326date_str = today.strftime('%B %d, %Y').replace(' 0', ' ') # Remove leading zero327328new_section = f'v{new_version} - {date_str}\n'329new_section += '-' * len(new_section.strip()) + '\n'330new_section += summary.strip()331332return new_section333334335def update_whatsnew_with_editor(new_version: str, summary: str) -> bool:336"""Update the whatsnew.rst file with the new release, allowing user to edit content.337338Returns:339True if successful, False if cancelled by user340"""341whatsnew_path = Path(__file__).parent.parent / 'docs' / 'src' / 'whatsnew.rst'342343# Prepare the initial content for the new release344new_release_content = prepare_whatsnew_content(new_version, summary)345346# Let the user edit the content347edited_content = edit_content(new_release_content, 'release notes')348if edited_content is None:349return False350351# Read the current whatsnew.rst file352with open(whatsnew_path, 'r') as f:353content = f.read()354355# Find the position after the note section356note_end = content.find('\n\nv')357if note_end == -1:358# If no versions found, add after the document description359note_end = content.find('changes to the API.\n') + len('changes to the API.\n')360361# Insert the new section362content = content[:note_end] + '\n\n' + edited_content + content[note_end:]363364with open(whatsnew_path, 'w') as f:365f.write(content)366367return True368369370def build_docs() -> None:371"""Build the documentation using the unified build script."""372build_script = Path(__file__).parent / 'build_docs.py'373374if build_script.exists():375# Use the new unified build script376status('š Building documentation with unified script...')377result = subprocess.run([sys.executable, str(build_script), 'html'])378else:379# Fallback to make html if build script doesn't exist380docs_src_path = Path(__file__).parent.parent / 'docs' / 'src'381status('š Building documentation with make...')382result = subprocess.run(['make', 'html'], cwd=docs_src_path)383384if result.returncode != 0:385print('ā Error building documentation', file=sys.stderr)386sys.exit(1)387388status('ā Documentation built successfully')389390391def stage_files() -> None:392"""Stage all modified files for commit."""393status('š¦ Staging files for commit...')394395files_to_stage = [396'pyproject.toml',397'singlestoredb/__init__.py',398'docs/src/whatsnew.rst',399'docs/', # All generated documentation files400]401402for file_path in files_to_stage:403subprocess.run(['git', 'add', file_path], check=True)404status(f' ā Staged {file_path}')405406status('ā All modified files staged for commit')407408409def main() -> None:410parser = argparse.ArgumentParser(411description='Bump version and generate documentation',412formatter_class=argparse.RawDescriptionHelpFormatter,413epilog='''Examples:414%(prog)s patch415%(prog)s minor --summary "* Added new feature X\\n* Fixed bug Y"416%(prog)s major --summary "Breaking changes:\\n* Removed deprecated API"''',417)418parser.add_argument(419'bump_type',420choices=['major', 'minor', 'patch'],421help='Type of version bump',422)423parser.add_argument(424'--summary',425default=None,426help='Optional summary for the release notes (supports reStructuredText and \\n for newlines)',427)428429args = parser.parse_args()430431total_start_time = time.time()432433print('š Starting version bump process', file=sys.stderr)434print('=' * 50, file=sys.stderr)435436# Step 1: Get current version437step(1, 6, 'Reading current version')438current_version = get_current_version()439status(f'Current version: {current_version}')440441# Calculate new version442new_version = bump_version(current_version, args.bump_type)443status(f'New version will be: {new_version}')444445# Step 2: Update version in files446step(2, 6, 'Updating version in files')447start_time = time.time()448449update_version_in_file(Path(__file__).parent.parent / 'pyproject.toml', current_version, new_version)450status(' ā Updated pyproject.toml')451452update_version_in_file(453Path(__file__).parent.parent / 'singlestoredb' / '__init__.py',454current_version,455new_version,456)457status(' ā Updated singlestoredb/__init__.py')458459elapsed = time.time() - start_time460status(f'ā Version files updated in {elapsed:.1f}s')461462# Step 3: Generate release summary463step(3, 6, 'Generating release summary')464start_time = time.time()465466if args.summary:467status('Using provided summary')468# Replace literal \n with actual newlines469summary = args.summary.replace('\\n', '\n')470else:471status('š Analyzing git history...')472git_log = get_git_log_since_last_release(current_version)473summary = summarize_changes(git_log)474475elapsed = time.time() - start_time476status(f'ā Release summary generated in {elapsed:.1f}s')477478# Step 4: Update whatsnew.rst with editor479step(4, 6, 'Updating release notes')480start_time = time.time()481482status('š Opening editor for release notes...')483if not update_whatsnew_with_editor(new_version, summary):484print('\nā Operation cancelled by user', file=sys.stderr)485status('š Reverting version changes...')486487# Revert version changes488update_version_in_file(Path(__file__).parent.parent / 'pyproject.toml', new_version, current_version)489update_version_in_file(490Path(__file__).parent.parent / 'singlestoredb' / '__init__.py',491new_version,492current_version,493)494495status('ā Version changes reverted')496sys.exit(1)497498elapsed = time.time() - start_time499status(f'ā Release notes updated in {elapsed:.1f}s')500501# Step 5: Build documentation502step(5, 6, 'Building documentation')503build_docs()504505# Step 6: Stage files506step(6, 6, 'Staging files for commit')507stage_files()508509total_elapsed = time.time() - total_start_time510print('=' * 50, file=sys.stderr)511print(f'š Version bump completed successfully in {total_elapsed:.1f}s!', file=sys.stderr)512print(f'š Version: {current_version} ā {new_version}', file=sys.stderr)513print('', file=sys.stderr)514515# Prompt user to commit and push516if prompt_yes_no('Do you want to commit and push now?', default=True):517print('', file=sys.stderr)518if execute_commit_and_push(new_version):519print('', file=sys.stderr)520open_actions_page()521print('', file=sys.stderr)522print('ā Code checks and smoke tests triggered automatically', file=sys.stderr)523print('', file=sys.stderr)524print('š Next step:', file=sys.stderr)525print(' š Run resources/create_release.py once tests complete', file=sys.stderr)526else:527print('', file=sys.stderr)528print('ā ļø Commit/push failed. Please manually run:', file=sys.stderr)529print(' š git commit -m "Prepare for v{} release" && git push'.format(new_version), file=sys.stderr)530else:531print('', file=sys.stderr)532print('š Next steps:', file=sys.stderr)533print(' š git commit -m "Prepare for v{} release" && git push'.format(new_version), file=sys.stderr)534print(' ā Code checks and smoke tests will trigger automatically', file=sys.stderr)535print(' š Run resources/create_release.py once tests complete', file=sys.stderr)536537538if __name__ == '__main__':539main()540541542