Stella

L’enfer des paquets Python : la folie des formats (3 / 7)

La gestion des paquets Python est parfois un enfer. Mais au fait, c’est quoi, un paquet ? Évidemment, puisque rien n’est simple, puisque rien ne nous sera épargné, la non-réponse à cette question est : ça dépend…

💕💕💕

Cet article fait partie d’une série de 7 articles larmoyants sur la création de paquets Python :

  1. Le sac de nœuds
  2. Les racines du mal
  3. La folie des formats
  4. Des fichiers partout
  5. La boîte à outils
  6. L’expression des besoins
  7. La solution minimale

Avant toute autre chose, j’aimerais faire un gros bisou à l’ensemble des membres de l’équipe PyPA. Je me plains beaucoup dans cette suite d’articles, cela ne m’empêche pas d’avoir une immense et sincère dose de respect pour le travail sisyphéen déjà accompli.

Ceci étant dit : nous voilà (re)partis pour le chouinage 😭.

💕💕💕

Un paquet ?

Si vous êtes ici, il y a de grandes chances que vous ayez déjà installé un paquet Python dans votre vie. Vous avez peut-être utilisé un environnement virtuel, utilisé pip ou installé un module proposé par votre distribution. Quoi qu’il en soit, vous avez sans doute réussi, d’une manière ou d’une autre, à mettre des bibliothèques Python quelque part sur votre système.

Logo de PyPI
Ça donne envie, comme ça, un joli paquet Python. Mais dans la vraie vie…

Déjà, quand on parle d’un paquet Python, il faudrait être clair. Et ce n’est pas facile, parce qu’il n’existe pas un seul format de paquets, mais toute une ribambelle.

(Avertissement : nous n’avons pas vocation à faire une présentation exhaustive, et nous n’allons donc pas balayer l’intégralité de ce qui a existé et existe encore (un tout petit aperçu pour satisfaire votre curiosité). Nous emprunterons également certains raccourcis qui prendront quelques libertés avec la vérité, histoire de laisser à l’article la chance d’être à peu près lisible et digeste.)

(Et non, vous n’avez pas rêvé : la suite de 5 articles s’est subrepticement transformée en une suite de 7 articles, parce que c’est quand même bien de prendre le temps de savoir de quoi on parle. Vous avez évité de justesse que cet article devienne un roman interminable, pour vous en tirer avec juste un long blabla sur les formats de paquets, ne me remerciez pas, c’est tout naturel.)

Sources

La façon la plus simple de distribuer le code, c’est d’en faire une archive compressée et de l’envoyer telle quelle. C’est à peu près ce qui se passe lorsque l’on choisit de créer un paquet source pour un module en Python.

Aussi simple qu’elle soit, cette méthode nécessite tout de même un peu de configuration. On doit déterminer quels fichiers sont inclus dans l’archive, y compris les divers fichiers de métadonnées et de configuration d’outils tiers (Tox, Coverage, Pytest…).

Cette archive est donc relativement simple à créer, mais elle comporte un problème de taille : elle n’offre aucune structuration particulière, aucune organisation pour les métadonnées. Elle donne les fichiers et permet à l’installeur de les installer comme il le ferait avec un dossier contenant les sources sur votre disque dur.

Et alors, me direz-vous ? Eh bien, sans en avoir l’air, cette limitation est absolument terrible, à plusieurs titres.

La première chose, c’est la gestion de dépendances. Les paquets dépendent d’autres paquets, comme vous le savez sans doute. En n’ayant pas d’autres métadonnées, l’installeur est contraint d’aller lire, par exemple, le fichier setup.py. Et là, vous avez un aperçu du problème : s’il faut exécuter un fichier Python pour connaître les dépendances d’un paquet (et récursivement de ses dépendances !) vous allez passer à sacré temps à télécharger des paquets, exécuter du code, résoudre des dépendances, télécharger, exécuter, résoudre, télécharger… Pour la faire court : la résolution de dépendances ne peut pas être résolue à partir d’un simple arbre statique, tous les nœuds sont dynamiques. Et si vous vous posez la question : oui, c’est l’enfer.

Ça ne s’arrête pas là. L’installation, elle aussi, nécessite l’exécution de code, puisqu’elle dépend du fichier setup.py. Pour du Python simple dans des paquets simples, ce n’est pas particulièrement gênant, mais c’est une autre histoire dès que l’on a besoin de compiler du C par exemple. L’utilisateur se retrouve obligé d’installer les outils nécessaires à l’installation pour tout ce qui est spécifique au système d’exploitation ou à la version de Python. C’est complexe, c’est long, ça peut vite être très rebutant pour les utilisateurs, et c’est une source infinie de bugs.

