Skip to content

Commit a91a15a

Browse files
feat(updater): prereleases, semver max, auto-check opt-out
- List GitHub releases (paginated) and pick single highest PEP 440 version newer than local; include alpha/beta/rc and GitHub prerelease flag. - ReleaseInfo.is_prerelease drives dialog copy with feedback email and issues URL from config. - Persist updater/auto_check_enabled; skip deferred check when disabled; Help dialog checkbox + opt-out on update notification (manual check still uses force=True). Made-with: Cursor
1 parent 487536e commit a91a15a

4 files changed

Lines changed: 363 additions & 58 deletions

File tree

src/dbs_annotator/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@
2222
FS_ORG_NAME = "WyssCenter"
2323
FS_APP_NAME = "DBSAnnotator"
2424

25+
# Canonical upstream (releases + issue tracker; keep aligned with updater repo slug).
26+
APP_REPOSITORY_URL = "https://github.com/Brain-Modulation-Lab/App_ClinicalDBSAnnot"
27+
APP_ISSUES_URL = f"{APP_REPOSITORY_URL}/issues"
28+
UPDATE_FEEDBACK_EMAIL = "richard.koehler@wysscenter.ch"
29+
2530
# File paths (relative to executable)
2631
ICON_FILENAME = "logoneutral.png"
2732
ICO_FILENAME = "logoneutral.ico"

src/dbs_annotator/utils/updater.py

Lines changed: 108 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010
* The HTTP fetch runs on a worker thread via :class:`QThreadPool`; the
1111
main-thread slot is only invoked if a strictly-newer version is found.
1212
* The user can always trigger a check from a menu / button with
13-
``force=True``.
13+
``force=True`` (even when automatic checks are disabled in preferences).
14+
* Among all published (non-draft) releases, only the **highest** version
15+
greater than the running build is considered (PEP 440 ordering, including
16+
alpha / beta / rc). GitHub's ``/releases/latest`` endpoint is not used
17+
because it omits pre-releases.
1418
1519
The release repository is hardcoded to the canonical upstream; change
1620
:data:`DEFAULT_RELEASES_REPO` if the project moves.
@@ -25,6 +29,7 @@
2529
from collections.abc import Callable
2630
from dataclasses import dataclass
2731
from datetime import UTC, datetime, timedelta
32+
from typing import Any, cast
2833

2934
from packaging.version import InvalidVersion, Version
3035
from PySide6.QtCore import QObject, QRunnable, QSettings, QThreadPool, Signal
@@ -39,6 +44,9 @@
3944
DEFAULT_COOLDOWN = timedelta(hours=24)
4045
DEFAULT_TIMEOUT_SECONDS = 10
4146
_LAST_CHECK_KEY = "updater/last_check_iso"
47+
_AUTO_CHECK_KEY = "updater/auto_check_enabled"
48+
_RELEASES_PAGE_SIZE = 100
49+
_MAX_RELEASE_PAGES = 5
4250

4351

4452
@dataclass(frozen=True)
@@ -50,6 +58,9 @@ class ReleaseInfo:
5058
html_url: str
5159
published_at: str
5260
body: str
61+
#: ``True`` if GitHub marked the release as pre-release or the tag parses
62+
#: as a PEP 440 pre-release (alpha / beta / rc).
63+
is_prerelease: bool
5364

5465

5566
def _parse_version(tag: str) -> Version | None:
@@ -66,6 +77,21 @@ def _parse_version(tag: str) -> Version | None:
6677
return None
6778

6879

