Skip to content

Route GUI Qt imports through qtpy, keeping PySide6 preferred - #3490

Draft
C-Achard wants to merge 16 commits into
devfrom
cy/gui-qtpy-compat
Draft

C-Achard wants to merge 16 commits into
devfrom
cy/gui-qtpy-compat

Conversation

@C-Achard

@C-Achard C-Achard commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Route GUI Qt imports through qtpy, keeping PySide6 preferred

Scope

Every Qt import in deeplabcut/gui/ goes through qtpy instead of PySide6, 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.

  • Ran GUI tests locally

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

  • 26 GUI modules import from qtpy rather than PySide6; swap only aside from widgets.py, where Qt moves to qtpy.QtCore because PySide6 re-exporting it from QtGui is binding-specific.
  • deeplabcut/gui/__init__.py selects the binding by precedence: an explicit valid QT_API, then a binding already imported in the process, then PySide6, then qtpy's own autodetection.
  • The selector normalises os.environ["QT_API"] to qtpy.API after selection, so matplotlib's independent detection resolves the same binding instead of loading a second one.
  • When no binding can be imported, the raised ImportError reports 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.
  • An unsupported QT_API value raises a ValueError naming the valid values, in place of qtpy's PythonQtValueError, whose message reports the variable name rather than the offending value.
  • matplotlib.backends.backend_qt5agg is replaced by backend_qtagg, which exports the same names without setting matplotlib's _QT_FORCE_QT5_BINDING.
  • deeplabcut/__main__.py determines GUI availability by importing deeplabcut.gui rather than PySide6, and prints that module's error rather than a fixed message.
  • tests/gui/ skips on qtpy.QtWidgets rather than PySide6; 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.4 is declared in the [gui] extra instead of arriving transitively through qdarkstyle or napari.
  • Important notes regarding dependencies:
    • backend_qtagg requires matplotlib 3.5, so the floor is raised from >=3.3 to >=3.5. The resolved version is unchanged (3.8.4); the recorded specifier in uv.lock is updated to match. The <3.9 ceiling is untouched — Matplotlib v3.9 compatibility #3394 is the code preparation for lifting that end.
    • uv.lock resolves 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__.py is in scope because this change makes deeplabcut.gui raise where it previously could not. The entry point probed PySide6 and then imported deeplabcut.gui.launch_script unguarded. import PySide6 succeeds on the Python package even when the compiled QtCore cannot 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 running dlc, rather than only callers importing deeplabcut.gui from a script.
  • ImportError rather than ModuleNotFoundError is caught throughout, because qtpy raises QtBindingsNotFoundError, which subclasses the former but not the latter.
  • Three defects predating this branch are fixed where the migration touches them: qdarkstyle.load_stylesheet_pyside6 does not exist in the pinned QDarkStyle 3.1, so launch_script.py raised on every launch and fell through to the PySide2 code path; window.darkmode() requested the PySide2 stylesheet unconditionally while lightmode() did not; and the removed lite-install message rendered as pip install'deeplabcut[gui]''.
  • The qdarkstyle calls pass palette=. The zero-argument load_stylesheet() and the load_stylesheet_<binding>() helpers write os.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.py patched QMessageBox on the QtWidgets module, which never reached window.py because 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)

C-Achard and others added 14 commits September 7, 2026 14:48
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.
@C-Achard
C-Achard requested a lite review from Copilot September 8, 2026 09:38
@C-Achard C-Achard self-assigned this Sep 8, 2026
@C-Achard C-Achard added enhancement New feature or request GUI issues relating to GUI bug fix dependencies Pull requests that update a dependency file napari-dlc Related to napari-deeplabcut (labeling workflow, crop, frame extraction...) labels Sep 8, 2026
@C-Achard C-Achard linked an issue Sep 8, 2026 that may be closed by this pull request

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 PySide6 imports across deeplabcut/gui/ with qtpy imports, including a binding-selection mechanism in deeplabcut/gui/__init__.py.
  • Update GUI launch and tests to key off qtpy (and patch GUI test behavior to avoid blocking dialogs).
  • Add qtpy>=2.4 to the [gui] extra and raise the matplotlib floor to >=3.5 (to use backend_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.

Comment thread deeplabcut/gui/launch_script.py Outdated
Comment thread deeplabcut/__main__.py Outdated
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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:18 imports qtpy.QtWidgets before deeplabcut.gui, so those tests always run after qtpy has already selected a binding; there is no coverage for explicit valid/invalid QT_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 qtpy itself 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. Handle err.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

@C-Achard C-Achard added the 3.1 label Sep 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3.1 bug fix dependencies Pull requests that update a dependency file enhancement New feature or request GUI issues relating to GUI napari-dlc Related to napari-deeplabcut (labeling workflow, crop, frame extraction...)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GUI: Use qtpy compat layer rather than PySide6 in imports

2 participants