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..ec5d6f6a8 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) @@ -309,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: @@ -330,6 +347,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,21 +381,31 @@ 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, 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 - def _drain_update_process_output(self): - if self._update_process is None: + if process is None: return - data = bytes(self._update_process.readAll()) + data = bytes(process.readAll()) if not data: return @@ -371,62 +421,114 @@ 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 + 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") 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 + 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) + 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.") + 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 + + 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): + 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._progress_bar.hide() - 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" 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._disconnect_update_process(process) + 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() diff --git a/tests/gui/test_auto_update.py b/tests/gui/test_auto_update.py new file mode 100644 index 000000000..cc6d51abf --- /dev/null +++ b/tests/gui/test_auto_update.py @@ -0,0 +1,141 @@ +import sys + +import pytest + +pytest.importorskip("PySide6") + +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"]), + ], +) +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]", + ], + ) + ]