Skip to content

Commit f63d689

Browse files
Merge pull request #63 from richardkoehler/fix/updater-github-404-no-releases
Fix/updater GitHub 404 no releases
2 parents 16cf39b + f9c9211 commit f63d689

7 files changed

Lines changed: 415 additions & 34 deletions

File tree

.pre-commit-config.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ repos:
55
- id: actionlint
66

77
- repo: https://github.com/astral-sh/uv-pre-commit
8-
rev: 0.11.7
8+
rev: 0.11.6
99
hooks:
1010
- id: uv-lock
1111

12+
# ruff-pre-commit rev pins `ruff==<same semver>`; keep in sync with PyPI and uv.lock.
1213
- repo: https://github.com/astral-sh/ruff-pre-commit
13-
rev: v0.15.11
14+
rev: v0.15.10
1415
hooks:
1516
- id: ruff-format
1617
- id: ruff-check

docs/faq.rst

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,29 @@ General
66

77
**Does the application require an internet connection?**
88

9-
No. The application is fully offline. It only reads and writes local files.
9+
No for normal clinical use: session recording, editing, and export work fully
10+
offline and only read and write local files. Optionally, the application can
11+
contact the public GitHub *releases* API (about once per day when enabled) to
12+
see whether a newer build is published; that request does not include patient
13+
or session content. If the network is unavailable or the check fails, the app
14+
continues without blocking. You can turn off automatic update checks from
15+
**Help** (or from the opt-out on an update notification).
1016

1117
**Is my patient data sent anywhere?**
1218

1319
No. All data stays on your local machine. No telemetry, no cloud sync.
1420

21+
**How does the automatic update checker work?**
22+
23+
When automatic checks are enabled, the app compares your installed version with
24+
published releases on the upstream GitHub repository (including pre-releases
25+
when they are the newest applicable tag), and notifies you if a strictly newer
26+
semver is available. Only one candidate release is considered—the highest
27+
version above yours. Update checks run in the background, do not block startup,
28+
and are skipped silently on errors. Use **Help** to toggle “Automatically check
29+
for updates”, or disable them from the checkbox on the update dialog if you do
30+
not want further automatic notifications.
31+
1532
**Which DBS systems are supported?**
1633

1734
The application is system-agnostic for data recording. Electrode visualisation

newsfragments/63.changed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Update checker compares all published GitHub releases for the highest applicable newer semver (including pre-releases), handles repos with no releases quietly, surfaces pre-release guidance with support links, and lets users turn off automatic update checks from Help or the notification.

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 = "lucia.poma@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: 115 additions & 22 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,42 +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)
120148
with urllib.request.urlopen(request, timeout=self._timeout) as response:
121-
payload = json.loads(response.read().decode("utf-8"))
149+
return json.loads(response.read().decode("utf-8"))
122150

123-
tag = str(payload.get("tag_name", ""))
124-
if not tag:
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+
)
156+
try:
157+
payload = self._urlopen_json(url)
158+
except urllib.error.HTTPError as exc:
159+
if exc.code == 404:
160+
logger.debug(
161+
"No GitHub releases list for %s (HTTP %s); treat as no update",
162+
self._repo,
163+
exc.code,
164+
)
165+
return None
166+
raise
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:
125186
return None
126187

127-
remote = _parse_version(tag)
128188
local = _parse_version(self._current_version)
129-
if remote is None or local is None:
189+
if local is None:
130190
logger.debug(
131-
"Skipping update comparison for tag=%r current=%r",
132-
tag,
191+
"Skipping update comparison; local version not PEP 440: %r",
133192
self._current_version,
134193
)
135194
return None
136-
if payload.get("prerelease"):
137-
return None
138-
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:
139213
return None
140214

215+
gh_prerelease = bool(best_payload.get("prerelease"))
216+
is_prerelease = gh_prerelease or best_remote.is_prerelease
217+
141218
return ReleaseInfo(
142-
version=str(remote),
143-
tag_name=tag,
144-
html_url=str(payload.get("html_url", "")),
145-
published_at=str(payload.get("published_at", "")),
146-
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,
147225
)
148226

149227

@@ -153,6 +231,8 @@ class UpdateChecker(QObject):
153231
Create one of these on the main thread (typically owned by the main
154232
window) and call :meth:`check_async`. A ``check_async(force=True)`` call
155233
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`).
156236
"""
157237

158238
update_available = Signal(object)
@@ -178,6 +258,16 @@ def __init__(
178258
self._signals.up_to_date.connect(self._on_up_to_date)
179259
self._signals.failed.connect(self._on_failed)
180260

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+
181271
def _on_update_available(self, release: ReleaseInfo) -> None:
182272
self._record_check_time()
183273
self.update_available.emit(release)
@@ -200,13 +290,16 @@ def check_async(
200290
"""Schedule a background check.
201291
202292
Args:
203-
force: If True, bypass the cooldown.
293+
force: If True, bypass the cooldown and automatic-check opt-out.
204294
now: Injectable clock, only for tests.
205295
206296
Returns:
207297
True if a check was scheduled; False if the cooldown suppressed
208-
it.
298+
it, automatic checks are disabled, or (when not forced) opt-out
299+
applies.
209300
"""
301+
if not force and not self.auto_update_checks_enabled():
302+
return False
210303
if not force and not self._cooldown_elapsed(now()):
211304
return False
212305

0 commit comments

Comments
 (0)