Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
singlestore-labs
GitHub Repository: singlestore-labs/singlestoredb-python
Path: blob/main/resources/bump_version.py
798 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
import tempfile
24
import time
25
import webbrowser
26
from pathlib import Path
27
28
29
def status(message: str) -> None:
30
"""Show status messages to indicate progress."""
31
print(f'šŸ“‹ {message}', file=sys.stderr)
32
33
34
def step(step_num: int, total_steps: int, message: str) -> None:
35
"""Show a numbered step with progress."""
36
print(f'šŸ“ Step {step_num}/{total_steps}: {message}', file=sys.stderr)
37
38
39
def get_current_version() -> str:
40
"""Get the current version from pyproject.toml."""
41
pyproject_path = Path(__file__).parent.parent / 'pyproject.toml'
42
with open(pyproject_path, 'r') as f:
43
content = f.read()
44
45
match = re.search(r'^version\s*=\s*["\'](.+)["\']$', content, re.MULTILINE)
46
if not match:
47
raise ValueError('Could not find version in pyproject.toml')
48
49
return match.group(1).strip()
50
51
52
def bump_version(current_version: str, bump_type: str) -> str:
53
"""Bump the version number based on the bump type."""
54
parts = current_version.split('.')
55
major = int(parts[0])
56
minor = int(parts[1])
57
patch = int(parts[2])
58
59
if bump_type == 'major':
60
major += 1
61
minor = 0
62
patch = 0
63
elif bump_type == 'minor':
64
minor += 1
65
patch = 0
66
elif bump_type == 'patch':
67
patch += 1
68
else:
69
raise ValueError(f'Invalid bump type: {bump_type}')
70
71
return f'{major}.{minor}.{patch}'
72
73
74
def update_version_in_file(file_path: Path, old_version: str, new_version: str) -> None:
75
"""Update version in a file."""
76
with open(file_path, 'r') as f:
77
content = f.read()
78
79
# For pyproject.toml
80
if file_path.name == 'pyproject.toml':
81
pattern = r'^(version\s*=\s*["\'])' + re.escape(old_version) + r'(["\'])$'
82
replacement = r'\g<1>' + new_version + r'\g<2>'
83
content = re.sub(pattern, replacement, content, flags=re.MULTILINE)
84
85
# For __init__.py
86
elif file_path.name == '__init__.py':
87
pattern = r"^(__version__\s*=\s*['\"])" + re.escape(old_version) + r"(['\"])$"
88
replacement = r'\g<1>' + new_version + r'\g<2>'
89
content = re.sub(pattern, replacement, content, flags=re.MULTILINE)
90
91
with open(file_path, 'w') as f:
92
f.write(content)
93
94
95
def get_git_log_since_last_release(current_version: str) -> str:
96
"""Get git commits since the last release."""
97
# Get the tag for the current version (which should be the last release)
98
try:
99
# Try to find the last tag that matches a version pattern
100
result = subprocess.run(
101
['git', 'tag', '-l', '--sort=-version:refname', 'v*'],
102
capture_output=True,
103
text=True,
104
check=True,
105
)
106
tags = result.stdout.strip().split('\n')
107
108
# Find the tag matching the current version or the most recent tag
109
current_tag = f'v{current_version}'
110
if current_tag in tags:
111
last_tag = current_tag
112
elif tags:
113
last_tag = tags[0]
114
else:
115
# If no tags found, get all commits
116
last_tag = None
117
except subprocess.CalledProcessError:
118
last_tag = None
119
120
# Get commits since last tag
121
if last_tag:
122
cmd = ['git', 'log', f'{last_tag}..HEAD', '--oneline', '--no-merges']
123
else:
124
# Get last 20 commits if no tag found
125
cmd = ['git', 'log', '--oneline', '--no-merges', '-20']
126
127
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
128
return result.stdout.strip()
129
130
131
def summarize_changes(git_log: str) -> str:
132
"""Summarize the git log into categories."""
133
lines = git_log.split('\n')
134
if not lines or not lines[0]:
135
return 'No changes since last release.'
136
137
features = []
138
fixes = []
139
other = []
140
141
for line in lines:
142
if not line:
143
continue
144
145
# Remove commit hash
146
parts = line.split(' ', 1)
147
if len(parts) > 1:
148
message = parts[1]
149
else:
150
continue
151
152
# Categorize based on commit message
153
lower_msg = message.lower()
154
if any(word in lower_msg for word in ['add', 'feat', 'feature', 'implement', 'new']):
155
features.append(message)
156
elif any(word in lower_msg for word in ['fix', 'bug', 'patch', 'correct', 'resolve']):
157
fixes.append(message)
158
else:
159
other.append(message)
160
161
summary = []
162
163
if features:
164
# summary.append('**New Features:**')
165
for feat in features[:5]: # Limit to 5 most recent
166
summary.append(f'* {feat}')
167
if len(features) > 5:
168
summary.append(f'* ...and {len(features) - 5} more features')
169
# summary.append('')
170
171
if fixes:
172
# summary.append('**Bug Fixes:**')
173
for fix in fixes[:5]: # Limit to 5 most recent
174
summary.append(f'* {fix}')
175
if len(fixes) > 5:
176
summary.append(f'* ...and {len(fixes) - 5} more fixes')
177
# summary.append('')
178
179
if other:
180
# summary.append('**Other Changes:**')
181
for change in other[:3]: # Limit to 3 most recent
182
summary.append(f'* {change}')
183
if len(other) > 3:
184
summary.append(f'* ...and {len(other) - 3} more changes')
185
186
return '\n'.join(summary) if summary else '* Various improvements and updates'
187
188
189
def edit_content(content: str, description: str = 'content') -> str | None:
190
"""Open the default editor to edit content and return the edited result.
191
192
Args:
193
content: The initial content to edit
194
description: Description of what's being edited (for messages)
195
196
Returns:
197
The edited content, or None if the user cancelled (empty content)
198
"""
199
# Get the editor from environment variables
200
editor = os.environ.get('EDITOR') or os.environ.get('VISUAL') or 'vi'
201
202
# Create a temporary file with the content
203
with tempfile.NamedTemporaryFile(mode='w+', suffix='.txt', delete=False) as tmp_file:
204
tmp_file.write(content)
205
tmp_file.flush()
206
tmp_path = tmp_file.name
207
208
try:
209
print(f'\nOpening editor to edit {description}...')
210
print(f'Editor: {editor}')
211
print('Save and exit to continue, or clear all content to cancel.')
212
213
# Open the editor
214
result = subprocess.run([editor, tmp_path])
215
216
if result.returncode != 0:
217
print(f'Editor exited with non-zero status: {result.returncode}')
218
return None
219
220
# Read the edited content
221
with open(tmp_path, 'r') as f:
222
edited_content = f.read().strip()
223
224
if not edited_content:
225
print('Content is empty - cancelling operation.')
226
return None
227
228
return edited_content
229
230
finally:
231
# Clean up the temporary file
232
try:
233
os.unlink(tmp_path)
234
except OSError:
235
pass
236
237
238
def prompt_yes_no(question: str, default: bool = True) -> bool:
239
"""Prompt user for yes/no input with a default value.
240
241
Args:
242
question: The question to ask the user
243
default: Default value (True for yes, False for no)
244
245
Returns:
246
True for yes, False for no
247
"""
248
prompt_suffix = '[Y/n]' if default else '[y/N]'
249
prompt = f'{question} {prompt_suffix}: '
250
251
while True:
252
response = input(prompt).strip().lower()
253
254
if not response:
255
return default
256
257
if response in ('y', 'yes'):
258
return True
259
elif response in ('n', 'no'):
260
return False
261
else:
262
print('Please answer y or n')
263
264
265
def execute_commit_and_push(new_version: str) -> bool:
266
"""Execute git commit and push commands.
267
268
Args:
269
new_version: The new version being released
270
271
Returns:
272
True if successful, False otherwise
273
"""
274
commit_msg = f'Prepare for v{new_version} release'
275
276
try:
277
# Execute git commit
278
status(f'šŸ“ Committing changes: {commit_msg}')
279
result = subprocess.run(
280
['git', 'commit', '-m', commit_msg],
281
capture_output=True,
282
text=True,
283
)
284
285
if result.returncode != 0:
286
print(f'āŒ Error committing changes: {result.stderr}', file=sys.stderr)
287
return False
288
289
status('āœ… Changes committed successfully')
290
291
# Execute git push
292
status('šŸš€ Pushing to remote repository...')
293
result = subprocess.run(
294
['git', 'push'],
295
capture_output=True,
296
text=True,
297
)
298
299
if result.returncode != 0:
300
print(f'āŒ Error pushing changes: {result.stderr}', file=sys.stderr)
301
return False
302
303
status('āœ… Changes pushed successfully')
304
return True
305
306
except Exception as e:
307
print(f'āŒ Unexpected error: {e}', file=sys.stderr)
308
return False
309
310
311
def open_actions_page() -> None:
312
"""Open the GitHub Actions page in the default web browser."""
313
actions_url = 'https://github.com/singlestore-labs/singlestoredb-python/actions'
314
status(f'🌐 Opening GitHub Actions page: {actions_url}')
315
316
try:
317
webbrowser.open(actions_url)
318
status('āœ… Browser opened successfully')
319
except Exception as e:
320
print(f'āš ļø Could not open browser: {e}', file=sys.stderr)
321
print(f' Please visit: {actions_url}', file=sys.stderr)
322
323
324
def prepare_whatsnew_content(new_version: str, summary: str) -> str:
325
"""Prepare the content for the new release section."""
326
today = datetime.date.today()
327
date_str = today.strftime('%B %d, %Y').replace(' 0', ' ') # Remove leading zero
328
329
new_section = f'v{new_version} - {date_str}\n'
330
new_section += '-' * len(new_section.strip()) + '\n'
331
new_section += summary.strip()
332
333
return new_section
334
335
336
def update_whatsnew_with_editor(new_version: str, summary: str) -> bool:
337
"""Update the whatsnew.rst file with the new release, allowing user to edit content.
338
339
Returns:
340
True if successful, False if cancelled by user
341
"""
342
whatsnew_path = Path(__file__).parent.parent / 'docs' / 'src' / 'whatsnew.rst'
343
344
# Prepare the initial content for the new release
345
new_release_content = prepare_whatsnew_content(new_version, summary)
346
347
# Let the user edit the content
348
edited_content = edit_content(new_release_content, 'release notes')
349
if edited_content is None:
350
return False
351
352
# Read the current whatsnew.rst file
353
with open(whatsnew_path, 'r') as f:
354
content = f.read()
355
356
# Find the position after the note section
357
note_end = content.find('\n\nv')
358
if note_end == -1:
359
# If no versions found, add after the document description
360
note_end = content.find('changes to the API.\n') + len('changes to the API.\n')
361
362
# Insert the new section
363
content = content[:note_end] + '\n\n' + edited_content + content[note_end:]
364
365
with open(whatsnew_path, 'w') as f:
366
f.write(content)
367
368
return True
369
370
371
def build_docs() -> None:
372
"""Build the documentation using the unified build script."""
373
build_script = Path(__file__).parent / 'build_docs.py'
374
375
if build_script.exists():
376
# Use the new unified build script
377
status('šŸ“š Building documentation with unified script...')
378
result = subprocess.run([sys.executable, str(build_script), 'html'])
379
else:
380
# Fallback to make html if build script doesn't exist
381
docs_src_path = Path(__file__).parent.parent / 'docs' / 'src'
382
status('šŸ“š Building documentation with make...')
383
result = subprocess.run(['make', 'html'], cwd=docs_src_path)
384
385
if result.returncode != 0:
386
print('āŒ Error building documentation', file=sys.stderr)
387
sys.exit(1)
388
389
status('āœ… Documentation built successfully')
390
391
392
def stage_files() -> None:
393
"""Stage all modified files for commit."""
394
status('šŸ“¦ Staging files for commit...')
395
396
files_to_stage = [
397
'pyproject.toml',
398
'singlestoredb/__init__.py',
399
'docs/src/whatsnew.rst',
400
'docs/', # All generated documentation files
401
]
402
403
for file_path in files_to_stage:
404
subprocess.run(['git', 'add', file_path], check=True)
405
status(f' āœ“ Staged {file_path}')
406
407
status('āœ… All modified files staged for commit')
408
409
410
def main() -> None:
411
parser = argparse.ArgumentParser(
412
description='Bump version and generate documentation',
413
formatter_class=argparse.RawDescriptionHelpFormatter,
414
epilog='''Examples:
415
%(prog)s patch
416
%(prog)s minor --summary "* Added new feature X\\n* Fixed bug Y"
417
%(prog)s major --summary "Breaking changes:\\n* Removed deprecated API"''',
418
)
419
parser.add_argument(
420
'bump_type',
421
choices=['major', 'minor', 'patch'],
422
help='Type of version bump',
423
)
424
parser.add_argument(
425
'--summary',
426
default=None,
427
help='Optional summary for the release notes (supports reStructuredText and \\n for newlines)',
428
)
429
430
args = parser.parse_args()
431
432
total_start_time = time.time()
433
434
print('šŸš€ Starting version bump process', file=sys.stderr)
435
print('=' * 50, file=sys.stderr)
436
437
# Step 1: Get current version
438
step(1, 6, 'Reading current version')
439
current_version = get_current_version()
440
status(f'Current version: {current_version}')
441
442
# Calculate new version
443
new_version = bump_version(current_version, args.bump_type)
444
status(f'New version will be: {new_version}')
445
446
# Step 2: Update version in files
447
step(2, 6, 'Updating version in files')
448
start_time = time.time()
449
450
update_version_in_file(Path(__file__).parent.parent / 'pyproject.toml', current_version, new_version)
451
status(' āœ“ Updated pyproject.toml')
452
453
update_version_in_file(
454
Path(__file__).parent.parent / 'singlestoredb' / '__init__.py',
455
current_version,
456
new_version,
457
)
458
status(' āœ“ Updated singlestoredb/__init__.py')
459
460
elapsed = time.time() - start_time
461
status(f'āœ… Version files updated in {elapsed:.1f}s')
462
463
# Step 3: Generate release summary
464
step(3, 6, 'Generating release summary')
465
start_time = time.time()
466
467
if args.summary:
468
status('Using provided summary')
469
# Replace literal \n with actual newlines
470
summary = args.summary.replace('\\n', '\n')
471
else:
472
status('šŸ” Analyzing git history...')
473
git_log = get_git_log_since_last_release(current_version)
474
summary = summarize_changes(git_log)
475
476
elapsed = time.time() - start_time
477
status(f'āœ… Release summary generated in {elapsed:.1f}s')
478
479
# Step 4: Update whatsnew.rst with editor
480
step(4, 6, 'Updating release notes')
481
start_time = time.time()
482
483
status('šŸ“ Opening editor for release notes...')
484
if not update_whatsnew_with_editor(new_version, summary):
485
print('\nāŒ Operation cancelled by user', file=sys.stderr)
486
status('šŸ”„ Reverting version changes...')
487
488
# Revert version changes
489
update_version_in_file(Path(__file__).parent.parent / 'pyproject.toml', new_version, current_version)
490
update_version_in_file(
491
Path(__file__).parent.parent / 'singlestoredb' / '__init__.py',
492
new_version,
493
current_version,
494
)
495
496
status('āœ… Version changes reverted')
497
sys.exit(1)
498
499
elapsed = time.time() - start_time
500
status(f'āœ… Release notes updated in {elapsed:.1f}s')
501
502
# Step 5: Build documentation
503
step(5, 6, 'Building documentation')
504
build_docs()
505
506
# Step 6: Stage files
507
step(6, 6, 'Staging files for commit')
508
stage_files()
509
510
total_elapsed = time.time() - total_start_time
511
print('=' * 50, file=sys.stderr)
512
print(f'šŸŽ‰ Version bump completed successfully in {total_elapsed:.1f}s!', file=sys.stderr)
513
print(f'šŸ“ Version: {current_version} → {new_version}', file=sys.stderr)
514
print('', file=sys.stderr)
515
516
# Prompt user to commit and push
517
if prompt_yes_no('Do you want to commit and push now?', default=True):
518
print('', file=sys.stderr)
519
if execute_commit_and_push(new_version):
520
print('', file=sys.stderr)
521
open_actions_page()
522
print('', file=sys.stderr)
523
print('āœ… Code checks and smoke tests triggered automatically', file=sys.stderr)
524
print('', file=sys.stderr)
525
print('šŸš€ Next step:', file=sys.stderr)
526
print(' šŸ“„ Run resources/create_release.py once tests complete', file=sys.stderr)
527
else:
528
print('', file=sys.stderr)
529
print('āš ļø Commit/push failed. Please manually run:', file=sys.stderr)
530
print(' šŸ“„ git commit -m "Prepare for v{} release" && git push'.format(new_version), file=sys.stderr)
531
else:
532
print('', file=sys.stderr)
533
print('šŸš€ Next steps:', file=sys.stderr)
534
print(' šŸ“„ git commit -m "Prepare for v{} release" && git push'.format(new_version), file=sys.stderr)
535
print(' āœ… Code checks and smoke tests will trigger automatically', file=sys.stderr)
536
print(' šŸ“„ Run resources/create_release.py once tests complete', file=sys.stderr)
537
538
539
if __name__ == '__main__':
540
main()
541
542