Introduction au packaging en python
Apprendre à packager son code est quelque chose de très utile pour n’importe quel développeur python. Cela permet de mieux comprendre comment python fonctionne et surtout de pouvoir partager son code avec les autres ou simplement le déployer dans un environnement d’exécution.
Alors comment ça marche ? Pourquoi ne pas se contenter de partager son code via git ? En fait, c’est plus complexe qu’il n’y paraît. Il y a même tout un groupe de travail, la Python Packaging Authority (alias pypa), qui bosse sur ce sujet depuis depuis 2011 et les c’est un domaine en constante évolution.
Dans cet article, on fera un petit tour d’horizon du packaging en python et on présentera une méthode simple pour packager son code en 2023.
Les packages en python
D’abord quelques définition :
Module python
Un module python un est fichier texte dont l’extension est .py
et qui contient du code python.
On appelle cela un module car il permet de “modulariser” le code source python en plusieurs fichiers .py
. On utilise ensuite la fonctionnalité d’import de python pour importer les élements d’un autre module.
Par exemple si l’on crée un fichier count_lines.py
qui contient une fonction count_lines_file
il est ensuite possible d’importer cette fonction :
def count_lines_file(filepath: str) -> int:
"""Count the number of lines in a file"""
return sum(1 for _ in open(filepath))
from count_lines import count_lines_file
count_lines_file("count_lines.py")
3
Lorsque l’on execute l’instruction from count_lines import count_lines_file
, python cherche le module dans le répertoire courant (là où python a été lancé) ; puis dans le répertoire d’installation de python /usr/lib/python3.11
; puis dans le répertoire où python installe les packages par défaut : /usr/lib/python3.11/site-packages
.
Dès que le module est trouvé, il est exécuté dans l’environnement python et les éléments qui y sont définis deviennent disponibles.
La liste des répertoires dans lequel python cherche les modules est grâce au package sys
:
import sys
sys.path
["", "/usr/lib/python3.11/python311.zip", "/usr/lib/python3.11/python311", "/usr/lib/python3.11/site-packages"]
Package python
Un package est un dossier qui regroupe un ensemble de modules python et qui facilite leur accès en créant un espace de nommage : from numpy.linalg import norm
.
Pour créer un package, il suffit de créer un fichier __init__.py
(qui peut tout a fait être vide) dans un dossier.
Le dossier est alors considéré par python comme un package.
Créons un package pour le module count_lines.py
:
count_package
├── __init__.py
└── count_lines.py
A présent on peut utiliser la notation “pointée” pour importer le module count_lines
:
from count_package.count_lines import count_lines_file
count_lines_file("count_lines.py")
3
Lorsque l’on execute l’instruction from count_package.count_lines import count_lines_file
, python cherche un dossier count_package
contenant un fichier __init__.py
dans les répertoires de sys.path
(le répertoire courant et les répertoires par défaut vus plus haut).
Si le package est trouvé, les modules qui y sont présents sont accessibles via la notation “pointée”.
Distribuer son package
Pour distribuer son package on crée une distribution. Il s’agit d’une archive contenant le package à distribuer et qui pourra ensuite être installé avec le gestionnaire de paquet pip.
Il en existe deux formats de distributions principaux :
-
Le format Source Distribution (sdist) : il s’agit d’une archive contenant l’ensemble du code source et des métadonnées
-
Le format Built Distribution : il s’agit d’un format de distribution où un certain nombre de choses ont été pré-compilées pour faciliter l’installation sur d’autres environnement. C’est notamment utile pour les modules écrits en C / C++.
Le format wheel
est le format de type Built Distribution de référence. Il s’agit du format développé par la Python Packaging Authority et qui est très souvent utilisé pour distribuer les packages.
Le workflow de distribution ressemble schématiquement à ça :
flowchart LR
A[Code source local] --> B("distribution (sdist, wheel)")
B --> C[Index de paquets python]
C --> D[téléchargement et installation]
Il existe de nombreux outils pour créer des distributions mais ici on se focalisera principalement sur les outils créés par la Python Packaging Authority et qui sont devenus des incontournables, à savoir setuptools
, build
et twine
.
setuptools
setuptools
est l’outil utilisé par la grande majorité des projets pour construire leurs distributions.
Reprenons notre exemple count_package
de tout à l’heure et voyons comment créer une distribution avec setuptools
.
L’arborescence de notre projet pourrait ressembler à ça :
projet_genial
├── count_package
│ ├── __init__.py
│ └── count_lines.py
├── tests
│ └── test_count_lines.py
├── .gitignore
├── LICENSE.md
└── README.md
Si on ne le lui dit pas, setuptools
n’a aucun moyen de savoir quels sont les fichiers et repertoires qui doivent être inclus dans notre distribution. Il ne peut pas non plus deviner notre adresse mail, le nom de notre projet, sa version,
etc.
setuptools
a donc besoin d’un fichier de configuration qui lui permettra de savoir quoi inclure dans la distribution.
Là on ça devient rigolo c’est qu’il y a trois types de fichiers de configuration possible pour setuptools (et on peut tous les utiliser en même temps…) :
- Le fichier
setup.py
, le plus ancien et le plus populaire (3.7 millions de résultats sur github) - Le fichier
setup.cfg
, le second venu et qui a fait de nombreux et nombreuses adeptes (0.4 millions de résultats sur github) - Le fichier
pyproject.toml
, le dernier venu et qui est à présent le standard officiel pour tous les outils de packaging python. (0.2 millions de résultats sur github)
pyproject.toml
est aujourd’hui le standard officiel mais reste encore très minoritaire par rapport à setup.py
, c’est pourquoi nous aborderons les trois formats de fichier.
setup.py
Le fichier setup.py
comme son extension le laisse penser est un fichier python. Il a la forme suivante :
from setuptools import setup
setup(
name='count_package',
author='me',
description='Package for counting the number of lines in files.'
version='0.0.1',
python_requires='>=3.7, <4',
install_requires=[
'pandas',
'importlib-metadata; python_version >= "3.8"',
],
)
Le fait qu’il s’agisse d’un fichier python fait à la fois sa force et sa faiblesse : il est possible de construire la configuration dynamiquement dans le code mais ça rend la configuration difficile à parser et à interfacer avec d’autres outils externes.
Puisqu’il s’agit d’un format propre à setuptools
les distributions de type sdist ne seront installables que si setuptools a bien été installé sur l’environnement cible et dans une version compatible.
De plus puisque l’immense majorité des projets se sont mis à utiliser setuptools et setup.py
il est devenu difficile de proposer des alternatives et des projets comme flit on dû être construit “par dessus” setuptools
, ce qui ne favorise pas l’innovation.
Par ailleurs son utilisation est souvent problématique.
Pour reprendre l’exemple de cet article, vous pourriez par exemple être tenté de d’introduire une condition if/else dans votre setup.py
pour gérer une dépendance nécessaire en python 2.7 en vous basant sur sys.version
mais ce faisant vous introduiriez un bug vicieux : la dépendance sera embarquée ou non en fonction de l’environnement qui compile la distribution de votre package et non pas en fonction de l’environnement de l’utilisateur qui l’installe.
Il est aussi tentant d’importer le package depuis setup.py
pour gérer la version sa version par exemple.
Ce qui fera planter les distributions sdist.
Aujourd’hui l’utilisation de setup.py
est possible mais il est conseillé de privilégier une utilisation déclarative.
De plus l’utilisation du fichier comme script : python setup.py
est déprécié comme l’indique la documentation de
setuptools :
It is important to remember, however, that running this file as a script (e.g. python setup.py sdist) is strongly discouraged, and that the majority of the command line interfaces are (or will be) deprecated (e.g. python setup.py install, python setup.py bdist_wininst, …).
We also recommend users to expose as much as possible configuration in a more declarative way via the pyproject.toml or setup.cfg, and keep the setup.py minimal with only the dynamic parts (or even omit it completely if applicable).
See Why you shouldn’t invoke setup.py directly for more background.
setup.cfg
Pour répondre aux problèmes mentionnés ci-dessus et rendre la configuration plus déclarative, la pypa a créé en 2016 le format de fichier setup.cfg
.
Le fichier setup.py
d’exemple plut haut est équivalant au fichier setup.cfg
suivant :
[metadata]
name = count_package
version = 0.0.1
author = me
description = Package for counting the number of lines in files.
[options]
python_requires = >=3.7,<4
install_requires =
pandas
importlib-metadata; python_version >= "3.8"
Ce format a fait de nombreuses et nombreux adeptes mais a récemment été supplanté par le format pyproject.toml
qui est dorénavant le moyen officiel de déclarer la configuration des packages python.
pyproject.toml
Le format pyproject.toml
en plus de reprendre l’approche déclarative de setup.cfg
, introduit sont lot de nouveauté.
Il est désormais possible (et même obligatoire) de spécifier le builder du package.
Il est également un moyen de centraliser les configuration de nombreux outils de développement de façon agnostique plutôt que de multiplier les fichiers de configuration du type tox.ini
, .coveragerc
, etc.
A propos du builder : comme je le faisais remarquer plus haut, lorsqu’un package est distribué sous format sdist, c’est
le client qui doit construire le package. Lorsqu’il exécute la commande pip install count_package
, pip doit donc récupérer la distribution sdit, puis construire le package à partir de la configuration qu’il s’y trouve.
Comment pip fait-il pour savoir quelle configuration utiliser ? Et comment utiliser un autre builder que setuptools
à ce moment là ?
Pour répondre à cette problématique, le format pyproject.toml
inclus une section obligatoire pour définir le builder qui doit être utilisé pour construire le package :
[build-system]
requires = [
"setuptools>=60",
"wheel>=0.30.0",
"cython>=0.29.4",
]
build-backend = "setuptools.build_meta"
Avec pyproject.toml
il est désormais possible de déclarer à pip
les dépendances nécessaire au build !
Il est alors tout à fait possible de spécifier l’utilisation d’un autre builder que setuptools, tel que flit :
[build-system]
requires = ["flit"]
build-backend = "flit.api:main"
Ce format est en train de s’imposer petit à petit comme le moyen de centraliser la configuration du package. Il est le format privilégié par setuptools et un certains nombres d’outils tiers l’utilise pour stocker leur configuration : black, pytest, isort, etc.
Voici un exemple de fichier pyproject.toml
pour notre package d’exemple count_package
:
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "count_package"
version = "0.0.1"
description = "Package for counting the number of lines in files."
name = "my_package"
authors = [
{name = "me", email = "email@me.fr"},
]
requires-python = ">=3.8,<4"
dependencies = [
"pandas",
'importlib-metadata; python_version >= "3.8"',
]
dynamic = ["version"]
La documentation de setuptools donne plus de détails la configuration au format pyproject.toml
Construire une distribution de son package
Une fois le fichier de configuration (de préférence pyproject.toml
) écrit, il ne vous reste plus qu’à construire votre package.
Pour ça, le moyen moderne de procéder et d’utiliser le package build
développé par la pypa :
pip install --upgrade build
python -m build
build
commencera par installer le builder spécifié dans votre fichier pyproject.toml
puis l’utilisera pour construire une distribution sdist et une wheel.
Vous pourrez ensuite utiliser le package twine
pour le publier sur le repository officiel pypi.
:::info
Pour des raisons de rétro-compatibilité avec les anciennes versions des librairies de packaging, vous pouvez créer un fichier setup.py
minimal en plus du fichier pyproject.toml
:
from setuptools import setup
setup()
:::
Gestion de la version
Dans l’exemple de pyproject.toml
vu plus haut la version du package est fixée manuellement. Il faut donc changer celle-ci à chaque fois que l’on souhaite publier une nouvelle version de son package.
Contrairement à poetry setuptools n’a pas (à ma connaissance) de commande intégrée pour modifier facilement la version du package dans le pyproject.toml
. En revanche la pypa propose un package que je trouve très pratique pour gérer la version du package en s’appuyant sur git ou mercurial : setuptools-scm
Cela s’utilise très simplement en ajoutant la dépendance au pyproject.toml
ainsi qu’en spécifiant que la version est dynamique :
[build-system]
requires = ["setuptools>=45", "setuptools_scm[toml]>=6.2"]
[project]
# version = "0.0.1" # Remove any existing version parameter.
dynamic = ["version"]
[tool.setuptools_scm]
write_to = "src/pkg/_version.py"
Lors de la construction du package setuptools-scm
recherchera parmi le dernier tag ayant un numéro de version valide puis en déduira le numéro de version du package. Par défaut la version est construite à partir de trois éléments :
- Le dernier tag ayant un numéro de version valide (exemple :
v1.2.3
) - La distance à ce tag (nombre de révision depuis ce tag)
- L’état du répertoire de travail (s’il y a des changements non commités)
Une fois le numéro de version déduit, un fichier _version.py
sera créé à l’intérieur de la distribution à l’endroit spécifié (ex : src/pkg/_version.py
) et qui permettra de connaître la version du package à partir de la distribution sans que l’historique git ne soit présent sur l’environnement cible.
:::info
Si vous avez l’habitude de renseigner vous même le numéro de version de vos packages sachez que les formats valides pour les versions sont régis par la PEP 440. Si vous ne respectez pas ces spécifications vous risquez probablement d’avoir des problèmes lors de la publication ou de l’installation de vos packages.
Notamment les versions v1.2.3-local
ou v1.2.3-dev
sont invalides.
:::
Le layout
Lors de la configuration de votre package, peut importe la méthode utilisée (setup.py
, setup.cfg
ou
pyproject.toml
), vous devez spécifier les packages et les sous-packages que vous souhaitez inclure dans votre
distribution :
[tool.setuptools]
packages = ["mypkg", "mypkg.subpkg1", "mypkg.subpkg2"]
Heureusement setuptools
possède une fonctionnalité de découverte automatique de vos packages et sous-packages. Celle-ci est compatible avec deux layout projets classique :
le layout dit à plat (flat-layout) :
count_package
├── count_package
│ ├── __init__.py
│ └── count_lines.py
├── tests
│ └── test_count_lines.py
├── .gitignore
├── LICENSE.md
└── README.md
et le layout avec un dossier src (src-layout) :
projet_genial
├── src
| ├── count_package
│ ├── __init__.py
│ └── count_lines.py
├── tests
│ └── test_count_lines.py
├── .gitignore
├── LICENSE.md
└── README.md
La différence peut paraître minime mais personnellement j’ai une grosse préférence pour le src-layout car cela empêche de prendre des mauvaises habitudes et cela force à comprendre comment le système d’import et d’installation de package fonctionne en python.
En effet, lorsque vous développez votre package vous tester au fur et à mesure les fonctionnalités que vous ajoutez à celui-ci.
Pour ce faire, on est très tenté de simplement importer notre package depuis notre fichier de test :
from count_package import count_lines
Cela fonctionnera si vous utilisez un flat-layout et que vous exécuter votre module de test depuis le répertoire qui contient le dossier count_package
car comme on l’a vu plus haut python inclus le répertoire courant dans a liste des répertoire où il recherche les modules.
Cependant c’est une mauvaise habitude pour deux raisons :
- D’abord, si vous utilisez setup.py, celui-ci se trouvant à la racine de votre projet, il est en capacité d’importer le package
count_package
qu’il est censé installé chez un client en mode sdist ce qui peut provoquer des bugs si on ne fait pas attention. - Ensuite, vous ne testez pas vraiment le package tel qu’il sera installé chez les autres ! En effet, il se peut par exemple que vous n’ayez pas pensé à inclure des fichiers de données dans votre fichier de configuration et vos tests devraient en conséquence planter. Mais comme ces fichiers sont présents chez vous, vous vous ne apercevez de rien.
Pour ces raisons, je pense qu’il est préférable d’opter pour un src-layout et d’utiliser une installation editable grâce à la commande : pip install -e .
pour le développement local. Cela permet d’installer le package en faisant un lien symbolique avec votre code afin que les modifications que vous y apportez soient immédiatement répercutées sur le package installé.
Pour une analyse approfondie des avantages du src-layout, je vous renvoie à cet article (qui date de 2014).
Les Alernatives à setuptools
Il existe aujourd’hui des alternatives solides à setuptools, en voici une liste non exhaustive :
- Flit : met l’accent sur la simplicité d’utilisation et de configuration.
- Pipenv : permet de gérer conjointement l’environnement virtuel de son projet et
ses dépendances. Ajoute de plus une fonctionnalité très appréciable : la génération de fichiers
Pipfile.lock
qui référence des versions exacte des dépendances pour permettre la reproduction à l’identique de l’environnement de développement. - Poetry : outil très puissant qui permet à la fois de gérer les environnements
virtuels, les dépendances (et les dépendances des dépendances), de générer un fichier
poetry.lock
similaire àPipfile.lock
, de publier son package, etc. Cependant il ne respecte pas certains standards PEP. - PDM : gestionnaire de paquet nouvelle génération pour python. Contrairement à PerformanceEntry, il respecte les standards PEP.
- Hatch : le nouvel outil de la Pypa pour gérer les projets python. Il possède de nombreuses fonctionnalités très intéressantes.
- uv : c’est pip écrit en rust de façon à le rendre 10 à 100 fois plus rapide.
TL;DR
- Utilisez le fichier pyproject.toml en suivant le guide setuptools.
- Un petit coup de
pip install build && python -m build
et votre package est prêt à être distribué. - Adoptez le src-layout.
En bonus :
- Essayez un gestionnaire next-gen Hatch, PDM ouPoetry.
- Je vous partage mon template
cookie-cutter
tout simple pour vos projets python.
Estimons nous heureuses et heureux
Tout ce que je vous ai raconté ici peut sembler beaucoup et pourtant je n’ai fait que survoler le sujet. En tout cas je pense que l’on peut s’estimer heureuses et heureux lorsque l’on voit ce qu’écrivait le site Sam & Max en 2018 :
D’abord on a
distutils
,setuptools
,distribute
, anddistribute2
qui ont tous été à un moment les “standards” recommandés pour packager une lib. Ensuite on a eu l’époque deseggs
,exe
, et autres trucs queeasy_install
allait chercher n’importe où dans la nature en suivant aveuglément des liens sur PyPi. Sans compter les machins qu’il fallait compiler à tout bout de champ. Et puis rien n’était chiffré au download,pip
n’était pas packagé avec Python, il crevait sur des erreurs stupides type encodage mal géré…À ça se rajoute que
virtualenv
était un truc à part, avec plein de concurrents, et linkait les packages système par défaut. Sans oublier qu’on avait paspython -m
.Bref, le packaging Python, ça a été vraiment la merde. Avec en plus une doc de merde.
C’est quand même beaucoup plus simple aujourd’hui.
Références
- Le Guide de pypa sur le packaging python : An Overview of Packaging for Python
- Un article pour comprendre les wheels : What Are Python Wheels and Why Should You Care?
- Une série de trois articles très éclairant sur le fonctionnement du packaging python, écrit par Bernát Gábor en 2019 :
- Un article à la gloire de
setup.cfg
sur le regretté site Sam & Max : à propos de setup.cfg - Question stackoverflow : What is pyproject.toml file for