#!/usr/bin/env python3 """Synchronize frontend package versions with pyproject.toml. pyproject.toml is the single source of truth for the project version. Run this script before building the frontend or deploying so the two files never drift apart. Usage: scripts/sync_version.py # update package.json and package-lock.json scripts/sync_version.py --check # exit 1 if they already differ """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent PYPROJECT = ROOT / "pyproject.toml" PACKAGE_JSON = ROOT / "frontend" / "web" / "package.json" PACKAGE_LOCK = ROOT / "frontend" / "web" / "package-lock.json" _VERSION_RE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE) def read_pyproject_version() -> str: text = PYPROJECT.read_text(encoding="utf-8") match = _VERSION_RE.search(text) if not match: raise RuntimeError(f"could not find version= in {PYPROJECT}") return match.group(1) def package_versions_match(version: str) -> bool: package_data = json.loads(PACKAGE_JSON.read_text(encoding="utf-8")) lock_data = json.loads(PACKAGE_LOCK.read_text(encoding="utf-8")) return ( package_data.get("version") == version and lock_data.get("version") == version and lock_data.get("packages", {}).get("", {}).get("version") == version ) def write_package_versions(version: str) -> bool: """Update package.json and package-lock.json. Returns True if changed.""" if package_versions_match(version): return False data = json.loads(PACKAGE_JSON.read_text(encoding="utf-8")) data["version"] = version PACKAGE_JSON.write_text( json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) lock_data = json.loads(PACKAGE_LOCK.read_text(encoding="utf-8")) lock_data["version"] = version lock_data.setdefault("packages", {}).setdefault("", {})["version"] = version PACKAGE_LOCK.write_text( json.dumps(lock_data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) return True def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--check", action="store_true", help="Exit with status 1 if versions already match (CI-friendly).", ) args = parser.parse_args() version = read_pyproject_version() if args.check: if not package_versions_match(version): print(f"OUT_OF_SYNC pyproject={version} frontend package versions differ") return 1 print(f"OK version={version}") return 0 changed = write_package_versions(version) if changed: print(f"synced frontend package versions -> {version}") else: print(f"already in sync: {version}") return 0 if __name__ == "__main__": sys.exit(main())