Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
singlestore-labs
GitHub Repository: singlestore-labs/singlestoredb-python
Path: blob/main/resources/bump_version.py
469 views
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', '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 / '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()
307
308