Packaging python
Je ne vais pas cependant PAS expliquer comment packager son projet (ou pas beaucoup, et ça restera facultatif ^^). À la place, je vais essayer d’expliquer grossièrement ce dont on parle quand on s’intéresse au packaging, et clarifier une confusion fréquente — en tout cas que j’ai faite moi.
Ce post n’est donc pas suffisant pour naviguer comme un poisson dans les eaux du packaging python, l’objectif est plutôt d’en sortir un peu mieux armé pour utiliser les autres ressources sur le sujet : docs, articles, tutos, etc. Pour atteindre cet objectif, j’ai essayé de reléguer les infos non-essentielles (mais intéressantes tout de même) dans des collapsibles, que vous pouvez allègrement ignorer en première lecture, comme celui-ci :
FACULTATIF = info non-essentielle (mais intéressante tout de même)
Nan, mais là, c’était juste pour illustrer : passez votre chemin, y’a rien à voir ^^'
Buckle up.
Fifty Two shades of packaging
La mère de toutes les confusions : il existe DEUX types de personnes qui sont intéressées par le packaging python, et elles ont des besoins TRÈS différents :
-
Le CONSOMMATEUR du package : prenons un développeur (appelons-le Motoki) qui travaille sur son projet
killerapp, qui va révolutionner tous les serveurs de France et de Navarre.killerappa parfois besoin de faire des requêtes HTTP, et le projet dépend donc de la librairierequests. Motoki va alors installerrequestspar exemple en lançant :pip install requests -
Le PRODUCTEUR du package : prenons un autre développeur (appelons-le Kenneth REITZ), qui a créé et maintient la librairie
requests. Pour que sa librairie puisse être utile, Kenneth doit pouvoir la distribuer à d’autres développeurs, et pour ce faire, il pourra commencer par lancer, depuis le répertoire de son projet :pip wheel .
Typiquement, un dev sera bien plus souvent dans la peau de Motoki que dans celle de Kenneth. Pourtant, consommateur et producteur sont les deux facettes d’un même sujet : pour que Motoki puisse installer sa librairie, il a bien fallu que quelqu’un (Kenneth) ait préparé les fichiers d’abord.
Beaucoup de ressources mélangent les deux points de vue, ce qui nuit à la compréhension du sujet. Par exemple la page d’accueil du site référent sur la question, qui mentionne à égalité l’installation et la distribution de packages :
Essential tools and concepts for working within the Python development ecosystem are covered in our Tutorials section:
to learn how to install packages, see …
to learn how to manage dependencies in a version controlled project, see …
to learn how to package and distribute your projects, see …
Cette confusion entre consommateur et producteur est renforcée par le fait que le même outil, pip, officiellement recommandé pour installer les packages python, peut être utilisé aussi bien par Motoki que par Kenneth, alors que leurs besoins n’ont rien à voir : Motoki veut installer une librairie dont son projet dépend, Kenneth veut préparer son code pour le rendre utilisable par d’autres développeurs.
Le packaging vu par Motoki, CONSOMMATEUR de package
Maintenant qu’on sait qu’il y a deux facettes au packaging, voyons un peu de quoi on parle.
Motoki veut que son code puisse utiliser requests. Par exemple, il veut pouvoir écrire :
import requests
r = requests.get("http://mysuperservice.com/api/")
TL;DR : le packaging, du point de vue de Motoki, ça veut dire :
-
télécharger une archive de code (mieux connue sous le nom de package ^^)
-
la décompresser
-
copier le code à un emplacement accessible à python
FACULTATIF = plus de détails sur cet "emplacement accessible à python"
La question est : où faut-il copier du code pour qu’il soit utilisable par une ligne du genre import requests ?
Le plus souvent, sous Linux, il s’agit du répertoire site-packages dans le virtualenv.
En simplifiant grossièrement, (plus de détails ici), lorsque python exécute import requests, il va rechercher un module nommé requests dans l’un des chemins du sys.path, l’exécuter, et créer un objet représentant le module (contenant notamment la fonction membre get, utilisée ci-dessus), accessible par la variable requests.
Ce qui m’intéresse ici est le sys.path : on facilement regarder ce qu’il y a dedans :
python -c 'import sys ; print("\n".join(sys.path))'
Le résultat est variable et dépend notamment de la plate-forme, et de si on utilise un virtualenv ou pas, mais on peut par exemple y trouver :
/path/to/virtualenvs/mysupervenv/local/lib/python2.7/site-packages
/path/to/virtualenvs/mysupervenv/lib/python2.7/site-packages
On peut vérifier que le code qu’on y met est importable par python :
cat << EOF > /path/to/virtualenvs/mysupervenv/lib/python2.7/site-packages/salut.py
def coucou_le_monde():
print("non, en fait rien")
EOF
python -c "import salut ; salut.coucou_le_monde()"
# affiche "non, en fait rien"
Et l’archive de code téléchargée, elle provient d’où ?
Dans la majorité des cas, de PyPI, un repo public de programmes et librairies python. Par défaut, c’est de PyPI que Motoki obtient requests, lorsqu’il fait :
pip install requests
FACULTATIF = zoom sur le téléchargement de requests depuis PyPI
Toujours en simplifiant, pip install requests déclenche une belle mécanique qui va regarder les versions disponibles sur la page de requests sur PyPI, télécharger une archive (le fameux wheel) de la version la plus récente, et la décompresser dans l’un des répertoires présents dans le sys.path.
Si on sait ce qu’on y cherche, on peut suivre ce qui se passe dans la sortie de la commande pip :
pip install -vvv requests
# pip interroge la page de requests sur pypi :
Collecting requests
1 location(s) to search for versions of requests:
* https://pypi.org/simple/requests/
Getting page https://pypi.org/simple/requests/
# on y trouve les différentes versions de requests, sous forme de liens vers des archives :
Analyzing links from page https://pypi.org/simple/requests/
Found link https://files.pythonhosted.org/packages/ba/bb/dfa0141a32d773c47e4dede1a617c59a23b74dd302e449cf85413fc96bc4/requests-0.2.0.tar.gz#sha256=813202ace4d9301a3c00740c700e012fb9f3f8c73ddcfe02ab558a8df6f175fd (from https://pypi.org/simple/requests/), version: 0.2.0
[... une tétrachiée d'autres versions ...]
Found link https://files.pythonhosted.org/packages/f5/4f/280162d4bd4d8aad241a21aecff7a6e46891b905a4341e7ab549ebaf7915/requests-2.23.0.tar.gz#sha256=b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6 (from https://pypi.org/simple/requests/) (requires-python:>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*), version: 2.23.0
# comme Motoki n'a pas précisé de version particulière, c'est la plus récente qui est utilisée :
Using version 2.23.0 (newest of versions: 0.2.0, [... une tétrachiée d'autres versions ...] , 2.22.0, 2.23.0)
# pip télécharge l'archive adéquate, et la décompresse :
Created temporary directory: /tmp/pip-unpack-AoA2ag
Downloading https://files.pythonhosted.org/packages/1a/70/1935c770cb3be6e3a8b78ced23d7e0f3b187f5cbfab4749523ed65d7c9b1/requests-2.23.0-py2.py3-none-any.whl (58kB)
# pip va aussi adresser les 4 dépendances de requests : urllib3, certifi, chardet, et idna.
# les dépendances vers urllib3 et certifi sont déjà résolues pour notre venv :
Requirement already satisfied: urllib3!=1.25.0,!=1.25.1,<1.26,>=1.21.1 in /media/truecrypt1/virtualenvs/mysupervenv/lib/python2.7/site-packages (from requests) (1.21.1)
Requirement already satisfied: certifi>=2017.4.17 in /media/truecrypt1/virtualenvs/mysupervenv/lib/python2.7/site-packages (from requests) (2017.4.17)
# pour les dépendances vers chardet et idna, la MÊME mécanique que pour requests se met en place :
Collecting chardet<4,>=3.0.2 (from requests)
1 location(s) to search for versions of chardet:
* https://pypi.org/simple/chardet/
[ ... mêmes actions que ce qui a permis d'installer requests, mais pour chardet, puis idna... ]
# Au final, pip a installé requests, ainsi que deux de ses dépendances : chardet et idna :
Installing collected packages: chardet, idna, requests
Successfully installed chardet-3.0.4 idna-2.9 requests-2.23.0
Cleaning up...
Cependant, même si PyPI est son fournisseur de package par défaut, pip sait installer des packages à partir d’autres provenances. Le tuto officiel sur l’installation de paquets via pip fait un meilleur travail de récap que les quelques lignes qui suivent, mais en gros, il est possible d’installer des packages :
-
depuis le repo public du projet ; pour
requests, c’est un repo github :pip install git+https://github.com/psf/requests#egg=requests -
depuis une archive locale (éventuellement, téléchargée avec
pip download) :pip download requests --dest /tmp/downloaded/ pip install /tmp/downloaded/requests-2.23.0-py2.py3-none-any.whl -
depuis un répertoire local contenant un projet python (dans ce cas, pip fabrique lui-même le package à partir du contenu du répertoire, avant de l’installer) :
git clone --depth=1 https://github.com/psf/requests /tmp/local-requests pip install /tmp/local-requests -
un cas particulier de ce qui précède : on peut installer un projet et ses dépendances directement depuis le répertoire de son projet avec
pip install .:git clone --depth=1 https://github.com/psf/requests /tmp/local-requests cd /tmp/local-requests pip install .Certains projets utilisent cette façon de faire (que je trouve moins intuitive qu’un
pip install -r requirements.txt) pour installer leurs dépendances. L’apparente magie depip install .m’a longtemps laissé perplexe et c’est allé mieux le jour où j’ai compris le mécanisme d’installation de package décrit ci-dessus.
Le point important à garder à l’esprit, c’est que dans tous ces cas, le TL;DR ci-dessus reste vrai : pip se contente de récupérer du code pour le mettre à un emplacement accessible à python.
FACULTATIF = pour aller plus loin, côté consommateur de package
Pour simplifier le propos, j’ai laissé plusieurs points intéressants de côté. Ils ne sont pas indispensables pour avoir une vision d’ensemble du packaging, mais vous les croiserez forcément si vous creusez un peu le sujet :
-
les virtualenvs et ce qu’ils apportent : l’un des sujets les plus importants, mais dont la compréhension ne présente pas de difficulté particulière
-
setuptools, le fait d’utilisersetup.pypour installer des packages (python setup.py installoupython setup.py develop), et pourquoi il reste préférable d’utiliserpip. -
la gestion récursive des dépendances
-
l’URL par défaut utilisée par pip pour contacter PyPI, et comment aller taper dans un pypi alternatif :
pip help install | grep "Base URL of Python Package Index" -i, --index-url <url> Base URL of Python Package Index (default https://pypi.org/simple). This should point to a repository compliant with PEP 503 (the simple repository API or a local directory laid out in -
la Simple Repository API exposée par PyPI (que je trouve un peu bizarre)
-
l’installation en mode editable avec
pip install -e .oupython setup.py develop -
l’installation d’extra, et ce que signifie la syntaxe avec crochets :
pip install -e .[mysuperextra] -
la différence à l’installation d’un package entre une source-distribution et une une built-distribution (notamment si le package a des extensions C, comme par exemple ujson)
-
les alternatives à pip, notamment pipenv (officiellement recommandé), et poetry, vus comme le futur™
Le packaging vu par Kenneth, PRODUCTEUR de package
TL;DR : une fois qu’on a compris ce que voulait faire Motoki, il devient plus facile de comprendre l’objectif de Kenneth :
-
mettre dans une archive les fichiers dont va avoir besoin Motoki…
-
…assorties des méta-données et informations lui permettant de les installer correctement
-
les publier sur PyPI
La bonne nouvelle, c’est que pip va (presque) tout gérer automatiquement : la seule chose que Kenneth doit faire, c’est lui donner à manger les infos dont il aura besoin : quels sont les fichiers qu’on veut distribuer (en effet, ce serait ballot d’intégrer le .gitignore ou des *.swp au package), et quelles sont les métadonnées du package (version du package, dépendances, version de python supportée, mais également auteur, license, etc.).
La façon canonique de passer ces infos à pip, et de les mettre dans un fichier setup.py à la racine du projet. Plus précisément, ce fichier setup.py appelle la fonction setuptools.setup, en lui passant en argument les infos nécessaires pour builder le package. C’est un peu déroutant au début d’appeler une fonction pour définir des métadonnées, mais on s’y fait.
Concrètement ça ressemble à quoi ?
Je garde le code de requests comme fil rouge, voici son setup.py à l’heure où j’écris ces lignes. Quand je dis que Kenneth doit donner à manger à pip les infos nécesaires, ça ressemble à ça :
-
l’argument
packagesindique à pip quels sont les fichiers du repo qui doivent être packagés :packages = ['requests'] # ligne 42 : variable intermédiaire # ... packages=packages, # ligne 78 : pip doit packager un seul répertoire : requests -
l’argument
install_requiresindique quelles sont les dépendances de requests :requires = [ # ligne 44 : variable intermédiaire 'chardet>=3.0.2,<4', 'idna>=2.5,<3', 'urllib3>=1.21.1,<1.26,!=1.25.0,!=1.25.1', 'certifi>=2017.4.17' ] # ... install_requires=requires, # ligne 83 : requests nécessite chardet+idna+urllib3+certifi -
l’argument
python_requiresindique les versions de python supportées :python_requires=">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*", -
les métadonnées du package (nom, version, auteur, license, etc.) sont définies dans un fichier annexe et utilisées par setup.py :
__title__ = 'requests' __description__ = 'Python HTTP for Humans.' __url__ = 'https://requests.readthedocs.io' __version__ = '2.23.0' __author__ = 'Kenneth Reitz' # [...]
Allez, je vais me la jouer "site du zéro", et proposer un TP. C’est vraiment pas indispensable pour avoir compris cette partie, mais j’entends → j’oublie ; je vois → je retiens ; toussa toussa : faire les choses moi-même m’aide à les comprendre, je pars du principe que ça va être pareil pour vous.
FACULTATIF : installe requests via un package que tu as buildé toi-même avec amour <3
À noter que c’est même pas obligé de prendre un vrai projet comme requests pour ça : c’est également très intéressant de packager un repo quasi-vide, et de trouver quelles sont les conditions minimales pour que pip accepte d’en faire un package.
-
builder un package à partir du repo requests
mkdir /tmp/monsupertp/ && cd /tmp/monsupertp/ git clone --depth=1 https://github.com/psf/requests local-requests cd local-requests # la ligne suivante builde la wheel : pip wheel --wheel-dir=/tmp/monsupertp/wheel-dir . -
trifouiller et regarder ce qu’il y a dans la wheel buildée :
cd /tmp/monsupertp/ # la wheel n'est qu'une archive zip des fichiers packagés + des métadonnées : file wheel-dir/requests-2.23.0-py2.py3-none-any.whl # wheel-dir/requests-2.23.0-py2.py3-none-any.whl: Zip archive data, at least v2.0 to extract unzip wheel-dir/requests-2.23.0-py2.py3-none-any.whl -d /tmp/monsupertp/unzipped-wheel ls /tmp/monsupertp/unzipped-wheel/ # requests requests-2.23.0.dist-info # les fichiers packagés sont bien ceux du repo : find unzipped-wheel/requests -type f -print0|xargs -0 md5sum|cut -d" " -f1|md5sum # 943bfbe6cf829cec797d84e09862f826 - find local-requests/requests -type f -print0|xargs -0 md5sum|cut -d" " -f1|md5sum # 943bfbe6cf829cec797d84e09862f826 - # les métadonnées sont bien celles qui étaient précisées dans l'appel à setuptools.setup() : cd unzipped-wheel/requests-2.23.0.dist-info/ grep "Python HTTP for Humans" METADATA # Summary: Python HTTP for Humans. grep "Programming Language :: Python" METADATA | grep "\." # Classifier: Programming Language :: Python :: 2.7 # Classifier: Programming Language :: Python :: 3.5 # Classifier: Programming Language :: Python :: 3.6 # Classifier: Programming Language :: Python :: 3.7 # Classifier: Programming Language :: Python :: 3.8 grep Requires-Dist METADATA | grep -v extra # Requires-Dist: certifi (>=2017.4.17) # Requires-Dist: chardet (>=3.0.2,<4) # Requires-Dist: idna (>=2.5,<3) # Requires-Dist: urllib3 (!=1.25.1,>=1.21.1,!=1.25.0,<1.26) -
vérifier qu’on peut utiliser la wheel pour installer
requests# (commande à lancer dans un virtualenv vierge) # note : pip se débrouille pour installer les dépendances de requests pip install /tmp/monsupertp/wheel-dir/requests-2.23.0-py2.py3-none-any.whl -
Profit !
Pour résumer, côté PRODUCTEUR, on veut "juste" remplir un setup.py correct pour que pip puisse bosser, et transformer un repo de code (parfois appelé source-tree) en un package.
FACULTATIF = pour aller plus loin, côté producteur de package
Pour simplifier le propos, j’ai laissé plusieurs points intéressants de côté. Ils ne sont pas indispensables pour avoir une vision d’ensemble du packaging, mais vous les croiserez forcément si vous creusez un peu le sujet :
-
l’utilisation directe de
setup.pypour builder un package (python setup.py sdist/bidst/bidst_wheel), le lien avecpip wheel, et l’absence de possibilité pour pip de builder des source-distribution (même si une issue est ouverte sur le sujet) -
c’est pas tout de créer un package, encore faut-il l’uploader sur PyPI, ce que pip ne sait pas faire : c’est le boulot de twine ou de flit
-
la différence à la création d’un package entre une source-distribution et une une built-distribution (notamment si le package a des extensions C, comme par exemple ujson)
-
l’utilisation de setup.cfg pour avoir un appel à
setuptools.setup(…)minimaliste -
le subtil art de préciser les fichiers qui feront partie du package : find_packages, MANIFEST.in + include_package_data, etc.
-
le non-moins subtil art de préciser les dépendances de son package, et l’apport de pipenv (ou poetry) sur le sujet
-
les wheels universelles ou pure-python
-
les alternatives à setuptools, via pyproject.toml, pourtant controversé
-
le fait que
wheel(qui fournitbdist_wheel),setuptools, ettwinesoient des packages tierces -
la customisation de l’étape de build de son package
-
les alternatives aux wheels comme format de built-distributions, et notamment les eggs, même s’ils sont moins intéressants que les wheels, et considérés comme deprecated.
En conclusion
L’objectif de cet article était de donner une vue générale du sujet (ce qui se passe quand on installe un package, et ce qu’il faut donc faire pour en produire un) avec comme espoir qu’il soit ainsi plus facile de comprendre les articles, docs, tutos et autres ressources sur le sujet.
Si on garde cette vision avec un peu de hauteur, le packaging python, c’est pas si compliqué :
-
le consommateur de package veut un outil (
pip) capable de récupérer une archive code (une wheel), et de la décompresser à un emplacement utilisable par python (sous Linux, un répertoiresite-packages) -
le producteur de package veut un outil (
pipousetuptools) capable de générer cette archive de code à partir de son repo, et le configure en indiquant dans un fichiersetup.pyle code à packager, et ses métadonnées
En dehors de la confusion entre ces deux rôles, le sujet est rendu ardu par le joyeux bordel que sont la doc et les outils. J’aurais pu commencer par là tellement c’est le bazar : l’histoire du packaging python a été longue et douloureuse, parsemée d’une ribambelle de librairies, outils et formats aujourd’hui dépréciés (en vrac, et ne visitez les liens que pour faire de l’archéologie : easy_install, distribute, distutils2, les eggs, …).
Concernant la doc, ça va beaucoup mieux depuis quelques années, et il n’est plus si compliqué de trouver la documentation des features, même inhabituelles. Restez tout de même critique à la lecture des docs et autres billets de blog sur le sujet : comme il n’y en a pas tant que ça d’une part, et que le sujet évolue vite d’autre part, beaucoup d’articles sont maintenant (partiellement) dépréciés. Cette remarque est valable aussi pour le présent article : cher lecteur du futur (c’est un pléonasme, ça, non ?), je t’invite à bien vérifier la date à laquelle j’ai écrit cet article.
Concernant les outils, ça vaut c’que ça vaut, mais pour commencer, j’aurais tendance à conseiller de tout ignorer à part :
-
setuptools= package tierce permettant de packager un projet, s’appuie surdistutils(qui est standard, mais que vous pouvez ignorer car rarement utilisé directement) -
pip= outil officiellement recommandé (et le plus utilisé) pour installer les packages python. pip utilisesetuptoolssous le capot. -
wheel= à la fois le format de built-distribution recommandé, et un package tierce permettant de les builder et fournissant une CLI pour les manipuler. Défini dans la PEP 427. -
twine/flit= utilitaires utilisés pour publier son package sur PyPI -
pipenv/poetry= pipenv est l’outil officiellement recommandé faisant le café pour la gestion des packages, car mélangeant le métier devirtualenv, depip, durequirements.txt, et dupackage.jsonde npm. poetry est une alternative à pipenv jouant le même rôle, souvent considérée comme mieux foutue.
Une petite dernière confusion à ne pas faire pour la route : le même terme package est utilisé dans le monde python pour désigner deux concepts bien distincts :
-
distribution package : une archive contenant du code, des données et métadonnées. Le lecteur attentif remarquera que c’est ce dont il était question tout au long du présent article.
-
import package : ce sont des regroupements de modules python, c’est à dire grosso-modo, des répertoires contenant des fichiers de code.
C’est le contexte qui dit de quoi on parle, n’hésitez-pas à vous référer au glossaire officiel, car à défaut d’être exhaustif, il a le mérite d’être particulièrement clair et concis.