Script autopatch.py : adapter automatiquement un plugin QGIS pour Qt6



Contexte

Depuis QGIS 3.34 (et surtout 3.36+), QGIS a basculé de Qt5 vers Qt6.
Problème : les plugins Python générés avec Qt Designer / pyuic5 utilisent encore les constantes de Qt5.
Résultat : au lancement du plugin, on se retrouve avec des erreurs du type :

AttributeError: type object 'QFrame' has no attribute 'Sunken'
AttributeError: type object 'QDialogButtonBox' has no attribute 'Cancel'

Ce script autopatch.py corrige automatiquement tous les fichiers Python d’un plugin pour remplacer les vieilles constantes Qt5 par leurs équivalents Qt6.


Le code complet

autopatch.py

import os

# Dossier où se trouve ton plugin
PLUGIN_DIR = r"C:\fuzzy\FuzzyAttributes4"

# Dictionnaire des correspondances Qt5 → Qt6
qt_constants = {
    # QFrame
    "QtWidgets.QFrame.HLine": "QtWidgets.QFrame.Shape.HLine",
    "QtWidgets.QFrame.VLine": "QtWidgets.QFrame.Shape.VLine",
    "QtWidgets.QFrame.StyledPanel": "QtWidgets.QFrame.Shape.StyledPanel",
    "QtWidgets.QFrame.Panel": "QtWidgets.QFrame.Shape.Panel",
    "QtWidgets.QFrame.Box": "QtWidgets.QFrame.Shape.Box",
    "QtWidgets.QFrame.NoFrame": "QtWidgets.QFrame.Shape.NoFrame",
    "QtWidgets.QFrame.WinPanel": "QtWidgets.QFrame.Shape.WinPanel",
    "QtWidgets.QFrame.Sunken": "QtWidgets.QFrame.Shadow.Sunken",
    "QtWidgets.QFrame.Raised": "QtWidgets.QFrame.Shadow.Raised",

    # Alignements
    "Qt.AlignLeft": "Qt.AlignmentFlag.AlignLeft",
    "Qt.AlignRight": "Qt.AlignmentFlag.AlignRight",
    "Qt.AlignHCenter": "Qt.AlignmentFlag.AlignHCenter",
    "Qt.AlignJustify": "Qt.AlignmentFlag.AlignJustify",
    "Qt.AlignTop": "Qt.AlignmentFlag.AlignTop",
    "Qt.AlignBottom": "Qt.AlignmentFlag.AlignBottom",
    "Qt.AlignVCenter": "Qt.AlignmentFlag.AlignVCenter",
    "Qt.AlignCenter": "Qt.AlignmentFlag.AlignCenter",

    # États des widgets
    "Qt.Checked": "Qt.CheckState.Checked",
    "Qt.Unchecked": "Qt.CheckState.Unchecked",
    "Qt.PartiallyChecked": "Qt.CheckState.PartiallyChecked",

    # Orientations
    "Qt.Horizontal": "Qt.Orientation.Horizontal",
    "Qt.Vertical": "Qt.Orientation.Vertical",

    # QDialogButtonBox
    "QtWidgets.QDialogButtonBox.Ok": "QtWidgets.QDialogButtonBox.StandardButton.Ok",
    "QtWidgets.QDialogButtonBox.Cancel": "QtWidgets.QDialogButtonBox.StandardButton.Cancel",
    "QtWidgets.QDialogButtonBox.Yes": "QtWidgets.QDialogButtonBox.StandardButton.Yes",
    "QtWidgets.QDialogButtonBox.No": "QtWidgets.QDialogButtonBox.StandardButton.No",
    "QtWidgets.QDialogButtonBox.Apply": "QtWidgets.QDialogButtonBox.StandardButton.Apply",
    "QtWidgets.QDialogButtonBox.Close": "QtWidgets.QDialogButtonBox.StandardButton.Close",
    "QtWidgets.QDialogButtonBox.Help": "QtWidgets.QDialogButtonBox.StandardButton.Help",
    "QtWidgets.QDialogButtonBox.Reset": "QtWidgets.QDialogButtonBox.StandardButton.Reset",
    "QtWidgets.QDialogButtonBox.RestoreDefaults": "QtWidgets.QDialogButtonBox.StandardButton.RestoreDefaults",

    # Shadows (sécurité pour d’autres cas)
    "QFrame.Sunken": "QFrame.Shadow.Sunken",
    "QFrame.Raised": "QFrame.Shadow.Raised",
    "QFrame.Plain": "QFrame.Shadow.Plain",
    "QFrame.Shadow": "QFrame.Shadow"
}

def patch_file(file_path):
    """Corrige un fichier .py en remplaçant les constantes Qt5 par Qt6"""
    changed = False
    with open(file_path, "r", encoding="utf-8") as f:
        lines = f.readlines()

    new_lines = []
    for line in lines:
        for old, new in qt_constants.items():
            if old in line:
                line = line.replace(old, new)
                changed = True
        new_lines.append(line)

    if changed:
        with open(file_path, "w", encoding="utf-8") as f:
            f.writelines(new_lines)
        print(f"[✔] Corrigé : {file_path}")
    else:
        print(f"[ ] Aucun changement : {file_path}")

