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/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/auto_update.py b/src/dbs_annotator/utils/auto_update.py new file mode 100644 index 0000000..7001df2 --- /dev/null +++ b/src/dbs_annotator/utils/auto_update.py @@ -0,0 +1,209 @@ +"""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.error +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" +# 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: + 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=_WINDOWS_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/utils/updater.py b/src/dbs_annotator/utils/updater.py index 1d6852e..30c2163 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: + """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..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 @@ -110,7 +115,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 +398,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.

  1. File setup: choose where to save the session file @@ -405,6 +410,11 @@ def _help_dialog_html(self) -> str:
  2. 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 +428,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 +496,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 +520,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 +550,52 @@ 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 _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.""" @@ -543,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/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_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 diff --git a/tests/unit/test_updater.py b/tests/unit/test_updater.py index 6a69a3d..4410df2 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,49 @@ 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 + seen: list[str] = [] + + def fake_get_version() -> str: + 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) + + 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()