Add GUI extra in auto-updater; support uv#3346
Conversation
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.
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.
There was a problem hiding this comment.
Pull request overview
This PR refactors the GUI auto-updater to support multiple installer backends, preferring uv when available with a pip fallback, and ensures DeepLabCut updates from the GUI always include the [gui] extra so GUI dependencies stay in sync.
Changes:
- Added
package_specs_for_updateandbuild_update_commandsutilities to generate update package specs (includingdeeplabcut[gui]) and a prioritized installer command list. - Refactored the GUI update flow to sequentially try each backend and aggregate per-backend failure output for clearer diagnostics.
- Added unit tests validating package spec generation and backend command selection.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
deeplabcut/gui/utils.py |
Adds helpers to build package specs (injecting [gui]) and generate backend-specific update commands (uv → pip). |
deeplabcut/gui/window.py |
Updates GUI auto-update execution to try backends in order and improve logging/error reporting across attempts. |
tests/gui/test_auto_update.py |
Adds tests for package spec normalization and backend command prioritization/fallback behavior. |
Comments suppressed due to low confidence (1)
tests/gui/test_auto_update.py:38
- The parameterization includes two identical
{"uv": "/mock/bin/uv"}cases expecting the same backend order. This duplicates test work without increasing coverage; remove the redundant entry to keep the test intent clear.
({}, ["pip"]),
({"uv": "/mock/bin/uv"}, ["uv", "pip"]),
(
{"uv": "/mock/bin/uv"},
["uv", "pip"],
),
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
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.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
deruyter92
left a comment
There was a problem hiding this comment.
Good fix! Just some minor comments and suggestions.
| if not self._start_next_update_command(): | ||
| self._cleanup_update_process() | ||
| QtWidgets.QMessageBox.warning( | ||
| self, | ||
| "Update failed", | ||
| "No available installer backend was found.", | ||
| ) | ||
|
|
There was a problem hiding this comment.
build_update_commands unconditionally appends the pip fallback so the list if never empty. This means that this code is unreachable, correct?
| 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 | ||
|
|
There was a problem hiding this comment.
The second condition is only met when (simulataneous) self._update_process is None AND process is None. Consider changing to:
| 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 | |
| if process is None or process is not self._update_process: | |
| return |
| 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 |
There was a problem hiding this comment.
| 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 | |
| process = self.sender() | |
| if process is None or process is not self._update_process: | |
| return | |
| if self._closing: | |
| self._cleanup_update_process() | |
| return |
| if output: | ||
| self.logger.error(output) | ||
|
|
||
| self._cleanup_update_process() |
There was a problem hiding this comment.
calls _progress_bar.hide() again. Maybe better to remove in one of both places to avoid confusion.
| self._disconnect_update_process(process) | ||
| self._update_process.deleteLater() |
There was a problem hiding this comment.
Currently this only works because the guard (process is not self._update_process → return). Safer is process.deleteLater() like in _on_update_process_finished:
| self._disconnect_update_process(process) | |
| self._update_process.deleteLater() | |
| self._disconnect_update_process(process) | |
| process.deleteLater() |
| 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 | ||
| """ |
There was a problem hiding this comment.
Small nitpick:
These functions seem designed as a generic public function for building normalized package specs install commands, but in practice they only work for very specific input (e.g. case-sensitive exact match for "deeplabcut"). Also they are specifically intended for the auto-update flow, not general GUI utilities. Maybe make these functions private or document this somehow, e.g.:
| 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 | |
| """ | |
| DLCPackage: TypeAlias = Literal["deeplabcut", "napari-deeplabcut"] | |
| def _package_specs_for_update(packages: list[DLCPackage]) -> 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[DLCPackage]) -> 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 | |
| """ |
Scope
uvinstead of justpip)Adds new utility functions for building update commands, refactors the update process to try preferred installers first with fallbacks, and adds tests for these.
Automated summary
Update backend improvements:
package_specs_for_updateandbuild_update_commandsutility functions indeeplabcut/gui/utils.pyto generate the correct package specifications (ensuringdeeplabcutis updated with[gui]extra) and to build a prioritized list of installer commands, preferringuvif available and always falling back topip([deeplabcut/gui/utils.pyR239-R292](https://github.com/DeepLabCut/DeepLabCut/pull/3346/files#diff-4279538071ed35262d378e598eb69f5d2e1aced94717f42138692f0d8c718e3cR239-R292)).deeplabcut/gui/window.pyto use the new backend selection logic, attempting each backend in order and providing more informative error messages and logs for each attempt ([[1]](https://github.com/DeepLabCut/DeepLabCut/pull/3346/files#diff-4dbcd4272a9f0fb80775a2e54ed8d52f229695085258e5b5d0172f05870a3772L344-R380),[[2]](https://github.com/DeepLabCut/DeepLabCut/pull/3346/files#diff-4dbcd4272a9f0fb80775a2e54ed8d52f229695085258e5b5d0172f05870a3772R336-R358),[[3]](https://github.com/DeepLabCut/DeepLabCut/pull/3346/files#diff-4dbcd4272a9f0fb80775a2e54ed8d52f229695085258e5b5d0172f05870a3772L408-L429),[[4]](https://github.com/DeepLabCut/DeepLabCut/pull/3346/files#diff-4dbcd4272a9f0fb80775a2e54ed8d52f229695085258e5b5d0172f05870a3772R418-R444),[[5]](https://github.com/DeepLabCut/DeepLabCut/pull/3346/files#diff-4dbcd4272a9f0fb80775a2e54ed8d52f229695085258e5b5d0172f05870a3772R404-R409)).Testing and reliability:
tests/gui/test_auto_update.pyto verify that package specs are built correctly and that the update command selection logic works as intended, including backend prioritization and fallback behavior ([tests/gui/test_auto_update.pyR1-R143](https://github.com/DeepLabCut/DeepLabCut/pull/3346/files#diff-fe6347302c05d4e806136ae254f592f3a74c323a95a79724b435dd5c0d83c75eR1-R143)).Dependency management:
[gui]extra, keeping GUI dependencies in sync and reducing the risk of mismatched versions ([deeplabcut/gui/utils.pyR239-R292](https://github.com/DeepLabCut/DeepLabCut/pull/3346/files#diff-4279538071ed35262d378e598eb69f5d2e1aced94717f42138692f0d8c718e3cR239-R292)).