From bf1ec07872cd365308a5d84db05ae93a23c3ad81 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 16:32:00 +0200 Subject: [PATCH 1/7] Support multiple update backends Add utilities to construct package specs and installer commands (supporting uv then pip) and import shutil/sys. Update MainWindow to run update commands in sequence: store a queue of backends, start each with _start_next_update_command, accumulate attempt outputs and backend names, and retry fallbacks when one backend fails. Improve error messages and logging, adjust progress/cleanup behavior, and surface a consolidated failure dialog if no backend succeeds. --- deeplabcut/gui/utils.py | 56 ++++++++++++++++++ deeplabcut/gui/window.py | 121 +++++++++++++++++++++++++++++---------- 2 files changed, 148 insertions(+), 29 deletions(-) diff --git a/deeplabcut/gui/utils.py b/deeplabcut/gui/utils.py index 1fae770fe..b63095cbb 100644 --- a/deeplabcut/gui/utils.py +++ b/deeplabcut/gui/utils.py @@ -10,6 +10,8 @@ # import json import re +import shutil +import sys import urllib.request from collections.abc import Callable @@ -234,3 +236,57 @@ def _is_up_to_date(installed: str, latest: str) -> bool: except InvalidVersion: return installed == latest return installed == latest + + +def package_specs_for_update(packages: list[str]) -> list[str]: + """Return package specs to install for GUI updates. + + DeepLabCut itself should be updated with the GUI extra so GUI dependencies + are kept in sync. + """ + specs = [] + + for package in packages: + normalized = package.strip() + + if normalized == "deeplabcut": + specs.append("deeplabcut[gui]") + else: + specs.append(normalized) + + return specs + + +def build_update_commands(packages: list[str]) -> list[tuple[str, str, list[str]]]: + """Build installer commands, ordered from preferred to fallback. + + Returns tuples of: + + (backend_name, program, arguments) + + The order is: + 1. uv, if available + 2. current-interpreter pip fallback + """ + specs = package_specs_for_update(packages) + commands = [] + + uv = shutil.which("uv") + if uv: + commands.append( + ( + "uv", + uv, + ["pip", "install", "--python", sys.executable, "-U", *specs], + ) + ) + + commands.append( + ( + "pip", + sys.executable, + ["-m", "pip", "install", "-U", *specs], + ) + ) + + return commands diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py index e5b8fb466..d1e972184 100644 --- a/deeplabcut/gui/window.py +++ b/deeplabcut/gui/window.py @@ -56,7 +56,7 @@ UnsupervizedIdTracking, VideoEditor, ) -from deeplabcut.gui.utils import UpdateChecker +from deeplabcut.gui.utils import UpdateChecker, build_update_commands from deeplabcut.gui.widgets import StreamReceiver, StreamWriter warnings.filterwarnings( @@ -98,6 +98,9 @@ def __init__(self, app): # Update checks self._update_process = None self._update_process_output = [] + self._update_commands = [] + self._update_attempt_outputs = [] + self._current_update_backend = None self._scheduled_update_check_silent = True self._update_check_timer = QtCore.QTimer(self) @@ -330,6 +333,29 @@ def _trigger_scheduled_update_check(self): return self._updater.check(silent=self._scheduled_update_check_silent) + def _start_next_update_command(self): + """Start the next update backend. Return True if one was started.""" + if not self._update_commands: + return False + + backend_name, program, arguments = self._update_commands.pop(0) + self._current_update_backend = backend_name + + self.status_bar.showMessage(f"Installing updates with {backend_name}...") + + self._update_process = QtCore.QProcess(self) + self._update_process.setProgram(program) + self._update_process.setArguments(arguments) + + self._update_process.finished.connect(self._on_update_process_finished) + self._update_process.errorOccurred.connect(self._on_update_process_error) + self._update_process.readyRead.connect(self._drain_update_process_output) + self._update_process.setProcessChannelMode(QtCore.QProcess.MergedChannels) + self._update_process.start() + + self.logger.info("Starting update command: %s %s", program, " ".join(arguments)) + return True + def _run_update_command(self, packages): if not packages: return @@ -341,15 +367,17 @@ def _run_update_command(self, packages): self.status_bar.showMessage("Installing updates...") self._update_process_output = [] - self._update_process = QtCore.QProcess(self) - self._update_process.setProgram(sys.executable) - self._update_process.setArguments(["-m", "pip", "install", "-U", *packages]) + self._update_attempt_outputs = [] + self._update_commands = build_update_commands(packages) + self._current_update_backend = None - self._update_process.finished.connect(self._on_update_process_finished) - self._update_process.errorOccurred.connect(self._on_update_process_error) - self._update_process.readyRead.connect(self._drain_update_process_output) - self._update_process.setProcessChannelMode(QtCore.QProcess.MergedChannels) - self._update_process.start() + if not self._start_next_update_command(): + self._cleanup_update_process() + QtWidgets.QMessageBox.warning( + self, + "Update failed", + "No available installer backend was found.", + ) def _drain_update_process_output(self): if self._update_process is None: @@ -373,7 +401,12 @@ def _cleanup_update_process(self): if self._update_process is not None: self._update_process.deleteLater() self._update_process = None + self._update_process_output = [] + self._update_commands = [] + self._update_attempt_outputs = [] + self._current_update_backend = None + self._progress_bar.hide() self.status_bar.showMessage("www.deeplabcut.org") @@ -382,19 +415,33 @@ def _on_update_process_error(self, error): self._cleanup_update_process() return + backend = self._current_update_backend or "installer" + error_strings = { - QtCore.QProcess.FailedToStart: ( - "Process failed to start. Check that pip is available and you have sufficient permissions." - ), - QtCore.QProcess.Crashed: "Update process crashed unexpectedly.", - QtCore.QProcess.Timedout: "Update process timed out.", - QtCore.QProcess.WriteError: "Could not write to update process.", - QtCore.QProcess.ReadError: "Could not read from update process.", - QtCore.QProcess.UnknownError: "An unknown error occurred.", + QtCore.QProcess.FailedToStart: f"The {backend} update process failed to start.", + QtCore.QProcess.Crashed: f"The {backend} update process crashed unexpectedly.", + QtCore.QProcess.Timedout: f"The {backend} update process timed out.", + QtCore.QProcess.WriteError: f"Could not write to the {backend} update process.", + QtCore.QProcess.ReadError: f"Could not read from the {backend} update process.", + QtCore.QProcess.UnknownError: f"An unknown {backend} update error occurred.", } - message = error_strings.get(error, "An unknown error occurred.") - QtWidgets.QMessageBox.warning(self, "Update failed", message) + message = error_strings.get(error, f"An unknown {backend} update error occurred.") + self.logger.warning(message) + self._update_attempt_outputs.append(message) + + if self._update_process is not None: + self._update_process.deleteLater() + self._update_process = None + + if self._start_next_update_command(): + return + + QtWidgets.QMessageBox.warning( + self, + "Update failed", + "No update backend completed successfully.\n\n" + "\n\n---\n\n".join(self._update_attempt_outputs), + ) self._cleanup_update_process() def _on_update_process_finished(self, exit_code, exit_status): @@ -405,28 +452,44 @@ def _on_update_process_finished(self, exit_code, exit_status): if self._update_process is None: return - self._progress_bar.hide() self._drain_update_process_output() output = "".join(self._update_process_output).strip() + backend = self._current_update_backend or "installer" if exit_status == QtCore.QProcess.NormalExit and exit_code == 0: + self._progress_bar.hide() + QtWidgets.QMessageBox.information( self, "Update complete", "The update completed successfully.\n\nPlease restart DeepLabCut to use the updated packages.", ) + if output: self.logger.info(output) - else: - QtWidgets.QMessageBox.warning( - self, - "Update failed", - "The update command did not complete successfully.\n\n" - f"{output or 'No additional output was captured.'}", - ) - if output: - self.logger.error(output) + + self._cleanup_update_process() + return + + failed_output = ( + f"{backend} failed with exit code {exit_code}.\n\n{output or 'No additional output was captured.'}" + ) + self._update_attempt_outputs.append(failed_output) + self.logger.warning(failed_output) + + self._update_process.deleteLater() + self._update_process = None + self._update_process_output = [] + + if self._start_next_update_command(): + return + + QtWidgets.QMessageBox.warning( + self, + "Update failed", + "No update backend completed successfully.\n\n" + "\n\n---\n\n".join(self._update_attempt_outputs), + ) self._cleanup_update_process() From 68cfb2e525c6203e99d348671f59b3fdc6a0d533 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 16:32:18 +0200 Subject: [PATCH 2/7] Add tests for auto-update GUI utils Add unit tests for deeplabcut.gui.utils functions package_specs_for_update and build_update_commands. Tests verify package_specs_for_update adds the GUI extra to 'deeplabcut', preserves other packages, and strips surrounding whitespace. Tests for build_update_commands assert backend ordering and that 'uv' is used when available, the exact command arguments (including use of sys.executable), and that a pip fallback is always present; monkeypatching of shutil.which is used to simulate installer availability. --- tests/gui/test_auto_update.py | 143 ++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 tests/gui/test_auto_update.py diff --git a/tests/gui/test_auto_update.py b/tests/gui/test_auto_update.py new file mode 100644 index 000000000..09ba0fb91 --- /dev/null +++ b/tests/gui/test_auto_update.py @@ -0,0 +1,143 @@ +import sys + +import pytest + +from deeplabcut.gui.utils import build_update_commands, package_specs_for_update + + +def test_package_specs_for_update_adds_gui_extra_to_deeplabcut(): + assert package_specs_for_update(["deeplabcut"]) == ["deeplabcut[gui]"] + + +def test_package_specs_for_update_preserves_other_packages(): + assert package_specs_for_update(["napari-deeplabcut"]) == ["napari-deeplabcut"] + + +def test_package_specs_for_update_handles_mixed_packages(): + assert package_specs_for_update(["deeplabcut", "napari-deeplabcut"]) == [ + "deeplabcut[gui]", + "napari-deeplabcut", + ] + + +def test_package_specs_for_update_strips_whitespace(): + assert package_specs_for_update([" deeplabcut ", " napari-deeplabcut "]) == [ + "deeplabcut[gui]", + "napari-deeplabcut", + ] + + +@pytest.mark.parametrize( + ("available_installers", "expected_backends"), + [ + ({}, ["pip"]), + ({"uv": "/mock/bin/uv"}, ["uv", "pip"]), + ( + {"uv": "/mock/bin/uv"}, + ["uv", "pip"], + ), + ], +) +def test_build_update_commands_backend_order(monkeypatch, available_installers, expected_backends): + def fake_which(name): + return available_installers.get(name) + + monkeypatch.setattr("deeplabcut.gui.utils.shutil.which", fake_which) + + commands = build_update_commands(["deeplabcut", "napari-deeplabcut"]) + + assert [backend for backend, _program, _args in commands] == expected_backends + + +def test_build_update_commands_uses_uv_when_available(monkeypatch): + monkeypatch.setattr( + "deeplabcut.gui.utils.shutil.which", + lambda name: "/mock/bin/uv" if name == "uv" else None, + ) + + commands = build_update_commands(["deeplabcut", "napari-deeplabcut"]) + + assert commands == [ + ( + "uv", + "/mock/bin/uv", + [ + "pip", + "install", + "--python", + sys.executable, + "-U", + "deeplabcut[gui]", + "napari-deeplabcut", + ], + ), + ( + "pip", + sys.executable, + [ + "-m", + "pip", + "install", + "-U", + "deeplabcut[gui]", + "napari-deeplabcut", + ], + ), + ] + + +def test_build_update_commands_uses_uv_then_pip(monkeypatch): + installers = {"uv": "/mock/bin/uv"} + + monkeypatch.setattr( + "deeplabcut.gui.utils.shutil.which", + lambda name: installers.get(name), + ) + + commands = build_update_commands(["deeplabcut"]) + + assert commands == [ + ( + "uv", + "/mock/bin/uv", + [ + "pip", + "install", + "--python", + sys.executable, + "-U", + "deeplabcut[gui]", + ], + ), + ( + "pip", + sys.executable, + [ + "-m", + "pip", + "install", + "-U", + "deeplabcut[gui]", + ], + ), + ] + + +def test_build_update_commands_always_has_pip_fallback(monkeypatch): + monkeypatch.setattr("deeplabcut.gui.utils.shutil.which", lambda _name: None) + + commands = build_update_commands(["deeplabcut"]) + + assert commands == [ + ( + "pip", + sys.executable, + [ + "-m", + "pip", + "install", + "-U", + "deeplabcut[gui]", + ], + ) + ] From 8f0d7ed303ee5e6b42bce9d549c1321fa2333e7a Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Tue, 26 May 2026 16:43:28 +0200 Subject: [PATCH 3/7] Reset update output and skip test if no PySide6 Initialize _update_process_output to an empty list when tearing down the update process to avoid carrying over stale output after process deletion. Also make the GUI auto-update test skip when PySide6 is not installed by adding pytest.importorskip("PySide6") to tests/gui/test_auto_update.py, preventing failures in environments without the GUI dependency. --- deeplabcut/gui/window.py | 1 + tests/gui/test_auto_update.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py index d1e972184..4fbf2c03f 100644 --- a/deeplabcut/gui/window.py +++ b/deeplabcut/gui/window.py @@ -432,6 +432,7 @@ def _on_update_process_error(self, error): if self._update_process is not None: self._update_process.deleteLater() + self._update_process_output = [] self._update_process = None if self._start_next_update_command(): diff --git a/tests/gui/test_auto_update.py b/tests/gui/test_auto_update.py index 09ba0fb91..b49f20330 100644 --- a/tests/gui/test_auto_update.py +++ b/tests/gui/test_auto_update.py @@ -2,6 +2,8 @@ import pytest +pytest.importorskip("PySide6") + from deeplabcut.gui.utils import build_update_commands, package_specs_for_update From faeb05d01028dd9e1096fae31a2fc7db8145c0d2 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Wed, 27 May 2026 16:37:39 +0200 Subject: [PATCH 4/7] Remove duplicate test Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/gui/test_auto_update.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/gui/test_auto_update.py b/tests/gui/test_auto_update.py index b49f20330..cc6d51abf 100644 --- a/tests/gui/test_auto_update.py +++ b/tests/gui/test_auto_update.py @@ -34,10 +34,6 @@ def test_package_specs_for_update_strips_whitespace(): [ ({}, ["pip"]), ({"uv": "/mock/bin/uv"}, ["uv", "pip"]), - ( - {"uv": "/mock/bin/uv"}, - ["uv", "pip"], - ), ], ) def test_build_update_commands_backend_order(monkeypatch, available_installers, expected_backends): From 8890667ceb3d510657ce7370cb941b257dd757c1 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 11:06:28 +0200 Subject: [PATCH 5/7] Guard and disconnect update process signals Add robust handling for the update QProcess to avoid races and leaks. Introduces _disconnect_update_process to safely disconnect signals, updates _drain_update_process_output to accept a process (and resolve sender()), and adds sender checks in error/finished handlers so only the current process is handled. Ensures signals are disconnected before deleteLater and prevents acting on stale or unrelated processes. --- deeplabcut/gui/window.py | 52 ++++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py index 4fbf2c03f..021d2cb51 100644 --- a/deeplabcut/gui/window.py +++ b/deeplabcut/gui/window.py @@ -312,6 +312,20 @@ def video_type(self, ext): def video_files(self): return self.files + def _disconnect_update_process(self, process): + if process is None: + return + + for signal, slot in ( + (process.finished, self._on_update_process_finished), + (process.errorOccurred, self._on_update_process_error), + (process.readyRead, self._drain_update_process_output), + ): + try: + signal.disconnect(slot) + except (TypeError, RuntimeError): + pass + def check_for_updates(self, *, silent=True, delay_ms=0): """Start an update check immediately or schedule it after a delay.""" if self._closing: @@ -379,11 +393,19 @@ def _run_update_command(self, packages): "No available installer backend was found.", ) - def _drain_update_process_output(self): - if self._update_process is None: + def _drain_update_process_output(self, process=None): + + if process is None: + sender = self.sender() + process = sender if sender is not None else self._update_process + + if process is not self._update_process: return - data = bytes(self._update_process.readAll()) + if process is None: + return + + data = bytes(process.readAll()) if not data: return @@ -399,6 +421,7 @@ def _drain_update_process_output(self): def _cleanup_update_process(self): if self._update_process is not None: + self._disconnect_update_process(self._update_process) self._update_process.deleteLater() self._update_process = None @@ -411,6 +434,11 @@ def _cleanup_update_process(self): self.status_bar.showMessage("www.deeplabcut.org") def _on_update_process_error(self, error): + + process = self.sender() + if process is not self._update_process: + return + if self._closing: self._cleanup_update_process() return @@ -430,10 +458,9 @@ def _on_update_process_error(self, error): self.logger.warning(message) self._update_attempt_outputs.append(message) - if self._update_process is not None: - self._update_process.deleteLater() - self._update_process_output = [] - self._update_process = None + self._disconnect_update_process(process) + self._update_process.deleteLater() + self._update_process = None if self._start_next_update_command(): return @@ -446,14 +473,18 @@ def _on_update_process_error(self, error): self._cleanup_update_process() def _on_update_process_finished(self, exit_code, exit_status): + process = self.sender() + if process is not self._update_process: + return + if self._closing: self._cleanup_update_process() return - if self._update_process is None: + if process is None: return - self._drain_update_process_output() + self._drain_update_process_output(process) output = "".join(self._update_process_output).strip() backend = self._current_update_backend or "installer" @@ -479,7 +510,8 @@ def _on_update_process_finished(self, exit_code, exit_status): self._update_attempt_outputs.append(failed_output) self.logger.warning(failed_output) - self._update_process.deleteLater() + self._disconnect_update_process(process) + process.deleteLater() self._update_process = None self._update_process_output = [] From 5298ced2d6a09bc01be90fe739f25938ceb7bfd2 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 11:18:02 +0200 Subject: [PATCH 6/7] Separate failed output for update retries Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- deeplabcut/gui/window.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py index 021d2cb51..606fb4cf5 100644 --- a/deeplabcut/gui/window.py +++ b/deeplabcut/gui/window.py @@ -454,13 +454,21 @@ def _on_update_process_error(self, error): QtCore.QProcess.UnknownError: f"An unknown {backend} update error occurred.", } + if process is not None: + self._drain_update_process_output(process) + + output = "".join(self._update_process_output).strip() message = error_strings.get(error, f"An unknown {backend} update error occurred.") - self.logger.warning(message) - self._update_attempt_outputs.append(message) + failed_output = ( + f"{message}\n\n{output}" if output else message + ) + self.logger.warning(failed_output) + self._update_attempt_outputs.append(failed_output) self._disconnect_update_process(process) self._update_process.deleteLater() self._update_process = None + self._update_process_output = [] if self._start_next_update_command(): return From defdc96b9a38df6762f1ee9c51df300e608ee851 Mon Sep 17 00:00:00 2001 From: Cyril Achard Date: Thu, 28 May 2026 15:42:21 +0200 Subject: [PATCH 7/7] Update window.py --- deeplabcut/gui/window.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/deeplabcut/gui/window.py b/deeplabcut/gui/window.py index 606fb4cf5..ec5d6f6a8 100644 --- a/deeplabcut/gui/window.py +++ b/deeplabcut/gui/window.py @@ -459,9 +459,7 @@ def _on_update_process_error(self, error): output = "".join(self._update_process_output).strip() message = error_strings.get(error, f"An unknown {backend} update error occurred.") - failed_output = ( - f"{message}\n\n{output}" if output else message - ) + failed_output = f"{message}\n\n{output}" if output else message self.logger.warning(failed_output) self._update_attempt_outputs.append(failed_output)