|
| 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() |
0 commit comments