#!/usr/bin/env python3 from __future__ import annotations import argparse import re import subprocess from pathlib import Path ROOT = Path(__file__).resolve().parents[1] ABOUT_FILE = ROOT / "iti_system" / "__about__.py" README_FILE = ROOT / "README.md" VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") ABOUT_VERSION_RE = re.compile(r'(__version__\s*=\s*")([^"]+)(")') README_DEP_RE = re.compile( r"(iti-system @ git\+ssh://git@example\.com/iTi-System\.git@)v\d+\.\d+\.\d+" ) def run(cmd: list[str], *, capture_output: bool = False) -> subprocess.CompletedProcess[str]: return subprocess.run( cmd, cwd=ROOT, text=True, capture_output=capture_output, check=True, ) def normalize_version(value: str) -> str: version = value[1:] if value.startswith("v") else value if not VERSION_RE.fullmatch(version): raise SystemExit(f"invalid version: {value}") return version def version_tuple(version: str) -> tuple[int, int, int]: major, minor, patch = version.split(".") return int(major), int(minor), int(patch) def bump_patch(version: str) -> str: major, minor, patch = version_tuple(version) return f"{major}.{minor}.{patch + 1}" def read_current_version() -> str: text = ABOUT_FILE.read_text(encoding="utf-8") match = ABOUT_VERSION_RE.search(text) if match is None: raise SystemExit(f"version not found in {ABOUT_FILE}") return match.group(2) def write_about_version(version: str) -> None: text = ABOUT_FILE.read_text(encoding="utf-8") new_text, count = ABOUT_VERSION_RE.subn(rf"\g<1>{version}\g<3>", text, count=1) if count != 1: raise SystemExit(f"version line not updated in {ABOUT_FILE}") ABOUT_FILE.write_text(new_text, encoding="utf-8") def write_readme_tags(version: str) -> None: text = README_FILE.read_text(encoding="utf-8") new_text, count = README_DEP_RE.subn(rf"\g<1>v{version}", text) if count != 1: raise SystemExit(f"dependency examples not updated in {README_FILE}") README_FILE.write_text(new_text, encoding="utf-8") def ensure_clean_tree() -> None: result = run(["git", "status", "--porcelain"], capture_output=True) if result.stdout.strip(): raise SystemExit("working tree is not clean") def ensure_branch() -> str: result = run(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], capture_output=True) branch = result.stdout.strip() if not branch: raise SystemExit("release requires a branch checkout") return branch def ensure_tag_absent(tag: str) -> None: result = subprocess.run( ["git", "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}"], cwd=ROOT, text=True, capture_output=True, ) if result.returncode == 0: raise SystemExit(f"tag already exists: {tag}") def main() -> int: parser = argparse.ArgumentParser(description="Release the iTi-System package") parser.add_argument( "version", nargs="?", help="target version, for example 0.2.1 or v0.2.1; defaults to patch bump", ) args = parser.parse_args() current_version = read_current_version() target_version = normalize_version(args.version) if args.version else bump_patch(current_version) target_tag = f"v{target_version}" if target_version == current_version: raise SystemExit(f"version already set to {target_version}") if version_tuple(target_version) <= version_tuple(current_version): raise SystemExit(f"target version must be newer than {current_version}") ensure_clean_tree() ensure_branch() ensure_tag_absent(target_tag) run(["uv", "run", "pytest", "-q"]) write_about_version(target_version) write_readme_tags(target_version) run(["git", "add", "iti_system/__about__.py", "README.md"]) run(["git", "commit", "-m", f"chore: release {target_tag}"]) run(["git", "tag", "-a", target_tag, "-m", f"release {target_tag}"]) branch = ensure_branch() run(["git", "push", "origin", branch, target_tag]) print(f"released {target_tag}") return 0 if __name__ == "__main__": raise SystemExit(main())