Aller au contenu

Organisation du code dans un script avec des fonctions#

Communication avec l'utilisateur des erreurs et des logs#

Avant de commencer à vraiment écrire un script avec des fonctions, regardons comment communiquer des informations à l'utilisateur.

On peut envoyer des messages vers l'utilisateur avec l'utilisation de la messageBar de la classe QgisInterface :

1
2
3
4
iface.messageBar().pushMessage('Erreur','On peut afficher une erreur', Qgis.Critical)
iface.messageBar().pushMessage('Avertissement','ou un avertissement', Qgis.Warning)
iface.messageBar().pushMessage('Information','ou une information', Qgis.Info)
iface.messageBar().pushMessage('Succès','ou un succès', Qgis.Success)

Cette fonction prend 3 paramètres :

  • un titre
  • un message
  • un niveau d'alerte

On peut aussi écrire des logs comme ceci (plus discret, mais plus verbeux) :

1
2
3
4
QgsMessageLog.logMessage('Une erreur est survenue','Notre outil', Qgis.Critical)
QgsMessageLog.logMessage('Un avertissement','Notre outil', Qgis.Warning)
QgsMessageLog.logMessage('Une information','Notre outil', Qgis.Info)
QgsMessageLog.logMessage('Un succès','Notre outil', Qgis.Success)

Cette fonction prend 3 paramètres :

  • un message
  • une catégorie, souvent le nom de l'extension ou de l'outil en question
  • un niveau d'alerte

Charger automatiquement plusieurs couches à l'aide d'un script#

La console, c'est bien, mais c'est très limitant. Passons à l'écriture d'un script qui va nous faciliter l'organisation du code.

Ci-dessous, voici le dernier script du chapitre précédent, mais avec la gestion des erreurs ci-dessus :

  • Redémarrer QGIS
  • N'ouvrez pas le projet précédent
  • Ouvrer la console, puis cliquer sur Afficher l'éditeur
  • Copier/coller le script ci-dessous
  • Exécuter le
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
from os.path import join, isfile, isdir
dossier = 'BDT_3-3_SHP_LAMB93_D0ZZ-EDYYYY-MM-DD'
thematique = 'ADMINISTRATIF'
couche = 'COMMUNE'

racine = QgsProject.instance().homePath()
if not racine:
    iface.messageBar().pushMessage('Erreur de chargement','Le projet n\'est pas enregistré', Qgis.Critical)
else:
    fichier_shape = join(racine, dossier, thematique, '{}.shp'.format(couche))
    if not isfile(fichier_shape):
        iface.messageBar().pushMessage('Erreur de chargement','Le chemin n\'existe pas: "{}"'.format(fichier_shape), Qgis.Critical)
    else:
        layer = QgsVectorLayer(fichier_shape, couche, 'ogr')
        if not layer.isValid():
            iface.messageBar().pushMessage('Erreur de chargement','La couche n\'est pas valide', Qgis.Critical)
        else:
            QgsProject.instance().addMapLayer(layer)
            iface.messageBar().pushMessage('Bravo','Well done!', Qgis.Success)
  • À l'aide du mémo Python :
  • Essayons de faire une fonction qui prend 2 paramètres
    • la thématique (le dossier)
    • le nom du shapefile
  • La fonction se chargera de faire le nécessaire, par exemple: charger_couche('ADMINISTRATIF', 'COMMUNE')
  • La fonction peut également retourner False si la couche n'est pas chargée (une erreur) ou sinon l'objet couche.
1
2
def charger_couche(thematique, couche):
    pass

Tip

Le mot-clé pass ne sert à rien. C'est un mot-clé Python pour rendre un bloc valide mais ne faisant rien. On peut le supprimer le bloc n'est pas vide.

Afficher la solution intermédiaire
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from os.path import join, isfile, isdir
dossier = 'BDT_3-3_SHP_LAMB93_D0ZZ-EDYYYY-MM-DD'


