Skip to content

Commit 2c70af4

Browse files
dev: add version bumping utility and robust smoke tests
1 parent ed9bd27 commit 2c70af4

4 files changed

Lines changed: 178 additions & 22 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
!output_files/
1010
!.github/
1111
!stubs/
12+
!scripts/
1213

1314
# unignore files
1415
!.gitignore

scripts/bump_version.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
#!/usr/bin/env python3
2+
import argparse
3+
import re
4+
import sys
5+
from pathlib import Path
6+
from typing import Any
7+
8+
9+
def parse_version(version_str: str) -> dict[str, Any]:
10+
"""
11+
Parses a PEP 440 version string into its components.
12+
Basic supported format: major.minor.patch[a|b|rcN]
13+
"""
14+
pattern = r'^(\d+)\.(\d+)\.(\d+)(?:([abc]|rc)(\d+))?$'
15+
match = re.match(pattern, version_str)
16+
if not match:
17+
raise ValueError(f"Version '{version_str}' does not match expected format X.Y.Z[a|b|rcN]")
18+
19+
major, minor, patch, pre_type, pre_num = match.groups()
20+
return {
21+
'major': int(major),
22+
'minor': int(minor),
23+
'patch': int(patch),
24+
'pre_type': pre_type,
25+
'pre_num': int(pre_num) if pre_num else None,
26+
}
27+
28+
29+
def stringify_version(components: dict[str, Any]) -> str:
30+
base = f'{components["major"]}.{components["minor"]}.{components["patch"]}'
31+
if components['pre_type']:
32+
return f'{base}{components["pre_type"]}{components["pre_num"] or 1}'
33+
return base
34+
35+
36+
def bump_version(components: dict[str, Any], part: str) -> dict[str, Any]:
37+
new = components.copy()
38+
39+
if part == 'major':
40+
new['major'] += 1
41+
new['minor'] = 0
42+
new['patch'] = 0
43+
new['pre_type'] = None
44+
new['pre_num'] = None
45+
elif part == 'minor':
46+
new['minor'] += 1
47+
new['patch'] = 0
48+
new['pre_type'] = None
49+
new['pre_num'] = None
50+
elif part == 'patch':
51+
new['patch'] += 1
52+
new['pre_type'] = None
53+
new['pre_num'] = None
54+
elif part in ['a', 'b', 'rc', 'alpha', 'beta']:
55+
pre_map = {'alpha': 'a', 'beta': 'b', 'rc': 'rc', 'a': 'a', 'b': 'b'}
56+
target_pre = pre_map[part]
57+
58+
if new['pre_type'] == target_pre:
59+
new['pre_num'] = (new['pre_num'] or 0) + 1
60+
else:
61+
# If changing pre type or moving to pre-release from final
62+
if not new['pre_type']:
63+
new['patch'] += 1
64+
new['pre_type'] = target_pre
65+
new['pre_num'] = 1
66+
elif part == 'final':
67+
new['pre_type'] = None
68+
new['pre_num'] = None
69+
else:
70+
raise ValueError(f'Unknown part to bump: {part}')
71+
72+
return new
73+
74+
75+
def main() -> None:
76+
parser = argparse.ArgumentParser(description='Bump Temoa version in temoa/__about__.py')
77+
parser.add_argument(
78+
'part',
79+
choices=['major', 'minor', 'patch', 'alpha', 'beta', 'rc', 'final', 'a', 'b'],
80+
help='The part of the version to bump',
81+
)
82+
parser.add_argument('--dry-run', action='store_true', help="Don't write to file")
83+
84+
args = parser.parse_args()
85+
86+
about_path = Path('temoa/__about__.py')
87+
if not about_path.exists():
88+
print(f'Error: {about_path} not found.')
89+
sys.exit(1)
90+
91+
content = about_path.read_text()
92+
version_match = re.search(r"__version__ = ['\"]([^'\"]+)['\"]", content)
93+
if not version_match:
94+
print('Error: Could not find __version__ in temoa/__about__.py')
95+
sys.exit(1)
96+
97+
old_version_str = version_match.group(1)
98+
try:
99+
components = parse_version(old_version_str)
100+
except ValueError as e:
101+
print(f'Error: {e}')
102+
sys.exit(1)
103+
104+
new_components = bump_version(components, args.part)
105+
new_version_str = stringify_version(new_components)
106+
107+
if old_version_str == new_version_str:
108+
print(f'Version is already {new_version_str}. No change made.')
109+
return
110+
111+
print(f'Bumping version: {old_version_str} -> {new_version_str}')
112+
113+
if not args.dry_run:
114+
new_content = content.replace(
115+
f"__version__ = '{old_version_str}'", f"__version__ = '{new_version_str}'"
116+
)
117+
new_content = new_content.replace(
118+
f'__version__ = "{old_version_str}"', f'__version__ = "{new_version_str}"'
119+
)
120+
121+
# Also update TEMOA_MAJOR and TEMOA_MINOR if they changed
122+
if new_components['major'] != components['major']:
123+
new_content = re.sub(
124+
r'TEMOA_MAJOR = \d+', f'TEMOA_MAJOR = {new_components["major"]}', new_content
125+
)
126+
if new_components['minor'] != components['minor']:
127+
new_content = re.sub(
128+
r'TEMOA_MINOR = \d+', f'TEMOA_MINOR = {new_components["minor"]}', new_content
129+
)
130+
131+
about_path.write_text(new_content)
132+
print(f'Successfully updated {about_path}')
133+
134+
print('\nNext steps:')
135+
print(f' git add {about_path}')
136+
print(f' git commit -m "chore: bump version to {new_version_str}"')
137+
print(f' git tag -a v{new_version_str} -m "Release v{new_version_str}"')
138+
print(f' git push origin v{new_version_str}')
139+
else:
140+
print('Dry run: file not modified.')
141+
142+
143+
if __name__ == '__main__':
144+
main()

