Coverage for scanpath_studio/build_info.py: 96%

139 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-07 21:10 +0000

1"""Which build of Scanpath Studio this is (#139). 

2 

3Releases are cut by hand: ``scanpath_studio.__release__`` is the number 

4``/release`` bumps and ``publish.yml`` checks a tag against. Between releases 

5every merge to ``main`` is a different program, so ``__version__`` — and 

6everything that shows it (About, ``--version``, crash reports, export READMEs, 

7saved configs) — reports the exact build, worked out from the first source that 

8knows: 

9 

101. a git checkout of this repository (``git describe``); 

112. a ``_build.json`` stamp beside the package, which the desktop spec writes; 

123. the commit pip recorded for a ``pip install git+…`` (PEP 610); 

134. otherwise the release itself — also the answer whenever one of the above 

14 fails, so a missing ``git`` or a clone without tags breaks nothing. 

15 

16The result is a PEP 440 version, so builds sort between releases: 

17``0.35.0 < 0.35.0.post3+g8f18219 < 0.35.1``. Standard library and 

18``packaging`` only; nothing here touches the network. 

19""" 

20 

21from __future__ import annotations 

22 

23import json 

24import os 

25import re 

26import subprocess 

27import sys 

28import tomllib 

29from collections.abc import Callable 

30from dataclasses import asdict, dataclass 

31from functools import cache 

32from pathlib import Path 

33 

34from packaging.version import InvalidVersion, Version 

35 

36DIST_NAME = "scanpath-studio" 

37STAMP_NAME = "_build.json" 

38GIT_TIMEOUT_S = 2.0 

39_PACKAGE_DIR = Path(__file__).resolve().parent 

40# `git describe --tags --long --dirty`: v<release>-<distance>-g<hash>[-dirty] 

41_DESCRIBE = re.compile( 

42 r"^v(?P<release>.+)-(?P<distance>\d+)-g(?P<commit>[0-9a-f]+)(?P<dirty>-dirty)?$" 

43) 

44 

45#: How each kind of install is named to people (``scanpath-studio version``). 

46INSTALL_KINDS = { 

47 "desktop": "the desktop app", 

48 "checkout": "a git checkout", 

49 "vcs": "pip, from git", 

50 "uv-tool": "uv tool", 

51 "pipx": "pipx", 

52 "uv": "uv pip", 

53 "pip": "pip", 

54} 

55 

56 

57@dataclass(frozen=True) 

58class BuildInfo: 

59 """One build: its PEP 440 ``version`` and how that was worked out. 

60 

61 ``release`` is the release it descends from, ``distance`` the commits since 

62 (``None`` when unknown), ``commit`` the abbreviated hash, ``dirty`` whether 

63 tracked files had uncommitted changes, and ``source`` one of 

64 ``"checkout"``, ``"stamp"``, ``"vcs"`` or ``"release"``. ``vcs_url`` is the 

65 repository a ``pip install git+…`` came from. 

66 """ 

67 

68 version: str 

69 release: str 

70 distance: int | None = 0 

71 commit: str = "" 

72 dirty: bool = False 

73 source: str = "release" 

74 vcs_url: str = "" 

75 

76 def describe(self) -> str: 

77 """The build in a sentence, relative to its release.""" 

78 if self.source == "vcs": 

79 return f"Installed from git at {self.commit}, based on v{self.release}" 

80 if self.distance: 

81 commits = "commit" if self.distance == 1 else "commits" 

82 text = ( 

83 f"Development build — {self.distance} {commits} after " 

84 f"v{self.release}, at {self.commit}" 

85 ) 

86 elif self.dirty: 

87 text = f"v{self.release} at {self.commit}" 

88 else: 

89 return f"Release v{self.release}" 

90 return text + (", with uncommitted changes" if self.dirty else "") 

91 

92 

93def _compose(release: str, distance: int, commit: str, dirty: bool) -> str: 

94 version = f"{release}.post{distance}" if distance else release 

