Skip to content

Commit 5c24fa1

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

5 files changed

Lines changed: 188 additions & 23 deletions

File tree

.github/workflows/publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ jobs:
2525
run: uv build
2626
- name: Verify tag matches version
2727
run: |
28-
VERSION=$(grep "__version__ =" temoa/__about__.py | cut -d "'" -f 2)
28+
VERSION=$(python3 -c "import ast; d = {}; content = open('temoa/__about__.py').read(); exec(compile(ast.parse(content), 'temoa/__about__.py', 'exec'), d); print(d['__version__'])")
2929
TAG=${GITHUB_REF#refs/tags/v}
3030
if [ "$VERSION" != "$TAG" ]; then
3131
echo "Error: Tag v$TAG does not match version $VERSION in temoa/__about__.py"

.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: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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+
pre_ordinal = {'a': 0, 'b': 1, 'rc': 2}
58+
59+
# Check for precedence to prevent downgrading within the same patch version
60+
if new['pre_type'] and pre_ordinal[target_pre] < pre_ordinal[new['pre_type']]:
61+
new['patch'] += 1
62+
new['pre_type'] = target_pre
63+
new['pre_num'] = 1
64+
elif new['pre_type'] == target_pre:
65+
new['pre_num'] = (new['pre_num'] or 0) + 1
66+
else:
67+
# If moving to higher precedence (e.g., a -> b) or starting from final
68+
if not new['pre_type']:
69+
new['patch'] += 1
70+
new['pre_type'] = target_pre
71+
new['pre_num'] = 1
72+
elif part == 'final':
73+
new['pre_type'] = None
74+
new['pre_num'] = None
75+
else:
76+
raise ValueError(f'Unknown part to bump: {part}')
77+
78+
return new
79+
80+
81+
def main() -> None:
82+
parser = argparse.ArgumentParser(description='Bump Temoa version in temoa/__about__.py')
83+
parser.add_argument(
84+
'part',
85+
choices=['major', 'minor', 'patch', 'alpha', 'beta', 'rc', 'final', 'a', 'b'],
86+
help='The part of the version to bump',
87+
)
88+
parser.add_argument('--dry-run', action='store_true', help="Don't write to file")
89+
90+
args = parser.parse_args()
91+
92+
about_path = Path('temoa/__about__.py')
93+
if not about_path.exists():
94+
print(f'Error: {about_path} not found.')
95+
sys.exit(1)
96+
97+
content = about_path.read_text()
98+
version_match = re.search(r"__version__ = ['\"]([^'\"]+)['\"]", content)
99+
if not version_match:
100+
print('Error: Could not find __version__ in temoa/__about__.py')
101+
sys.exit(1)
102+
103+
old_version_str = version_match.group(1)
104+
try:
105+
components = parse_version(old_version_str)
106+
except ValueError as e:
107+
print(f'Error: {e}')
108+
sys.exit(1)
109+
110+
new_components = bump_version(components, args.part)
111+
new_version_str = stringify_version(new_components)
112+
113+
if old_version_str == new_version_str:
114+
print(f'Version is already {new_version_str}. No change made.')
115+
return
116+
117+
print(f'Bumping version: {old_version_str} -> {new_version_str}')
118+
119+
if not args.dry_run:
120+
new_content = content.replace(
121+
f"__version__ = '{old_version_str}'", f"__version__ = '{new_version_str}'"
122+
)
123+
new_content = new_content.replace(
124+
f'__version__ = "{old_version_str}"', f'__version__ = "{new_version_str}"'
125+
)
126+
127+
# Also update TEMOA_MAJOR and TEMOA_MINOR if they changed
128+
if new_components['major'] != components['major']:
129+
new_content = re.sub(
130+
r'TEMOA_MAJOR = \d+', f'TEMOA_MAJOR = {new_components["major"]}', new_content
131+
)
132+
if new_components['minor'] != components['minor']:
133+
new_content = re.sub(
134+
r'TEMOA_MINOR = \d+', f'TEMOA_MINOR = {new_components["minor"]}', new_content
135+
)
136+
137+
about_path.write_text(new_content)
138+
print(f'Successfully updated {about_path}')
139+
140+
print('\nNext steps:')
141+
print(f' git add {about_path}')
142+
print(f' git commit -m "chore: bump version to {new_version_str}"')
143+
print(f' git tag -a v{new_version_str} -m "Release v{new_version_str}"')
144+
print(f' git push origin v{new_version_str}')
145+
else:
146+
print('Dry run: file not modified.')
147+
148+
149+
if __name__ == '__main__':
150+
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: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,45 @@
1+
import shutil
12
import subprocess
2-
import sys
3+
34
import temoa
45

6+
7+
def _find_temoa_path() -> str:
8+
path = shutil.which('temoa')
9+
if not path:
10+
raise RuntimeError('temoa executable not found in PATH')
11+
return path
12+
13+
514
def test_import() -> None:
6-
print(f"Importing temoa version: {temoa.__version__}")
15+
print(f'Importing temoa version: {temoa.__version__}')
716
assert temoa.__version__ is not None
817

18+
919
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
20+
print('Running temoa --version CLI command...')
21+
temoa_path = _find_temoa_path()
22+
23+
result = subprocess.run(
24+
[temoa_path, '--version'], capture_output=True, text=True, timeout=10, check=True
25+
)
26+
print(f'CLI output: {result.stdout.strip()}')
27+
assert 'Temoa Version:' in result.stdout
1528
assert temoa.__version__ in result.stdout
1629

30+
1731
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)
32+
print('Running temoa --help CLI command...')
33+
temoa_path = _find_temoa_path()
34+
35+
result = subprocess.run(
36+
[temoa_path, '--help'], capture_output=True, text=True, timeout=10, check=True
37+
)
38+
assert 'The Temoa Project' in result.stdout
39+
40+
41+
if __name__ == '__main__':
42+
test_import()
43+
test_cli()
44+
test_help()
45+
print('\n✅ Smoke test passed!')

0 commit comments

Comments
 (0)