Conversation
Co-Authored-By: Mark Harfouche <mark.harfouche@gmail.com>
Make GUI startup choose a Qt binding more predictably by honoring valid user-set `QT_API`, reusing an already loaded binding, and preferring `PySide6` before falling back to qtpy autodetection. It also adds clearer missing-binding errors, keeps `QT_API` synchronized with the binding qtpy actually loaded, and warns when the GUI is running on GPL-licensed PyQt bindings instead of the preferred LGPL `PySide6`.
Replace the static missing-Qt message with a dynamic helper that distinguishes between PySide6 not being installed and PySide6 failing to load. The new ImportError text now includes concrete recovery steps (`deeplabcut[gui]` install vs force-reinstall), shows the active Python executable to help diagnose environment mismatches, suggests activating the expected conda env, and links to installation docs.
Update GUI matplotlib imports from `backend_qt5agg` to `backend_qtagg` in evaluate network and shared widget modules. This aligns backend usage with newer matplotlib Qt backend naming and improves compatibility across Qt bindings.
Switch GUI dark theme setup to `qdarkstyle.load_stylesheet(palette=DarkPalette)` in both app launch and the runtime dark mode toggle. This avoids the binding-specific helpers that mutate `QT_API` and can desynchronize matplotlib from qtpy’s loaded backend. Also keep the bundled `style.qss` as a fallback if qdarkstyle fails to load, with clearer warning logging.
Replace the deprecated `app.exec_()` call in the GUI launch script with `app.exec()` to match newer Qt bindings and keep application startup compatible.
deeplabcut.gui now selects the Qt binding at import time and raises when none is usable. __main__ probed PySide6 and then imported launch_script unguarded, so a PySide6 that is installed but unloadable. `import PySide6` succeeds on the pure-Python parent package, only the compiled QtCore fails, passed the probe and crashed with an uncaught traceback on the launch import. Probe deeplabcut.gui instead, and catch ImportError rather than ModuleNotFoundError so qtpy's QtBindingsNotFoundError is covered. Printing the raised error also means the GUI's install guidance reaches `dlc` users, which is how they actually hit this; previously only script importers ever saw it. Drops the DLC lite branch, whose message rendered as `pip install'deeplabcut[gui]''`.
- The comment claimed the zero-arg load_stylesheet() and load_stylesheet_<binding>() helpers desynchronise matplotlib from the binding qtpy loaded. - Measured false in DeepLabCut's import order: matplotlib's qt_compat probes sys.modules before it reads QT_API, and deeplabcut.gui has already loaded PySide6.QtCore by the time the stylesheet is applied. - Those helpers do still write os.environ["QT_API"], so passing palette= remains correct, it just avoids leaving a false value behind rather than fixing a desync that never occurs here.
Patching "deeplabcut.gui.window.QMessageBox" targets the name actually resolved at call time.
Drop the GUI startup warning emitted when qtpy selects a GPL-licensed Qt binding. The module still exports the detected binding through QT_API, but no longer warns users to switch to PySide6.
The GUI imports matplotlib.backends.backend_qtagg, added in matplotlib 3.5, while the declared floor allowed 3.3. An install resolving 3.3 or 3.4 would fail to import the GUI.
There was a problem hiding this comment.
🟡 Changes recommended
There are confirmed cross-binding compatibility and CLI error-handling issues (notably QApplication.exec() usage and uncaught QT_API misconfiguration) that can break the stated “binding negotiation” goal.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR migrates DeepLabCut’s GUI stack to import Qt exclusively via qtpy, allowing the Qt binding to be negotiated at import time (rather than being forced to PySide6) while still keeping PySide6 as the preferred/required GUI dependency.
Changes:
- Replace direct
PySide6imports acrossdeeplabcut/gui/withqtpyimports, including a binding-selection mechanism indeeplabcut/gui/__init__.py. - Update GUI launch and tests to key off
qtpy(and patch GUI test behavior to avoid blocking dialogs). - Add
qtpy>=2.4to the[gui]extra and raise the matplotlib floor to>=3.5(to usebackend_qtagg).
File summaries
| File | Description |
|---|---|
| uv.lock | Adds qtpy to GUI extras and updates locked dependency specifiers (incl. matplotlib floor). |
| pyproject.toml | Declares qtpy>=2.4 in [gui] extra and raises matplotlib minimum to 3.5. |
| deeplabcut/gui/init.py | Implements Qt binding selection/normalization via QT_API + qtpy. |
| deeplabcut/main.py | Determines GUI availability by importing deeplabcut.gui and prints errors. |
| deeplabcut/gui/window.py | Routes Qt imports through qtpy; updates dark theme stylesheet loading. |
| deeplabcut/gui/widgets.py | Switches Matplotlib Qt backend to backend_qtagg and moves Qt imports to qtpy. |
| deeplabcut/gui/utils.py | Routes Qt imports (QtCore, QtNetwork) through qtpy. |
| deeplabcut/gui/tracklet_toolbox.py | Routes QMutex/QMessageBox imports through qtpy. |
| deeplabcut/gui/tabs/video_editor.py | Routes Qt imports through qtpy. |
| deeplabcut/gui/tabs/unsupervised_id_tracking.py | Routes Qt imports through qtpy. |
| deeplabcut/gui/tabs/train_network.py | Routes Qt imports through qtpy. |
| deeplabcut/gui/tabs/refine_tracklets.py | Routes Qt imports through qtpy. |
| deeplabcut/gui/tabs/open_project.py | Routes Qt imports through qtpy. |
| deeplabcut/gui/tabs/modelzoo.py | Routes Qt imports through qtpy. |
| deeplabcut/gui/tabs/manage_project.py | Routes Qt imports through qtpy. |
| deeplabcut/gui/tabs/label_frames.py | Routes Qt imports through qtpy. |
| deeplabcut/gui/tabs/extract_outlier_frames.py | Routes Qt imports through qtpy. |
| deeplabcut/gui/tabs/extract_frames.py | Routes Qt imports through qtpy. |
| deeplabcut/gui/tabs/evaluate_network.py | Uses Matplotlib backend_qtagg and routes Qt imports through qtpy. |
| deeplabcut/gui/tabs/create_videos.py | Routes Qt imports through qtpy. |
| deeplabcut/gui/tabs/create_training_dataset.py | Routes Qt imports through qtpy. |
| deeplabcut/gui/tabs/create_project.py | Routes Qt imports through qtpy. |
| deeplabcut/gui/tabs/analyze_videos.py | Routes Qt imports through qtpy. |
| deeplabcut/gui/launch_script.py | Switches Qt imports to qtpy and updates qdarkstyle usage / app exec call. |
| deeplabcut/gui/gui_assets.py | Routes QIcon/QPixmap imports through qtpy. |
| deeplabcut/gui/displays/shuffle_metadata_viewer.py | Routes Qt imports through qtpy. |
| deeplabcut/gui/displays/selected_shuffle_display.py | Routes Qt imports through qtpy. |
| deeplabcut/gui/dialogs/debug_dialog.py | Routes Qt imports through qtpy. |
| deeplabcut/gui/config_file_monitor.py | Routes Qt imports through qtpy. |
| deeplabcut/gui/components.py | Routes Qt imports through qtpy. |
| tests/gui/conftest.py | Skips GUI tests based on qtpy.QtWidgets and imports Qt via qtpy. |
| tests/gui/test_worker.py | Skips based on qtpy.QtWidgets instead of PySide6. |
| tests/gui/test_task_error.py | Skips based on qtpy.QtWidgets, switches imports to qtpy, and patches QMessageBox correctly. |
| tests/gui/test_selected_shuffle_display.py | Skips based on qtpy.QtWidgets and imports QtCore via qtpy. |
| tests/gui/test_main_window_config.py | Skips based on qtpy.QtWidgets and imports Qt via qtpy. |
| tests/gui/test_config_file_monitor.py | Skips based on qtpy.QtWidgets and imports Qt via qtpy. |
| tests/gui/test_config_errors.py | Skips based on qtpy.QtWidgets instead of PySide6. |
| tests/gui/test_config_editor.py | Skips based on qtpy.QtWidgets and imports Qt via qtpy. |
| tests/gui/test_auto_update.py | Skips based on qtpy.QtWidgets instead of PySide6. |
Review details
- Files reviewed: 38/39 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Restore the Qt application event loop call in `launch_script.py` by switching from `app.exec()` to `app.exec_()`. This keeps the GUI launcher compatible with the Qt bindings expected by DeepLabCut.
Update `deeplabcut.__main__` to handle GUI startup import errors more robustly by catching both `ImportError` and `ValueError`, then exiting via `sys.exit(...)` with the error message. This ensures `python -m deeplabcut` fails with a proper non-zero exit status instead of printing and returning.
There was a problem hiding this comment.
🔵 Needs a closer look
Two moderate issues remain in error diagnosis and binding-negotiation test coverage.
Review details
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
deeplabcut/gui/init.py:58
- The new binding-negotiation logic is not exercised by the GUI suite.
tests/gui/conftest.py:18importsqtpy.QtWidgetsbeforedeeplabcut.gui, so those tests always run after qtpy has already selected a binding; there is no coverage for explicit valid/invalidQT_API, PySide6 preference, fallback, or environment normalization. Add isolated subprocess tests for these branches, since module and environment state cannot be reset reliably in-process.
deeplabcut/gui/init.py:77 - This also catches the case where
qtpyitself is missing, but_no_binding_message()diagnoses only PySide6. If PySide6 is installed independently and qtpy is absent, users are incorrectly told that PySide6 could not load and to reinstall it, which cannot fix the failure. Handleerr.name == "qtpy"separately and direct users to install the GUI extra.
- Files reviewed: 38/39 changed files
- Comments generated: 0 new
- Review effort level: Balanced
Route GUI Qt imports through qtpy, keeping PySide6 preferred
Scope
Every Qt import in
deeplabcut/gui/goes throughqtpyinstead ofPySide6, and the binding is negotiated at import time rather than forced.This retains PySide6 as the preferred binding and a hard dependency which is installed with the GUI extra.
No GPL package is added to dependencies.
qtpy is MIT licensed, and does not bundle PyQt5 or PyQt6.
Users can now select an independently installed Qt binding of their choice.
Adapts the similar patch from conda-forge by @hmaarrfk, thanks again!
Mentioned in #2887, closes #3356.
Motivation
DeepLabCut hard-coded PySide6 in every GUI import and forced QT_API=pyside6 at package import, so it could not share a process with a Qt stack another package had already selected.
Routing through qtpy lets the binding be negotiated instead of dictated, which broadens compatibility without changing what DeepLabCut requires or prefers.
Main changes
qtpyrather thanPySide6; swap only aside fromwidgets.py, whereQtmoves toqtpy.QtCorebecause PySide6 re-exporting it fromQtGuiis binding-specific.deeplabcut/gui/__init__.pyselects the binding by precedence: an explicit validQT_API, then a binding already imported in the process, then PySide6, then qtpy's own autodetection.os.environ["QT_API"]toqtpy.APIafter selection, so matplotlib's independent detection resolves the same binding instead of loading a second one.ImportErrorreports whether PySide6 is absent or present-but-unloadable, prints the interpreter in use, and links the installation docs. Made warnings and errors more informative and user-friendly.QT_APIvalue raises aValueErrornaming the valid values, in place of qtpy'sPythonQtValueError, whose message reports the variable name rather than the offending value.matplotlib.backends.backend_qt5aggis replaced bybackend_qtagg, which exports the same names without setting matplotlib's_QT_FORCE_QT5_BINDING.deeplabcut/__main__.pydetermines GUI availability by importingdeeplabcut.guirather thanPySide6, and prints that module's error rather than a fixed message.tests/gui/skips onqtpy.QtWidgetsrather thanPySide6; the submodule is used because qtpy can be installed with no binding present, in which case the tests should skip rather than fail during collection. This is mostly to match actual qtpy imports in the code.qtpy>=2.4is declared in the[gui]extra instead of arriving transitively through qdarkstyle or napari.backend_qtaggrequires matplotlib 3.5, so the floor is raised from>=3.3to>=3.5. The resolved version is unchanged (3.8.4); the recorded specifier inuv.lockis updated to match. The<3.9ceiling is untouched — Matplotlib v3.9 compatibility #3394 is the code preparation for lifting that end.uv.lockresolves PySide6 6.9.3, due to the Linux split in pyproject, whereas 6.11+ is installed via pip install. uv does not fork and requests 6.9.3 in the lockfile.Additional context
deeplabcut/__main__.pyis in scope because this change makesdeeplabcut.guiraise where it previously could not. The entry point probedPySide6and then importeddeeplabcut.gui.launch_scriptunguarded.import PySide6succeeds on the Python package even when the compiledQtCorecannot load, so that probe passes and the unguarded import reaches the new selector, producing an uncaught traceback. It also means the new installation guidance reaches users runningdlc, rather than only callers importingdeeplabcut.guifrom a script.ImportErrorrather thanModuleNotFoundErroris caught throughout, because qtpy raisesQtBindingsNotFoundError, which subclasses the former but not the latter.qdarkstyle.load_stylesheet_pyside6does not exist in the pinned QDarkStyle 3.1, solaunch_script.pyraised on every launch and fell through to the PySide2 code path;window.darkmode()requested the PySide2 stylesheet unconditionally whilelightmode()did not; and the removed lite-install message rendered aspip install'deeplabcut[gui]''.palette=. The zero-argumentload_stylesheet()and theload_stylesheet_<binding>()helpers writeos.environ["QT_API"], which would leave a value inconsistent with the loaded binding. The resulting stylesheet is byte-identical to the previous one, so the theme is unchanged.tests/gui/test_task_error.pypatchedQMessageBoxon theQtWidgetsmodule, which never reachedwindow.pybecause that module binds the name directly at import. The test constructed a real modal dialog and blocked. It is fixed here because the suite cannot otherwise complete. I think Qt versions differences are why this did not surface earlier, @deruyter92 let me know if you can reproduce (before a419a77)