backend / python
Plugins et chargement dynamique : importlib, entry points
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
.pypar son chemin sur le disque avecimportlib.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.
| Outil | Charge un module... | Cas d'usage |
|---|---|---|
importlib.import_module(nom) | déjà installé, par son nom | sélection dynamique selon la config |
spec_from_file_location(nom, chemin) | par son chemin sur disque | plugins déposés hors du path standard |
pkgutil.iter_modules(...) | découvre tous les modules d'un package | scan 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.
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)) # TrueRésumé
importlib.import_moduleimporte un module dont le nom n'est connu qu'à l'exécution (chaîne dynamique).pkgutil.iter_modulesdécouvre les sous-modules d'un package pour un système de plugins "in-tree".importlib.metadata.entry_pointsest 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
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.