def charger_couche(thematique, couche):
    """Fonction qui charge une couche shapefile dans une thématique."""
    racine = QgsProject.instance().homePath()
    if not racine:
        iface.messageBar().pushMessage('Erreur de chargement','Le projet n\'est pas enregistré', Qgis.Critical)
    else:
        fichier_shape = join(racine, dossier, thematique, '{}.shp'.format(couche))
        if not isfile(fichier_shape):
            iface.messageBar().pushMessage('Erreur de chargement','Le chemin n\'existe pas: "{}"'.format(fichier_shape), Qgis.Critical)
        else:
            layer = QgsVectorLayer(fichier_shape, couche, 'ogr')
            if not layer.isValid():
                iface.messageBar().pushMessage('Erreur de chargement','La couche n\'est pas valide', Qgis.Critical)
            else:
                QgsProject.instance().addMapLayer(layer)
                iface.messageBar().pushMessage('Bravo','Well done!', Qgis.Success)

thematique = 'ADMINISTRATIF'
couche = 'COMMUNE'
charger_couche(thematique, couche)

Améliorons encore cette solution intermédiaire avec la gestion des erreurs et aussi en gardant le code le plus à gauche possible grâce à l'instruction return qui ordonne la sortie de la fonction.

Afficher une des solutions finales
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from os.path import join, isfile, isdir

def charger_couche(thematique, couche):
    """Fonction qui charge une couche shapefile dans une thématique."""
    dossier = 'BDT_3-3_SHP_LAMB93_D0ZZ-EDYYYY-MM-DD'

    racine = QgsProject.instance().homePath()
    if not racine:
        iface.messageBar().pushMessage('Erreur de chargement','Le projet n\'est pas enregistré', Qgis.Critical)
        return False

    fichier_shape = join(racine, dossier, thematique, '{}.shp'.format(couche))
    if not isfile(fichier_shape):
        iface.messageBar().pushMessage('Erreur de chargement','Le chemin n\'existe pas: "{}"'.format(fichier_shape), Qgis.Critical)
        return False

    layer = QgsVectorLayer(fichier_shape, couche, 'ogr')
    if not layer.isValid():
        iface.messageBar().pushMessage('Erreur de chargement','La couche n\'est pas valide', Qgis.Critical)
        return False

    QgsProject.instance().addMapLayer(layer)
    iface.messageBar().pushMessage('Bravo','Well done!', Qgis.Success)
    return layer

charger_couche('ADMINISTRATIF', 'COMMUNE')
charger_couche('ADMINISTRATIF', 'ARRONDISSEMENT')
  • Essayons de faire une fonction qui liste les shapefiles d'une certaine thématique.

On peut utiliser la méthode os.walk(path) permet de parcourir un chemin et de lister les répertoires et les fichiers.

Ou alors on peut utiliser une autre méthode, un peu plus à la mode en utilisant le mode pathlib qui comporte également les fonctions isfile, isdir etc.

En utilisant le module os.walk, un peu historique :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import os

def liste_shapefiles(thematique):
    """Liste les shapefiles d'une thématique."""
    dossier = 'BDT_3-3_SHP_LAMB93_D0ZZ-EDYYYY-MM-DD'
    racine = QgsProject.instance().homePath()
    shapes = []
    for root, directories, files in os.walk(os.path.join(racine, dossier, thematique)):
        for file in files:
            if file.lower().endswith('.shp'):
                shapes.append(file.replace('.shp', ''))
    return shapes

shapes = liste_shapefiles('ADMINISTRATIF')
print(shapes)

En utilisant le "nouveau" module pathlib:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from pathlib import Path

def liste_shapefiles(thematique):
    """Liste les shapefiles d'une thématique."""
    racine = QgsProject.instance().homePath()
    dossier = Path(racine).joinpath('BDT_3-3_SHP_LAMB93_D0ZZ-EDYYYY-MM-DD', thematique)
    shapes = []
    for file in dossier.iterdir():
        if file.suffix.lower() == '.shp':
            shapes.append(file.stem)
    return shapes