temoa/__about__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import re
22

3-
__version__ = '4.0.0a1'
3+
__version__ = '4.0.0a2'
44

55
# Parse the version string to get major and minor versions
66
# We use a regex to be robust against versions like "4.1a1" or "4.0.0.dev1"

tests/smoke_test.py

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,42 @@
1+
import shutil
12
import subprocess
2-
import sys
3+
34
import temoa
45

6+
57
def test_import() -> None:
6-
print(f"Importing temoa version: {temoa.__version__}")
8+
print(f'Importing temoa version: {temoa.__version__}')
79
assert temoa.__version__ is not None
810

11+
912
def test_cli() -> None:
10-
print("Running temoa --version CLI command...")
11-
result = subprocess.run(["temoa", "--version"], capture_output=True, text=True)
12-
print(f"CLI output: {result.stdout.strip()}")
13-
assert result.returncode == 0
14-
assert "Temoa Version:" in result.stdout
13+
print('Running temoa --version CLI command...')
14+
temoa_path = shutil.which('temoa')
15+
if not temoa_path:
16+
raise RuntimeError('temoa executable not found in PATH')
17+
18+
result = subprocess.run(
19+
[temoa_path, '--version'], capture_output=True, text=True, timeout=10, check=True
20+
)
21+
print(f'CLI output: {result.stdout.strip()}')
22+
assert 'Temoa Version:' in result.stdout
1523
assert temoa.__version__ in result.stdout
1624

25+
1726
def test_help() -> None:
18-
print("Running temoa --help CLI command...")
19-
result = subprocess.run(["temoa", "--help"], capture_output=True, text=True)
20-
assert result.returncode == 0
21-
assert "The Temoa Project" in result.stdout
22-
23-
if __name__ == "__main__":
24-
try:
25-
test_import()
26-
test_cli()
27-
test_help()
28-
print("\n✅ Smoke test passed!")
29-
except Exception as e:
30-
print(f"\n❌ Smoke test failed: {e}")
31-
sys.exit(1)
27+
print('Running temoa --help CLI command...')
28+
temoa_path = shutil.which('temoa')
29+
if not temoa_path:
30+
raise RuntimeError('temoa executable not found in PATH')
31+
32+
result = subprocess.run(
33+
[temoa_path, '--help'], capture_output=True, text=True, timeout=10, check=True
34+
)
35+
assert 'The Temoa Project' in result.stdout
36+
37+
38+
if __name__ == '__main__':
39+
test_import()
40+
test_cli()
41+
test_help()
42+
print('\n✅ Smoke test passed!')

0 commit comments

Comments
 (0)