Retour au cours

backend / python

Plugins et chargement dynamique : importlib, entry points

Leçon 361 exercice

Explication

Ce que vous allez apprendre

  • Importer un module dont le nom n'est connu qu'à l'exécution avec importlib.import_module
  • Charger un fichier .py par son chemin sur le disque avec importlib.util.spec_from_file_location
  • Découvrir automatiquement tous les plugins d'un package avec pkgutil.iter_modules
  • Comprendre le rôle des entry points (pyproject.toml) dans l'écosystème de plugins Python
  • Reconnaître les limites du chargement dynamique pour l'analyse statique et les IDE

Dans quel contexte ?

Un outil de reporting interne doit permettre à différentes équipes d'ajouter leurs propres formats d'export (CSV, JSON, Excel) sans jamais modifier le fichier principal de l'application. Chaque équipe dépose son plugin dans un dossier dédié, et l'application le découvre et le charge automatiquement au démarrage, exactement comme le fait pytest avec ses propres plugins. Cette leçon montre les briques importlib/pkgutil qui rendent cela possible.

Le problème : ajouter des fonctionnalités sans toucher au code central

Imaginez un éditeur de texte qui doit accepter des extensions écrites par des tiers, ou une application qui doit choisir dynamiquement, selon la configuration, quelle "implémentation" d'un service utiliser (base de données locale ou service cloud). Dans les deux cas, on veut pouvoir ajouter du code après coup, sans modifier le fichier principal ni connaître à l'avance le nom exact du module. C'est le rôle d'une architecture à plugins.

Import statique vs import dynamique

Normalement, import json est résolu à l'écriture du code : le nom du module est fixe. Le module importlib permet l'inverse : importer un module dont le nom est une simple chaîne de caractères connue seulement à l'exécution (importlib.import_module(f"backends.{nom}")). C'est ce qui permet de sélectionner un composant selon une configuration, une variable d'environnement, ou un fichier découvert dynamiquement.

OutilCharge un module...Cas d'usage
importlib.import_module(nom)déjà installé, par son nomsélection dynamique selon la config
spec_from_file_location(nom, chemin)par son chemin sur disqueplugins déposés hors du path standard
pkgutil.iter_modules(...)découvre tous les modules d'un packagescan automatique d'un dossier de plugins

Charger un fichier par chemin, pas par nom de module

Aller plus loin : importlib.util.spec_from_file_location permet de charger un fichier .py situé n'importe où sur le disque, même hors du chemin de recherche standard des modules Python — utile pour un système de plugins où les extensions sont déposées dans un dossier dédié par l'utilisateur.

Le lien avec les entry points

Les "entry points" (mentionnés dans le titre) sont un mécanisme de packaging (déclaré dans pyproject.toml, vu en leçon 29) qui permet à un package installé de "s'annoncer" auprès d'une application hôte, sans que celle-ci connaisse son nom à l'avance. C'est le fondement de l'écosystème de plugins de nombreux outils Python (pytest, par exemple).

Le piège à garder en tête

Le chargement dynamique de code réduit le contrôle statique : un outil d'analyse ou un IDE ne peut pas toujours "voir" ce qui sera réellement chargé à l'exécution. À réserver aux cas où l'extensibilité est un vrai besoin architectural, pas une commodité.

Piège fréquent

Un module chargé dynamiquement par importlib.import_module(f"...{nom}") échappe à l'analyse statique des IDE et des outils comme mypy : aucune autocomplétion, aucune détection d'erreur avant l'exécution. Documentez clairement l'interface attendue (une classe Backend avec des méthodes précises) pour compenser cette perte de contrôle.

Commandes & code

Plugins et chargement dynamique

Construire une architecture extensible sans modifier le code central.

python
import importlib
import importlib.util
import pkgutil
import sys
from pathlib import Path

# --- importlib.import_module : importer un module dont le NOM est une chaine dynamique ---
nom_module = "json"
module = importlib.import_module(nom_module)
print(module.dumps({"cle": "valeur"}))

# Import dynamique conditionnel : selectionner une implementation selon la configuration
def charger_backend(nom: str):
    module = importlib.import_module(f"mon_app.backends.{nom}")
    return module.Backend()

# --- Recharger un module modifie sans redemarrer le processus (dev uniquement) ---
importlib.reload(module)

# --- importlib.util.spec_from_file_location : charger un fichier .py par CHEMIN ---
def charger_module_depuis_chemin(chemin: Path, nom_module: str):
    spec = importlib.util.spec_from_file_location(nom_module, chemin)
    if spec is None or spec.loader is None:
        raise ImportError(f"Impossible de charger {chemin}")
    module = importlib.util.module_from_spec(spec)
    sys.modules[nom_module] = module      # necessaire pour un import relatif eventuel dans le plugin
    spec.loader.exec_module(module)
    return module

