Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ repos:
- id: actionlint

- repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.11.7
rev: 0.11.6
hooks:
- id: uv-lock

# ruff-pre-commit rev pins `ruff==<same semver>`; keep in sync with PyPI and uv.lock.
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.11
rev: v0.15.10
hooks:
- id: ruff-format
- id: ruff-check
Expand Down
19 changes: 18 additions & 1 deletion docs/faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,29 @@ General

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

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

**Is my patient data sent anywhere?**

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

**How does the automatic update checker work?**

When automatic checks are enabled, the app compares your installed version with
published releases on the upstream GitHub repository (including pre-releases
when they are the newest applicable tag), and notifies you if a strictly newer
semver is available. Only one candidate release is considered—the highest
version above yours. Update checks run in the background, do not block startup,
and are skipped silently on errors. Use **Help** to toggle “Automatically check
for updates”, or disable them from the checkbox on the update dialog if you do
not want further automatic notifications.

**Which DBS systems are supported?**

The application is system-agnostic for data recording. Electrode visualisation
Expand Down
1 change: 1 addition & 0 deletions newsfragments/63.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +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.
5 changes: 5 additions & 0 deletions src/dbs_annotator/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@
FS_ORG_NAME = "WyssCenter"
FS_APP_NAME = "DBSAnnotator"

# Canonical upstream (releases + issue tracker; keep aligned with updater repo slug).
APP_REPOSITORY_URL = "https://github.com/Brain-Modulation-Lab/App_ClinicalDBSAnnot"
APP_ISSUES_URL = f"{APP_REPOSITORY_URL}/issues"
UPDATE_FEEDBACK_EMAIL = "lucia.poma@wysscenter.ch"

# File paths (relative to executable)
ICON_FILENAME = "logoneutral.png"
ICO_FILENAME = "logoneutral.ico"
Expand Down
137 changes: 115 additions & 22 deletions src/dbs_annotator/utils/updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
* The HTTP fetch runs on a worker thread via :class:`QThreadPool`; the
main-thread slot is only invoked if a strictly-newer version is found.
* The user can always trigger a check from a menu / button with
``force=True``.
``force=True`` (even when automatic checks are disabled in preferences).
* Among all published (non-draft) releases, only the **highest** version
greater than the running build is considered (PEP 440 ordering, including
alpha / beta / rc). GitHub's ``/releases/latest`` endpoint is not used
because it omits pre-releases.

The release repository is hardcoded to the canonical upstream; change
:data:`DEFAULT_RELEASES_REPO` if the project moves.
Expand All @@ -25,6 +29,7 @@
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any, cast

from packaging.version import InvalidVersion, Version
from PySide6.QtCore import QObject, QRunnable, QSettings, QThreadPool, Signal
Expand All @@ -39,6 +44,9 @@
DEFAULT_COOLDOWN = timedelta(hours=24)
DEFAULT_TIMEOUT_SECONDS = 10
_LAST_CHECK_KEY = "updater/last_check_iso"
_AUTO_CHECK_KEY = "updater/auto_check_enabled"
_RELEASES_PAGE_SIZE = 100
_MAX_RELEASE_PAGES = 5


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


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


def _coerce_bool(value: object, default: bool) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
s = value.lower().strip()
if s in ("false", "0", "no", ""):
return False
if s in ("true", "1", "yes"):
return True
return default
if value is None:
return default
return bool(value)