80+
def _coerce_bool(value: object, default: bool) -> bool:
81+
if isinstance(value, bool):
82+
return value
83+
if isinstance(value, str):
84+
s = value.lower().strip()
85+
if s in ("false", "0", "no", ""):
86+
return False
87+
if s in ("true", "1", "yes"):
88+
return True
89+
return default
90+
if value is None:
91+
return default
92+
return bool(value)
93+
94+
6995
class _CheckSignals(QObject):
7096
"""Qt signals for a check worker.
7197
@@ -96,7 +122,7 @@ def __init__(
96122

97123
def run(self) -> None:
98124
try:
99-
latest = self._fetch_latest_release()
125+
latest = self._fetch_newest_applicable_release()
100126
except Exception as exc:
101127
logger.info("Update check failed: %s", exc)
102128
self._signals.failed.emit(str(exc))
@@ -108,53 +134,94 @@ def run(self) -> None:
108134

109135
self._signals.update_available.emit(latest)
110136

111-
def _fetch_latest_release(self) -> ReleaseInfo | None:
112-
url = f"https://api.github.com/repos/{self._repo}/releases/latest"
113-
request = urllib.request.Request(
137+
def _request(self, url: str) -> urllib.request.Request:
138+
return urllib.request.Request(
114139
url,
115140
headers={
116141
"Accept": "application/vnd.github+json",
117142
"User-Agent": f"DBSAnnotator/{self._current_version}",
118143
},
119144
)
145+
146+
def _urlopen_json(self, url: str) -> object:
147+
request = self._request(url)
148+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
149+
return json.loads(response.read().decode("utf-8"))
150+
151+
def _fetch_releases_page(self, page: int) -> list[dict] | None:
152+
url = (
153+
f"https://api.github.com/repos/{self._repo}/releases"
154+
f"?per_page={_RELEASES_PAGE_SIZE}&page={page}"
155+
)
120156
try:
121-
with urllib.request.urlopen(request, timeout=self._timeout) as response:
122-
payload = json.loads(response.read().decode("utf-8"))
157+
payload = self._urlopen_json(url)
123158
except urllib.error.HTTPError as exc:
124-
# GitHub returns 404 when repo has no published releases (or repo missing).
125159
if exc.code == 404:
126160
logger.debug(
127-
"No GitHub releases/latest for %s (HTTP %s); treat as no update",
161+
"No GitHub releases list for %s (HTTP %s); treat as no update",
128162
self._repo,
129163
exc.code,
130164
)
131165
return None
132166
raise
133-
134-
tag = str(payload.get("tag_name", ""))
135-
if not tag:
167+
if not isinstance(payload, list):
168+
return []
169+
return cast(list[dict[str, Any]], payload)
170+
171+
def _fetch_all_releases(self) -> list[dict]:
172+
merged: list[dict] = []
173+
for page in range(1, _MAX_RELEASE_PAGES + 1):
174+
batch = self._fetch_releases_page(page)
175+
if batch is None:
176+
return []
177+
merged.extend(batch)
178+
if len(batch) < _RELEASES_PAGE_SIZE:
179+
break
180+
return merged
181+
182+
def _fetch_newest_applicable_release(self) -> ReleaseInfo | None:
183+
"""Return single newest published release with version *>* local."""
184+
payloads = self._fetch_all_releases()
185+
if not payloads:
136186
return None
137187

138-
remote = _parse_version(tag)
139188
local = _parse_version(self._current_version)
140-
if remote is None or local is None:
189+
if local is None:
141190
logger.debug(
142-
"Skipping update comparison for tag=%r current=%r",
143-
tag,
191+
"Skipping update comparison; local version not PEP 440: %r",
144192
self._current_version,
145193
)
146194
return None
147-
if payload.get("prerelease"):
148-
return None
149-
if remote <= local:
195+
196+
best_remote: Version | None = None
197+
best_payload: dict | None = None
198+
199+
for payload in payloads:
200+
if payload.get("draft"):
201+
continue
202+
tag = str(payload.get("tag_name", ""))
203+
if not tag:
204+
continue
205+
remote = _parse_version(tag)
206+
if remote is None or remote <= local:
207+
continue
208+
if best_remote is None or remote > best_remote:
209+
best_remote = remote
210+
best_payload = payload
211+
212+
if best_remote is None or best_payload is None:
150213
return None
151214

215+
gh_prerelease = bool(best_payload.get("prerelease"))
216+
is_prerelease = gh_prerelease or best_remote.is_prerelease
217+
152218
return ReleaseInfo(
153-
version=str(remote),
154-
tag_name=tag,
155-
html_url=str(payload.get("html_url", "")),
156-
published_at=str(payload.get("published_at", "")),
157-
body=str(payload.get("body", "")),
219+
version=str(best_remote),
220+
tag_name=str(best_payload.get("tag_name", "")),
221+
html_url=str(best_payload.get("html_url", "")),
222+
published_at=str(best_payload.get("published_at", "")),
223+
body=str(best_payload.get("body", "")),
224+
is_prerelease=is_prerelease,
158225
)
159226

160227

@@ -164,6 +231,8 @@ class UpdateChecker(QObject):
164231
Create one of these on the main thread (typically owned by the main
165232
window) and call :meth:`check_async`. A ``check_async(force=True)`` call
166233
bypasses the cooldown -- wire it to a "Check for updates" menu action.
234+
Automatic checks respect :meth:`auto_update_checks_enabled` (stored in
235+
``QSettings`` under :data:`_AUTO_CHECK_KEY`).
167236
"""
168237

