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

1"""Is there a newer Scanpath Studio than this build? (#139) 

2 

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""" 

10 

11from __future__ import annotations 

12 

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 

23 

24import truststore 

25from packaging.version import InvalidVersion, Version 

26 

27from .build_info import BuildInfo, build_info, install_kind 

28 

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 

33 

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} 

43 

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} 

54 

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) 

63 

64 

65@dataclass(frozen=True) 

66class Asset: 

67 """One file attached to a release; ``digest`` is ``"sha256:<hex>"`` or ``""``.""" 

68 

69 name: str 

70 url: str 

71 size: int = 0 

72 digest: str = "" 

73 

74 

75@dataclass(frozen=True) 

76class Release: 

77 """A published release: ``version`` is the tag without its ``v``, ``url`` its page.""" 

78 

79 version: str 

80 tag: str 

81 published_at: str 

82 url: str 

83 assets: tuple[Asset, ...] = () 

84 

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) 

88 

89 

90@dataclass(frozen=True) 

91class UpdateCheck: 

92 """The answer to "is there a newer release than this build?". 

93 

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 """ 

101 

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 

109 

110 

111class UpdateCheckError(Exception): 

112 """A check that could not be made; ``str()`` is the reason, for people.""" 

113 

114 

115def _ssl_context() -> ssl.SSLContext: 

116 """TLS trust for the request: what the operating system trusts (#391). 

117 

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) 

126 

127 

128def _urlopen(request: urllib.request.Request, timeout: float): 

129 return urllib.request.urlopen(request, timeout=timeout, context=_ssl_context()) 

130 

131 

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`. 

136 

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) 

166 

167 

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." 

185 

186 

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 

208 

209 

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)) 

219 

220 

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, "") 

227 

228 

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}" 

235 

236 

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. 

245 

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 )