shapes = liste_shapefiles('ADMINISTRATIF')
print(shapes)

Tip

Il faut se référer à la documentation du module pathlib pour comprendre le fonctionnement de cette classe.

  • Permettre le chargement automatique de toute une thématique.
Afficher la solution complète
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import os
from os.path import join, isfile, isdir
dossier = 'BDT_3-3_SHP_LAMB93_D0ZZ-EDYYYY-MM-DD'
# couche = 'COMMUNE'

def liste_shapesfiles(thematique):
    """Liste les shapes d'une thématique"""
    racine = QgsProject.instance().homePath()
    if not racine:
        iface.messageBar().pushMessage('Erreur de chargement','Le projet n\'est pas enregistré', Qgis.Critical)
        return False

    shapes = []
    for root, directories, files in os.walk(os.path.join(racine, dossier, thematique)):
        # print(files)
        for file in files:
            # print(file)
            if file.lower().endswith('.shp'):
                # print(file)
                shapes.append(file.replace(".shp", ""))

    return shapes

def charger_couche(thematique, couche):

    """Fonction qui charge des couches suivant une thématique."""
    racine = QgsProject.instance().homePath()
    if not racine:
        iface.messageBar().pushMessage('Erreur de chargement','Le projet n\'est pas enregistré', Qgis.Critical)
        return False

    fichier_shape = join(racine, dossier, thematique, '{}.shp'.format(couche))
    if not isfile(fichier_shape):
        iface.messageBar().pushMessage('Erreur de chargement','Le chemin n\'existe pas: "{}"'.format(fichier_shape), Qgis.Critical)
        return False

    layer = QgsVectorLayer(fichier_shape, couche, 'ogr')
    if not layer.isValid():
        iface.messageBar().pushMessage('Erreur de chargement','La couche n\'est pas valide', Qgis.Critical)
        return False

    QgsProject.instance().addMapLayer(layer)
    iface.messageBar().pushMessage('Bravo','Well done!', Qgis.Success)
    return layer


thematique = 'ADMINISTRATIF'
shapes = liste_shapesfiles(thematique)
for shape in shapes:
    charger_couche(thematique, shape)

Extraction des informations sous forme d'un fichier CSV.#

On souhaite désormais réaliser une fonction d'export des métadonnées de nos couches au format CSV, avec son CSVT. Il existe déjà un module CSV dans Python pour nous aider à écrire un fichier de type CSV, mais nous n'allons pas l'utiliser. Nous allons plutôt utiliser l'API QGIS pour créer une nouvelle couche en mémoire comportant les différentes informations que l'on souhaite exporter. Puis, nous allons utiliser l'API pour exporter cette couche mémoire au format CSV (l'équivalent dans QGIS de l'action Exporter la couche).

Les différents champs qui devront être exportés sont :

  • son nom
  • son type de géométrie (format humain, lisible)
  • la projection
  • le nombre d'entité
  • l'encodage
  • si le seuil de visibilité est activé
  • la source (le chemin) de la donnée

Exemple de sortie#

nom type projection nombre_entite encodage source seuil_de_visibilite
couche_1 Line EPSG:4326 5 UTF-8 /tmp/...geojson False
couche_2 Tab No geometry 0 /tmp/...shp True

Petit mémo avec des exemples#

Pour créer une couche tabulaire en mémoire, code qui vient du cookbook :

1
layer_info = QgsVectorLayer('None', 'info', 'memory')

La liste des couches :

1
layers = QgsProject.instance().mapLayers()

Créer une entité ayant déjà les champs préconfigurés d'une couche vecteur, et y affecter des valeurs :

1
2
feature = QgsFeature(objet_qgsvectorlayer.fields())
feature['nom'] = "NOM"

Obtenir le dossier du project actuel :

