Skip to content

Commit 01134c6

Browse files
committed
Add version bump utility
1 parent 25f0396 commit 01134c6

File tree

1 file changed

+306
-0
lines changed

1 file changed

+306
-0
lines changed

resources/bump_version.py

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Command for bumping version and generating documentation.
4+
5+
Usage: python bump_version.py [major|minor|patch] [--summary "Release notes"]
6+
7+
Examples:
8+
python bump_version.py patch
9+
python bump_version.py minor --summary "* Added new feature X\n* Fixed bug Y"
10+
python bump_version.py major --summary "Breaking changes:\n* Removed deprecated API"
11+
12+
Note: Release notes should be in reStructuredText format.
13+
14+
"""
15+
from __future__ import annotations
16+
17+
import argparse
18+
import datetime
19+
import os
20+
import re
21+
import subprocess
22+
import sys
23+
from pathlib import Path
24+
25+
26+
def get_current_version() -> str:
27+
"""Get the current version from setup.cfg."""
28+
setup_cfg_path = Path(__file__).parent.parent / 'setup.cfg'
29+
with open(setup_cfg_path, 'r') as f:
30+
content = f.read()
31+
32+
match = re.search(r'^version\s*=\s*(.+)$', content, re.MULTILINE)
33+
if not match:
34+
raise ValueError('Could not find version in setup.cfg')
35+
36+
return match.group(1).strip()
37+
38+
39+
def bump_version(current_version: str, bump_type: str) -> str:
40+
"""Bump the version number based on the bump type."""
41+
parts = current_version.split('.')
42+
major = int(parts[0])
43+
minor = int(parts[1])
44+
patch = int(parts[2])
45+
46+
if bump_type == 'major':
47+
major += 1
48+
minor = 0
49+
patch = 0
50+
elif bump_type == 'minor':
51+
minor += 1
52+
patch = 0
53+
elif bump_type == 'patch':
54+
patch += 1
55+
else:
56+
raise ValueError(f'Invalid bump type: {bump_type}')
57+
58+
return f'{major}.{minor}.{patch}'
59+
60+
61+
def update_version_in_file(file_path: Path, old_version: str, new_version: str) -> None:
62+
"""Update version in a file."""
63+
with open(file_path, 'r') as f:
64+
content = f.read()
65+
66+
# For setup.cfg
67+
if file_path.name == 'setup.cfg':
68+
pattern = r'^(version\s*=\s*)' + re.escape(old_version) + r'$'
69+
replacement = r'\g<1>' + new_version
70+
content = re.sub(pattern, replacement, content, flags=re.MULTILINE)
71+
72+
# For __init__.py
73+
elif file_path.name == '__init__.py':
74+
pattern = r"^(__version__\s*=\s*['\"])" + re.escape(old_version) + r"(['\"])$"
75+
replacement = r'\g<1>' + new_version + r'\g<2>'
76+
content = re.sub(pattern, replacement, content, flags=re.MULTILINE)
77+
78+
with open(file_path, 'w') as f:
79+
f.write(content)
80+
81+
82+
def get_git_log_since_last_release(current_version: str) -> str:
83+
"""Get git commits since the last release."""
84+
# Get the tag for the current version (which should be the last release)
85+
try:
86+
# Try to find the last tag that matches a version pattern
87+
result = subprocess.run(
88+
['git', 'tag', '-l', '--sort=-version:refname', 'v*'],
89+
capture_output=True,
90+
text=True,
91+
check=True,
92+
)
93+
tags = result.stdout.strip().split('\n')
94+
95+
# Find the tag matching the current version or the most recent tag
96+
current_tag = f'v{current_version}'
97+
if current_tag in tags:
98+
last_tag = current_tag
99+
elif tags:
100+
last_tag = tags[0]
101+
else:
102+
# If no tags found, get all commits
103+
last_tag = None
104+
except subprocess.CalledProcessError:
105+
last_tag = None
106+
107+
# Get commits since last tag
108+
if last_tag:
109+
cmd = ['git', 'log', f'{last_tag}..HEAD', '--oneline', '--no-merges']
110+
else:
111+
# Get last 20 commits if no tag found
112+
cmd = ['git', 'log', '--oneline', '--no-merges', '-20']
113+
114+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
115+
return result.stdout.strip()
116+
117+
118+
def summarize_changes(git_log: str) -> str:
119+
"""Summarize the git log into categories."""
120+
lines = git_log.split('\n')
121+
if not lines or not lines[0]:
122+
return 'No changes since last release.'
123+
124+
features = []
125+
fixes = []
126+
other = []
127+
128+
for line in lines:
129+
if not line:
130+
continue
131+
132+
# Remove commit hash
133+
parts = line.split(' ', 1)
134+
if len(parts) > 1:
135+
message = parts[1]
136+
else:
137+
continue
138+
139+
# Categorize based on commit message
140+
lower_msg = message.lower()
141+
if any(word in lower_msg for word in ['add', 'feat', 'feature', 'implement', 'new']):
142+
features.append(message)
143+
elif any(word in lower_msg for word in ['fix', 'bug', 'patch', 'correct', 'resolve']):
144+
fixes.append(message)
145+
else:
146+
other.append(message)
147+
148+
summary = []
149+
150+
if features:
151+
summary.append('**New Features:**')
152+
for feat in features[:5]: # Limit to 5 most recent
153+
summary.append(f'* {feat}')
154+
if len(features) > 5:
155+
summary.append(f'* ...and {len(features) - 5} more features')
156+
summary.append('')
157+
158+
if fixes:
159+
summary.append('**Bug Fixes:**')
160+
for fix in fixes[:5]: # Limit to 5 most recent
161+
summary.append(f'* {fix}')
162+
if len(fixes) > 5:
163+
summary.append(f'* ...and {len(fixes) - 5} more fixes')
164+
summary.append('')
165+
166+
if other:
167+
summary.append('**Other Changes:**')
168+
for change in other[:3]: # Limit to 3 most recent
169+
summary.append(f'* {change}')
170+
if len(other) > 3:
171+
summary.append(f'* ...and {len(other) - 3} more changes')
172+
173+
return '\n'.join(summary) if summary else '* Various improvements and updates'
174+
175+
176+
def update_whatsnew(new_version: str, summary: str) -> None:
177+
"""Update the whatsnew.rst file with the new release."""
178+
whatsnew_path = Path(__file__).parent.parent / 'docs' / 'src' / 'whatsnew.rst'
179+
180+
with open(whatsnew_path, 'r') as f:
181+
content = f.read()
182+
183+
# Find the position after the note section
184+
note_end = content.find('\n\nv')
185+
if note_end == -1:
186+
# If no versions found, add after the document description
187+
note_end = content.find('changes to the API.\n') + len('changes to the API.\n')
188+
189+
# Create new release section
190+
today = datetime.date.today()
191+
date_str = today.strftime('%B %d, %Y').replace(' 0', ' ') # Remove leading zero
192+
193+
new_section = f'\n\nv{new_version} - {date_str}\n'
194+
new_section += '-' * (len(new_section) - 3) + '\n'
195+
new_section += summary.strip()
196+
197+
# Insert the new section
198+
content = content[:note_end] + new_section + content[note_end:]
199+
200+
with open(whatsnew_path, 'w') as f:
201+
f.write(content)
202+
203+
204+
def build_docs() -> None:
205+
"""Build the documentation."""
206+
docs_src_path = Path(__file__).parent.parent / 'docs' / 'src'
207+
208+
# Change to docs/src directory
209+
original_dir = os.getcwd()
210+
os.chdir(docs_src_path)
211+
212+
try:
213+
# Run make html
214+
result = subprocess.run(['make', 'html'], capture_output=True, text=True)
215+
if result.returncode != 0:
216+
print('Error building documentation:')
217+
print(result.stderr)
218+
sys.exit(1)
219+
print('Documentation built successfully')
220+
finally:
221+
# Change back to original directory
222+
os.chdir(original_dir)
223+
224+
225+
def stage_files() -> None:
226+
"""Stage all modified files for commit."""
227+
# Stage version files
228+
subprocess.run(['git', 'add', 'setup.cfg'], check=True)
229+
subprocess.run(['git', 'add', 'sqlalchemy_singlestoredb/__init__.py'], check=True)
230+
subprocess.run(['git', 'add', 'docs/src/whatsnew.rst'], check=True)
231+
232+
# Stage any generated documentation files
233+
subprocess.run(['git', 'add', 'docs/'], check=True)
234+
235+
print('\nAll modified files have been staged for commit.')
236+
print("You can now commit with: git commit -m 'Bump version to X.Y.Z'")
237+
238+
239+
def main() -> None:
240+
parser = argparse.ArgumentParser(
241+
description='Bump version and generate documentation',
242+
formatter_class=argparse.RawDescriptionHelpFormatter,
243+
epilog='''Examples:
244+
%(prog)s patch
245+
%(prog)s minor --summary "* Added new feature X\\n* Fixed bug Y"
246+
%(prog)s major --summary "Breaking changes:\\n* Removed deprecated API"''',
247+
)
248+
parser.add_argument(
249+
'bump_type',
250+
choices=['major', 'minor', 'patch'],
251+
help='Type of version bump',
252+
)
253+
parser.add_argument(
254+
'--summary',
255+
default=None,
256+
help='Optional summary for the release notes (supports reStructuredText and \\n for newlines)',
257+
)
258+
259+
args = parser.parse_args()
260+
261+
# Get current version
262+
current_version = get_current_version()
263+
print(f'Current version: {current_version}')
264+
265+
# Calculate new version
266+
new_version = bump_version(current_version, args.bump_type)
267+
print(f'New version: {new_version}')
268+
269+
# Update version in files
270+
print('\nUpdating version in files...')
271+
update_version_in_file(Path(__file__).parent.parent / 'setup.cfg', current_version, new_version)
272+
update_version_in_file(
273+
Path(__file__).parent.parent / 'sqlalchemy_singlestoredb' / '__init__.py',
274+
current_version,
275+
new_version,
276+
)
277+
278+
# Get summary - either from argument or from git history
279+
if args.summary:
280+
print('\nUsing provided summary...')
281+
# Replace literal \n with actual newlines
282+
summary = args.summary.replace('\\n', '\n')
283+
else:
284+
print('\nAnalyzing git history...')
285+
git_log = get_git_log_since_last_release(current_version)
286+
summary = summarize_changes(git_log)
287+
288+
# Update whatsnew.rst
289+
print('\nUpdating whatsnew.rst...')
290+
update_whatsnew(new_version, summary)
291+
292+
# Build documentation
293+
print('\nBuilding documentation...')
294+
build_docs()
295+
296+
# Stage files
297+
print('\nStaging files for commit...')
298+
stage_files()
299+
300+
print(f'\n✅ Version bumped from {current_version} to {new_version}')
301+
print('✅ Documentation updated and built')
302+
print('✅ Files staged for commit')
303+
304+
305+
if __name__ == '__main__':
306+
main()

0 commit comments

Comments
 (0)