QGIS 3 → QGIS 4 / Qt6

QGIS 3 → QGIS 4 / Qt6

Notes from migrating two plugins

Ismail Sunni · Camptocamp Tech Lunch 2026 #3

Both now dual-support QGIS 3 (Qt5) and QGIS 4 (Qt6).

Ismail Sunni · Camptocamp Tech Lunch
QGIS 3 → QGIS 4 / Qt6

Meet the plugins

Encoded Polyline

Encode/decode Google-style polylines between QGIS line layers and strings. Small, no UI state.

QGIS Hub Plugin

Browse and pull resources (styles, models, 3D, scripts) from the QGIS Hub directly inside QGIS.

Ismail Sunni · Camptocamp Tech Lunch
QGIS 3 → QGIS 4 / Qt6

Why now?

  • QGIS 4.0 shipped 6 March 2026 (4.0.2 out 1 May 2026) — on Qt6
  • PyQt5-only plugins silently disappear from users' QGIS 4 installs
  • Mechanical work is straightforward
  • The interesting failures are in:
    • tests · network code · regex · packaging · CI

A tiny plugin doesn't expose most of these.

Ismail Sunni · Camptocamp Tech Lunch
QGIS 3 → QGIS 4 / Qt6

The mechanical 80%

# Imports
from PyQt5.X import Y          # ❌
from qgis.PyQt.X import Y      # ✅ shim picks Qt5/Qt6 at runtime

# Dialogs
dialog.exec_()                  # ❌
dialog.exec()                   # ✅
  • Drop six and other Py2-era polyfills
  • metadata.txt: add qgisMaximumVersion=4.99
  • Don't add supportsQt6=True — no longer recognized
Ismail Sunni · Camptocamp Tech Lunch
QGIS 3 → QGIS 4 / Qt6

Scoped enums — bigger than it sounds

PyQt5 accepts unscoped names and raw ints. PyQt6 accepts neither.

Qt5-only Works on both
Qt.UserRole Qt.ItemDataRole.UserRole
Qt.KeepAspectRatio Qt.AspectRatioMode.KeepAspectRatio
QSizePolicy.Minimum QSizePolicy.Policy.Minimum
QNetworkReply.NoError QNetworkReply.NetworkError.NoError
QDialogButtonBox.Reset QDialogButtonBox.StandardButton.Reset
Ismail Sunni · Camptocamp Tech Lunch
QGIS 3 → QGIS 4 / Qt6

The test-suite trap

# Worked on Qt5 (int → enum coercion). Fails on Qt6.
mock_reply.error.return_value = 0
proxy.sort(0, 1)
# Qt6-safe
mock_reply.error.return_value = QNetworkReply.NetworkError.NoError
proxy.sort(0, Qt.SortOrder.DescendingOrder)

These regressions only surface on the Qt6 CI job.
Qt5 CI stays green and you don't notice.

Ismail Sunni · Camptocamp Tech Lunch
QGIS 3 → QGIS 4 / Qt6

Things Qt6 removed: QRegExp

# 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(): ...

Different API, not a drop-in replacement.

Ismail Sunni · Camptocamp Tech Lunch
QGIS 3 → QGIS 4 / Qt6

Things Qt6 removed: webp

  • Qt6 in QGIS 4 does not bundle the webp image-format plugin
  • QPixmap("foo.webp") silently returns null — no error, broken thumbnail

Hub Plugin's fix:

  1. Probe QImageReader.supportedImageFormats() at import
  2. Fall back to one-shot Pillow → PNG conversion (cached beside original)
  3. Pillow is an optional dependency

If webp ever returns to Qt6, the probe skips the conversion automatically.

Ismail Sunni · Camptocamp Tech Lunch
QGIS 3 → QGIS 4 / Qt6

Plugin loading + module gotchas

QGIS 4 stopped putting the plugin dir on sys.path.

import polyline                  # ❌ ModuleNotFoundError on QGIS 4
from . import polyline           # ✅

PyQt5-only processing scripts (hosted on QGIS Hub) crash on Qt6.
Hub Plugin catches it and surfaces a clear message instead of a raw
traceback — script is still saved to disk so users can fix and retry.

Ismail Sunni · Camptocamp Tech Lunch
QGIS 3 → QGIS 4 / Qt6

CI surprises

Run both matrices: Qt5 LTS and Qt6.

matrix:
  include:
    - image: qgis/qgis:release-3_34
      pip-extra-args: ""
    - image: qgis/qgis:4.0
      pip-extra-args: "--break-system-packages"
  • qgis/qgis:4.0 = Debian Trixie + Python 3.13 → PEP 668 externally-managed
  • --break-system-packages needed per matrix entry (older Qt5 pip rejects unknown flags)
  • Drop pip install -U pip setuptools wheel — Trixie's wheel has no RECORD file
Ismail Sunni · Camptocamp Tech Lunch
QGIS 3 → QGIS 4 / Qt6

Plugin-repo gates

Validator now requires tracker= and homepage= in metadata.txt.
Cryptic error message: "Cannot find metadata tracker."

New: automatic security scanning on every uploaded version

  • 🚫 Bandit (blocking) — shell injection, eval/exec, weak crypto
  • 🚫 detect-secrets (blocking) — hardcoded API keys, tokens
  • ℹ️ Flake8 + file analysis (informational)

Run locally first:

pip install bandit detect-secrets flake8
bandit -r .  &&  detect-secrets scan  &&  flake8
Ismail Sunni · Camptocamp Tech Lunch
QGIS 3 → QGIS 4 / Qt6

Security scan results

✅ Clean

🚫 Blocked

Ismail Sunni · Camptocamp Tech Lunch
QGIS 3 → QGIS 4 / Qt6

Packaging tip

# git archive ships everything tracked — CLAUDE.md, screenshots, CI configs
git archive --prefix=plugin/ -o plugin.zip HEAD \
    __init__.py main_dialog.py polyline.py \
    metadata.txt redLineStyle.qml encodedPolyline.svg LICENSE

Pass an explicit file list so the uploaded zip is just runtime files.
Smaller download, fewer surprises in the security scanner.

Ismail Sunni · Camptocamp Tech Lunch
QGIS 3 → QGIS 4 / Qt6

Takeaway

  • Tiny plugin: ~half a day
  • Mid-size plugin (tests, network, UI): ~2 days
    • Most time in CI matrix + test-mock fixes, not the plugin code itself
  • Use an LLM — it speeds up the mechanical bits a lot, and gives you headroom to add features/tests/docs you'd otherwise skip

Do it now

QGIS 4 is fresh. Your bug reports are still quiet.
Easier to migrate before users start filing them.

Ismail Sunni · Camptocamp Tech Lunch
QGIS 3 → QGIS 4 / Qt6

Thanks

Questions?

Slides: ismailsunni.id/slides/qgis-4-migration

Plugins:

Docs:

Ismail Sunni · Camptocamp Tech Lunch