1
2
projet_qgis = Path(QgsProject.instance().fileName())
dossier_qgis = projet_qgis.parent

Pour utiliser une session d'édition, on peut faire :

1
2
3
layer.startEditing()  # Début de la session
layer.commitChanges()  # Fin de la session en enregistrant
layer.rollback()  # Fin de la session en annulant les modifications

Les contextes Python#

On peut également faire une session d'édition avec un "contexte Python" :

1
2
3
4
5
6
7
from qgis.core import edit

with edit(layer):
    # Faire une édition sur la couche
    pass

# À la fin du bloc d'indentation, la session d'édition est automatiquement close, même en cas d'erreur Python
Exemple de l'utilisation d'un contexte Python avec la session d'édition

Sans contexte, la couche reste en mode édition en cas d'erreur fatale Python

1
2
3
4
5
6
7
8
9
layer = iface.activeLayer()

layer.startEditing()

# Code inutile, mais qui va volontairement faire une exception Python
a = 10 / 0

layer.commitChanges()
print("Fin du script")

Mais utilisons désormais un contexte Python à l'aide dewith, sur une couche qui n'est pas en édition :

1
2
3
4
5
6
7
layer = iface.activeLayer()

with edit(layer):
    # Code inutile, mais qui va volontairement faire une exception Python
    a = 10 / 0

print("Fin du script")

On peut lire le code comme En éditant la couche "layer", faire :.

Petit mémo des classes#

Nous allons avoir besoin de plusieurs classes dans l'API QGIS :

  • QgsProject : PyQGIS / CPP
  • QgsVectorLayer : PyQGIS / CPP
  • Enregistrer un fichier avec QgsVectorFileWriter : PyQGIS / CPP
  • Un champ dans une couche vecteur : QgsField (PyQGIS / CPP), attention à ne pas confondre avec QgsFields (PyQGIS / CPP) qui lui représente un ensemble de champs.
  • Une entité QgsFeature PyQGIS / CPP
  • Pour le type de géométrie : Utiliser QgsVectorLayer geometryType() et également la méthode QgsWkbTypes.geometryDisplayString() pour sa conversion en chaîne "lisible"
  • PyQGIS / CPP

Pour le type de champ, on va avoir besoin de l'API Qt également :

  • Documentation Qt5 sur QMetaType
  • Remplacer QMetaType par QVariant et aussi exception QString par String
  • Par exemple :
    • Pour créer un nouveau champ de type string : QgsField('nom', QVariant.String)
    • Pour créer un nouveau champ de type entier : QgsField('nombre_entité', QVariant.Int)

Note

Note perso, je pense qu'avec la migration vers Qt6, cela va pouvoir se simplifier un peu pour les QVariant...

Étapes#

Il va y avoir plusieurs étapes dans ce script :

  1. Créer une couche en mémoire
  2. Ajouter des champs à cette couche en utilisant une session d'édition
  3. Récupérer la liste des couches présentes dans la légende
  4. Itérer sur les couches pour ajouter ligne par ligne les métadonnées dans une session d'édition
  5. Enregistrer en CSV la couche mémoire

Tip

Pour déboguer, on peut afficher la couche mémoire en question avec QgsProject.instance().addMapLayer(layer_info)

QgsvectorLayer pyqgis ne cntient pas Addfeature#

AJouter indice QgsWkbTypês V4 fileName

Solution#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
from os.path import join

layers = QgsProject.instance().mapLayers()
if not layers:
    iface.messageBar().pushMessage('Pas de couche','Attention, il n\'a pas de couche', Qgis.Warning)

layers = [layer for layer in layers.values()]

layer_info = QgsVectorLayer('None', 'info', 'memory')

fields = []
fields.append(QgsField('nom', QVariant.String))
fields.append(QgsField('type', QVariant.String))
fields.append(QgsField('projection', QVariant.String))
fields.append(QgsField('nombre_entite', QVariant.Int))
fields.append(QgsField('encodage', QVariant.String))
fields.append(QgsField('source', QVariant.String))
fields.append(QgsField('seuil_de_visibilite', QVariant.String))