169238
update_available = Signal(object)
@@ -189,6 +258,16 @@ def __init__(
189258
self._signals.up_to_date.connect(self._on_up_to_date)
190259
self._signals.failed.connect(self._on_failed)
191260

261+
def auto_update_checks_enabled(self) -> bool:
262+
"""Whether startup / periodic background checks are allowed."""
263+
raw = self._settings.value(_AUTO_CHECK_KEY, True)
264+
return _coerce_bool(raw, True)
265+
266+
def set_auto_update_checks_enabled(self, enabled: bool) -> None:
267+
"""Persist preference for automatic update checks."""
268+
self._settings.setValue(_AUTO_CHECK_KEY, enabled)
269+
self._settings.sync()
270+
192271
def _on_update_available(self, release: ReleaseInfo) -> None:
193272
self._record_check_time()
194273
self.update_available.emit(release)
@@ -211,13 +290,16 @@ def check_async(
211290
"""Schedule a background check.
212291
213292
Args:
214-
force: If True, bypass the cooldown.
293+
force: If True, bypass the cooldown and automatic-check opt-out.
215294
now: Injectable clock, only for tests.
216295
217296
Returns:
218297
True if a check was scheduled; False if the cooldown suppressed
219-
it.
298+
it, automatic checks are disabled, or (when not forced) opt-out
299+
applies.
220300
"""
301+
if not force and not self.auto_update_checks_enabled():
302+
return False
221303
if not force and not self._cooldown_elapsed(now()):
222304
return False
223305

src/dbs_annotator/views/wizard_window.py

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
navigation, and coordinates views with the controller.
66
"""
77

8+
import html
89
import logging
910
import os
1011
import typing
@@ -14,6 +15,7 @@
1415
from PySide6.QtGui import QDesktopServices, QIcon, QPixmap
1516
from PySide6.QtWidgets import (
1617
QAbstractButton,
18+
QCheckBox,
1719
QDialog,
1820
QFrame,
1921
QHBoxLayout,
@@ -31,14 +33,17 @@
3133
)
3234

3335
from ..config import (
36+
APP_ISSUES_URL,
3437
APP_NAME,
38+
APP_REPOSITORY_URL,
3539
APP_VERSION,
3640
BASE_DPI,
3741
FONT_SCALE_ENABLED,
3842
ICON_FILENAME,
3943
ICONS_DIR,
4044
RESPONSIVE_WINDOW_RATIOS,
4145
SCREEN_SIZE_THRESHOLDS,
46+
UPDATE_FEEDBACK_EMAIL,
4247
WINDOW_MAX_SIZE_RATIO,
4348
WINDOW_MIN_SIZE,
4449
WINDOW_SIZE_RATIO,
@@ -102,7 +107,11 @@ def __init__(self, app):
102107
self._update_checker = UpdateChecker(parent=self)
103108
self._update_checker.update_available.connect(self._on_update_available)
104109
# Defer slightly so the window is painted before any dialog appears.
105-
QTimer.singleShot(1500, lambda: self._update_checker.check_async())
110+
QTimer.singleShot(1500, self._run_deferred_update_check)
111+
112+
def _run_deferred_update_check(self) -> None:
113+
"""Background update check; skipped if user disabled automatic checks."""
114+
self._update_checker.check_async()
106115

107116
def _setup_window(self) -> None:
108117
"""Configure the main window properties with responsive sizing."""
@@ -371,7 +380,8 @@ def _show_info_dialog(self) -> None:
371380
# Description
372381
desc_text = QTextEdit()
373382
desc_text.setReadOnly(True)
374-
desc_text.setHtml("""
383+
desc_text.setHtml(
384+
f"""
375385
<h3>About this application</h3>
376386
<p>DBS Annotator is a specialized tool for clinicians and researchers
377387
working with Deep Brain Stimulation (DBS) systems. This application provides:</p>
@@ -400,17 +410,28 @@ def _show_info_dialog(self) -> None:
400410
</ol>
401411
402412
<h3>Support & Contact</h3>
403-
<p><b>GitHub Repository:</b> <a href='https://github.com/your-username/dbs-annotator'>https://github.com/your-username/dbs-annotator</a></p>
404-
<p>For bug reports, feature requests, or general support, please visit our GitHub repository
405-
or contact Lucia Poma directly at</b> <a href='mailto:lucia.poma@wysscenter.ch'>lucia.poma@wysscenter.ch</a></p>.
413+
<p><b>Repository:</b> <a href="{APP_REPOSITORY_URL}">{APP_REPOSITORY_URL}</a></p>
414+
<p>For bug reports and feature requests, use the
415+
<a href="{APP_ISSUES_URL}">issue tracker</a> or email
416+
<a href="mailto:{UPDATE_FEEDBACK_EMAIL}">{UPDATE_FEEDBACK_EMAIL}</a>.</p>
406417
407418
<h3>License</h3>
408419
<p>This software is released under an open-source license. Please see the GitHub repository
409420
for detailed licensing information.</p>
410-
""")
421+
"""
422+
)
411423

412424
layout.addWidget(desc_text)
413425

426+
auto_check_cb = QCheckBox(
427+
"Automatically check for updates (about once per day)"
428+
)
429+
auto_check_cb.setChecked(self._update_checker.auto_update_checks_enabled())
430+
auto_check_cb.toggled.connect(
431+
self._update_checker.set_auto_update_checks_enabled
432+
)
433+
layout.addWidget(auto_check_cb)
434+
414435
# Footer buttons
415436
button_layout = QHBoxLayout()
416437

@@ -480,22 +501,48 @@ def on_failed(error: str) -> None:
480501
def _on_update_available(self, release: ReleaseInfo) -> None:
481502
"""Show a non-blocking dialog when a newer release is published."""
482503
box = QMessageBox(self)
504+
box.setTextFormat(Qt.TextFormat.RichText)
483505
box.setIcon(QMessageBox.Icon.Information)
484-
box.setWindowTitle("Update available")
506+
box.setWindowTitle(
507+
"Pre-release available" if release.is_prerelease else "Update available"
508+
)
485509
box.setText(
486510
f"A new version of {APP_NAME} is available: "
487-
f"<b>{release.version}</b> (you have {APP_VERSION})."
511+
f"<b>{html.escape(release.version)}</b> "
512+
f"(you have {html.escape(APP_VERSION)})."
488513
)
514+
info_parts: list[str] = []
515+
if release.is_prerelease:
516+
mail_href = html.escape(f"mailto:{UPDATE_FEEDBACK_EMAIL}", quote=True)
517+
issues_href = html.escape(APP_ISSUES_URL, quote=True)
518+
email_lbl = html.escape(UPDATE_FEEDBACK_EMAIL)
519+
info_parts.append(
520+
"<p><b>Note:</b> This is <u>not</u> a stable release; bugs may occur. "
521+
"If you try this version, please report issues to "
522+
f'<a href="{mail_href}">{email_lbl}</a> or on '
523+
f'<a href="{issues_href}">GitHub</a>.</p>'
524+
)
489525
notes = release.body.strip() if release.body else ""
490526
if notes:
491527
excerpt = notes if len(notes) <= 600 else notes[:600] + "…"
492-
box.setInformativeText("Release notes:\n\n" + excerpt)
528+
excerpt_html = html.escape(excerpt).replace("\n", "<br>")
529+
info_parts.append(f"<p>Release notes:</p><p>{excerpt_html}</p>")
530+
if info_parts:
531+
box.setInformativeText("".join(info_parts))
532+
533+
opt_out_cb = QCheckBox("Don't notify me automatically about new updates")
534+
opt_out_cb.setChecked(False)
535+
box.setCheckBox(opt_out_cb)
536+
493537
download_btn = box.addButton(
494538
"Open download page", QMessageBox.ButtonRole.AcceptRole
495539
)
496540
box.addButton("Remind me later", QMessageBox.ButtonRole.RejectRole)
497541
box.exec()
498542

543+
if opt_out_cb.isChecked():
544+
self._update_checker.set_auto_update_checks_enabled(False)
545+
499546
if box.clickedButton() is download_btn and release.html_url:
500547
QDesktopServices.openUrl(QUrl(release.html_url))
501548

0 commit comments

Comments
 (0)