def patch_all_py_files():
    """Parcourt tout le dossier du plugin et corrige les .py"""
    for root, dirs, files in os.walk(PLUGIN_DIR):
        for file in files:
            if file.endswith(".py"):  # tous les fichiers Python
                patch_file(os.path.join(root, file))

if __name__ == "__main__":
    patch_all_py_files()


Comment ça marche ?

  1. Dictionnaire de correspondances
    Tout en haut, on définit une table de conversion qt_constants qui fait le lien entre les anciennes constantes Qt5 et les nouvelles Qt6.
    Exemple :

    • QtWidgets.QFrame.Sunken devient QtWidgets.QFrame.Shadow.Sunken
    • QtWidgets.QDialogButtonBox.Cancel devient QtWidgets.QDialogButtonBox.StandardButton.Cancel

  2. Analyse ligne par ligne
    Le script lit chaque fichier .py du plugin, parcourt les lignes et applique les remplacements.
  3. Écriture des fichiers corrigés
    Si un remplacement a eu lieu, le fichier est réécrit directement (⚠️ il vaut mieux faire une sauvegarde du plugin avant).
  4. Rapport en console
    Le script affiche :

    • [✔] Corrigé : ... si le fichier contenait des constantes Qt5 remplacées,
    • [ ] Aucun changement : ... si le fichier ne nécessitait pas de correction.


Mode d’emploi

  1. Copiez autopatch.py à côté de votre plugin QGIS (ou ailleurs, mais pas à l’intérieur).
  2. Éditez la ligne suivante pour mettre le bon chemin de votre plugin : PLUGIN_DIR = r"C:\Users\...\AppData\Roaming\QGIS\QGIS3\profiles\default\python\plugins\NomDuPlugin"
  3. Faites une sauvegarde de votre plugin (par précaution).
  4. Lancez le script dans une console (cmd ou PowerShell) : python autopatch.py
  5. Ouvrez QGIS → votre plugin devrait fonctionner sans erreur avec Qt6.


1. Pourquoi ça coince ?

  • QGIS est construit sur PyQt (Python + Qt).
  • Jusqu’à QGIS 3.28 inclus, la base était Qt5.
  • Depuis QGIS 3.34 (et encore plus avec la 3.40 à venir), QGIS migre progressivement vers Qt6.

Or, PyQt6 a changé plein de noms de classes, constantes et énumérations :

  • Ce qui s’écrivait avant Qt.AlignLeft devient Qt.AlignmentFlag.AlignLeft.
  • Les boutons de QDialogButtonBox ont été déplacés (OkStandardButton.Ok).
  • Les ombres de cadres (QFrame.Sunken, etc.) sont devenues des sous-énums (QFrame.Shadow.Sunken).

Résultat :

  • Votre plugin, écrit pour Qt5, fonctionne parfaitement sous QGIS basé sur Qt5 (3.28, LTR actuelle).
  • Mais sous Qt6, Python crashe dès qu’il rencontre un symbole Qt5 qui n’existe plus → d’où les erreurs AttributeError: type object 'QFrame' has no attribute 'Sunken'.


2. Quelles conséquences pour votre plugin ?

Cas 1 : QGIS basé sur Qt5 (ex. 3.28 LTR)

  • Votre plugin fonctionne normalement.
  • Si vous appliquez les changements Qt6 dedans, il risque de ne plus fonctionner sous Qt5 car les anciens noms ne sont plus reconnus.

Cas 2 : QGIS basé sur Qt6 (ex. 3.34, 3.40+)

  • Votre plugin plante immédiatement si vous ne faites rien.
  • Une fois corrigé (Qt5 → Qt6), il fonctionne comme avant, le comportement de votre plugin ne change pas : ce ne sont que des renommages, pas des modifications de logique.

Donc :

  • Le cœur fonctionnel (calculs, algorithmes, interface utilisateur) reste identique.
  • Seuls les noms des constantes Qt doivent être adaptés.
  • Votre plugin n’aura aucune différence visible dans QGIS : les fenêtres, boutons, cases à cocher, aperçus graphiques s’affichent pareil.


3. Compatibilité double Qt5 / Qt6

Comme beaucoup de développeurs veulent un seul plugin qui marche à la fois sous 3.28 (Qt5) et sous 3.34+ (Qt6), il y a trois stratégies possibles :

  1. Deux branches du plugin

    • Une version Qt5 pour QGIS ≤ 3.28.
    • Une version Qt6 pour QGIS ≥ 3.34.
    • Avantage : simple et propre.
    • Inconvénient : vous devez maintenir deux versions.

  2. Un patch automatique (comme autopatch.py)

    • Vous gardez voytre code en Qt5.
    • Quand vous voulez publier pour Qt6, vous lancez le script → il réécrit les lignes incompatibles.
    • Vous publiez le résultat comme version Qt6.

  3. Détection dynamique dans le code

    • Au lieu de patcher, vous testez au lancement :
      from qgis.PyQt import QtCore, QtWidgets
      try:
      AlignLeft = QtCore.Qt.AlignmentFlag.AlignLeft # Qt6
      except AttributeError:
      AlignLeft = QtCore.Qt.AlignLeft # Qt5
    • Plus complexe mais ça permet un seul plugin universel.