class _CheckSignals(QObject):
"""Qt signals for a check worker.

Expand Down Expand Up @@ -96,7 +122,7 @@ def __init__(

def run(self) -> None:
try:
latest = self._fetch_latest_release()
latest = self._fetch_newest_applicable_release()
except Exception as exc:
logger.info("Update check failed: %s", exc)
self._signals.failed.emit(str(exc))
Expand All @@ -108,42 +134,94 @@ def run(self) -> None:

self._signals.update_available.emit(latest)

def _fetch_latest_release(self) -> ReleaseInfo | None:
url = f"https://api.github.com/repos/{self._repo}/releases/latest"
request = urllib.request.Request(
def _request(self, url: str) -> urllib.request.Request:
return urllib.request.Request(
url,
headers={
"Accept": "application/vnd.github+json",
"User-Agent": f"DBSAnnotator/{self._current_version}",
},
)

def _urlopen_json(self, url: str) -> object:
request = self._request(url)
with urllib.request.urlopen(request, timeout=self._timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
return json.loads(response.read().decode("utf-8"))

tag = str(payload.get("tag_name", ""))
if not tag:
def _fetch_releases_page(self, page: int) -> list[dict] | None:
url = (
f"https://api.github.com/repos/{self._repo}/releases"
f"?per_page={_RELEASES_PAGE_SIZE}&page={page}"
)
try:
payload = self._urlopen_json(url)
except urllib.error.HTTPError as exc:
if exc.code == 404:
logger.debug(
"No GitHub releases list for %s (HTTP %s); treat as no update",
self._repo,
exc.code,
)
return None
raise
if not isinstance(payload, list):
return []
return cast(list[dict[str, Any]], payload)

def _fetch_all_releases(self) -> list[dict]:
merged: list[dict] = []
for page in range(1, _MAX_RELEASE_PAGES + 1):
batch = self._fetch_releases_page(page)
if batch is None:
return []
merged.extend(batch)
if len(batch) < _RELEASES_PAGE_SIZE:
break
return merged

def _fetch_newest_applicable_release(self) -> ReleaseInfo | None:
"""Return single newest published release with version *>* local."""
payloads = self._fetch_all_releases()
if not payloads:
return None

remote = _parse_version(tag)
local = _parse_version(self._current_version)
if remote is None or local is None:
if local is None:
logger.debug(
"Skipping update comparison for tag=%r current=%r",
tag,
"Skipping update comparison; local version not PEP 440: %r",
self._current_version,
)
return None
if payload.get("prerelease"):
return None
if remote <= local:

best_remote: Version | None = None
best_payload: dict | None = None

for payload in payloads:
if payload.get("draft"):
continue
tag = str(payload.get("tag_name", ""))
if not tag:
continue
remote = _parse_version(tag)
if remote is None or remote <= local:
continue
if best_remote is None or remote > best_remote:
best_remote = remote
best_payload = payload

if best_remote is None or best_payload is None:
return None

gh_prerelease = bool(best_payload.get("prerelease"))
is_prerelease = gh_prerelease or best_remote.is_prerelease

return ReleaseInfo(
version=str(remote),
tag_name=tag,
html_url=str(payload.get("html_url", "")),
published_at=str(payload.get("published_at", "")),
body=str(payload.get("body", "")),
version=str(best_remote),
tag_name=str(best_payload.get("tag_name", "")),
html_url=str(best_payload.get("html_url", "")),
published_at=str(best_payload.get("published_at", "")),
body=str(best_payload.get("body", "")),
is_prerelease=is_prerelease,
)


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

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

def auto_update_checks_enabled(self) -> bool:
"""Whether startup / periodic background checks are allowed."""
raw = self._settings.value(_AUTO_CHECK_KEY, True)
return _coerce_bool(raw, True)

def set_auto_update_checks_enabled(self, enabled: bool) -> None:
"""Persist preference for automatic update checks."""
self._settings.setValue(_AUTO_CHECK_KEY, enabled)
self._settings.sync()

def _on_update_available(self, release: ReleaseInfo) -> None:
self._record_check_time()
self.update_available.emit(release)
Expand All @@ -200,13 +290,16 @@ def check_async(
"""Schedule a background check.

Args:
force: If True, bypass the cooldown.
force: If True, bypass the cooldown and automatic-check opt-out.
now: Injectable clock, only for tests.

Returns:
True if a check was scheduled; False if the cooldown suppressed
it.
it, automatic checks are disabled, or (when not forced) opt-out
applies.
"""
if not force and not self.auto_update_checks_enabled():
return False
if not force and not self._cooldown_elapsed(now()):
return False

Expand Down
Loading