# --- pkgutil.iter_modules : decouvrir tous les plugins d'un package a l'execution ---
# Structure supposee :
# mon_app/plugins/
#   __init__.py
#   plugin_export_csv.py
#   plugin_export_json.py
def decouvrir_plugins(package_plugins):
    plugins = {}
    for _, nom_module, _ in pkgutil.iter_modules(package_plugins.__path__):
        module = importlib.import_module(f"{package_plugins.__name__}.{nom_module}")
        if hasattr(module, "register"):
            plugins[nom_module] = module.register()
    return plugins

# --- Pattern registre : chaque plugin s'auto-enregistre via un decorateur ---
REGISTRE_EXPORTATEURS = {}

def exportateur(nom: str):
    def decorateur(classe):
        REGISTRE_EXPORTATEURS[nom] = classe
        return classe
    return decorateur

@exportateur("csv")
class ExportateurCSV:
    def exporter(self, donnees):
        return "\n".join(",".join(map(str, ligne)) for ligne in donnees)

@exportateur("json")
class ExportateurJSON:
    def exporter(self, donnees):
        import json
        return json.dumps(donnees)

def exporter(nom_format: str, donnees):
    if nom_format not in REGISTRE_EXPORTATEURS:
        raise ValueError(f"Format inconnu : {nom_format}. Disponibles : {list(REGISTRE_EXPORTATEURS)}")
    return REGISTRE_EXPORTATEURS[nom_format]().exporter(donnees)

print(exporter("csv", [[1, 2], [3, 4]]))

# --- Entry points : LE mecanisme standard pour des plugins DISTRIBUES en packages separes ---
# Dans le pyproject.toml du package "mon-plugin-slack" (installe separement via pip) :
'''
[project.entry-points."mon_app.exportateurs"]
slack = "mon_plugin_slack:ExportateurSlack"
'''
# Le package central peut alors decouvrir TOUS les plugins installes, sans les connaitre a l'avance :
from importlib.metadata import entry_points

def charger_plugins_installes():
    plugins = {}
    for point in entry_points(group="mon_app.exportateurs"):
        classe_plugin = point.load()          # importe dynamiquement le module cible
        plugins[point.name] = classe_plugin
    return plugins

# plugins_disponibles = charger_plugins_installes()
# -> {"slack": <class ExportateurSlack>, "csv": <class ExportateurCSV>, ...}
# N'importe qui peut publier "mon-plugin-teams" sur PyPI et l'application le decouvre
# automatiquement des qu'il est installe, SANS modifier une seule ligne du code central.

# --- Chargement paresseux (lazy loading) d'un plugin lourd, seulement si utilise ---
class GestionnaireExportateursParesseux:
    def __init__(self):
        self._points = {p.name: p for p in entry_points(group="mon_app.exportateurs")}
        self._instances = {}

    def obtenir(self, nom: str):
        if nom not in self._instances:
            if nom not in self._points:
                raise KeyError(f"Plugin '{nom}' non installe")
            classe = self._points[nom].load()      # import reel seulement au premier usage
            self._instances[nom] = classe()
        return self._instances[nom]

# --- Isolation : verifier l'interface d'un plugin avant de l'activer (contrat via Protocol) ---
from typing import Protocol, runtime_checkable

@runtime_checkable
class Exportateur(Protocol):
    def exporter(self, donnees: list) -> str: ...

def valider_plugin(classe) -> bool:
    instance = classe()
    return isinstance(instance, Exportateur)

print(valider_plugin(ExportateurCSV))     # True

Résumé

  • importlib.import_module importe un module dont le nom n'est connu qu'à l'exécution (chaîne dynamique).
  • pkgutil.iter_modules découvre les sous-modules d'un package pour un système de plugins "in-tree".
  • importlib.metadata.entry_points est le mécanisme standard pour des plugins distribués en packages PyPI séparés.
  • Un Protocol (@runtime_checkable) valide qu'un plugin tiers respecte le contrat attendu avant activation.

Exercices pratiques

1 disponible
1

Mission : ouvrir un outil de reporting aux plugins d'équipes externes

Objectif : Construire un système de découverte automatique de plugins d'export sans modifier le code central, et sécuriser le chargement dynamique avec un Protocol.

Contexte

Un outil de reporting interne doit permettre à différentes équipes d'ajouter leurs propres formats d'export (CSV, JSON, Excel, Slack) sans jamais toucher au fichier principal de l'application. Chaque équipe dépose son module d'export dans un dossier plugins/ dédié, mais actuellement, ajouter un nouveau format oblige encore à modifier une longue chaîne if nom_format == "csv": ... elif nom_format == "json": ... dans le cœur de l'application.

Tu dois remplacer cette chaîne de conditions par un système de découverte automatique, puis protéger l'application contre un plugin mal formé grâce à un Protocol.

Résoudre l’exercice →