Greboca  

Suport technique et veille technologique

Aujourd’hui, les grandes entreprises et administrations publiques hésitent entre continuer à utiliser des logiciels propriétaires ou basculer vers les Logiciels Libres. Pourtant, la plupart des logiciels libres sont capables de bien traiter les données issues des logiciels propriétaire, et parfois avec une meilleur compatibilité.

C’est alors la barrière de la prise en main qui fait peur, et pourtant...

Les logiciels libres

L’aspect « Logiciel Libre » permet une évolution rapide et une plus grande participation des utilisateurs. Les aides et tutoriels foisonnent sur Internet ou sont directement inclus dans le logiciel lui-même.

Enfin, les concepteurs sont plus proches des utilisateurs, ce qui rend les logiciels libres plus agréable à utiliser et conviviaux.

Grâce à la disponibilité des logiciels libres, vous trouverez facilement des services de support techniques et la licence n’est plus un frein à l’utilisation de ces logiciels par votre personnel.

Notre support technique concerne essentiellement les logiciels libres, que ce soit sous forme de services ponctuels ou de tutoriels.

DLFP - Dépêches  -  L'installation et la distribution de paquets Python (2/4)

 -  Décembre 2023 - 

Cette dépêche est la deuxième d’une série de quatre sur le packaging en Python :

  1. L’histoire du packaging Python
  2. Tour de l’écosystème actuel
  3. Le casse-tête du code compilé
  4. La structure de la communauté en question

Je vais donc proposer un aperçu plus ou moins complet des différents outils, et de ce qu’ils font ou ne font pas, en essayant de les comparer. Mais je parlerai aussi des fichiers de configuration, des dépôts où les paquets sont publiés, des manières d’installer Python lui-même, et de l’interaction de tout ceci avec les distributions Linux. En revanche, je laisse de côté pour l’instant les paquets écrits en C, C++ ou Rust et la complexité qu’ils apportent.

Sommaire

Commençons par un outil dont à peu près tout utilisateur de Python a entendu parler : pip.

Pip

L’installeur de paquets pip est un outil fondamental et omniprésent. Son nom est un acronyme récursif signifiant « Pip Installs Packages ». Il permet d’installer un paquet depuis le PyPI, mais aussi depuis une copie locale du code source, ou encore depuis un dépôt Git. Il dispose, bien sûr, d’un résolveur de dépendances pour trouver des versions à installer qui soient compatibles entre elles. Il possède aussi un certain nombre de fonctionnalités standard pour un gestionnaire de paquets, comme désinstaller un paquet, lister les paquets installés, etc.

S’il y a un outil de packaging quasi universel, c’est bien pip. Par exemple, la page de chaque paquet sur PyPI (exemple) affiche une commande pour l’installer, à savoir pip install . Quand la documentation d’un paquet donne des instructions d’installation, elles utilisent généralement pip.

De plus, la distribution officielle de Python permet de boostraper très simplement pip avec la commande python -m ensurepip. Cela rend pip très facile à installer, et lui donne un caractère officiel, soutenu par les développeurs de Python, caractère que n’ont pas la plupart des autres outils que je vais mentionner.

Même les autres outils qui installent aussi des paquets depuis le PyPI (comme pipx, Hatch, tox, etc.) le font presque tous en utilisant, en interne, pip (sauf Poetry qui est un peu à part).

Dans l’univers parallèle de Conda et Anaconda, les utilisateurs sont souvent obligés d’utiliser pip dans un environnement Conda parce qu’un paquet donné n’est pas disponible au format Conda (ce qui crée, d’ailleurs, des problèmes de compatibilité, mais c’est un autre sujet).

Les dangers de pip sous Linux

Malheureusement, sous Linux spécifiquement, l’interface en ligne de commande de pip a longtemps été un moyen très facile de se tirer une balle dans le pied. En effet, la commande simple

pip install 

tentait d’installer le paquet au niveau du système, de manière visible pour tous les utilisateurs de la machine (typiquement dans /usr/lib/pythonX.Y/site-packages/). Bien sûr, il faut des permissions pour cela. Que fait Monsieur Toutlemonde quand il voit « permission denied error » ? Élémentaire, mon cher Watson :

sudo pip install 

Or, sous Linux, installer des paquets avec pip au niveau du système, c’est mal. Je répète : c’est MAL. Ou plutôt, c’est valable dans 0,1% des cas et dangereux dans 99,9% des cas.

J’insiste : ne faites JAMAIS sudo pip install ou sudo pip uninstall. (Sauf si vous savez parfaitement ce que vous faites et que vous avez scrupuleusement vérifié qu’il n’y a aucun conflit.)

Le souci ? Les distributions Linux contiennent, elles aussi, des paquets écrits en Python, qui sont installés au même endroit que celui dans lequel installe la commande sudo pip install. Pip peut donc écraser un paquet installé par le système avec une version différente du même paquet, potentiellement incompatible avec le reste, ce qui peut avoir des conséquences catastrophiques. Il suffit de penser que DNF, le gestionnaire de paquets de Fedora, est écrit en Python, pour avoir une idée des dégâts potentiels !

Aujourd’hui, heureusement, la commande pip install (sans sudo), au lieu d’échouer avec une erreur de permissions, installe par défaut dans un emplacement spécifique à l’utilisateur, typiquement ~/.local/lib/pythonX.Y/site-packages/ (ce qui devait auparavant se faire avec pip install --user , l’option --user restant disponible si on veut être explicite). De plus, pip émet un avertissement sous Linux lorsqu’exécuté avec les droits root (source). Ainsi, pip install est devenu beaucoup moins dangereux.

