From 4fa5393100036403426ef9ef2bbcf68bc1766c6a Mon Sep 17 00:00:00 2001
From: Lucia Poma
Date: Thu, 21 May 2026 16:43:15 +0200
Subject: [PATCH 1/5] help and versioning fixes
---
docs/index.rst | 52 +++++++++++++----------
src/dbs_annotator/config.py | 21 ++++++++++
src/dbs_annotator/utils/updater.py | 49 ++++++++++++++++++++--
src/dbs_annotator/views/wizard_window.py | 43 +++++++++++++++----
tests/unit/test_updater.py | 53 +++++++++++++++++++++++-
5 files changed, 183 insertions(+), 35 deletions(-)
diff --git a/docs/index.rst b/docs/index.rst
index 3058eb8..6d74ce4 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -8,22 +8,26 @@ DBS Annotator
:align: center
:width: 180px
-|
-
**DBS Annotator** is a desktop application for recording and analysing
-Deep Brain Stimulation (DBS) clinical programming sessions. It guides the
-clinician or researcher through the **Complete Workflow** — from initial
-electrode configuration and baseline scales, through real-time stimulation
-adjustments, to the automatic generation of structured Word and PDF reports.
+Deep Brain Stimulation (DBS) clinical programming sessions. It guides
+the clinician or researcher through a DBS programming pipeline: initial
+electrode configuration, clinical scales, and general annotations;
+real-time stimulation adjustments; and session-specific scale changes and
+notes. Finally, it can generate structured Word and PDF reports from the
+programming session data.
-Developed at the **Brain Modulation Lab, Massachusetts General Hospital** (Boston, USA),
-the **Wyss Center for Bio and Neuroengineering** (Geneva, Switzerland), and
-**Charité Universitätsmedizin Berlin** (Germany).
+Developed at the **Brain Modulation Lab, Massachusetts General Hospital**
+(Boston, USA), the **Wyss Center for Bio and Neuroengineering** (Geneva,
+Switzerland), and **Charité Universitätsmedizin Berlin** (Germany).
.. note::
- | Version |release|.
- | Copyright © Massachusetts General Hospital, Wyss Center for Bio and Neuroengineering, and Charité Universitätsmedizin Berlin.
- | Contact: lucia.poma@wysscenter.ch
+
+ **Version:** |release|
+
+ Copyright © Massachusetts General Hospital, Wyss Center for Bio and
+ Neuroengineering, and Charité Universitätsmedizin Berlin.
+
+ **Contact:** lucia.poma@wysscenter.ch
----
@@ -68,17 +72,19 @@ Quick Overview
:header-rows: 0
* - **Complete Workflow**
- - Record stimulation parameters, clinical scales, and notes step-by-step.
- Export a structured report (Word / PDF) with tables, electrode diagrams,
- and session-scale timeline charts.
+ - Record stimulation parameters, clinical scales, and notes
+ step-by-step in a timestamped TSV table. Export a structured report
+ (Word / PDF) with tables, electrode diagrams, and session-scale
+ timeline charts.
* - **Annotations-only Workflow**
- - Quick timestamped text notes without the full stimulation workflow.
- * - **Longitudinal report**
- - Combine multiple session files into a single comparative document with
- overview tables, clinical and session-scale charts, electrode diagrams,
- and programming summaries.
+ - Quick timestamped text notes.
+ * - **Session and longitudinal reports**
+ - Combine single or multiple session files into a single comparative
+ document with overview tables, clinical and session-scale charts,
+ electrode diagrams, and programming summaries.
* - **BIDS-compliant output**
- - Data saved as ``sub-XX_ses-YYYYMMDD_task-programming_run-XX_events.tsv``.
+ - Data saved as
+ ``sub-XXXX_ses-YYYYMMDD_task-_run-XX_.``.
* - **Self-contained desktop app**
- - Packaged installers (``.msi``, ``.dmg``, ``.deb``); no separate Python
- runtime required.
+ - Packaged installers (``.msi``, ``.dmg``, ``.deb``); no separate
+ Python runtime required.
diff --git a/src/dbs_annotator/config.py b/src/dbs_annotator/config.py
index b9db7a0..7c69d48 100644
--- a/src/dbs_annotator/config.py
+++ b/src/dbs_annotator/config.py
@@ -39,6 +39,27 @@
# Canonical upstream (releases + issue tracker; keep aligned with updater repo slug).
APP_REPOSITORY_URL = "https://github.com/Brain-Modulation-Lab/DBSAnnotator"
APP_ISSUES_URL = f"{APP_REPOSITORY_URL}/issues"
+
+
+def github_repository_slug(repository_url: str) -> str:
+ """Return ``owner/repo`` from a ``https://github.com/owner/repo`` URL."""
+ prefix = "https://github.com/"
+ if not repository_url.startswith(prefix):
+ raise ValueError(
+ f"Expected a GitHub repository URL starting with {prefix!r}, "
+ f"got {repository_url!r}"
+ )
+ rest = repository_url.removeprefix(prefix).strip("/")
+ owner, sep, repo = rest.partition("/")
+ if not sep or not owner or not repo:
+ raise ValueError(
+ f"Could not parse owner/repo from GitHub URL {repository_url!r}"
+ )
+ return f"{owner}/{repo.split('/')[0]}"
+
+
+# GitHub Releases API slug for :mod:`dbs_annotator.utils.updater`.
+RELEASES_GITHUB_REPO = github_repository_slug(APP_REPOSITORY_URL)
# Primary contact for feedback (same person as APP_LEAD_AUTHOR).
UPDATE_FEEDBACK_EMAIL = "lucia.poma@wysscenter.ch"
diff --git a/src/dbs_annotator/utils/updater.py b/src/dbs_annotator/utils/updater.py
index 1d6852e..4179fc5 100644
--- a/src/dbs_annotator/utils/updater.py
+++ b/src/dbs_annotator/utils/updater.py
@@ -30,18 +30,20 @@
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
+from pathlib import Path
from typing import Any, cast
import certifi
from packaging.version import InvalidVersion, Version
from PySide6.QtCore import QObject, QRunnable, QSettings, QThreadPool, Signal
+from ..config import RELEASES_GITHUB_REPO
from ..version import get_version
logger = logging.getLogger(__name__)
#: Owner/repo pair on GitHub whose releases advertise new builds.
-DEFAULT_RELEASES_REPO = "Brain-Modulation-Lab/DBSAnnotator"
+DEFAULT_RELEASES_REPO = RELEASES_GITHUB_REPO
DEFAULT_COOLDOWN = timedelta(hours=24)
DEFAULT_TIMEOUT_SECONDS = 10
@@ -51,9 +53,28 @@
_MAX_RELEASE_PAGES = 5
+def _ca_bundle_path() -> str:
+ """Return a readable CA bundle path (Briefcase/MSI layouts may break ``where()``)."""
+ path = certifi.where()
+ if Path(path).is_file():
+ return path
+ try:
+ from importlib.resources import as_file, files
+
+ ref = files("certifi").joinpath("cacert.pem")
+ with as_file(ref) as bundle:
+ return str(bundle)
+ except Exception:
+ logger.warning(
+ "certifi CA bundle not found at %r; HTTPS update checks may fail",
+ path,
+ )
+ return path
+
+
def _ssl_context() -> ssl.SSLContext:
"""CA bundle for HTTPS in packaged apps (Briefcase MSI/ZIP on Windows)."""
- return ssl.create_default_context(cafile=certifi.where())
+ return ssl.create_default_context(cafile=_ca_bundle_path())
@dataclass(frozen=True)
@@ -255,15 +276,26 @@ def __init__(
) -> None:
super().__init__(parent)
self._repo = repo
- self._current_version = current_version or get_version()
+ # None => resolve via get_version() on each check (matches Help UI / metadata).
+ self._version_override = current_version
self._cooldown = cooldown
self._timeout = timeout
self._settings = QSettings()
+ self._check_in_progress = False
self._signals = _CheckSignals()
self._signals.update_available.connect(self._on_update_available)
self._signals.up_to_date.connect(self._on_up_to_date)
self._signals.failed.connect(self._on_failed)
+ def is_busy(self) -> bool:
+ """True while a background GitHub API request is in flight."""
+ return self._check_in_progress
+
+ def _installed_version(self) -> str:
+ if self._version_override is not None:
+ return self._version_override
+ return get_version()
+
def auto_update_checks_enabled(self) -> bool:
"""Whether startup / periodic background checks are allowed."""
raw = self._settings.value(_AUTO_CHECK_KEY, True)
@@ -274,15 +306,21 @@ def set_auto_update_checks_enabled(self, enabled: bool) -> None:
self._settings.setValue(_AUTO_CHECK_KEY, enabled)
self._settings.sync()
+ def _finish_check(self) -> None:
+ self._check_in_progress = False
+
def _on_update_available(self, release: ReleaseInfo) -> None:
+ self._finish_check()
self._record_check_time()
self.update_available.emit(release)
def _on_up_to_date(self) -> None:
+ self._finish_check()
self._record_check_time()
self.up_to_date.emit()
def _on_failed(self, error: str) -> None:
+ self._finish_check()
# Intentionally do NOT record a check time on hard failures so the
# next launch retries instead of waiting out the cooldown.
self.failed.emit(error)
@@ -308,10 +346,13 @@ def check_async(
return False
if not force and not self._cooldown_elapsed(now()):
return False
+ if self._check_in_progress:
+ return False
+ self._check_in_progress = True
worker = _CheckWorker(
repo=self._repo,
- current_version=self._current_version,
+ current_version=self._installed_version(),
timeout=self._timeout,
signals=self._signals,
)
diff --git a/src/dbs_annotator/views/wizard_window.py b/src/dbs_annotator/views/wizard_window.py
index 074a382..0e900bd 100644
--- a/src/dbs_annotator/views/wizard_window.py
+++ b/src/dbs_annotator/views/wizard_window.py
@@ -110,7 +110,7 @@ def __init__(self, app):
# Background update check. Runs once per cooldown window (24h by
# default); offline / rate-limited failures are logged silently.
- self._update_checker = UpdateChecker(current_version=APP_VERSION, parent=self)
+ self._update_checker = UpdateChecker(parent=self)
self._update_checker.update_available.connect(self._on_update_available)
# Defer slightly so the window is painted before any dialog appears.
QTimer.singleShot(1500, self._run_deferred_update_check)
@@ -393,7 +393,7 @@ def _help_dialog_html(self) -> str:
return f"""
General Overview
The main use of {html.escape(APP_NAME)} is a standard pipeline for
- DBS programming sessions: baseline setup, session scales, and
+ DBS programming sessions: baseline setup, session scales, and
real-time recording of each stimulation configuration you test in clinic.
- File setup: choose where to save the session file
@@ -405,6 +405,11 @@ def _help_dialog_html(self) -> str:
- Active recording: adjust parameters, score scales, add notes;
each configuration is saved as it is recorded.
+ {html.escape(APP_NAME)} records each stimulation configuration
+ you enter during a session in a consistent, reviewable form. Word and
+ PDF reports summarise that programming history for clinical follow-up,
+ audit, and research, together with scale scores and clinical
+ observations.
Timestamped data for analysis
Every entry is written immediately to a tab-separated
@@ -418,7 +423,7 @@ def _help_dialog_html(self) -> str:
clinical inspection without reopening the raw TSV.
You can also build a longitudinal report from several
task-programming files (same subject), or use
- Annotations-only Workflow for lightweight timestamped notes.
+ Annotations-only Workflow for lightweight timestamped notes.
Copyright
© 2025-{year} {html.escape(COPYRIGHT_HOLDERS)}
@@ -486,6 +491,15 @@ def _show_info_dialog(self) -> None:
def _manual_update_check(self, button: QPushButton) -> None:
"""Force an update check and show the result, used by the Help dialog."""
+ checker = self._update_checker
+ if checker.is_busy():
+ QMessageBox.information(
+ self,
+ "Checking for updates",
+ "An update check is already in progress. Please wait a moment.",
+ )
+ return
+
button.setEnabled(False)
original_text = button.text()
button.setText("Checking…")
@@ -501,6 +515,15 @@ def cleanup() -> None:
except (RuntimeError, TypeError):
pass
connections.clear()
+ try:
+ checker.update_available.connect(self._on_update_available)
+ except RuntimeError:
+ pass
+
+ try:
+ checker.update_available.disconnect(self._on_update_available)
+ except RuntimeError:
+ pass
def on_available(release: ReleaseInfo) -> None:
cleanup()
@@ -522,13 +545,19 @@ def on_failed(error: str) -> None:
f"Could not reach the update server:\n\n{error}",
)
- connections.append((self._update_checker.update_available, on_available))
- connections.append((self._update_checker.up_to_date, on_up_to_date))
- connections.append((self._update_checker.failed, on_failed))
+ connections.append((checker.update_available, on_available))
+ connections.append((checker.up_to_date, on_up_to_date))
+ connections.append((checker.failed, on_failed))
for signal, slot in connections:
signal.connect(slot)
- self._update_checker.check_async(force=True)
+ if not checker.check_async(force=True):
+ cleanup()
+ QMessageBox.warning(
+ self,
+ "Update check failed",
+ "Could not start the update check. Please try again in a moment.",
+ )
def _on_update_available(self, release: ReleaseInfo) -> None:
"""Show a non-blocking dialog when a newer release is published."""
diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py
index 6a69a3d..974313d 100644
--- a/tests/unit/test_updater.py
+++ b/tests/unit/test_updater.py
@@ -10,8 +10,14 @@
import pytest
+from dbs_annotator.config import RELEASES_GITHUB_REPO
from dbs_annotator.utils import updater as updater_mod
-from dbs_annotator.utils.updater import UpdateChecker, _CheckSignals, _CheckWorker
+from dbs_annotator.utils.updater import (
+ DEFAULT_RELEASES_REPO,
+ UpdateChecker,
+ _CheckSignals,
+ _CheckWorker,
+)
class _FakeResp:
@@ -53,6 +59,10 @@ def test_fetch_releases_404_raises() -> None:
worker._fetch_newest_applicable_release()
+def test_default_releases_repo_matches_config() -> None:
+ assert DEFAULT_RELEASES_REPO == RELEASES_GITHUB_REPO
+
+
def test_urlopen_uses_certifi_ssl_context() -> None:
signals = _CheckSignals()
worker = _CheckWorker("o/r", "1.0.0", 10.0, signals)
@@ -69,6 +79,47 @@ def test_urlopen_uses_certifi_ssl_context() -> None:
assert mock_open.call_args.kwargs.get("context") is mock_ctx.return_value
+def test_check_async_uses_fresh_installed_version(monkeypatch) -> None:
+ checker = UpdateChecker(current_version=None)
+ checker._settings = MagicMock()
+ checker._settings.value.return_value = True
+ versions = iter(["1.0.0", "2.0.0"])
+
+ def fake_get_version() -> str:
+ return next(versions)
+
+ monkeypatch.setattr("dbs_annotator.utils.updater.get_version", fake_get_version)
+
+ pool = MagicMock()
+ with patch(
+ "dbs_annotator.utils.updater.QThreadPool.globalInstance",
+ return_value=pool,
+ ):
+ checker.check_async(force=True)
+ worker = pool.start.call_args[0][0]
+ assert worker._current_version == "1.0.0"
+
+ checker._finish_check()
+ checker.check_async(force=True)
+ worker2 = pool.start.call_args[0][0]
+ assert worker2._current_version == "2.0.0"
+
+
+def test_check_async_busy_blocks_second_start() -> None:
+ checker = UpdateChecker(current_version="1.0.0")
+ checker._settings = MagicMock()
+ checker._settings.value.return_value = True
+ checker._check_in_progress = True
+
+ pool = MagicMock()
+ with patch(
+ "dbs_annotator.utils.updater.QThreadPool.globalInstance",
+ return_value=pool,
+ ):
+ assert checker.check_async(force=True) is False
+ pool.start.assert_not_called()
+
+
@pytest.mark.parametrize("code", [403, 500, 502])
def test_fetch_releases_other_http_raises(code: int) -> None:
signals = _CheckSignals()
From eb9014201dca4555e1692871a4ac7af3418c42a8 Mon Sep 17 00:00:00 2001
From: Lucia Poma
Date: Thu, 21 May 2026 17:17:45 +0200
Subject: [PATCH 2/5] releasing and help fixes
---
src/dbs_annotator/utils/auto_update.py | 206 +++++++++++++++++++++++
src/dbs_annotator/views/wizard_window.py | 95 ++++++++---
tests/unit/test_auto_update.py | 75 +++++++++
3 files changed, 353 insertions(+), 23 deletions(-)
create mode 100644 src/dbs_annotator/utils/auto_update.py
create mode 100644 tests/unit/test_auto_update.py
diff --git a/src/dbs_annotator/utils/auto_update.py b/src/dbs_annotator/utils/auto_update.py
new file mode 100644
index 0000000..a75bbe4
--- /dev/null
+++ b/src/dbs_annotator/utils/auto_update.py
@@ -0,0 +1,206 @@
+"""Launch platform install scripts to upgrade a packaged DBS Annotator build."""
+
+from __future__ import annotations
+
+import logging
+import os
+import ssl
+import subprocess
+import sys
+import tempfile
+import urllib.request
+from pathlib import Path
+
+import certifi
+
+from ..config import RELEASES_GITHUB_REPO
+
+logger = logging.getLogger(__name__)
+
+_INSTALL_SCRIPT_BRANCH = "main"
+_USER_AGENT = "DBSAnnotator-AutoUpdate/1.0"
+
+
+def _install_script_url(filename: str) -> str:
+ return (
+ f"https://raw.githubusercontent.com/{RELEASES_GITHUB_REPO}/"
+ f"{_INSTALL_SCRIPT_BRANCH}/scripts/{filename}"
+ )
+
+
+def _download_install_script(filename: str) -> Path:
+ url = _install_script_url(filename)
+ request = urllib.request.Request(
+ url,
+ headers={"User-Agent": _USER_AGENT},
+ )
+ ctx = ssl.create_default_context(cafile=certifi.where())
+ with urllib.request.urlopen(request, timeout=60, context=ctx) as response:
+ data = response.read()
+ suffix = ".ps1" if filename.endswith(".ps1") else ".sh"
+ fd, path_str = tempfile.mkstemp(prefix="dbs_annotator_install_", suffix=suffix)
+ os.close(fd)
+ path = Path(path_str)
+ path.write_bytes(data)
+ if suffix == ".sh":
+ path.chmod(0o755)
+ return path
+
+
+def automatic_update_supported() -> bool:
+ """True on platforms where ``scripts/install.*`` can be launched."""
+ return (
+ sys.platform == "win32"
+ or sys.platform == "darwin"
+ or sys.platform.startswith("linux")
+ )
+
+
+def automatic_update_targets_packaged_install() -> bool:
+ """True when the updater installs into the standard Briefcase location."""
+ return bool(getattr(sys, "frozen", False))
+
+
+def launch_automatic_update(
+ tag_name: str,
+ *,
+ dry_run: bool = False,
+) -> tuple[bool, str]:
+ """Start the GitHub release installer for *tag_name* (e.g. ``v0.4.0b2``).
+
+ Args:
+ dry_run: If True, run the platform install script in preview mode only
+ (PowerShell ``-WhatIf`` / ``install.sh --dry-run``). No files are
+ written.
+
+ Returns:
+ ``(True, user_message)`` on success, ``(False, error_message)`` otherwise.
+ The installer runs in a separate process; the user must restart the app.
+ """
+ tag = tag_name.strip()
+ if not tag:
+ return False, "Release tag is missing."
+
+ try:
+ if sys.platform == "win32":
+ return _launch_windows(tag, dry_run=dry_run)
+ if sys.platform == "darwin":
+ return _launch_unix(tag, "install.sh", dry_run=dry_run)
+ if sys.platform.startswith("linux"):
+ return _launch_unix(tag, "install.sh", dry_run=dry_run)
+ return False, f"Automatic update is not supported on {sys.platform!r}."
+ except OSError as exc:
+ logger.info("Automatic update failed: %s", exc)
+ return False, str(exc)
+ except urllib.error.URLError as exc:
+ logger.info("Could not download install script: %s", exc)
+ return False, f"Could not download the installer script:\n\n{exc}"
+
+
+def _launch_windows(tag: str, *, dry_run: bool = False) -> tuple[bool, str]:
+ script = _download_install_script("install.ps1")
+ try:
+ cmd = [
+ "powershell.exe",
+ "-NoProfile",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-File",
+ str(script),
+ "-VersionTag",
+ tag,
+ "-GitHubRepository",
+ RELEASES_GITHUB_REPO,
+ ]
+ if dry_run:
+ cmd.append("-WhatIf")
+ subprocess.Popen(
+ cmd,
+ creationflags=subprocess.CREATE_NEW_CONSOLE,
+ close_fds=True,
+ )
+ finally:
+ if not dry_run:
+ try:
+ script.unlink(missing_ok=True)
+ except OSError:
+ pass
+
+ if dry_run:
+ return True, (
+ "Dry run: a PowerShell window will show what the installer would do "
+ "(no files are changed). Check that window for errors before running "
+ "a real update."
+ )
+ return True, (
+ "The updater is running in a new window. When it finishes, close this "
+ "application and open DBS Annotator again from the Start menu."
+ )
+
+
+def _launch_unix(tag: str, filename: str, *, dry_run: bool = False) -> tuple[bool, str]:
+ script = _download_install_script(filename)
+ args = "--dry-run" if dry_run else ""
+ wrapper = script.with_name(f"run_{script.name}")
+ wrapper.write_text(
+ "#!/bin/sh\n"
+ f'export DBS_ANNOTATOR_INSTALL_REPO="{RELEASES_GITHUB_REPO}"\n'
+ f'export DBS_ANNOTATOR_VERSION="{tag}"\n'
+ f'exec "{script}" {args} "{tag}"\n',
+ encoding="utf-8",
+ )
+ wrapper.chmod(0o755)
+
+ if sys.platform == "darwin":
+ cmd = [
+ "osascript",
+ "-e",
+ f'tell application "Terminal" to do script "{wrapper}"',
+ ]
+ subprocess.Popen(cmd, start_new_session=True, close_fds=True)
+ else:
+ launched = False
+ for term_cmd in (
+ ["x-terminal-emulator", "-e", str(wrapper)],
+ ["konsole", "-e", str(wrapper)],
+ ["gnome-terminal", "--", str(wrapper)],
+ ):
+ if _which(term_cmd[0]):
+ subprocess.Popen(
+ term_cmd,
+ start_new_session=True,
+ close_fds=True,
+ )
+ launched = True
+ break
+ if not launched:
+ env = os.environ.copy()
+ env["DBS_ANNOTATOR_INSTALL_REPO"] = RELEASES_GITHUB_REPO
+ env["DBS_ANNOTATOR_VERSION"] = tag
+ cmd = [str(script)]
+ if dry_run:
+ cmd.append("--dry-run")
+ cmd.append(tag)
+ subprocess.Popen(
+ cmd,
+ env=env,
+ start_new_session=True,
+ close_fds=True,
+ )
+
+ if dry_run:
+ return True, (
+ "Dry run: a terminal window will show what the installer would do "
+ "(no files are changed). Check that window for errors before running "
+ "a real update."
+ )
+ return True, (
+ "The updater is running. When it finishes, quit this application and "
+ "reopen DBS Annotator from your applications menu."
+ )
+
+
+def _which(name: str) -> str | None:
+ from shutil import which
+
+ return which(name)
diff --git a/src/dbs_annotator/views/wizard_window.py b/src/dbs_annotator/views/wizard_window.py
index 0e900bd..63e05de 100644
--- a/src/dbs_annotator/views/wizard_window.py
+++ b/src/dbs_annotator/views/wizard_window.py
@@ -12,7 +12,7 @@
from datetime import datetime
from typing import Protocol
-from PySide6.QtCore import QSize, Qt, QTimer, QUrl
+from PySide6.QtCore import QSize, Qt, QTimer
from PySide6.QtGui import QDesktopServices, QIcon, QPixmap
from PySide6.QtWidgets import (
QAbstractButton,
@@ -56,6 +56,11 @@
)
from ..controllers import WizardController
from ..utils import get_theme_manager, resource_path, rounded_pixmap
+from ..utils.auto_update import (
+ automatic_update_supported,
+ automatic_update_targets_packaged_install,
+ launch_automatic_update,
+)
from ..utils.scale_preset_manager import get_scale_preset_manager
from ..utils.updater import ReleaseInfo, UpdateChecker
from .annotation_only_view import AnnotationsFileView, AnnotationsSessionView
@@ -559,6 +564,39 @@ def on_failed(error: str) -> None:
"Could not start the update check. Please try again in a moment.",
)
+ def _show_release_notes_dialog(self, release: ReleaseInfo) -> None:
+ """Show full release notes for *release* in a separate dialog."""
+ dialog = QDialog(self)
+ dialog.setWindowTitle(f"Release notes — {release.version}")
+ dialog.setMinimumSize(520, 400)
+
+ layout = QVBoxLayout(dialog)
+ browser = QTextBrowser()
+ browser.setOpenExternalLinks(True)
+ notes = release.body.strip() if release.body else ""
+ if notes:
+ notes_html = html.escape(notes).replace("\n", "
")
+ browser.setHtml(f"{notes_html}
")
+ else:
+ browser.setHtml(
+ "No release notes were published for this version.
"
+ )
+ if release.html_url:
+ browser.append(
+ f''
+ "View on GitHub
"
+ )
+ browser.anchorClicked.connect(QDesktopServices.openUrl)
+ layout.addWidget(browser)
+
+ close_btn = QPushButton("Close")
+ close_btn.clicked.connect(dialog.accept)
+ row = QHBoxLayout()
+ row.addStretch()
+ row.addWidget(close_btn)
+ layout.addLayout(row)
+ dialog.exec()
+
def _on_update_available(self, release: ReleaseInfo) -> None:
"""Show a non-blocking dialog when a newer release is published."""
box = QMessageBox(self)
@@ -572,40 +610,51 @@ def _on_update_available(self, release: ReleaseInfo) -> None:
f"{html.escape(release.version)} "
f"(you have {html.escape(APP_VERSION)})."
)
- info_parts: list[str] = []
if release.is_prerelease:
mail_href = html.escape(f"mailto:{UPDATE_FEEDBACK_EMAIL}", quote=True)
issues_href = html.escape(APP_ISSUES_URL, quote=True)
email_lbl = html.escape(UPDATE_FEEDBACK_EMAIL)
- info_parts.append(
+ box.setInformativeText(
"Note: This is not a stable release; bugs may occur. "
- "If you try this version, please report issues to "
+ "If you encounter issues, please report them to "
f'{email_lbl} or on '
f'GitHub.
'
)
- notes = release.body.strip() if release.body else ""
- if notes:
- excerpt = notes if len(notes) <= 600 else notes[:600] + "…"
- excerpt_html = html.escape(excerpt).replace("\n", "
")
- info_parts.append(f"Release notes:
{excerpt_html}
")
- if info_parts:
- box.setInformativeText("".join(info_parts))
-
- opt_out_cb = QCheckBox("Don't notify me automatically about new updates")
- opt_out_cb.setChecked(False)
- box.setCheckBox(opt_out_cb)
-
- download_btn = box.addButton(
- "Open download page", QMessageBox.ButtonRole.AcceptRole
+
+ update_btn = None
+ notes_btn = box.addButton(
+ "View release notes", QMessageBox.ButtonRole.ActionRole
)
+ if automatic_update_supported():
+ update_btn = box.addButton("Update now", QMessageBox.ButtonRole.AcceptRole)
box.addButton("Remind me later", QMessageBox.ButtonRole.RejectRole)
box.exec()
- if opt_out_cb.isChecked():
- self._update_checker.set_auto_update_checks_enabled(False)
-
- if box.clickedButton() is download_btn and release.html_url:
- QDesktopServices.openUrl(QUrl(release.html_url))
+ clicked = box.clickedButton()
+ if clicked is update_btn:
+ ok, detail = launch_automatic_update(release.tag_name)
+ if ok:
+ extra = ""
+ if not automatic_update_targets_packaged_install():
+ extra = (
+ "Running from source: the installer places the "
+ "released build in the standard install location "
+ "(not this development checkout).
"
+ )
+ started = QMessageBox(self)
+ started.setIcon(QMessageBox.Icon.Information)
+ started.setWindowTitle("Update started")
+ started.setTextFormat(Qt.TextFormat.RichText)
+ started.setText(f"{html.escape(detail)}
{extra}")
+ started.exec()
+ else:
+ QMessageBox.warning(
+ self,
+ "Update failed",
+ f"Could not start the automatic updater:\n\n{detail}",
+ )
+ elif clicked is notes_btn:
+ self._show_release_notes_dialog(release)
def _toggle_theme(self) -> None:
"""Toggle between dark and light themes."""
diff --git a/tests/unit/test_auto_update.py b/tests/unit/test_auto_update.py
new file mode 100644
index 0000000..3db3300
--- /dev/null
+++ b/tests/unit/test_auto_update.py
@@ -0,0 +1,75 @@
+"""Tests for :mod:`dbs_annotator.utils.auto_update`."""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from dbs_annotator.utils import auto_update as au
+
+
+def test_install_script_url() -> None:
+ url = au._install_script_url("install.ps1")
+ assert "Brain-Modulation-Lab/DBSAnnotator" in url
+ assert url.endswith("/scripts/install.ps1")
+
+
+def test_launch_windows_invokes_powershell(tmp_path: Path) -> None:
+ script = tmp_path / "install.ps1"
+ script.write_text("# stub", encoding="utf-8")
+
+ with (
+ patch.object(au, "_download_install_script", return_value=script),
+ patch.object(au.subprocess, "Popen") as mock_popen,
+ ):
+ ok, msg = au._launch_windows("v0.4.0b2")
+
+ assert ok is True
+ assert "new window" in msg.lower()
+ mock_popen.assert_called_once()
+ cmd = mock_popen.call_args[0][0]
+ assert cmd[0] == "powershell.exe"
+ assert "-VersionTag" in cmd
+ assert "v0.4.0b2" in cmd
+ assert "-WhatIf" not in cmd
+
+
+def test_launch_windows_dry_run_adds_whatif(tmp_path: Path) -> None:
+ script = tmp_path / "install.ps1"
+ script.write_text("# stub", encoding="utf-8")
+
+ with (
+ patch.object(au, "_download_install_script", return_value=script),
+ patch.object(au.subprocess, "Popen") as mock_popen,
+ ):
+ ok, msg = au._launch_windows("v0.4.0b2", dry_run=True)
+
+ assert ok is True
+ assert "dry run" in msg.lower()
+ cmd = mock_popen.call_args[0][0]
+ assert "-WhatIf" in cmd
+
+
+def test_launch_automatic_update_empty_tag() -> None:
+ ok, msg = au.launch_automatic_update("")
+ assert ok is False
+ assert "tag" in msg.lower()
+
+
+@pytest.mark.skipif(sys.platform != "win32", reason="windows-only path")
+def test_launch_automatic_update_windows() -> None:
+ with (
+ patch.object(au, "_launch_windows", return_value=(True, "ok")) as mock_win,
+ patch.object(au, "_launch_unix"),
+ ):
+ ok, msg = au.launch_automatic_update("v1.0.0")
+ assert ok is True
+ mock_win.assert_called_once_with("v1.0.0", dry_run=False)
+
+
+def test_automatic_update_supported_on_windows() -> None:
+ with patch.object(sys, "platform", "win32"):
+ assert au.automatic_update_supported() is True
From aae10b21a9daaab89c243aacbc41afcd0d942a09 Mon Sep 17 00:00:00 2001
From: Lucia Poma
Date: Thu, 21 May 2026 17:27:22 +0200
Subject: [PATCH 3/5] docs: note in-app and changelog
---
CHANGELOG.md | 14 ++++++++++++++
src/dbs_annotator/utils/updater.py | 2 +-
2 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c97fc77..a660f14 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- In-app **Update now** on the update-available dialog: downloads and runs the
+ platform install scripts (``install.ps1`` on Windows, ``install.sh`` on macOS /
+ Linux) for the selected GitHub release tag, then prompts you to restart the
+ app from the Start menu or applications launcher.
+- **View release notes** on the same dialog opens full release notes (and a
+ GitHub link) in a separate window.
+- ``dbs_annotator.utils.auto_update`` with optional ``dry_run=True`` (PowerShell
+ ``-WhatIf`` / ``install.sh --dry-run``) for maintainers to preview an install.
- Read the Docs screenshot pipeline: home screen (light and dark theme), clinical
and session scales settings dialogs, and regenerated workflow captures at
native resolution (HiDPI-aware screen grab, improved PNG settings).
@@ -24,7 +32,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Help** dialog rewritten around the standard **Complete Workflow** programming
pipeline, timestamped ``task-programming`` TSV output, and Word/PDF reports;
+ notes that each stimulation configuration is saved and summarised in reports;
copyright lists MGH, Wyss Center, and Charité; MIT license noted.
+- Update-available dialog: removed the extra “don't notify automatically”
+ checkbox (that preference stays under **Help** only); release notes are no
+ longer inlined—use **View release notes**; **Open download page** removed in
+ favour of **Update now**.
+- Pre-release update notice: “If you encounter issues, please report them to …”.
- Annotations-only file step header title: **Clinical Annotations Setup** (was
"Output File").
- Home screen buttons: **Complete Workflow** and **Annotations-only Workflow**.
diff --git a/src/dbs_annotator/utils/updater.py b/src/dbs_annotator/utils/updater.py
index 4179fc5..30c2163 100644
--- a/src/dbs_annotator/utils/updater.py
+++ b/src/dbs_annotator/utils/updater.py
@@ -54,7 +54,7 @@
def _ca_bundle_path() -> str:
- """Return a readable CA bundle path (Briefcase/MSI layouts may break ``where()``)."""
+ """Readable CA bundle path (Briefcase/MSI layouts may break ``where()``)."""
path = certifi.where()
if Path(path).is_file():
return path
From 4213cff89ec1dd346ddff03db6af67803ef386b2 Mon Sep 17 00:00:00 2001
From: Lucia Poma
Date: Thu, 21 May 2026 17:33:44 +0200
Subject: [PATCH 4/5] all os update
---
src/dbs_annotator/utils/auto_update.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/dbs_annotator/utils/auto_update.py b/src/dbs_annotator/utils/auto_update.py
index a75bbe4..7001df2 100644
--- a/src/dbs_annotator/utils/auto_update.py
+++ b/src/dbs_annotator/utils/auto_update.py
@@ -8,6 +8,7 @@
import subprocess
import sys
import tempfile
+import urllib.error
import urllib.request
from pathlib import Path
@@ -19,6 +20,8 @@
_INSTALL_SCRIPT_BRANCH = "main"
_USER_AGENT = "DBSAnnotator-AutoUpdate/1.0"
+# Windows-only; absent on Linux/macOS (CI runs unit tests there too).
+_WINDOWS_NEW_CONSOLE = getattr(subprocess, "CREATE_NEW_CONSOLE", 0)
def _install_script_url(filename: str) -> str:
@@ -116,7 +119,7 @@ def _launch_windows(tag: str, *, dry_run: bool = False) -> tuple[bool, str]:
cmd.append("-WhatIf")
subprocess.Popen(
cmd,
- creationflags=subprocess.CREATE_NEW_CONSOLE,
+ creationflags=_WINDOWS_NEW_CONSOLE,
close_fds=True,
)
finally:
From bd7882f4e90e8f8fb1b45c1de0023a50c5f88ef8 Mon Sep 17 00:00:00 2001
From: Lucia Poma
Date: Thu, 21 May 2026 17:37:06 +0200
Subject: [PATCH 5/5] mac release update
---
tests/conftest.py | 10 +++++++++-
tests/unit/test_updater.py | 6 ++++--
2 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/tests/conftest.py b/tests/conftest.py
index 6941a11..76da7a8 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -17,6 +17,8 @@
else:
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+from unittest.mock import MagicMock, patch
+
import pytest
from dbs_annotator.views.wizard_window import WizardWindow
@@ -25,7 +27,13 @@
@pytest.fixture
def wizard(qtbot, qapp):
"""Main wizard window bound to the session QApplication."""
- w = WizardWindow(qapp)
+ # Do not schedule deferred GitHub update checks (network + cross-test leaks).
+ with (
+ patch("dbs_annotator.views.wizard_window.UpdateChecker") as checker_cls,
+ patch("dbs_annotator.views.wizard_window.QTimer.singleShot"),
+ ):
+ checker_cls.return_value = MagicMock()
+ w = WizardWindow(qapp)
qtbot.addWidget(w)
w.show()
return w
diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py
index 974313d..4410df2 100644
--- a/tests/unit/test_updater.py
+++ b/tests/unit/test_updater.py
@@ -83,10 +83,12 @@ def test_check_async_uses_fresh_installed_version(monkeypatch) -> None:
checker = UpdateChecker(current_version=None)
checker._settings = MagicMock()
checker._settings.value.return_value = True
- versions = iter(["1.0.0", "2.0.0"])
+ seen: list[str] = []
def fake_get_version() -> str:
- return next(versions)
+ v = "1.0.0" if len(seen) == 0 else "2.0.0"
+ seen.append(v)
+ return v
monkeypatch.setattr("dbs_annotator.utils.updater.get_version", fake_get_version)