with edit(layer_info):
    for field in fields:
        layer_info.addAttribute(field)

QgsProject.instance().addMapLayer(layer_info)

with edit(layer_info):
    for layer in layers:
        feature = QgsFeature(layer_info.fields())
        feature.setAttribute("nom", layer.name())
        feature.setAttribute("projection", layer.crs().authid())
        feature.setAttribute("nombre_entite", layer.featureCount())
        feature.setAttribute("encodage", layer.dataProvider().encoding())
        feature.setAttribute("source", layer.source())
        feature.setAttribute("type", QgsWkbTypes.geometryDisplayString(layer.geometryType()))
        feature.setAttribute("seuil_visibilite", layer.hasScaleBasedVisibility())
        layer_info.addFeature(feature)

base_name = QgsProject.instance().baseName()
QgsVectorFileWriter.writeAsVectorFormat(
    layer_info,
    join(QgsProject.instance().homePath(), f'{base_name}.csv'),
    'utf-8',
    QgsCoordinateReferenceSystem(),
    'CSV',
    layerOptions=['CREATE_CSVT=YES']
)

# Afficher une messageBar pour confirmer que c'est OK, en vert ;-)
Pour la version avec writeAsVectorFormatV3

Il faut désormais donner le contexte pour une éventuelle reprojection que l'on trouve dans la classe QgsProject : QgsProject.instance().transformContext().

L'ensemble des options se donne via une nouvelle variable QgsVectorFileWriter.SaveVectorOptions().

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
options = QgsVectorFileWriter.SaveVectorOptions()
options.driverName = 'CSV'
options.fileEncoding = 'UTF-8'
options.layerOptions = ['CREATE_CSVT=YES', 'SEPARATOR=TAB']

base_name = QgsProject.instance().baseName()
QgsVectorFileWriter.writeAsVectorFormatV3(
    layer_info,
    join(QgsProject.instance().homePath(), f'{base_name}.csv'),
    QgsProject.instance().transformContext(),
    options,
)

Warning

Ajouter une couche raster et retester le script ... surprise 🎁

Tip

Pour obtenir en Python la liste des fournisseurs GDAL/OGR :

1
2
from osgeo import ogr
[ogr.GetDriver(i).GetDescription() for i in range(ogr.GetDriverCount())]    
ou dans le menu Préférences ➡ Options ➡ GDAL ➡ Pilotes vecteurs

Finalisation#

Idéalement, il faut vérifier le résultat de l'enregistrement du fichier. Les différentes méthodes writeAsVectorFormat retournent systématiquement un tuple avec un code d'erreur et un message si nécessaire, voir la documentation.

En cas de succès, il est pratique d'avertir l'utilisateur. On peut aussi fournir un lien pour ouvrir l'explorateur de fichier :

1
2
3
4
5
6
7
8
9
base_name = QgsProject.instance().baseName()
output_file = Path(QgsProject.instance().homePath()).joinpath(f'{base_name}.csv')
iface.messageBar().pushSuccess(
    "Export OK des couches 👍",
    (
        "Le fichier CSV a été enregistré dans "
        "<a href=\"{}\">{}</a>"
    ).format(output_file.parent, output_file)
)

Connection d'un signal à une fonction#

Nous avons pu voir que dans la documentation des librairies Qt et QGIS, il y a une section Signals.

Cela sert à déclencher du code Python lorsqu'un signal est émis.

Par exemple, dans la classe QgsMapLayer, cherchons un signal qui est émis après (before) que la session d'édition commence.

1
variable_de_lobjet.nom_du_signal.connect(nom_de_la_fonction)

Note, il ne faut pas écrire nom_de_la_fonction() car on ne souhaite pas appeler la fonction, juste connecter.