Attention, j’ai bien dit moins dangereux… mais dangereux quand même ! Pourquoi, s’il n’efface plus les paquets du système ? Parce que si un paquet est installé à la fois par le système, et par pip au niveau de l’utilisateur, la version de pip va prendre le dessus, car le dossier utilisateur a priorité sur le dossier système. Le résultat est que le conflit, en réalité, persiste : il reste possible de casser un paquet système en installant une version incompatible avec pip au niveau utilisateur. Seulement, c’est beaucoup plus facile à corriger (il suffit d’un rm -rf ~/.local/lib/pythonX.Y/site-packages/*, alors qu’un conflit dans le dossier système peut être quasi irréparable).

La seule option qui soit sans danger est de ne jamais rien installer en dehors d’un environnement virtuel (voir plus bas pour les instructions).

Pour finir, la PEP 668 a créé un mécanisme pour qu’une distribution Linux puisse marquer les dossiers de paquets Python qu’elle contrôle. Pip refuse (par défaut) de modifier ces dossiers et affiche un avertissement qui mentionne les environnements virtuels. Debian (à partir de Debian Bookworm), Ubuntu (à partir d’Ubuntu Lunar) et d’autres distributions Linux, ont choisi de mettre en place cette protection. Donc, désormais, sudo ou pas, pip install en dehors d’un environnement virtuel donne une erreur (on peut forcer l’opération avec l’option --break-system-packages).

En revanche, Fedora n’a pas implémenté la protection, espérant réussir à créer un dossier pour pip qui soit au niveau système mais séparé du dossier de la distribution Linux, pour que pip install soit complètement sûr et qu’il n’y ait pas besoin de cette protection. Je recommande la présentation de Miro Hrončok à la conférence PyCon polonaise en janvier 2023, qui explique le casse-tête dans les menus détails. Petite citation en avant-goût : « The fix is quite trivial when you design it, and it only strikes back when you actually try to do it ».

Pip est un outil de bas niveau

Pip a une autre chausse-trappe qui est surprenant quand on est habitué au gestionnaire de paquets d’une distribution Linux. Petite illustration :

$ python -m venv my-venv/ # crée un environnement isolé vide pour la démonstration

$ source my-venv/bin/activate # active l’environnement

$ pip install myst-parser
[...]
Successfully installed MarkupSafe-2.1.3 Pygments-2.16.1 alabaster-0.7.13 [...]
[...]

$ pip install mdformat-deflist
[...]
Installing collected packages: markdown-it-py, mdit-py-plugins, mdformat, mdformat-deflist [...]
ERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.
myst-parser 2.0.0 requires markdown-it-py~=3.0, but you have markdown-it-py 2.2.0 which is incompatible.
myst-parser 2.0.0 requires mdit-py-plugins~=0.4, but you have mdit-py-plugins 0.3.5 which is incompatible.
Successfully installed markdown-it-py-2.2.0 mdformat-0.7.17 mdformat-deflist-0.1.2 mdit-py-plugins-0.3.5 [...]

$ echo $?
0

Comme on peut le voir, la résolution des dépendances par pip ne prend pas en compte les paquets déjà installés dans l’environnement. Autrement dit, pour installer un paquet X, pip va simplement regarder quelles sont les dépendances de X (y compris les dépendances transitives), trouver un ensemble de versions qui soient compatibles entre elles, et les installer. Pip ne vérifie pas que les versions des paquets sont aussi compatibles avec ceux qui sont déjà installés. Ou plutôt, il les vérifie, mais seulement après avoir fait l’installation, à un moment où le mal est déjà fait, et uniquement pour afficher un avertissement. Dans l’exemple ci-dessus, on installe d’abord myst-parser, dont la dernière version dépend de markdown-it-py version 3.x, puis on installe mdformat-deflist, qui dépend de markdown-it-py version 1.x ou 2.x. En installant mdformat-deflist, Pip installe aussi, comme dépendance, markdown-it-py 2.x, ce qui casse le myst-parser installé précédemment.

Ceci n’est naturellement pas du goût de tout le monde (je me rappelle d’ailleurs d’une enquête utilisateur faite par les développeurs de Pip il y a quelques années, où ils posaient la question de savoir ce que Pip devait faire dans cette situation). La morale est que pip est surtout un outil conçu pour créer un environnement virtuel où se trouvent toutes les dépendances dont on a besoin, pas pour s’en servir comme de apt ou dnf, en installant et désinstallant manuellement des dépendances. Et surtout, que pip install X; pip install Y n’est absolument pas équivalent à pip install X Y, et c’est la seconde forme qui est correcte.

Les environnements virtuels : venv, virtualenv, pipx

Les environnements virtuels permettent de travailler avec des ensembles de paquets différents, installés de façon indépendante entre eux. L’outil d’origine pour les créer est virtualenv. Néanmoins, le plus utilisé aujourd’hui est venv, qui est une version réduite de virtualenv intégrée à la bibliothèque standard. Malheureusement, venv est plus lent et n’a pas toutes les fonctionnalités de virtualenv, qui reste donc utilisé également…

Pour créer un environnement virtuel (avec venv), on exécute :

python -m venv nom-de-l-environnement

Cela crée un dossier nom-de-l-environnement/. Chaque environnement est donc stocké dans un dossier. À l’intérieur de ce dossier se trouve notamment un sous-dossier bin/ avec des exécutables :

  • un exécutable python, qui ouvre un interpréteur Python ayant accès aux paquets de l’environnement virtuel (et, par défaut, seulement eux),

  • un exécutable pip, qui installe les paquets à l’intérieur de l’environnement.

De plus, pour simplifier l’utilisation dans un shell, on peut « activer » l’environnement, avec une commande qui dépend du shell. Par exemple, sous les shells UNIX classiques (bash, zsh), on exécute

source nom-de-l-environnement/bin/activate

Cette commande modifie la variable PATH pour y ajouter nom-de-l-environnement/bin/ afin que (par exemple) la commande python invoque nom-de-l-environnement/bin/python.

Malgré cela, les environnements virtuels restent un niveau de confort en dessous du Python du système, puisqu’il faut activer un environnement avant de s’en servir, ou écrire à chaque fois le chemin dossier-environnement/bin/. Bien sûr, il faut aussi mémoriser les commandes, et puis c’est si facile de faire pip install dans l’environnement global (non virtuel). Donc, beaucoup n’y prêtent malheureusement pas attention et installent au niveau global, ce qui cause des conflits de dépendances (c’est maintenant refusé par défaut sous Debian et dérivés, comme je l’expliquais dans la section précédente, mais c’est toujours majoritaire sous macOS et Windows).

C’est aussi pour rendre plus pratiques les environnements virtuels qu’existent pléthore d’outils qui les créent et/ou activent pour vous. Je termine avec l’un de ces outils, lié à la fois à pip et aux environnements virtuels, j’ai nommé pipx. À première vue, pipx a une interface qui ressemble à celle de pip, avec par exemple des sous-commandes pipx install, pipx uninstall et pipx list. Mais, à la différence de pip, qui installe un paquet dans un environnement déjà créé, pipx va, pour chaque paquet installé, créer un nouvel environnement virtuel dédié. Pipx est principalement destiné à installer des outils dont l’interface est en ligne de commande, pas sous forme d’un module importable en Python. Pipx utilise pip, pour ne pas trop réinventer la roue quand même. Au final,

$ pipx install pycowsay

revient à quelque chose comme

$ python -m venv ~/.local/pipx/pycowsay/
$ ~/.local/pipx/pycowsay/bin/pip install pycowsay
$ ln -s ~/.local/pipx/pycowsay/bin/pycowsay ~/.local/bin/pycowsay

Pour résumer, pipx permet d’installer des outils en ligne de commande, de manière isolée, qui n’interfèrent pas avec le système ou entre eux, sans avoir à gérer les environnements virtuels soi-même.

L’invocation d’un build backend : build

Pour déposer son projet sur PyPI, il faut d’abord obtenir deux fichiers : une sdist (source distribution), qui est essentiellement une archive .tar.gz du code avec des métadonnées ajoutées, et un paquet installable au format wheel, d’extension .whl. L’outil build sert à générer ces deux fichiers. Il s’invoque comme ceci, dans le dossier du code source :

python -m build

Petit exemple dans le dépôt de Sphinx (l’outil de documentation le plus répandu dans le monde Python) :

$ python -m build
* Creating venv isolated environment...
* Installing packages in isolated environment... (flit_core>=3.7)
* Getting build dependencies for sdist...
* Building sdist...
* Building wheel from sdist
* Creating venv isolated environment...
* Installing packages in isolated environment... (flit_core>=3.7)
* Getting build dependencies for wheel...
* Building wheel...
Successfully built sphinx-7.3.0.tar.gz and sphinx-7.3.0-py3-none-any.whl

$ ls dist/
sphinx-7.3.0-py3-none-any.whl  sphinx-7.3.0.tar.gz

Comme on peut le comprendre, build est un outil très simple. L’essentiel de sa documentation tient en une courte page. Il crée un environnement virtuel pour installer le build backend, en l’occurrence Flit, puis se contente d’invoquer celui-ci.

Le transfert sur PyPI : twine

À l’image de build, twine est un outil fort simple qui remplit une seule fonction et la remplit bien : déposer la sdist et le wheel sur PyPI (ou un autre dépôt de paquets). En continuant l’exemple précédent, on écrirait :

twine upload dist/*

Après avoir fourni un login et mot de passe, le projet est publié, il peut être installé avec pip, et possède sa page https://pypi.org/project/nom-du-projet.

La configuration d’un projet : le fichier pyproject.toml

pyproject.toml est le fichier de configuration adopté par à peu près tous les outils de packaging, ainsi que de nombreux outils qui ne sont pas liés au packaging (par exemple les linters comme Ruff, les auto-formateurs comme Black ou le même Ruff, etc.). Il est écrit dans le langage de configuration TOML. On a besoin d’un pyproject.toml pour n’importe quel projet publié sur PyPI, et même, souvent, pour les projets qui ne sont pas distribués sur PyPI (comme pour configurer Ruff).

Dans ce fichier se trouvent trois sections possibles — des « tables », en jargon TOML. La table [build-system] détermine le build backend du projet (je reviens plus bas sur le choix du build backend). La table [project] contient les informations de base, comme le nom du projet, la version, les dépendances, etc. Quant à la table [tool], elle est utilisée via des sous-tables [tool.] : tout outil peut lire de la configuration dans sa sous-table dédiée. Rien n’est standardisé par des spécifications PyPA dans la table [tool], chaque outil y fait ce qu’il veut.

Avant qu’on me dise que pyproject.toml est mal documenté, ce qui a pu être vrai, je précise que des efforts ont été faits à ce niveau dans les dernières semaines, par moi et d’autres, ce qui donne un guide du pyproject.toml normalement complet et compréhensible, ainsi qu’une explication sur ce qui est déprécié ou non concernant setup.py et un guide sur la migration de setup.py vers pyproject.toml. Tout ceci réside sur packaging.python.org, qui est un site officiel de la PyPA rassemblant des tutoriels, guides et spécifications techniques.

Les build backends pour code Python pur

Le build backend est chargé de générer les sdists et les wheels que l’on peut ensuite mettre sur PyPI avec twine ou autre. Il est spécifié dans le fichier pyproject.toml. Par exemple, pour utiliser Flit, la configuration est :

[build-system]
requires = ["flit_core>=3.7"]
build-backend = "flit_core.buildapi"

requires est la liste des dépendances (des paquets sur PyPI), et build-backend est le nom d’un module (qui doit suivre une interface standardisée).

Il peut sembler étrange qu’il faille, même pour un projet simple, choisir son build backend. Passons donc en revue les critères de choix : de quoi est responsable le build backend ?

D’abord, il doit traduire les métadonnées du projet. En effet, dans les sdists et wheels, les métadonnées sont encodées dans un format un peu étrange, à base de MIME, qui est conservé au lieu d’un format plus moderne comme TOML ou JSON, pour des raisons de compatibilité. La plupart des build backends se contentent de prendre les valeurs dans la table [project] du pyproject.toml et de les copier directement sous la bonne forme, mais setuptools permet aussi de configurer les métadonnées via setup.py ou setup.cfg, également pour préserver la compatibilité, et il y a aussi des build backends comme Poetry qui n’ont pas adopté la table [project] (j’y reviens dans la section sur Poetry).

De plus, les build backends ont souvent des façons de calculer dynamiquement certaines métadonnées, typiquement la version, qui peut être lue depuis un attribut __version__, ou déterminée à partir du dernier tag Git.

C’est aussi le build backend qui décide des fichiers du projet à inclure ou exclure dans la sdist et le wheel. En particulier, on trouve généralement des options qui permettent d’inclure des fichiers autres que .py dans le wheel (c’est le wheel qui détermine ce qui est installé au final, alors que la sdist peut aussi contenir les tests etc.). Cela peut servir, par exemple, aux paquets qui doivent être distribués avec des icônes, des données en JSON, des templates Django…

Enfin, s’il y a des extensions en C, C++, Rust ou autre, le build backend est chargé de les compiler.

Il existe aujourd’hui de nombreux build backends. Beaucoup sont spécifiques à un type d’extensions compilées, ils sont présentés dans la troisième dépêche. Voici les build backends principaux pour du code Python pur.

setuptools

C’est le build backend historique. Il reste très largement utilisé.

Avant qu’arrive pyproject.toml, il n’y avait qu’un build backend, setuptools, et il était configuré soit par le setup.py, soit par un fichier en syntaxe INI, nommé setup.cfg (qui est l’ancêtre de pyproject.toml). Ainsi, il existe aujourd’hui trois manières différentes de configurer setuptools, à savoir setup.py, setup.cfg et pyproject.toml. On rencontre les trois dans les projets existants. La façon recommandée aujourd’hui est pyproject.toml pour tout ce qui est statique, sachant que setup.py, qui est écrit en Python, peut toujours servir s’il y a besoin de configuration programmable.

Aujourd’hui, setuptools ne se veut plus qu’un build backend, mais historiquement, en tant que descendant de distutils, il a beaucoup de fonctionnalités, désormais dépréciées, pour installer des paquets ou autres. On peut se faire une idée de l’ampleur des évolutions qui ont secoué le packaging au fil des années en parcourant l’abondante documentation des fonctionnalités obsolètes, notamment cette page, celle-ci ou encore celle-là.

Flit

Flit est l’exact opposé de setuptools. C’est le build backend qui vise à être le plus simple et minimal possible. Il n’y a pratiquement pas de configuration autre que la configuration standardisée des métadonnées dans la table [project] du pyproject.toml.

Flit se veut volontairement inflexible (« opinionated »), pour qu’il n’y ait pas de choix à faire. Avec Flit, un projet appelé nom-projet doit obligatoirement fournir un module et un seul, soit nom_projet.py, soit nom_project/. De même, il est possible d’inclure des fichiers autres que .py, mais ils doivent obligatoirement se trouver tous dans un dossier dédié au même niveau que le pyproject.toml.

Flit dispose aussi d’une interface en ligne de commande minimale, avec des commandes flit build (équivalent de python -m build), flit publish (équivalent de twine upload), flit install (équivalent de pip install .), et flit init (qui initialise un projet).

Hatchling

Hatchling est le build backend associé à Hatch, un outil tout-en-un dont il sera question plus loin.

Contrairement à setuptools, il est plutôt facile d’utilisation, et il fait plus souvent ce qu’on veut par défaut. Contrairement à Flit, il offre aussi des options de configuration plus avancées (comme pour inclure plusieurs modules dans un paquet), ainsi que la possibilité d’écrire des plugins.

PDM-Backend

De même que hatchling est associé à Hatch, PDM-Backend est associé à PDM. Je n'en ai pas d'expérience, mais à lire sa documentation, il me semble plus ou moins équivalent en fonctionnalités à hatchling, avec des options un peu moins fines.

Poetry-core

Comme les deux précédents, Poetry-core est associé à un outil plus vaste, à savoir Poetry.

Par rapport à hatchling et PDM-backend, il est moins sophistiqué (il ne permet pas de lire la version depuis un attribut dans le code ou depuis un tag Git).

La gestion des versions de Python : pyenv

L’une des difficultés du packaging Python est que l’interpréteur Python lui-même n’est généralement pas compilé upstream et téléchargé depuis le site officiel, du moins pas sous Linux (c’est davantage le cas sous Windows, et plus ou moins le cas sous macOS). L’interpréteur est plutôt fourni de l’extérieur, à savoir, sous Linux, par le gestionnaire de paquets de la distribution, ou bien, sous macOS, par XCode, Homebrew ou MacPorts. Cela peut aussi être un Python compilé à partir du code source sur la machine de l’utilisateur.

Ce modèle est différent d’autres langages comme Rust, par exemple. Pour installer Rust, la plupart des gens utilisent Rustup, un script qui télécharge des exécutables statiques compilés upstream (le fameux curl | bash tant décrié…).

Le but de pyenv est de simplifier la gestion des versions de Python. On exécute, par exemple, pyenv install 3.10.2 pour installer Python 3.10.2. Comme pyenv va compiler le code source, il faut quand même installer soi-même les dépendances (avec leurs en-têtes C).

Un outil de test et d’automatisation : tox

À partir du moment où un projet grossit, il devient souvent utile d’avoir de petits scripts qui automatisent des tâches courantes, comme exécuter les tests, mettre à jour tel fichier à partir de tel autre, ou encore compiler des catalogues de traduction en format MO à partir des fichiers PO. Il devient également nécessaire de tester le projet sur différentes versions de Python, ou encore avec différentes versions des dépendances.

Tout cela est le rôle de tox. Il se configure avec un fichier tox.ini. Voici un exemple tiré de Pygments:

[tox]
envlist = py

[testenv]
description =
    run tests with pytest (you can pass extra arguments for pytest,
    e.g., "tox -- --update-goldens")
deps =
    pytest >= 7.0
    pytest-cov
    pytest-randomly
    wcag-contrast-ratio
commands =
    pytest {posargs}
use_develop = True

On peut avoir plusieurs sections [testenv:xxx] qui définissent des environnements virtuels. Chaque environnement est créé avec une version de Python ainsi qu’une certaine liste de dépendances, et peut déclarer des commandes à exécuter. Ces commandes ne passent pas par un shell, ce qui garantit que le tox.ini reste portable.

Interlude : vous avez dit lock file?

Pour faire simple, un lock file est un fichier qui décrit de manière exacte un environnement de sorte qu’il puisse être reproduit. Prenons un exemple. Imaginons une application Web déployée sur plusieurs serveurs, qui a besoin de la bibliothèque requests. Elle va déclarer cette dépendance dans sa configuration. Éventuellement, elle fixera une borne sur la version (par exemple requests>=2.31), pour être sûre d’avoir une version compatible. Mais le paquet requests a lui-même des dépendances. On souhaiterait que l’environnement soit vraiment reproductible — que des serveurs différents n’aient pas des versions différentes des dépendances, même si les environnements sont installés à des moments différents, entre lesquels des dépendances publient des nouvelles versions. Sinon, on risque des bugs difficiles à comprendre qui ne se manifestent que sur l’un des serveurs.

La même problématique se pose pour développer une application à plusieurs. Sauf si l’application doit être distribuée dans des environnements variés (par exemple empaquetée par des distributions Linux), il ne vaut pas la peine de s’embarrasser de versions différentes des dépendances. Il est plus simple de fixer toutes les versions pour tout le monde.

Dans la vraie vie, une application peut avoir des centaines de dépendances, dont quelques-unes directes et les autres indirectes. Il devient très fastidieux de maintenir à la main une liste des versions exactes de chaque dépendance.

Avec un lock file, on s’assure de geler un ensemble de versions de tous les paquets qui sera le même pour tous les contributeurs, et pour tous les déploiements d’une application. On sépare, d’un côté, la déclaration des dépendances directes minimales supposées compatibles avec l’application, écrite à la main, et de l’autre côté, la déclaration des versions exactes de toutes les dépendances, générée automatiquement. Concrètement, à partir de la contrainte requests>=2.31, un générateur de lock file pourrait écrire un lock file qui fixe les versions certifi==2023.11.17, charset-normalizer==3.3.2, idna==3.4, requests==2.31.0, urllib3==2.1.0. À la prochaine mise à jour du lock file, certaines de ces versions pourraient passer à des versions plus récentes publiées entre-temps.

Le concept de lock file est en revanche beaucoup plus discutable pour une bibliothèque (par opposition à une application), étant donné qu’une bibliothèque est faite pour être utilisée dans d’autres projets, et que si un projet a besoin des bibliothèques A et B, où A demande requests==2.31.0 alors que B demande requests==2.30.0, il n’y a aucun moyen de satisfaire les dépendances. Pour cette raison, une bibliothèque doit essayer de minimiser les contraintes sur ses dépendances, ce qui est fondamentalement opposé à l’idée d’un lock file.

Il existe plusieurs outils qui permettent de générer et d’utiliser un lock file. Malheureusement, l’un des plus gros problèmes actuels du packaging Python est le manque criant, sinon d’un outil, d’un format de lock file standardisé. Il y a eu une tentative avec la PEP 665, rejetée par manque de consensus (mais avant qu’on soupire qu’il suffisait de se mettre d’accord : il y a de vraies questions techniques qui se posent, notamment sur l’adoption d’un standard qui ne permet pas de faire tout ce que font certains outils existants, qui risquerait de fragmenter encore plus au lieu d’aider).

Un gestionnaire de lock file : pip-tools

pip-tools est un outil assez simple pour générer et utiliser un lock file. Il se compose de deux parties, pip-compile et pip-sync.

La commande pip-compile, prend un ensemble de déclarations de dépendances, soit dans un pyproject.toml, soit dans un fichier spécial requirements.in. Elle génère un fichier requirements.txt qui peut être installé par pip.

Quant à la commande pip-sync, c’est simplement un raccourci pour installer les dépendances du requirements.txt.

Les locks files sont donc des fichiers requirements.txt, un format pas si bien défini puisqu’un requirements.txt est en fait essentiellement une série d’arguments et d’options en ligne de commande à passer à pip.

Les outils « tout-en-un »

Face à la prolifération d’outils à installer et mémoriser, certains ont essayé de créer une expérience plus cohérente avec des outils unifiés. Malheureusement, ce serait trop simple s’ils s’étaient accordés sur un projet commun…

Poetry

Poetry est un outil un peu à part qui fait à peu près tout par lui-même.

Poetry se destine aux développeurs de bibliothèques et d’applications. Toutefois, en pratique, il est plutôt orienté vers les applications.

La configuration se fait entièrement dans le fichier pyproject.toml. Poetry s’utilise toujours avec son build backend Poetry-core, donc la partie [build-system] du pyproject.toml est configurée comme ceci :

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

En revanche, contrairement à la plupart des autres build backends, Poetry n’accepte pas la configuration dans la table [project]. À la place, il faut écrire les métadonnées sur le projet (nom, version, mainteneurs, etc.) dans la table [tool.poetry], dans un format différent du format standard.

La particularité de Poetry qui le rend à part est d’être centré profondément sur le concept de lock file, d’insister fortement sur le Semantic Versioning, et d’avoir son propre résolveur de dépendances. Poetry n’installe jamais rien dans l’environnement virtuel du projet avant d’avoir généré un lock file qui trace les versions installées. Sa configuration a aussi des raccourcis typiques du semantic versioning, comme la syntaxe nom_du_paquet = "^3.4", qui dans la table [project] s’écrirait plutôt "nom_du_paquet >= 3.4, < 4.

(Ironiquement, les versions de Poetry lui-même ne suivent pas le Semantic Versioning.)

Je ne vais pas présenter les commandes de Poetry une par une car cette dépêche est déjà bien trop longue. Je renvoie à la documentation. Disons simplement qu’un projet géré avec Poetry se passe de pip, de build, de twine, de venv et de pip-tools.

PDM

Je l’avoue, je connais mal PDM. D’après ce que j’en ai compris, il est assez semblable à Poetry dans son interface, mais suit tout de même plus les standards, en mettant sa configuration dans la table [project], et en utilisant le résolveur de dépendances de pip. (Je parlerai tout de même, dans la quatrième dépêche, de la motivation à l’origine du développement de PDM, qui cache toute une histoire. Pour ceux qui ont compris, oui, c’est bien résumé par un nombre qui est multiple de 97.)

Hatch

Hatch est plus récent que Poetry et PDM. (Il n’est pas encore au niveau de Poetry en termes de popularité, mais loin devant PDM.) Il est encore en développement rapide.

Comparé à Poetry et PDM, il ne gère pas, pour l’instant, les lock files. (Ici aussi, il y a une histoire intéressante : l’auteur a d’abord voulu attendre que les discussions de standardisation aboutissent à un format commun, mais vu l’absence de progrès, il a fait savoir récemment qu’il allait implémenter un format non standardisé, comme Poetry et PDM le font déjà.)

En contrepartie, Hatch gère aussi les versions de Python. Il est capable d’installer ou de désinstaller une version très simplement, sachant que, contrairement à pyenv, il ne compile pas sur la machine de l’utilisateur mais télécharge des versions précompilées (beaucoup plus rapide, et aucune dépendance à installer soi-même). Il a aussi une commande fmt pour reformater le projet (plutôt que de définir soi-même un environnement pour cela dans Poetry ou PDM), et il est prévu qu’il gagne bientôt des commandes comme hatch test et hatch doc également.

De plus, dans Poetry, lorsque l’on déclare, par exemple, un environnement pour compiler la documentation, avec une dépendance sur sphinx >= 7, cette dépendance est résolue en même temps que les dépendances principales du projet. Donc, si votre générateur de documentation demande une certaine version, mettons, de Jinja2 (ou n’importe quel autre paquet), vous êtes forcé d’utiliser la même version pour votre propre projet, même si l’environnement pour exécuter votre projet n’a rien à voir avec l’environnement pour générer sa documentation. C’est la même chose avec PDM. Je trouve cette limitation assez frustrante, et Hatch n’a pas ce défaut.

La création d’exécutables indépendants et installeurs : PyInstaller, cx_freeze, briefcase, PyOxidizer (etc.)

Distribuer son projet sur PyPI est bien beau, mais pour installer un paquet du PyPI, il faut d’abord avoir Python et savoir se servir d’un terminal pour lancer pip. Quid de la distribution d’une application graphique à des gens qui n’ont aucune connaissance technique ?

Pour cela, il existe une pléthore d’outils qui créent des installeurs, contenant à la fois Python, une application, ses dépendances, une icône d’application, et en bref, tout ce qu’il faut pour satisfaire les utilisateurs qui n’y comprennent rien et réclament juste une « application normale » avec un « installeur normal », un appli-setup.exe ou Appli.dmg. Les plus connus sont PyInstaller et py2exe. Plus récemment est aussi apparu briefcase.

Il y a aussi d’autres outils qui ne vont pas jusqu’à créer un installeur graphique, mais se contentent d’un exécutable qui peut être lancé en ligne de commande. Ce sont notamment cx_freeze et PyOxidizer, mais il y en a bien d’autres.

Malheureusement, toute cette classe d’usages est l’un des gros points faibles de l’écosystème actuel. PyInstaller, par exemple, est fondé sur des principes assez douteux qui datent d’une époque où le packaging était beaucoup moins évolué qu’aujourd’hui (voir notamment ce commentaire). Pour faire simple, PyInstaller détecte les import dans le code pour trouver les fichiers à inclure dans l’application, au lieu d’inclure toutes les dépendances déclarées par les mainteneurs. Il semble que briefcase soit meilleur de ce point de vue.

De manière plus générale, embarquer un interpréteur Python est techniquement compliqué, notamment à cause de l’interaction avec des bibliothèques système (comme OpenSSL), et chacun de ces projets résout ces difficultés d’une manière différente qui a ses propres limitations.

Conda, un univers parallèle

Comme expliqué dans la première dépêche sur l’historique du packaging, Conda est un outil entièrement indépendant de tout le reste. Il ne peut pas installer de paquets du PyPI, son format de paquet est différent, ses environnements virtuels sont différents. Il est développé par une entreprise, Anaconda Inc (mais publié sous une licence libre). Et surtout, bien que chacun puisse publier des paquets sur anaconda.org, il reste principalement utilisé à travers des dépôts de paquets comprenant plusieurs milliers de paquets, qui sont gérés non pas par les auteurs du code concerné, mais par des mainteneurs propres au dépôt de paquets, à la manière d’une distribution Linux, ou de Homebrew et MacPorts sous macOS. En pratique, les deux dépôts principaux sont Anaconda, qui est maintenu par Anaconda Inc, et conda-forge, maintenu par une communauté de volontaires.

Quelques outils gravitent autour de Conda (mais beaucoup moins que les outils compatibles PyPI, car Conda est plus unifié). Je pense notamment à Condax, qui est à Conda ce que pipx est à pip. Il y a aussi conda-lock pour les lock files.

Grâce à son modèle, Conda permet une distribution très fiable des extensions C et C++, ce qui constitue son atout principal. Un inconvénient majeur est le manque de compatibilité avec PyPI, qui reste la source unique pour la plupart des paquets, Conda n’ayant que les plus populaires.

Petite comparaison des résolveurs de dépendances

Les résolveurs de dépendances sont des composants invisibles, mais absolument cruciaux des systèmes de packaging. Un résolveur de dépendances prend un ensemble de paquets, et de contraintes sur ces paquets, de la forme « l’utilisateur demande le paquet A version X.Y au minimum et version Z.T au maximum », ou « le paquet A version X.Y dépend du paquet B version Z.T au minimum et U.V au maximum ». Il est chargé de déterminer un ensemble de versions compatibles, si possible récentes.

Cela paraît simple, et pourtant, le problème de la résolution de dépendances est NP-complet (c’est facile à démontrer), ce qui signifie que, sauf à prouver fausse l'hypothèse du temps exponentiel (et si vous le faites, vous deviendrez célèbre et couronné de gloire et du prix Turing), il n’existe pas d’algorithme pour le résoudre qui ait une complexité meilleure qu’exponentielle. Les algorithmes utilisés en pratique se fondent soit sur des heuristiques, soit sur une traduction en problème SAT et appel d’un SAT-solveur. Le bon résolveur est celui qui réussira à résoudre efficacement les cas rencontrés en pratique. Pour revenir à Python, il y a aujourd’hui trois résolveurs de dépendances principaux pour les paquets Python.

Le premier est celui de pip, qui est implémenté dans resolvelib. Il utilise des heuristiques relativement simples. Historiquement, il s’est construit sur une contrainte forte : jusqu’à récemment (PEP 658), il n’y avait aucun moyen sur PyPI de télécharger seulement les métadonnées d’un paquet sans télécharger le paquet entier. Donc, il n’était pas possible d’obtenir tout le graphe de dépendances entier avant de commencer la résolution, car cela aurait nécessité de télécharger le code entier de toutes les versions de chaque dépendance. Or, il n’y a aucun solveur SAT existant (à ma connaissance) qui permette de modifier incrémentalement le problème. Par conséquent, pip était de toute façon forcé d’adopter une stratégie ad-hoc. La contrainte a été levée, mais l’algorithme est resté.

Le deuxième résolveur est celui de Conda. (En fait, le résolveur est en train de changer, mais l’ancien et le nouveau sont similaires sur le principe.) Contrairement à pip, Conda télécharge à l’avance un fichier qui donne les dépendances de chaque version de chaque paquet, ce qui lui permet de traduire le problème de résolution entier en problème SAT et d’appliquer un solveur SAT.

Enfin, le troisième résolveur fait partie de Poetry. Si j’ai bien compris ceci, il utilise l’algorithme PubGrub, qui ne traduit pas le problème en SAT, mais le résout plutôt avec une méthode inspirée de certains solveurs SAT.

En pratique, dans mon expérience, le solveur de pip se montre rapide la plupart du temps (sauf dans les cas vraiment complexes avec beaucoup de dépendances et de contraintes).

Toujours dans mon expérience, la résolution de dépendances dans Conda est en revanche franchement lente. À sa décharge, je soupçonne que le résolveur lui-même n’est pas spécialement lent (voire, plus rapide que celui de pip ? je ne sais pas), mais comme Conda a pour principe de ne prendre quasiment rien dans le système, en ayant des paquets comme wget, freetype, libxcb, pcre2, etc. etc. etc., certains paquets ont un nombre absolument effrayant de dépendances. Par exemple, il y a quelque temps, j’ai eu à demander à conda-lock un environnement satisfaisant les contraintes suivantes :

- pyqt=5.15.9
- sip=6.7.11
- pyqt-builder=1.15.2
- cmake=3.26.4
- openjpeg=2.5.0
- jpeg=9e
- compilers=1.6.0
- boost-cpp
- setuptools=68.0.0
- wheel

Sur mon ordinateur, il faut environ 7 minutes pour que Conda calcule l’environnement résultant — j’insiste sur le fait que rien n’est installé, ce temps est passé uniquement dans le résolveur de dépendances. Le lock file créé contient environ 250 dépendances (!).

À titre illustratif : « Conda has gotten better by taking more shortcuts and guessing things (I haven't had a 25+ hour solve in a while) » — Henry Schreiner

Quant au résolveur de Poetry, même si je n’ai jamais utilisé sérieusement Poetry, je crois savoir que sa lenteur est l’une des objections les plus fréquentes à cet outil. Voir par exemple ce bug avec 335 👍. (Je trouve aussi révélateur que sur les premiers résultats renvoyés par une recherche Google de « poetry dependency resolver », une moitié environ se plaigne de la lenteur du résolveur.)

D’un autre côté, le solveur de Poetry n’est appelé que lorsque le lock file est mis à jour, donc beaucoup moins souvent que celui de pip ou même Conda. Il y a un vrai compromis à faire : le résolveur de Poetry se veut plus précis (il est censé trouver plus souvent une solution avec des versions récentes), mais en contrepartie, la mise à jour du lock file peut prendre, apparemment, une dizaine de minutes dans certains cas.

Conclusion et avis personnels

Je termine en donnant mes choix très personnels et partiellement subjectifs, avec lesquels tout le monde ne sera pas forcément d’accord.

D’abord, il faut une manière d’installer des outils en ligne de commande distribués sous forme de paquets Python. Il est sage de donner à chacun son environnement virtuel pour éviter que leurs dépendances n’entrent en conflit, ce qui peut arriver très vite. Pour cela, on a essentiellement le choix entre pipx, ou créer à la main un environnement virtuel à chaque fois. Sans hésiter, je choisis pipx.

(Il y a un problème de boostrap parce que pipx est lui-même un outil du même genre. La solution consiste à l’installer avec un paquet système, que la plupart des distributions fournissent, normalement sous le nom python3-pipx.)

Ensuite, pour travailler sur un projet, on a le choix entre utiliser build et twine à la main pour générer la sdist et le wheel et les distribuer sur PyPI, ou bien utiliser un outil plus unifié, soit Flit, soit Poetry, soit PDM, soit Hatch. Dans le premier cas, on peut utiliser n’importe quel build backend, dans le deuxième, on est potentiellement restreint au build backend associé à l’outil unifié (c’est le cas avec Flit et Poetry, mais pas avec PDM, et plus avec Hatch depuis très récemment).

Parlons d’abord du build backend. À vrai dire, lorsque les builds backends ont été introduits (par la PEP 517, voir dépêche précédente), la motivation était largement de permettre l’émergence d’alternatives à setuptools au vu du triste état de setuptools. L’objectif est atteint, puisqu’il y a désormais des alternatives mille fois meilleures. L’ennui, c’est qu’il y a aussi un peu trop de choix. Donc, comparons.

D’abord, il y a setuptools. Sa configuration est franchement compliquée, par exemple je ne comprends pas précisément les douze mille options de configuration qui contrôlent les modules qui sont inclus dans les wheels. De plus, setuptools est vraiment excessivement verbeux. Dans le dépôt de Pygments, un module dont je suis mainteneur, python -m build | wc -l comptait 4190 lignes de log avec setuptools, à comparer à 10 lignes depuis que nous sommes passés à hatchling. Mais surtout, le problème avec setuptools, c’est qu’il y a mille et une fonctionnalités mutantes dont on ne sait pas très bien si elles sont obsolètes, et la documentation est, pour moi, tout simplement incompréhensible. Entendons-nous bien : j’ai beaucoup de respect pour les gens qui maintiennent setuptools, c’est absolument essentiel vu le nombre de paquets qui utilisent setuptools parce que c’était historiquement le seul outil, mais aujourd’hui, peu contestent que setuptools est moins bon qu’à peu près n’importe quel build backend dans la concurrence, et on ne peut pas le simplifier, justement à cause de toute la compatibilité à garder.

Alors… Flit ? Pour les débutants, ce n’est pas mal. Mais la force de Flit, son inflexibilité, est aussi son défaut. Exemple ici et où, suite à un commentaire de Ploum sur la dépêche précédente, je l’ai aidé à résoudre son problème de packaging, dont la racine était que Flit ne permet tout simplement pas d’avoir plusieurs modules Python dans un même paquet.

Alors… Poetry-core ? PDM-backend ? Hatchling ? Les différences sont moins marquées, donc parlons un peu des outils unifiés qui leur sont associés.

D’abord, que penser de Poetry ? Je précise que ne l’ai jamais utilisé moi-même sur un vrai projet. À ce que j’en ai entendu, la plupart de ceux qui l’utilisent l’apprécient et le trouvent plutôt intuitif. Par ailleurs, comme décrit plus haut, il a son propre résolveur de dépendances, et celui-ci est particulièrement lent au point que la génération du lock file peut prendre du temps. Soit. Mais je suis un peu sceptique à cause de points plus fondamentaux, notamment le fait que les dépendances de votre générateur de documentation contraignent celles de votre projet, ce que je trouve assez idiot. Je recommande aussi ces deux posts très détaillés sur le blog d’Henry Schreiner : Should You Use Upper Bound Version Constraints? et Poetry versions. Pour faire court, Poetry incite fortement à mettre des contraintes de version maximum (comme jinja2 < 3), ce qui est problématique quand les utilisateurs de Poetry se mettent à en abuser sans s’en rendre compte. Et il a aussi des opinions assez spéciales sur la résolution de dépendances, par exemple il vous force à mettre une contrainte < 4 sur Python lui-même dès qu’une de vos dépendances le fait, alors que tout projet Poetry le fait par défaut. J’ajoute le fait qu’on ne peut pas utiliser un autre build backend avec Poetry que Poetry-core. En corollaire, on ne peut pas utiliser Poetry sur un projet si tout le projet n’utilise pas Poetry, ce qui implique de changer des choses pour tous les contributeurs (alors que PDM et Hatch peuvent fonctionner avec un build backend différent). C’est pour moi un gros point noir.

Alors… PDM ? Honnêtement, je n’en ai pas assez d’expérience pour juger vraiment. Je sais qu’il corrige la plupart des défauts de Poetry, mais garde le « défaut du générateur de documentation ».

Alors… Hatch ? C’est celui que j’utilise depuis quelques mois, et jusqu’ici, j’en suis plutôt satisfait. C’est un peu dommage qu’il n’ait pas encore les lock files, mais je n’en ai pas besoin pour mes projets.

Je n’utilise pas pyenv. Déjà avant Hatch, je trouvais qu’il représentait trop de travail à configurer par rapport au service rendu, que je peux facilement faire à la main avec ./configure && make dans le dépôt de Python. Et depuis que j’utilise Hatch, il le fait pour moi, sans avoir à compiler Python.

De même, je n’utilise plus tox, je fais la même chose avec Hatch (avec la nuance que si j’ai déjà tanné mes co-mainteneurs pour remplacer make par tox, j’hésite à re-changer pour Hatch…). J’ai fini par me méfier du format INI, qui est piégeux (c’est subjectif) et mal spécifié (il y en a mille variantes incompatibles).

Donc, en résumé, j’utilise seulement deux outils, qui sont pipx, et Hatch. (Et j’espère n’avoir bientôt plus besoin de pipx non plus.) Mais si vous avez besoin de lock files, vous pourriez remplacer Hatch par PDM.

Je termine la comparaison avec un mot sur Conda. À mon avis, entre écosystème Conda et écosystème PyPI, le choix est surtout pragmatique. Qu’on le veuille ou non, l’écosystème Python s’est historiquement construit autour de PyPI, qui reste prédominant. Malheureusement, la réponse de Conda à « Comment installer dans un environnement Conda un paquet PyPI qui n’est pas disponible au format Conda ? » est « Utilisez pip, mais à vos risques et périls, ce n’est pas supporté », ce qui n’est pas fantastique lorsque l’on a besoin d’un tel paquet (cela arrive très vite). D’un autre côté, pour le calcul scientifique, Conda peut être plus adapté, et il y a des pans entiers de ce domaine, comme le géospatial, qui fonctionnent avec Conda et ne fonctionnent pas du tout avec PyPI.

J’espère que ce très long panorama était instructif et aidait à y voir plus clair dans l’écosystème. Pour la troisième dépêche, je vous proposerai un focus sur la distribution des modules d’extension écrits dans des langages compilés, sur ses difficultés inextricables, et sur les raisons pour lesquelles le modèle de Conda en fait une meilleure plateforme que PyPI pour ces extensions.

Commentaires : voir le flux Atom ouvrir dans le navigateur

par jeanas, Benoît Sibaud, Nils Ratusznik, Ysabeau

DLFP - Dépêches

LinuxFr.org

Codeberg, la forge en devenir pour les projets libres ?

 -  25 avril - 

Face aux risques que fait peser GitHub sur le monde des logiciels libres suite à son rachat par Microsoft en 2018, une alternative semble avoir (...)


L’informatique sans écran

 -  21 avril - 

Lors d’un Noël de ma tendre jeunesse pré-adolescente est arrivé un « ordinateur » dans le foyer. Ce PC (Intel 386) a été installé dans le bureau et a (...)


Entretien avec GValiente à propos de Butano

 -  16 avril - 

GValiente développe un SDK pour créer des jeux pour la console Game Boy Advance : Butano.Cet entretien revient sur son parcours et les raisons (...)


Nouveautés d'avril 2024 de la communauté Scenari

 -  11 avril - 

Scenari est un ensemble de logiciels open source dédiés à la production collaborative, publication et diffusion de documents multi-support. Vous (...)


Annuaire de projets libres (mais pas de logiciels)

 -  9 avril - 

Les communs sont une source énorme de partage !S’il est plutôt facile dans le monde francophone de trouver des ressources logicielles (Merci (...)