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
« 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).
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:
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.
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"""
21from __future__ import annotations
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
34from packaging.version import InvalidVersion, Version
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)
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}
57@dataclass(frozen=True)
58class BuildInfo:
59 """One build: its PEP 440 ``version`` and how that was worked out.
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 """
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 = ""
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 "")
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 "")
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)
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
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)
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
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.
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)
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")
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
207def _dist_text(name: str) -> str | None:
208 """A file from this distribution's installed metadata, or ``None``."""
209 from importlib import metadata
211 try:
212 return metadata.distribution(DIST_NAME).read_text(name)
213 except metadata.PackageNotFoundError:
214 return None
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 )
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 )
262@cache
263def build_info() -> BuildInfo:
264 """This process's build, worked out once — ``scanpath_studio.__version__``."""
265 from scanpath_studio import __release__
267 return resolve(__release__)
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`.
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"