PEP 810 – Imports différés explicites
(pep-previews--4622.org.readthedocs.build)- En Python, il est d’usage de déclarer tous les imports au niveau du module
- Mais à l’exécution, même des modules de dépendances inutiles sont chargés immédiatement, ce qui pose des problèmes de vitesse de démarrage et d’utilisation mémoire
- Jusqu’ici, on utilisait souvent des imports différés manuels, par exemple à l’intérieur des fonctions, mais cela avait le désavantage de compliquer la maintenance et la gestion des dépendances
- Ce PEP 810 introduit une syntaxe d’import différé explicite avec le nouveau mot-clé
lazy, local, explicite, contrôlé et granulaire - Cette fonctionnalité permet de charger les modules uniquement au moment où ils sont réellement nécessaires, tout en améliorant la latence au démarrage, le gaspillage mémoire et la transparence de la structure du code
Situation actuelle des imports Python et problèmes associés
- En Python, il est généralement d’usage d’écrire les instructions
importen haut du module - Cette approche réduit les duplications, permet de visualiser d’un coup d’œil la structure des dépendances d’import, et minimise l’overhead d’exécution en n’important qu’une seule fois
- Cependant, lorsque le programme démarre et que le premier module (
main) est chargé, il est fréquent qu’une chaîne d’imports charge immédiatement de nombreux modules de dépendance qui ne seront en réalité jamais utilisés - C’est particulièrement visible dans les outils CLI : même lorsqu’on n’appelle que l’aide globale, des dizaines de modules peuvent être préchargés, ce qui ajoute un overhead inutile à chaque sous-commande
Solutions existantes et leurs limites
- On retarde souvent manuellement le moment de l’import, par exemple en déplaçant les imports à l’intérieur des fonctions
- Mais cette méthode nuit à la cohérence et à la maintenabilité, et rend plus difficile la compréhension de l’ensemble des dépendances
- D’après l’analyse de la bibliothèque standard, dans le code sensible aux performances, environ 17 % de tous les imports sont déjà utilisés à des fins de différé à l’intérieur de fonctions ou de méthodes
- Il existe des outils liés au chargement différé, comme
importlib.util.LazyLoaderou le package tierslazy_loader, mais ils ne couvrent pas tous les cas ou souffrent de l’absence d’un standard unique
PEP 810 : introduction des imports différés explicites
-
Introduction du nouveau mot-clé souple
lazy(qui n’a un sens que dans certains contextes et peut aussi servir de nom de variable, etc.) -
lazyne peut être utilisé que devant une instruction import ; il ne peut pas être utilisé dans les portées de fonctions/classes/with/try, ni avec les star imports -
Chaque instruction d’import est distinguée explicitement pour différer le chargement du module jusqu’au moment de son utilisation
lazy import nom_du_module lazy from nom_du_module import nom
Mode d’implémentation des imports différés explicites et règles syntaxiques
-
Cas d’erreur de syntaxe :
- Interdits dans une fonction, dans une classe, dans
try/with, ainsi qu’avec les star imports (*)
- Interdits dans une fonction, dans une classe, dans
-
Exemple d’utilisation :
import sys lazy import json print('json' in sys.modules) # False (pas encore chargé) result = json.dumps({"hello": "world"}) # chargé au premier usage print('json' in sys.modules) # True (chargement différé du module terminé) -
Au niveau du module, il est aussi possible de déclarer les cibles lazy via l’attribut
__lazy_modules__sous forme de liste de chaînes__lazy_modules__ = ["json"] import json # traité comme lazy
Contrôle du comportement via drapeaux globaux et filtres
-
Il est possible de contrôler l’activation du mode lazy au niveau d’un module ou de l’ensemble de l’application via un drapeau global ou une fonction de filtrage
-
Une fonction de filtrage permet aussi d’appliquer des exceptions en eager import à certains modules spécifiques
def my_filter(importer, name, fromlist): if name in {'problematic_module'}: return False # eager import return True # lazy import sys.set_lazy_imports_filter(my_filter)
Comportement à l’exécution et gestion des erreurs
-
Avec un lazy import, l’import réel ne se produit pas au moment de l’instruction, mais au premier accès au nom
-
Si l’import échoue, le système montre clairement à la fois l’emplacement de définition et celui où l’erreur s’est produite grâce au chaînage d’exceptions (
traceback chaining)lazy from json import dumsp # faute de frappe result = dumsp({"key": "value"}) # ImportError au moment du premier accès réel
Bénéfices mémoire et performances
- Les modules différés n’apparaissent que dans l’ensemble
sys.lazy_moduleset ne sont pas enregistrés danssys.modulesavant leur utilisation réelle - Après utilisation, ils sont remplacés par un objet module normal, sans pénalité de performance supplémentaire
- Sur des charges de travail réelles, on observe une réduction de 50 à 70 % de la latence de démarrage et une baisse de 30 à 40 % de la mémoire utilisée
Résumé du fonctionnement
- Au premier accès à un objet lazy, une reification (import réel puis remplacement) se produit
- Si du code externe accède au
__dict__du module, tous les objets lazy sont chargés de force (reification) - Lorsqu’on récupère le dictionnaire via
globals(), le proxy lazy est conservé et nécessite un accès direct
Annotations de type et optimisation de TYPE_CHECKING
- Avec
lazy from module import nom, les imports utilisés uniquement pour les types garantissent un coût d’exécution nul - Cela permet de remplacer les conditions existantes basées sur
from typing import TYPE_CHECKINGpar un code plus concis et plus clair
Différences avec le précédent PEP 690 et caractéristiques d’implémentation
- PEP 810 adopte une approche opt-in explicite, par import individuel, fondée sur un objet proxy simple
- À l’inverse, PEP 690 reposait sur une structure de lazy import globale et implicite
Points d’attention et interactions entre modules
- Les star imports (
*) ne sont pas pris en charge en lazy (ils restent toujours eager) - Les import hooks et loaders personnalisés continuent de fonctionner tels quels au moment de la reification
- Même dans un environnement multithread, l’import n’a lieu qu’une seule fois de manière thread-safe, avec une liaison sûre garantie
- Si un même module est utilisé à la fois en lazy et en eager, la version eager a toujours priorité
Guide d’application au code et migration
- Lors de l’adoption dans du code existant, il est recommandé d’utiliser le profiling pour convertir uniquement les imports nécessaires en lazy et de procéder progressivement
- L’utilisation de
__lazy_modules__permet aussi une compatibilité avec les versions antérieures à Python 3.15
Autres questions et réponses importantes
- Les effets de bord au moment de l’import (par exemple des patterns d’enregistrement) sont différés jusqu’au premier accès. Si ces side effects sont indispensables, il est recommandé d’utiliser explicitement un schéma de fonction d’initialisation
- Les problèmes de circular import ne sont pas entièrement résolus par les lazy imports (ils ne peuvent être atténués que si l’accès est lui-même retardé)
- Les performances sur les hot paths sont automatiquement optimisées après le premier usage, car toute vérification lazy disparaît complètement (adaptive specialization du bytecode)
- Le vrai module n’est enregistré dans
sys.modulesqu’après la reification (premier usage) - Contrairement à
importlib.util.LazyLoader, aucune configuration séparée n’est nécessaire, les performances sont préservées et la syntaxe standard est plus claire
Conclusion
- PEP 810 ajoute le mot-clé
lazyaux instructions d’import Python, afin d’optimiser de façon concise et prévisible les problèmes de performance liés au chargement inutile de modules dans de nombreux contextes, comme les CLI à sous-commandes, les grandes applications ou les annotations de type - Ce nouveau mot-clé permet de définir finement le moment d’introduction et la cible, ce qui le rend adapté à une adoption progressive et au tuning des performances en production
- Il s’agit d’une évolution concrète du système d’import de Python, capable de répondre simultanément aux exigences de visibilité, maintenabilité et performances
1 commentaires
Avis Hacker News
Mon outil CLI llm.datasette.io prend en charge les plugins, mais j’ai reçu beaucoup de plaintes sur son démarrage trop lent, même pour des commandes comme
llm --help. En vérifiant, j’ai constaté que des plugins populaires importaient par défaut des paquets lourds comme pytorch, ce qui bloquait tout le démarrage. J’ai donc indiqué dans la documentation destinée aux auteurs de plugins qu’il fallait importer les dépendances uniquement à l’intérieur des fonctions, quand c’est nécessaire (lien vers la doc correspondante), mais ce serait bien mieux si Python gérait ça directement au niveau du langageOn peut implémenter cette fonctionnalité dès aujourd’hui dans des outils (lien explicatif), mais cette approche s’applique globalement à tout le processus, donc si on importe numpy en mode paresseux, tous ses sous-modules le deviennent aussi. Au final, si on n’a pas besoin de tout numpy, il peut ne jamais être importé, mais cela peut aussi disperser de façon imprévisible dans l’exécution les imports partiels au moment où ils deviennent nécessaires. Autre résultat d’expérimentation : avec
import foo.bar.baz,fooetfoo.barsont tout de même chargés immédiatement, et seulfoo.bar.bazdevient paresseux. C’est probablement une partie de la raison pour laquelle le PEP parle de « mostly ». Si j’améliore encore mon implémentation, je pense pouvoir corriger çaJe recommande de commencer par parser la ligne de commande afin de traiter des options comme
--helpsans import, et de ne lancer les imports que lorsque c’est réellement nécessaire. Dit plus simplement, on peut concevoir l’outil pour n’importer qu’une fois les options simples traitées et seulement s’il reste encore du travail à faireDes propositions de lazy import ont déjà existé par le passé, et la plus récente a été rejetée en 2022 (lien vers la discussion correspondante). Si je me souviens bien, le lazy import existe déjà dans Cinder, la variante de CPython chez Meta, et ce sont justement des gens qui travaillaient sur Cinder qui portent ce PEP. Les débats portaient sur « opt-in ou opt-out ? », « jusqu’où s’applique-t-il ? », « faut-il l’ajouter comme build flag de CPython ? », etc. Au final, le Steering Council l’aurait rejeté à cause de la complexité liée à une sémantique d’import divisée en deux. J’espère vraiment que cette nouvelle proposition passera, j’ai très envie d’utiliser cette fonctionnalité
J’apprécie particulièrement le fait que ce soit en opt-in, avec une application granulaire à plusieurs niveaux et même un interrupteur global pour désactiver le tout. C’est une spécification très bien construite dans le cadre de contraintes nombreuses
Moi aussi j’espère que cette proposition passera, mais je ne suis pas optimiste. Cela va casser énormément de code et provoquer toute une série de problèmes inattendus. Les instructions
importont fondamentalement des effets de bord, et si on change le moment où ils se produisent, on risque de se battre longtemps avec des bugs incompréhensibles. Ce n’est pas du catastrophisme, c’est une inquiétude fondée. Si le lazy import n’a existé que chez Meta, ce n’est pas un hasard : il faut sans doute les ressources de Meta pour gérer quelque chose comme ça. Beaucoup de gens ne voient que « pandas, numpy, ou mon module bizarre mal fichu est trop lent, donc ce serait bien si c’était plus rapide », alors qu’à mon avis, très peu comprennent réellement le fonctionnement du système d’import de Python. Beaucoup soutiennent même l’idée sans savoir comment un lazy import s’implémente. Si on lit le PEP 690, les inconvénients sont nombreux : par exemple, du code qui ajoute des fonctions à un registre central via des décorateurs se casse. La bibliothèque Dash, par exemple, relie une interface JavaScript et des callbacks Python au moment de l’import grâce à des décorateurs ; si l’import devient paresseux, ce genre de front-end cesse tout simplement de fonctionner. Des services avec énormément d’utilisateurs peuvent casser immédiatement. On entend « c’est opt-in, donc si ça ne convient pas, il suffit de désactiver le lazy import », mais si les imports sont transitifs ? Que faire si un processus critique doit démarrer seulement après initialisation complète du front-end ? Dans un écosystème où s’entremêlent le code et les bibliothèques de nombreuses personnes, qui peut prévoir l’impact ? Contrairement aux type hints, cela modifie réellement le comportement à l’exécution. Les instructionsimportsont présentes dans pratiquement tout code Python non trivial, donc introduire le lazy import change fondamentalement la manière dont le code s’exécute. Et il y a d’autres cas bizarres encore mentionnés dans le PEP. C’est un problème bien plus difficile qu’il n’y paraîtCe serait formidable de pouvoir écrire des imports avec version explicite comme
import torch==2.6.0+cu124,import numpy>=1.2.6, et de pouvoir installer/importer simultanément plusieurs versions d’un paquet dans un même environnement Python. J’aimerais vraiment qu’on en finisse avec l’enfer conda/virtualenv/docker/bazelJe ne déteste pas l’idée, mais je ne l’accueille pas non plus avec un immense enthousiasme. En l’état, on dirait qu’on va finir par mettre
lazydevant presque tous lesimport, sauf dans quelques rares cas où il faut vraiment un import eager. Du coup, le code devient plus sale, et comme ce comportement ne deviendra probablement jamais la valeur par défaut, cette lourdeur syntaxique restera pour toujours. J’aurais préféré un système où c’est le module qui déclare son opt-in au lazy loading, sans changement dans la syntaxeimport. Ainsi, seules les grosses bibliothèques auraient à se soucier de la paresse de chargement. Bien sûr, cela impliquerait que l’interpréteur explore le système de fichiers au moment de l’import, avec d’autres inconvénientsSi tout le monde se met à utiliser massivement le lazy import sans problème particulier, cela voudra dire qu’il aurait dû être le comportement par défaut, et que c’est plutôt eager qui aurait dû être le mot-clé optionnel. Ce genre de changement de paradigme n’est pas inédit en Python : divers éléments qui construisaient des listes de manière eager en v2 sont devenus des generators en v3, sans que cela ne pose vraiment de problème
S’il existait un flag en ligne de commande pour rendre paresseux les imports de modules dans tout Python, je l’utiliserais sans hésiter. En pratique, en dehors de scripts ou de code vraiment trivial, avoir des effets de bord au chargement d’un module est un antipattern à éviter absolument
Je ne pense pas que ce soit au module de décider s’il doit être chargé de façon paresseuse. Seul l’appelant peut savoir s’il a besoin d’un lazy load, donc il est logique que l’option soit du côté du code qui importe. N’importe quel module peut être chargé paresseusement, et même s’il a des effets de bord, l’appelant peut vouloir différer ceux-ci lui aussi
J’aimerais pouvoir déclarer des options de lazy loading via des regex dans
pyproject.tomlÀ chaque nouvelle fonctionnalité passée — type hints, walrus, asyncio, dataclasses, etc. — on a entendu des inquiétudes similaires, mais en pratique, il n’y a pas eu une adoption massive uniforme ni un remplacement complet des anciens patterns. Beaucoup d’utilisateurs se contentent encore d’un Python modernisé au niveau de 2.4 et restent parfaitement productifs. Cela fonctionne très bien depuis 20 ans, donc je pense que ça ira
Si cela vous intéresse, je recommande lazyimp, qui implémente le lazy import de manière très pratique sous forme de context manager. En général, il suffit d’entourer les
importpar un blocwith, ce qui s’intègre bien aux outils existants, et si on a besoin de déboguer, il est facile de repasser en import eager. Grâce à une extension C qui modifie lef_builtinsdes frames, l’approche est plus puissante qu’un hookimportlib. Ce n’est pas parfait, mais il existe aussi une version thread-safe et une version avec handler global. J’étais prudent au début, mais j’ai maintenant migré presque toute ma base de code vers ça ; je n’ai rencontré aucun vrai problème en pratique, à part ne pas avoir pris soin d’enregistrer certains traitements module par module, et le gain de vitesse perçu est énorme, donc j’en suis très satisfaitLe fait que les linters Python imposent de placer les imports en haut du fichier est vraiment pénible. À chaque fois que j’utilise la méthode évidente pour faire des lazy imports, j’obtiens une erreur de lint. Et le problème dépasse largement la simple performance : par exemple, lorsqu’on a besoin d’une bibliothèque spécifique à une plateforme, on peut vouloir ne l’importer que sur cette plateforme, alors qu’un import en haut de fichier peut faire échouer tout simplement l’import
Dans ce cas, je pense qu’il faut simplement corriger le linter
La plupart des linters peuvent être contournés avec un commentaire comme
#noqa E402Avec ça, on remplace le meta path finder par un wrapper, qui remplace le loader par
LazyLoader. Lorsqu’un import s’exécute, le nom du module est d’abord lié à<class 'importlib.util._LazyModule'>, puis le vrai module est chargé lorsqu’on accède à un attribut. Code d’expérimentation :Je ne sais toutefois pas exactement ce que signifie le « mostly » employé dans le PEP
J’ai l’impression que les risques de sécurité vis-à-vis des threads sont sous-estimés avec les lazy imports. Il est totalement imprévisible de savoir quand un import va s’exécuter, dans quel thread, et sous quels verrous ; on ne peut rien garantir en dehors du verrou d’import lui-même. Avant, même si du code dangereux s’exécutait à l’import d’un module, cela se produisait en général pendant une phase d’initialisation majoritairement mono-thread, donc le problème restait limité. En mode lazy, les erreurs risquent d’apparaître de manière vraiment imprévisible, façon Heisenbug. Les imports au niveau des fonctions ont aussi ce risque, mais au moins ils gardent une certaine prévisibilité puisqu’ils s’exécutent explicitement au tout début du code concerné
Cela me semble être une bonne fonctionnalité, facile à expliquer, avec de vrais cas d’usage et une portée raisonnable (globale, ou simple via mot-clé). J’aime bien
Parmi les PEP récents, c’est probablement celui qui me paraît le plus propre du point de vue utilisateur. J’attends le résultat après le traditionnel bikeshedding syntaxique
Je trouve que c’est un PEP préparé avec beaucoup de soin : validation sur des cas réels et des edge cases, compromis appropriés, approche non excessive, peaufinage répété. C’est particulièrement impressionnant compte tenu du risque qu’il y a à toucher à un système aussi fondamental pour un grand langage utilisé par des communautés très diverses dans le monde entier
J’espère qu’ils ont bien retenu la leçon du rejet du PEP-690. Dans notre base de code aussi, on avait essayé d’implémenter quelque chose de ce genre nous-mêmes, mais on n’a jamais réussi à obtenir un comportement vraiment exploitable
Le danger du lazy import, c’est qu’il peut facilement produire des erreurs d’exécution inattendues dans des services de longue durée. Cela ressemble à un avantage de démarrage rapide, mais le compromis est d’accepter que l’exécution puisse s’arrêter plus tard à cause d’un échec d’import. Il peut aussi y avoir des edge cases supplémentaires où l’on ne peut plus garantir au lancement du programme ce qui sera importé ou non
Malgré tout, c’est un vrai problème qu’il faut absolument résoudre. Ce n’est pas seulement une question de vitesse de démarrage : avec de grosses dépendances, le démarrage de Python devient absurdement lent. Les gros projets ne peuvent pas non plus embarquer toutes les bibliothèques lourdes que tous les utilisateurs n’emploieront pas forcément ; les développeurs utilisent déjà des contournements encore plus tordus, qui ajoutent eux aussi des problèmes absurdes. Ce serait déjà une énorme avancée rien que de ne plus devoir cacher ou dupliquer des imports au niveau des fonctions, et il ne s’agit de toute façon que d’une fonctionnalité optionnelle du langage
Des tests automatisés peuvent largement atténuer le risque, et cela vaut la peine en échange d’un démarrage plus rapide. Le temps de démarrage n’est en aucun cas un problème purement « cosmétique ». Dans un monolithe Django, j’ai déjà vécu des attentes de 10 à 15 secondes à chaque management command, test ou reload de conteneur, uniquement à cause de quelques bibliothèques lourdes. Une fois les imports différés avec du lazy import, la différence a été énorme
Nous avons tendance à préférer les imports explicites en haut de fichier, précisément pour faire remonter les problèmes de dépendances dès le démarrage du programme. Avec des lazy imports, on introduit l’inconvénient de ne découvrir un problème que lorsqu’un certain chemin de code est exécuté, peut-être des heures ou des jours plus tard
La majeure partie du temps sert en réalité à importer puis décharger des modules vendor inutilisés — par exemple, presque 100 modules rien que du côté de Requests. Après diagnostic, on constate que plus de 500 modules sont importés inutilement au total
Je ne comprends pas non plus pourquoi les générateurs de code produisent de plus en plus souvent des
importlocaux dans les fonctions au lieu d’imports en haut de fichier. Je n’ai pas envie d’encourager ce pattern, car il rend plus difficile l’analyse des dépendances du module et augmente le risque d’introduire plus tard des dépendances circulairesJe n’ai pas encore lu tout le PEP, mais je me dis qu’il serait utile d’avoir une validation des dépendances via un flag en ligne de commande ou un outil externe, un peu comme les outils associés aux type hints
Je me demande qui désigne exactement ce « nous »
Est-ce que ce n’est pas simplement quelque chose que les tests devraient couvrir ?