""" Version management for ObsiGate. Source unique de vérité : le fichier `VERSION` à la racine du dépôt, au format `MAJEUR.MINEUR.CORRECTIF` (incrémenté à chaque livraison par `scripts/bump_version.py`, hook git `commit-msg`). get_version() retourne toujours une chaîne propre "x.y.z" — jamais "-dev", "0.0.0-dev" ou "-N-gHASH" : le même numéro s'affiche dans le badge d'en-tête, la boîte À propos et /api/health. Les sources sont consultées dans cet ordre : 1. variable d'environnement OBSIGATE_VERSION (surcharge explicite / tests) ; 2. `VERSION` à la racine du dépôt (source unique, copiée dans l'image Docker) ; 3. `backend/VERSION` (ancien emplacement, encore produit par certains builds) ; 4. dernier tag git (`git describe --tags --abbrev=0`) ; 5. "0.0.0". Exemples : VERSION = 2.3.0 -> "2.3.0" tag v2.3.0, 4 commits -> "2.3.0" aucun VERSION / git -> "0.0.0" """ from __future__ import annotations import os import subprocess # nosec B404 from pathlib import Path _ROOT = Path(__file__).resolve().parent.parent # racine du dépôt ObsiGate VERSION_FILE = _ROOT / "VERSION" # source unique de vérité LEGACY_VERSION_FILE = Path(__file__).resolve().parent / "VERSION" # backend/VERSION _ENV_VAR = "OBSIGATE_VERSION" def _run_git(args: list[str]) -> str: """Run a git command in the repo root; return stdout (stripped) or ''.""" try: # argv fixe (git + args internes), sans shell : pas d'injection. result = subprocess.run( # nosec B404 B603 B607 ["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 _read_version_file(path: Path) -> str: """Clean base of a VERSION file, or '' when absent/unreadable/invalid.""" try: if not path.exists(): return "" return _clean_base(path.read_text(encoding="utf-8", errors="replace")) except OSError: return "" 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 — never a -suffix. Priority: OBSIGATE_VERSION -> ./VERSION -> backend/VERSION -> latest git tag -> "0.0.0". """ # 1) Explicit override (docker-compose, tests, builds hors dépôt) override = _clean_base(os.environ.get(_ENV_VAR, "")) if override: return override # 2) Source unique de vérité : VERSION à la racine du dépôt base = _read_version_file(VERSION_FILE) if base: return base # 3) Ancien emplacement (backend/VERSION, baké par certains builds) base = _read_version_file(LEGACY_VERSION_FILE) if base: return base # 4) Dernier tag git base = _clean_base(_run_git(["describe", "--tags", "--abbrev=0"])) if base: return base # 5) Rien d'exploitable 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())