4. En résumé

  • Un plugin Qt5 ne fonctionne pas directement sous Qt6, car beaucoup de noms ont changé.
  • Le fonctionnement fonctionnel du plugin ne change pas → c’est uniquement du « renommage technique ».
  • Le script autopatch.py sert à traduire automatiquement Qt5 → Qt6 dans tous les .py du plugin.
  • Vous pouvez soit :

    • publier deux versions séparées (Qt5 et Qt6),
    • ou gérer une version patchée dynamiquement.


5. Exemple concret

Actuellement votre code (après autopatch) ressemble sûrement à ça :

frame.setFrameShape(QtWidgets.QFrame.Shape.HLine)   # Qt6 only
label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)  # Qt6 only

Sous QGIS Qt5 → ça crashe, car Shape et AlignmentFlag n’existent pas.


Voici une fonction utilitaire “qt_compat.py” que vous pouvez ajouter dans votre plugin et importer partout, pour gérer automatiquement Qt5/Qt6 sans vous casser la tête

6. Fonction qt-compat.py

autopatch.py

from qgis.PyQt import QtWidgets, QtCore

# ---------
# QFrame
# ---------
def frame_shape(shape_name: str):
    """
    Retourne la valeur correcte d'une constante QFrame (Shape).
    Exemple : frame_shape("HLine"), frame_shape("VLine")
    """
    if hasattr(QtWidgets.QFrame, "Shape"):  # Qt6
        return getattr(QtWidgets.QFrame.Shape, shape_name)
    else:  # Qt5
        return getattr(QtWidgets.QFrame, shape_name)


def frame_shadow(shadow_name: str):
    """
    Retourne la valeur correcte d'une constante QFrame (Shadow).
    Exemple : frame_shadow("Sunken"), frame_shadow("Raised")
    """
    if hasattr(QtWidgets.QFrame, "Shadow"):  # Qt6
        return getattr(QtWidgets.QFrame.Shadow, shadow_name)
    else:
        return getattr(QtWidgets.QFrame, shadow_name)


# ---------
# QSizePolicy
# ---------
def size_policy(policy_name: str):
    """
    Retourne la valeur correcte d'une constante QSizePolicy.
    Exemple : size_policy("Expanding"), size_policy("Fixed")
    """
    if hasattr(QtWidgets.QSizePolicy, "Policy"):  # Qt6
        return getattr(QtWidgets.QSizePolicy.Policy, policy_name)
    else:
        return getattr(QtWidgets.QSizePolicy, policy_name)


# ---------
# QDialogButtonBox
# ---------
def dialog_button(button_name: str):
    """
    Retourne la valeur correcte pour un bouton standard de QDialogButtonBox.
    Exemple : dialog_button("Ok"), dialog_button("Cancel"), dialog_button("Apply")
    """
    if hasattr(QtWidgets.QDialogButtonBox, "StandardButton"):  # Qt6
        return getattr(QtWidgets.QDialogButtonBox.StandardButton, button_name)
    else:
        return getattr(QtWidgets.QDialogButtonBox, button_name)


# ---------
# Qt Alignment
# ---------
def alignment(align_name: str):
    """
    Retourne la valeur correcte pour les alignements Qt.
    Exemple : alignment("AlignLeft"), alignment("AlignCenter"), alignment("AlignRight")
    """
    if hasattr(QtCore, "AlignmentFlag"):  # Qt6
        return getattr(QtCore.AlignmentFlag, align_name)
    else:
        return getattr(QtCore, align_name)


# ---------
# QFont
# ---------
def font_weight(weight_name: str):
    """
    Retourne la valeur correcte d'un poids de police.
    Exemple : font_weight("Bold"), font_weight("Normal")
    """
    if hasattr(QtGui.QFont, "Weight"):  # Qt6
        return getattr(QtGui.QFont.Weight, weight_name)
    else:
        return getattr(QtGui.QFont, weight_name)

Ce que vous devrez faire avec votre module

Au lieu de coder en dur, vous appellez les helpers :

from . import qt_compat   # votre fichier de compatibilité

frame.setFrameShape(qt_compat.frame_shape("HLine"))
label.setAlignment(qt_compat.alignment("AlignCenter"))
buttonBox.addButton(qt_compat.dialog_button("Ok"))


Résultat

  • Sous Qt5 → qt_compat renverra QtWidgets.QFrame.HLine ou Qt.AlignCenter.
  • Sous Qt6 → il renverra QtWidgets.QFrame.Shape.HLine ou Qt.AlignmentFlag.AlignCenter.
  • Même code, plugin universel Qt5 + Qt6.


Donc, il faut remplacer partout les usages directs de Qt.* et QtWidgets.QFrame.* par les fonctions.


Si cet article vous a intéressé et que vous pensez qu'il pourrait bénéficier à d'autres personnes, n'hésitez pas à le partager sur vos réseaux sociaux en utilisant les boutons ci-dessous. Votre partage est apprécié !

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *