Comme beaucoup (beaucoup) d’autres avant moi, je blogge sur le 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 :

  1. 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. killerapp a parfois besoin de faire des requêtes HTTP, et le projet dépend donc de la librairie requests. Motoki va alors installer requests par exemple en lançant :

    pip install requests
  2. 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 :

  1. télécharger une archive de code (mieux connue sous le nom de package ^^)

  2. la décompresser

  3. 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 :

  1. depuis le repo public du projet ; pour requests, c’est un repo github :

    pip install git+https://github.com/psf/requests#egg=requests
  2. 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
  3. 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
  4. 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 de pip 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 :

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 :

  1. mettre dans une archive les fichiers dont va avoir besoin Motoki…​

  2. …​assorties des méta-données et informations lui permettant de les installer correctement

  3. 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 packages indique à 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_requires indique 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_requires indique 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.

  1. 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 .
  2. 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)
  3. 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
  4. 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 :

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épertoire site-packages)

  • le producteur de package veut un outil (pip ou setuptools) capable de générer cette archive de code à partir de son repo, et le configure en indiquant dans un fichier setup.py le 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 :

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.