Malgré ses limitations, ce format restera sans doute nécessaire pendant longtemps. On peut, pour de nombreuses raisons, vouloir obtenir la source dans le format le plus simple possible. Catastrophique conséquence : les outils d’installation de paquets Python devront supporter ce format, et toute la mécanique complexe qu’il entraîne, pendant de nombreuses années encore.

Egg

Pour pallier ces manques, un format de paquets a été créé en 2004 : les eggs.

Outre la référence habituelle aux Monty Python, l’idée part d’une bonne intention : construire des informations additionnelles qui permettent aux dépendances d’un projet d’être vérifiées et satisfaites à l’exécution. À l’époque, ce format permet avant tout d’utiliser easy_install pour installer facilement un module et ses dépendances. Il permet d’inclure des modules prêts à l’emploi, avec des extensions C déjà compilées. Il permet également, par un système d’espaces de noms, de distribuer des plugins. Et surtout, il peut directement se mettre à un endroit accessible à Python, sans requérir d’installation supplémentaire.

Construit au-dessus de setuptools, il apporte un grand nombre d’évolutions qui semblent aujourd’hui évidentes : des dépendances avec des versions fixes, une série de métadonnées précalculées, un mécanisme d’accès aux métadonnées et aux fichiers annexes depuis le code du module…

Génial 🎆👏🐈😍.

Pourtant, les eggs sont rapidement dépassés par leur architecture aussi novatrice (c’est une blague, c’est totalement pompé sur les jars de Java) que totalement bancale (ça, par contre, ce n’est pas une blague). Utiliser un format d’archive où le code n’a pas à être décompressé rend très complexe la gestion des erreurs, avec des traces menant à des fichiers introuvables sur le disque. L’inclusion du bytecode rend les eggs potentiellement dépendants d’une version de Python, tout comme l’inclusion des fichiers compilés les rend profondément liés à une architecture.

Et surtout, surtout… Le format n’est pas spécifié, donnant à l’implémentation une importance démesurée. Les ajouts successifs à setuptools, parfois mal ou pas du tout documentés, en font un grand jeu de roulette russe où la rétrocompatibilité, la reproductibilité et la simplicité sont constamment, allègrement, impitoyablement négligés. Les façons de rendre les eggs utilisables par l’interpréteur se sont multipliées pour gérer des cas toujours plus complexes, jusqu’à arriver à un énorme tas de solutions à base de liens symboliques, de fichiers contenant des chemins absolus, d’archives temporairement décompressées et autres abominations que nous laisserons sagement dans leur boîte de Pandore.

Wheel

Après cette petite erreur de jeunesse, qui aura somme toute fait perdre la raison à toute personne dotée d’un cerveau normalement constitué, une PEP est apparue pour mettre tout le monde d’accord : la PEP 427 introduisant les wheels.

Les wheels sont, à leur création en 2012, une évolution salutaire des eggs, dans le sens où elles en reprennent les bonnes idées sans en garder les abominations. On tient enfin une belle spécification, qui connaîtra son lot d’améliorations elles aussi spécifiées, et qui permettra à une bonne dose d’outils de fonctionner ensemble sans dépendre de détails d’implémentation.

L’idée géniale (j’exagère à peine) des wheels est d’inclure dans le nom de fichier un certain nombre d’informations simplifiant considérablement la gestion de dépendances. Le code à inclure est différent selon les versions de Python ? Les extensions sont compilées différemment selon le système d’exploitation ? On distribue alors différentes wheels pour une même version d’un module, et l’installeur fera tout seul son marché pour trouver la version qui correspond à l’utilisateur.

Les wheels ont aussi la bonne idée d’embarquer les métadonnées sous une forme spécifiée (par la PEP 345). Ce format permet de décrire de manière complexe des dépendances conditionnelles, des dépendances externes, les versions de Python supportées, et plus globalement tout ce qu’il faut pour gérer correctement la distribution du code, l’installation facile et la possibilité de construire un arbre de dépendances statique.

Aujourd’hui, tout le monde a intérêt à utiliser des wheels pour la distribution et l’installation de paquets. Si tout n’est pas parfait dans la gestion de paquets Python, les wheels sont aujourd’hui l’exemple le plus marquant de ce qui fonctionne très bien. Ils ont permis une transition en douceur jusqu’à arriver à une situation où les eggs ont très largement disparu, et où les paquets sources servent de moins en moins… grâce à tout un écosystème de logiciels créés autour de ce nouveau format et à une rétrocompatibilité astucieuse.

Parce que oui, les wheels sont formidables, mais tout ceci n’aurait pas été possible avec l’arrivée préalable de pip, en 2008, destiné à remplacer easy_install. Mais n’allons pas trop vite ! Nous aurons le temps d’y revenir un peu plus tard, dans nos prochains articles…

À suivre…