95 local = [f"g{commit}"] if (distance or dirty) else [] 

96 if dirty: 

97 local.append("dirty") 

98 return version + ("+" + ".".join(local) if local else "") 

99 

100 

101def from_describe(output: str, source: str = "checkout") -> BuildInfo | None: 

102 """Read one ``git describe --tags --long --dirty`` line; ``None`` if it isn't one.""" 

103 match = _DESCRIBE.match(output.strip()) 

104 if not match: 

105 return None 

106 release, commit = match["release"], match["commit"] 

107 distance, dirty = int(match["distance"]), bool(match["dirty"]) 

108 version = _compose(release, distance, commit, dirty) 

109 try: 

110 Version(version) 

111 except InvalidVersion: 

112 return None 

113 return BuildInfo(version, release, distance, commit, dirty, source) 

114 

115 

116def _is_this_project(root: Path) -> bool: 

117 try: 

118 with (root / "pyproject.toml").open("rb") as handle: 

119 project = tomllib.load(handle).get("project") 

120 except (OSError, ValueError): # unreadable, not UTF-8, or not TOML 

121 return False 

122 return isinstance(project, dict) and project.get("name") == DIST_NAME 

123 

124 

125def is_checkout(root: Path = _PACKAGE_DIR.parent) -> bool: 

126 """Whether ``root`` is this repository's own root (a ``.git`` and our pyproject).""" 

127 return (root / ".git").exists() and _is_this_project(root) 

128 

129 

130def _git_env() -> dict[str, str]: 

131 """``os.environ`` without ``GIT_*`` (a hook's ``GIT_DIR`` would redirect git 

132 to another repository), and with optional locks off so reading the version 

133 never takes ``.git/index.lock``.""" 

134 env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} 

135 env["GIT_OPTIONAL_LOCKS"] = "0" 

136 return env 

137 

138 

139def read_checkout( 

140 root: Path = _PACKAGE_DIR.parent, *, run: Callable = subprocess.run 

141) -> BuildInfo | None: 

142 """``git describe`` the checkout at ``root``, when it is this project's. 

143 

144 Only this repository's own root counts — a ``.git`` there and a 

145 ``pyproject.toml`` naming ``scanpath-studio`` — so an install in a venv that 

146 happens to sit inside some other repository is never described by it. 

147 """ 

148 if not is_checkout(root): 

149 return None 

150 env = _git_env() 

151 options = { 

152 "capture_output": True, 

153 "text": True, 

154 "timeout": GIT_TIMEOUT_S, 

155 "check": False, 

156 "env": env, 

157 } 

158 try: 

159 done = run( 

160 [ 

161 "git", 

162 "-C", 

163 str(root), 

164 "describe", 

165 "--tags", 

166 "--long", 

167 "--match", 

168 "v[0-9]*", 

169 ], 

170 **options, 

171 ) 

172 except (OSError, subprocess.SubprocessError): 

173 return None 

174 if done.returncode != 0: 

175 return None 

176 # `describe --dirty` would refresh and rewrite .git/index, which can make 

177 # another session's `git commit` fail; `status` with optional locks off 

178 # does not. 

179 try: 

180 status = run( 

181 ["git", "-C", str(root), "status", "--porcelain", "--untracked-files=no"], 

182 **options, 

183 ) 

184 dirty = status.returncode == 0 and bool(status.stdout.strip()) 

185 except (OSError, subprocess.SubprocessError): 

186 dirty = False 

187 line = done.stdout.strip() 

188 return from_describe(line + "-dirty" if dirty else line) 

189 

190 

191def write_stamp(info: BuildInfo, path: Path) -> None: 

192 """Write ``info`` where :func:`read_stamp` finds it (the desktop spec's step).""" 

193 path.write_text(json.dumps(asdict(info)), encoding="utf-8") 

194 

195 

196def read_stamp(path: Path = _PACKAGE_DIR / STAMP_NAME) -> BuildInfo | None: 

