Update `bump_version.py`

This commit is contained in:
Adam Turner 2024-07-24 20:15:43 +01:00
parent 55eddad705
commit d409907d9e
2 changed files with 111 additions and 107 deletions

View File

@ -1,6 +1,3 @@
Release x.y.z (in development)
==============================
Dependencies Dependencies
------------ ------------

View File

@ -3,121 +3,125 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import dataclasses
import re import re
import sys import sys
import time import time
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterator, Sequence from collections.abc import Iterator, Sequence
from typing import TypeAlias
script_dir = Path(__file__).parent script_dir = Path(__file__).parent
package_dir = script_dir.parent package_dir = script_dir.parent
RELEASE_TYPE = {'a': 'alpha', 'b': 'beta'}
VersionInfo: TypeAlias = tuple[int, int, int, str, int] @dataclasses.dataclass(frozen=True, slots=True)
class VersionInfo:
major: int
minor: int
micro: int
level: Literal['a', 'b', 'rc', 'final']
serial: int
@property
def releaselevel(self) -> Literal['alpha', 'beta', 'candidate', 'final']:
if self.level == 'final':
return 'final'
if self.level == 'a':
return 'alpha'
if self.level == 'b':
return 'beta'
if self.level == 'rc':
return 'candidate'
msg = f'Unknown release level: {self.level}'
raise RuntimeError(msg)
def stringify_version(version_info: VersionInfo, in_develop: bool = True) -> str: @property
version = '.'.join(str(v) for v in version_info[:3]) def is_final(self) -> bool:
if not in_develop and version_info[3] != 'final': return self.level == 'final'
version += version_info[3][0] + str(version_info[4])
return version @property
def version(self) -> str:
return f'{self.major}.{self.minor}.{self.micro}'
@property
def release(self) -> str:
return f'{self.major}.{self.minor}.{self.micro}{self.level}{self.serial}'
def bump_version(path: Path, version_info: VersionInfo, in_develop: bool = True) -> None: @property
version = stringify_version(version_info, in_develop) def version_tuple(self) -> tuple[int, int, int]:
return self.major, self.minor, self.micro
with open(path, encoding='utf-8') as f: @property
lines = f.read().splitlines() def release_tuple(self) -> tuple[int, int, int, str, int]:
return self.major, self.minor, self.micro, self.releaselevel, self.serial
for i, line in enumerate(lines):
if line.startswith('__version__ = '):
lines[i] = f"__version__ = '{version}'"
continue
if line.startswith('version_info = '):
lines[i] = f'version_info = {version_info}'
continue
if line.startswith('_in_development = '):
lines[i] = f'_in_development = {in_develop}'
continue
with open(path, 'w', encoding='utf-8') as f:
f.write('\n'.join(lines) + '\n')
def parse_version(version: str) -> VersionInfo: def parse_version(version: str) -> VersionInfo:
matched = re.search(r'^(\d+)\.(\d+)$', version) # Final version:
if matched: # - "X.Y.Z" -> (X, Y, Z, 'final', 0)
major, minor = matched.groups() # - "X.Y" -> (X, Y, 0, 'final', 0) [shortcut]
return (int(major), int(minor), 0, 'final', 0) if matched := re.fullmatch(r'(\d+)\.(\d+)(?:\.(\d+))?', version):
major, minor, micro = matched.groups(default='0')
return VersionInfo(int(major), int(minor), int(micro), 'final', 0)
matched = re.search(r'^(\d+)\.(\d+)\.(\d+)$', version) # Pre-release versions:
if matched: # - "X.Y.ZaN" -> (X, Y, Z, 'alpha', N)
major, minor, rev = matched.groups() # - "X.Y.ZbN" -> (X, Y, Z, 'beta', N)
return (int(major), int(minor), int(rev), 'final', 0) # - "X.Y.ZrcN" -> (X, Y, Z, 'candidate', N)
if matched := re.fullmatch(r'(\d+)\.(\d+)\.(\d+)(a|b|rc)(\d+)', version):
major, minor, micro, level, serial = matched.groups()
return VersionInfo(int(major), int(minor), int(micro), level, int(serial)) # type: ignore[arg-type]
matched = re.search(r'^(\d+)\.(\d+)\s*(a|b|alpha|beta)(\d+)$', version) msg = f'Unknown version: {version}'
if matched: raise RuntimeError(msg)
major, minor, typ, relver = matched.groups()
release = RELEASE_TYPE.get(typ, typ)
return (int(major), int(minor), 0, release, int(relver))
matched = re.search(r'^(\d+)\.(\d+)\.(\d+)\s*(a|b|alpha|beta)(\d+)$', version)
if matched:
major, minor, rev, typ, relver = matched.groups()
release = RELEASE_TYPE.get(typ, typ)
return (int(major), int(minor), int(rev), release, int(relver))
raise RuntimeError('Unknown version: %s' % version)
class Skip(Exception): def bump_version(path: Path, version_info: VersionInfo, in_develop: bool = True) -> None:
pass if in_develop or version_info.is_final:
version = version_info.version
@contextmanager
def processing(message: str) -> Iterator[None]:
try:
print(message + ' ... ', end='')
yield
except Skip as exc:
print('skip: %s' % exc)
except Exception:
print('error')
raise
else: else:
print('done') version = version_info.release
with open(path, encoding='utf-8') as f:
lines = f.read().splitlines(keepends=True)
for i, line in enumerate(lines):
if line.startswith('__version__ = '):
lines[i] = f"__version__ = '{version}'\n"
continue
if line.startswith('version_info = '):
lines[i] = f'version_info = {version_info.release_tuple}\n'
continue
if line.startswith('_in_development = '):
lines[i] = f'_in_development = {in_develop}\n'
continue
with open(path, 'w', encoding='utf-8') as f:
f.writelines(lines)
class Changes: class Changes:
def __init__(self, path: Path) -> None: def __init__(self, path: Path) -> None:
self.path = path self.path = path
self.fetch_version()
def fetch_version(self) -> None:
with open(self.path, encoding='utf-8') as f: with open(self.path, encoding='utf-8') as f:
version = f.readline().strip() version = f.readline().strip()
matched = re.search(r'^Release (.*) \((.*)\)$', version) matched = re.fullmatch(r'Release (.*) \((.*)\)', version)
if matched is None: if matched is None:
raise RuntimeError('Unknown CHANGES format: %s' % version) msg = f'Unknown CHANGES format: {version}'
raise RuntimeError(msg)
self.version, self.release_date = matched.groups() self.version, release_date = matched.groups()
self.version_info = parse_version(self.version) self.in_development = release_date == 'in development'
if self.release_date == 'in development': self.version_tuple = parse_version(self.version).version_tuple
self.in_development = True
else:
self.in_development = False
def finalize_release_date(self) -> None: def finalise_release_date(self) -> None:
release_date = time.strftime('%b %d, %Y') release_date = time.strftime('%b %d, %Y')
heading = f'Release {self.version} (released {release_date})' heading = f'Release {self.version} (released {release_date})'
with open(self.path, 'r+', encoding='utf-8') as f: with open(self.path, 'r+', encoding='utf-8') as f:
f.readline() # skip first two lines f.readline() # skip first two lines
f.readline() f.readline()
@ -130,21 +134,8 @@ class Changes:
f.write(self.filter_empty_sections(body)) f.write(self.filter_empty_sections(body))
def add_release(self, version_info: VersionInfo) -> None: def add_release(self, version_info: VersionInfo) -> None:
if version_info[-2:] in (('beta', 0), ('final', 0)): heading = f'Release {version_info.version} (in development)'
version = stringify_version(version_info) tmpl = (script_dir / 'CHANGES_template.rst').read_text(encoding='utf-8')
else:
reltype = version_info[3]
version = (
f'{stringify_version(version_info)} '
f'{RELEASE_TYPE.get(reltype, reltype)}{version_info[4] or ""}'
)
heading = 'Release %s (in development)' % version
with open(script_dir / 'CHANGES_template.rst', encoding='utf-8') as f:
f.readline() # skip first two lines
f.readline()
tmpl = f.read()
with open(self.path, 'r+', encoding='utf-8') as f: with open(self.path, 'r+', encoding='utf-8') as f:
body = f.read() body = f.read()
@ -156,39 +147,55 @@ class Changes:
f.write('\n') f.write('\n')
f.write(body) f.write(body)
def filter_empty_sections(self, body: str) -> str: @staticmethod
def filter_empty_sections(body: str) -> str:
return re.sub('^\n.+\n-{3,}\n+(?=\n.+\n[-=]{3,}\n)', '', body, flags=re.MULTILINE) return re.sub('^\n.+\n-{3,}\n+(?=\n.+\n[-=]{3,}\n)', '', body, flags=re.MULTILINE)
def parse_options(argv: Sequence[str]) -> argparse.Namespace: class Skip(Exception):
pass
@contextmanager
def processing(message: str) -> Iterator[None]:
try:
print(message + ' ... ', end='')
yield
except Skip as exc:
print(f'skip: {exc}')
except Exception:
print('error')
raise
else:
print('done')
def parse_options(argv: Sequence[str]) -> tuple[VersionInfo, bool]:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('version', help='A version number (cf. 1.6b0)') parser.add_argument('version', help='A version number (cf. 1.6.0b0)')
parser.add_argument('--in-develop', action='store_true') parser.add_argument('--in-develop', action='store_true')
options = parser.parse_args(argv) options = parser.parse_args(argv)
options.version = parse_version(options.version) return parse_version(options.version), options.in_develop
return options
def main() -> None: def main() -> None:
options = parse_options(sys.argv[1:]) version, in_develop = parse_options(sys.argv[1:])
with processing('Rewriting sphinx/__init__.py'): with processing('Rewriting sphinx/__init__.py'):
bump_version( bump_version(package_dir / 'sphinx' / '__init__.py', version, in_develop)
package_dir / 'sphinx' / '__init__.py', options.version, options.in_develop
)
with processing('Rewriting CHANGES'): with processing('Rewriting CHANGES'):
changes = Changes(package_dir / 'CHANGES.rst') changes = Changes(package_dir / 'CHANGES.rst')
if changes.version_info == options.version: if changes.version_tuple == version.version_tuple:
if changes.in_development: if changes.in_development and version.is_final and not in_develop:
changes.finalize_release_date() changes.finalise_release_date()
else: else:
reason = 'version not changed' reason = 'version not changed'
raise Skip(reason) raise Skip(reason)
else: else:
if changes.in_development: if changes.in_development:
print('WARNING: last version is not released yet: %s' % changes.version) print(f'WARNING: last version is not released yet: {changes.version}')
changes.add_release(options.version) changes.add_release(version)
if __name__ == '__main__': if __name__ == '__main__':