Qt6 / QGIS 4 migration notes

Reference notes for the QGIS 3 / Qt5 → QGIS 4 / Qt6 migration done in v0.6.0. Future maintainers hitting odd Qt6 behaviour: start here.

The plugin supports both Qt5 (QGIS 3.28+) and Qt6 (QGIS 4.x) from a single codebase. CI runs the integration suite on both qgis/qgis:release-3_34 and qgis/qgis:4.0 to catch regressions on either side.

1. Webp thumbnails (Qt6 has no webp plugin)

Qt5 in QGIS 3 ships the webp image-format plugin. Qt6 in QGIS 4 does not. QPixmap("foo.webp") silently returns a null pixmap with no visible error, just a broken thumbnail.

Workaround (qgis_hub_plugin/utilities/common.py):

  • Probe QImageReader.supportedImageFormats() once at import time (_QT_SUPPORTED_IMAGE_FORMATS).

  • When Qt cannot decode the downloaded format, fall back to a one-shot Pillow → PNG conversion stored beside the original (_convert_thumbnail_to_png). The PNG sibling is reused on subsequent calls.

  • Pillow is an optional dependency (HAS_PILLOW); without it, un-decodable thumbnails fall back to the default hub icon.

If a webp variant ever ships with Qt6 in a future QGIS release, the probe will detect it and skip the conversion path automatically.

2. Scoped Qt enums

PyQt5 accepts both Qt.UserRole (unscoped) and Qt.ItemDataRole.UserRole (scoped). PyQt6 only accepts the scoped form. Raw ints are no longer coerced to enum values either.

Use the scoped form everywhere:

Unscoped (Qt5-only)

Scoped (works on both)

Qt.UserRole

Qt.ItemDataRole.UserRole

Qt.KeepAspectRatio

Qt.AspectRatioMode.KeepAspectRatio

Qt.WaitCursor

Qt.CursorShape.WaitCursor

Qt.CaseInsensitive

QRegularExpression.PatternOption.CaseInsensitiveOption

QSizePolicy.Minimum

QSizePolicy.Policy.Minimum

QNetworkReply.NoError

QNetworkReply.NetworkError.NoError

QDialogButtonBox.Help

QDialogButtonBox.StandardButton.Help

QIODevice.WriteOnly

QIODevice.OpenModeFlag.WriteOnly

QItemSelectionModel.ClearAndSelect

QItemSelectionModel.SelectionFlag.ClearAndSelect

QFormLayout.LabelRole

QFormLayout.ItemRole.LabelRole

QMessageBox.Yes / .No

QMessageBox.StandardButton.Yes / .No

Watch out in tests. mock.error.return_value = 0 worked on Qt5 because PyQt compared int-to-enum loosely; on Qt6 it must be QNetworkReply.NetworkError.NoError. Same for proxy.sort(0, 1) — under Qt6 the second argument must be Qt.SortOrder.DescendingOrder. These failures only surface on the Qt6 CI job (or against a local QGIS 4 install) — they pass on Qt5 LTS CI.

3. QRegExp is gone in Qt6

Replace everywhere:

# Qt5-only
from qgis.PyQt.QtCore import QRegExp
proxy.setFilterRegExp(QRegExp(text, Qt.CaseInsensitive))
if regex.indexIn(s) != -1: ...

# Works on both
from qgis.PyQt.QtCore import QRegularExpression
proxy.setFilterRegularExpression(
    QRegularExpression(text, QRegularExpression.PatternOption.CaseInsensitiveOption)
)
if regex.match(s).hasMatch(): ...

4. Translations + f-strings

pylupdate cannot extract f-string contents from inside self.tr(...), so any runtime-interpolated values defeat translation:

# Wrong — the literal string seen by pylupdate is "Script "{resource.name}" added"
self.show_error_message(self.tr(f"Script “{resource.name}” added"))

# Right — pylupdate extracts "Script "{name}" added", placeholders fill at runtime
self.show_error_message(
    self.tr("Script “{name}” added").format(name=resource.name)
)

This is not Qt6-specific, but came up during the migration when new error branches were added. Several pre-existing tr(f"…") sites remain in the codebase — clean up opportunistically.

5. CI: PEP 668 on Trixie-based QGIS 4 image

The qgis/qgis:4.0 image is built on Debian Trixie with Python 3.13, which marks the system Python as externally managed (PEP 668). pip refuses to install into system site-packages without --break-system-packages.

In .github/workflows/tester.yml:

  • The flag is set per matrix entry (pip-extra-args), because older pip in the Qt5 image errors on unknown flags.

  • The pip install --upgrade pip setuptools wheel step was dropped: the Debian-managed wheel on Trixie has no RECORD file, so pip refuses to uninstall it for the upgrade. The container pip on both images is recent enough to install requirements/testing.txt directly.

6. Local dev venv

PyQGIS is not on PyPI. The venv must link to system packages:

python3 -m venv .venv --system-site-packages
source .venv/bin/activate
python -m pip install -U -r requirements/development.txt

A clean venv (without --system-site-packages) will not import qgis and the QGIS-dependent tests (tests/qgis/) will all fail with ModuleNotFoundError: No module named 'qgis'.

7. Friendlier error for PyQt5-only processing scripts

QGIS Hub still hosts processing scripts written against PyQt5 (from PyQt5.QtCore import ...). Under Qt6 these fail to import with a ModuleNotFoundError: No module named 'PyQt5'.

add_processing_script_to_qgis in gui/resource_browser.py catches the specific case and surfaces a clear message pointing at qgis.PyQt (the version-agnostic shim) instead of a raw traceback. The script is still saved to disk so the user can edit and re-add it.

8. Things still not working on Qt6

Tracked but not fixed in v0.6.0:

  • PyQt5-only processing scripts cannot be loaded until the upstream script is updated to import from qgis.PyQt. The error is surfaced cleanly (see section 7); a real fix would need upstream cooperation from script authors on QGIS Hub.

The relevant entry point is add_processing_script_to_qgis in gui/resource_browser.py.