""" Version management for ObsiGate. The canonical version is the numeric SemVer of the LATEST release tag (MAJOR.MINOR.PATCH). get_version() always returns a clean "x.y.z" string so the UI never shows "-dev", "0.0.0-dev" or a "-N-gHASH" suffix — the same number appears in the header badge, the About modal and the API health. Examples: tag v2.0.0 -> "2.0.0" HEAD 31 commits after -> "2.0.0" (release version, no dev clutter) no git / no VERSION -> "0.0.0" """ from __future__ import annotations import subprocess from pathlib import Path _ROOT = Path(__file__).resolve().parent.parent # ObsiGate repo root _VERSION_FILE = Path(__file__).resolve().parent / "VERSION" def _run_git(args: list[str]) -> str: """Run a git command in the repo root; return stdout (stripped) or ''.""" try: result = subprocess.run( ["git", *args], cwd=str(_ROOT), capture_output=True, text=True, timeout=5, check=False, ) if result.returncode == 0: return result.stdout.strip() except (FileNotFoundError, subprocess.TimeoutExpired, OSError): pass return "" def _clean_base(raw: str) -> str: """Reduce a version string to its numeric MAJOR.MINOR.PATCH base. Handles "v2.0.0", "2.0.0-31-gabc1234", "2.0.0-dev", "0.0.0-dev". Returns "" if no numeric triplet can be parsed. """ s = (raw or "").strip().lstrip("vV") base = s.split("-")[0] # strip -N-gHASH / -dev / -dirty suffixes parts = base.split(".") nums = [] for p in parts[:3]: if not p.isdigit(): break nums.append(str(int(p))) if len(nums) != 3: return "" return ".".join(nums) def get_git_describe() -> str: """Full `git describe` string (e.g. "2.0.0-31-gabc1234") or '' if no git.""" return _run_git(["describe", "--tags", "--dirty=-dirty"]).lstrip("v") def get_git_commit() -> str: """Short HEAD commit hash (e.g. "abc1234") or '' if unavailable.""" return _run_git(["rev-parse", "--short", "HEAD"]) def get_version() -> str: """Return the clean release version x.y.z (latest tag) — never a -suffix. Priority: latest git tag -> backend/VERSION file -> "0.0.0". """ # 1) Latest tag from git (works even with commits beyond the tag) tag = _run_git(["describe", "--tags", "--abbrev=0"]) base = _clean_base(tag) if base: return base # 2) backend/VERSION file (baked at build time by build.sh / CI / Docker) if _VERSION_FILE.exists(): base = _clean_base(_VERSION_FILE.read_text(encoding="utf-8")) if base: return base # 3) Nothing available return "0.0.0" def get_version_tuple() -> tuple[int, int, int]: """Return (major, minor, patch) tuple for programmatic use.""" raw = get_version() base = _clean_base(raw) or "0.0.0" major, minor, patch = (int(p) for p in base.split(".")) return (major, minor, patch) if __name__ == "__main__": print(get_version())