197 """The build a desktop bundle was made from, as its spec stamped it.""" 

198 try: 

199 data = json.loads(path.read_text(encoding="utf-8")) 

200 info = BuildInfo(**{**data, "source": "stamp"}) 

201 Version(info.version) 

202 except (OSError, ValueError, TypeError): 

203 return None 

204 return info 

205 

206 

207def _dist_text(name: str) -> str | None: 

208 """A file from this distribution's installed metadata, or ``None``.""" 

209 from importlib import metadata 

210 

211 try: 

212 return metadata.distribution(DIST_NAME).read_text(name) 

213 except metadata.PackageNotFoundError: 

214 return None 

215 

216 

217def read_vcs_install( 

218 release: str, *, read_text: Callable[[str], str | None] = _dist_text 

219) -> BuildInfo | None: 

220 """The commit pip recorded for ``pip install git+…`` (``direct_url.json``).""" 

221 try: 

222 data = json.loads(read_text("direct_url.json") or "") 

223 except ValueError: 

224 return None 

225 vcs = data.get("vcs_info") if isinstance(data, dict) else None 

226 if not isinstance(vcs, dict) or vcs.get("vcs") != "git" or not vcs.get("commit_id"): 

227 return None 

228 commit = str(vcs["commit_id"])[:7] 

229 version = f"{release}+g{commit}" 

230 try: 

231 Version(version) 

232 except InvalidVersion: 

233 return None 

234 return BuildInfo( 

235 version, 

236 release, 

237 None, 

238 commit, 

239 False, 

240 "vcs", 

241 str(data.get("url") or ""), 

242 ) 

243 

244 

245def resolve( 

246 release: str, 

247 *, 

248 root: Path = _PACKAGE_DIR.parent, 

249 stamp: Path = _PACKAGE_DIR / STAMP_NAME, 

250 run: Callable = subprocess.run, 

251 read_text: Callable[[str], str | None] = _dist_text, 

252) -> BuildInfo: 

253 """The first source that knows this build; the release itself otherwise.""" 

254 return ( 

255 read_checkout(root, run=run) 

256 or read_stamp(stamp) 

257 or read_vcs_install(release, read_text=read_text) 

258 or BuildInfo(release, release) 

259 ) 

260 

261 

262@cache 

263def build_info() -> BuildInfo: 

264 """This process's build, worked out once — ``scanpath_studio.__version__``.""" 

265 from scanpath_studio import __release__ 

266 

267 return resolve(__release__) 

268 

269 

270def install_kind( 

271 info: BuildInfo | None = None, 

272 *, 

273 frozen: bool | None = None, 

274 prefix: str | None = None, 

275 installer: str | None = None, 

276 checkout: bool | None = None, 

277) -> str: 

278 """How this copy was installed — a key of :data:`INSTALL_KINDS`. 

279 

280 It decides the update instruction (``updates.update_command``). The desktop 

281 bundle is frozen; a ``pip install git+…`` says so through ``info``; a 

282 checkout, even one without tags, is :func:`is_checkout`; ``uv tool`` and 

283 pipx are recognised by where their environments live; uv by the 

284 ``INSTALLER`` file it records; anything else is pip. 

285 """ 

286 if getattr(sys, "frozen", False) if frozen is None else frozen: 

287 return "desktop" 

288 info = build_info() if info is None else info 

289 if info.source == "vcs": 

290 return "vcs" 

291 if info.source == "checkout" or (is_checkout() if checkout is None else checkout): 

292 return "checkout" 

293 where = Path(sys.prefix if prefix is None else prefix).as_posix().lower() 

294 if "/uv/tools/" in where: 

295 return "uv-tool" 

296 if "/pipx/venvs/" in where: 

297 return "pipx" 

298 if installer is None: 

299 installer = _dist_text("INSTALLER") or "" 

300 return "uv" if installer.strip().lower() == "uv" else "pip"