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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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**.
Expand Down
52 changes: 29 additions & 23 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

----

Expand Down Expand Up @@ -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-<TASK>_run-XX_<type-of-data>.<ext>``.
* - **Self-contained desktop app**
- Packaged installers (``.msi``, ``.dmg``, ``.deb``); no separate Python
runtime required.
- Packaged installers (``.msi``, ``.dmg``, ``.deb``); no separate
Python runtime required.
21 changes: 21 additions & 0 deletions src/dbs_annotator/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
209 changes: 209 additions & 0 deletions src/dbs_annotator/utils/auto_update.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading