Coverage for scanpath_studio/updates.py: 94%
129 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"""Is there a newer Scanpath Studio than this build? (#139)
3Asked only when someone clicks *Check for updates* (Help → About), runs
4``scanpath-studio version --check`` or calls ``api.check_for_updates`` — never
5on its own (docs/privacy.md → *Network activity*). One source serves every
6install: GitHub's latest release, which leaves out drafts and pre-releases and
7lists each desktop archive with its sha256. :func:`check_for_updates` never
8raises; a check that could not be made says why in ``UpdateCheck.message``.
9"""
11from __future__ import annotations
13import http.client
14import json
15import platform
16import ssl
17import sys
18import urllib.error
19import urllib.request
20from collections.abc import Callable
21from dataclasses import dataclass
22from datetime import datetime
24import truststore
25from packaging.version import InvalidVersion, Version
27from .build_info import BuildInfo, build_info, install_kind
29REPO = "lacclab/scanpath-studio"
30LATEST_RELEASE_API = f"https://api.github.com/repos/{REPO}/releases/latest"
31RELEASES_PAGE = f"https://github.com/{REPO}/releases"
32TIMEOUT_S = 5.0
34#: The shell command that updates each kind of install (``build_info.INSTALL_KINDS``);
35#: ``vcs`` is built from the URL pip recorded, and the desktop app downloads.
36UPDATE_COMMANDS = {
37 "checkout": "git pull",
38 "uv-tool": "uv tool upgrade scanpath-studio",
39 "pipx": "pipx upgrade scanpath-studio",
40 "uv": "uv pip install -U scanpath-studio",
41 "pip": "pip install -U scanpath-studio",
42}
44#: Each release's desktop download per (platform, machine) — the names
45#: ``.github/workflows/desktop.yml`` gives them. There is no Intel Mac build.
46#: Windows people get the per-user installer; the ``.zip`` stays on releases as
47#: the folder #385's updater swaps.
48DESKTOP_ARCHIVES = {
49 ("darwin", "arm64"): "ScanpathStudio-macos-arm64.dmg",
50 ("win32", "amd64"): "ScanpathStudio-windows-x86_64-setup.exe",
51 ("win32", "x86_64"): "ScanpathStudio-windows-x86_64-setup.exe",
52 ("linux", "x86_64"): "ScanpathStudio-linux-x86_64.tar.gz",
53}
55_OFFLINE = "Couldn't reach GitHub to check — are you offline?"
56_BAD_CERT = (
57 "GitHub's certificate couldn't be verified on this computer, so the check "
58 "was not made."
59)
60_UNREADABLE = (
61 "GitHub sent an answer this version of the app can't read; try again later."
62)
65@dataclass(frozen=True)
66class Asset:
67 """One file attached to a release; ``digest`` is ``"sha256:<hex>"`` or ``""``."""
69 name: str
70 url: str
71 size: int = 0
72 digest: str = ""
75@dataclass(frozen=True)
76class Release:
77 """A published release: ``version`` is the tag without its ``v``, ``url`` its page."""
79 version: str
80 tag: str
81 published_at: str
82 url: str
83 assets: tuple[Asset, ...] = ()
85 def asset(self, name: str) -> Asset | None:
86 """The attached file called ``name``, if the release has it yet."""
87 return next((asset for asset in self.assets if asset.name == name), None)
90@dataclass(frozen=True)
91class UpdateCheck:
92 """The answer to "is there a newer release than this build?".
94 ``status`` is ``"up_to_date"``, ``"update_available"``, ``"ahead"`` (a
95 development build past the latest release) or ``"error"`` (the check could
96 not be made); ``message`` says it in a sentence. With an update available,
97 ``command`` is the shell command that updates this install or, in the
98 desktop app, ``download`` is this computer's archive (``None`` until the
99 release's desktop builds are uploaded).
100 """
102 status: str
103 current: str
104 message: str
105 latest: Release | None = None
106 install_kind: str = "pip"
107 command: str = ""
108 download: Asset | None = None
111class UpdateCheckError(Exception):
112 """A check that could not be made; ``str()`` is the reason, for people."""
115def _ssl_context() -> ssl.SSLContext:
116 """TLS trust for the request: what the operating system trusts (#391).
118 Python's own defaults read OpenSSL's CA list, which a python.org install
119 on macOS ships empty and a frozen desktop build may not find at all, and
120 which never holds the root a TLS-inspecting campus or company proxy
121 re-signs with. `truststore` verifies against the macOS Keychain, the
122 Windows certificate store or the system bundle instead, as the browser
123 does — the same trust ``datasets._open_url`` uses for downloads.
124 """
125 return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
128def _urlopen(request: urllib.request.Request, timeout: float):
129 return urllib.request.urlopen(request, timeout=timeout, context=_ssl_context())
132def latest_release(
133 timeout: float = TIMEOUT_S, *, opener: Callable | None = None
134) -> Release:
135 """GitHub's latest release of Scanpath Studio. Raises :class:`UpdateCheckError`.
137 ``opener(request, timeout)`` replaces the HTTP call (tests pass a fake).
138 """
139 opener = _urlopen if opener is None else opener
140 request = urllib.request.Request(
141 LATEST_RELEASE_API,
142 headers={
143 "Accept": "application/vnd.github+json",
144 "User-Agent": f"scanpath-studio/{build_info().version}",
145 },
146 )
147 try:
148 with opener(request, timeout=timeout) as response:
149 payload = json.load(response)
150 except urllib.error.HTTPError as error:
151 raise UpdateCheckError(_http_reason(error)) from error
152 except (ssl.SSLCertVerificationError, urllib.error.URLError) as error:
153 if isinstance(error, ssl.SSLCertVerificationError) or isinstance(
154 getattr(error, "reason", None), ssl.SSLCertVerificationError
155 ):
156 raise UpdateCheckError(_BAD_CERT) from error
157 raise UpdateCheckError(_OFFLINE) from error
158 except (
159 OSError,
160 http.client.HTTPException,
161 ) as error: # a refused connection, a timeout, a dropped answer
162 raise UpdateCheckError(_OFFLINE) from error
163 except ValueError as error:
164 raise UpdateCheckError(_UNREADABLE) from error
165 return _release_from(payload)
168def _http_reason(error: urllib.error.HTTPError) -> str:
169 headers = error.headers or {}
170 if (
171 error.code in (403, 429)
172 and str(headers.get("X-RateLimit-Remaining", "")) == "0"
173 ):
174 try:
175 reset = datetime.fromtimestamp(int(headers.get("X-RateLimit-Reset")))
176 when = f" after {reset:%H:%M}"
177 except (TypeError, ValueError, OverflowError, OSError):
178 when = " later"
179 return (
180 f"GitHub's limit on checks from this network is used up; try again{when}."
181 )
182 if error.code == 404:
183 return "GitHub lists no published release of Scanpath Studio."
184 return f"GitHub answered with an error (HTTP {error.code}); try again later."
187def _release_from(payload: object) -> Release:
188 try:
189 tag = str(payload["tag_name"])
190 assets = tuple(
191 Asset(
192 name=str(item["name"]),
193 url=str(item["browser_download_url"]),
194 size=int(item.get("size") or 0),
195 digest=str(item.get("digest") or ""),
196 )
197 for item in payload.get("assets") or ()
198 )
199 return Release(
200 version=tag.removeprefix("v"),
201 tag=tag,
202 published_at=str(payload.get("published_at") or ""),
203 url=str(payload.get("html_url") or RELEASES_PAGE),
204 assets=assets,
205 )
206 except (KeyError, TypeError, ValueError, AttributeError) as error:
207 raise UpdateCheckError(_UNREADABLE) from error
210def desktop_archive(
211 system: str | None = None, machine: str | None = None
212) -> str | None:
213 """This computer's desktop archive name, or ``None`` where there is no build."""
214 system = sys.platform if system is None else system
215 machine = (platform.machine() if machine is None else machine).lower()
216 if system.startswith("linux"):
217 system = "linux"
218 return DESKTOP_ARCHIVES.get((system, machine))
221def update_command(kind: str, info: BuildInfo) -> str:
222 """The shell command that updates this kind of install; ``""`` for the desktop app."""
223 if kind == "vcs":
224 url = info.vcs_url or f"https://github.com/{REPO}"
225 return f'pip install -U "git+{url}"'
226 return UPDATE_COMMANDS.get(kind, "")
229def _released_on(iso: str) -> str:
230 try:
231 when = datetime.fromisoformat(iso)
232 except ValueError:
233 return ""
234 return f"{when.day} {when:%b %Y}"
237def check_for_updates(
238 timeout: float = TIMEOUT_S,
239 *,
240 latest: Callable[[], Release] | None = None,
241 info: BuildInfo | None = None,
242 kind: str | None = None,
243) -> UpdateCheck:
244 """Compare this build with GitHub's latest release. Never raises.
246 ``latest`` replaces the GitHub request (the app passes a cached one, tests a
247 fake); ``info`` and ``kind`` default to this process's build and install.
248 """
249 info = build_info() if info is None else info
250 kind = install_kind(info) if kind is None else kind
251 try:
252 release = latest() if latest is not None else latest_release(timeout)
253 except UpdateCheckError as error:
254 return UpdateCheck("error", info.version, str(error), install_kind=kind)
255 try:
256 newest, current = Version(release.version), Version(info.version)
257 except InvalidVersion:
258 return UpdateCheck(
259 "error",
260 info.version,
261 f"GitHub's latest release, {release.tag}, isn't a version this app "
262 "can compare.",
263 latest=release,
264 install_kind=kind,
265 )
266 if newest == current:
267 return UpdateCheck(
268 "up_to_date",
269 info.version,
270 f"v{release.version} is the latest release.",
271 latest=release,
272 install_kind=kind,
273 )
274 if newest < current:
275 return UpdateCheck(
276 "ahead",
277 info.version,
278 f"{info.describe()}. The latest release is v{release.version}.",
279 latest=release,
280 install_kind=kind,
281 )
282 released = _released_on(release.published_at)
283 message = (
284 f"v{release.version} is out"
285 + (f" (released {released})" if released else "")
286 + f"; this is v{info.version}."
287 )
288 if kind != "desktop":
289 return UpdateCheck(
290 "update_available",
291 info.version,
292 message,
293 latest=release,
294 install_kind=kind,
295 command=update_command(kind, info),
296 )
297 name = desktop_archive()
298 download = release.asset(name) if name else None
299 if download is None:
300 message += (
301 " Its download for this computer isn't on the release page yet; "
302 "desktop builds are uploaded up to an hour after a release."
303 if name
304 else " There is no desktop build for this computer; see the release page."
305 )
306 return UpdateCheck(
307 "update_available",
308 info.version,
309 message,
310 latest=release,
311 install_kind=kind,
312 download=download,
313 )