Aller au contenu principal

Packager son code python

Disclaimer

Les informations et conclusions de cet article sont basées sur mes propres analyses et interprétations, elles ne sont pas entiÚrement fiables. Je vous invite à vous référer aux sources originelles et à me faire vos retour en commentaire.

Encore mieux, n'hĂ©sitez Ă  proposer directement des modifications sur Github 😉

Packager son code, c'est l'emballer dans un joli paquet afin que d'autres personnes puisse le rĂ©utiliser 🎁.

Il s'agit d'un concept trÚs important. A chaque fois que vous faites pip install unpackage ou import unpackage, vous utilisez des paquets que d'autres ont emballé pour vous.

Alors comment ça marche ? Ne pourrait-on pas se contenter de se partager notre code avec git ? Et bien, c'est plus complexe qu'il n'y paraßt. En fait il y a tout un groupe de travail, la Python Packaging Authority (alias pypa), qui bosse dessus depuis 2011.

Dans cette article, on fera un rapide tour d'horizon du packaging en python et on présentera une méthode simple pour packager son code en 2023.

Sommaire​

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 module car python permet d'importer les éléments définis dans les fichiers .py, ce qui évite de devoir mettre tout son code dans un seul gros fichier.

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 :

count_lines.py
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 :

Il existe de nombreux outils pour créer des distributions mais aujourd'hui 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 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 ils sont non mutuellement exclusifs) :

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 :

setup.py
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 :

setup.cfg
[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>=44",
"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 :

setup.py
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 :

pyproject.toml
[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 :

  1. Le dernier tag ayant un numéro de version valide (exemple : v1.2.3)
  2. La distance à ce tag (nombre de révision depuis ce tag)
  3. 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 :

pyproject.toml
[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) :

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) :

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 :

test_file.py
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. Gif montrant les fonctionnalitĂ©s de pipenv
  • 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. Gif montrant les fonctionnalitĂ©s de poetry

Je trouve pour ma part que Poetry est l'outil le plus prometteur au vu de sa capacité à résoudre les conflits entre les dépendances.

TL;DR Packager son code python en 2023​

Mes conseils pour packager votre code en 2023 :

  • 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 :

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, and distribute2 qui ont tous Ă©tĂ© Ă  un moment les "standards" recommandĂ©s pour packager une lib. Ensuite on a eu l'Ă©poque des eggs, exe, et autres trucs que easy_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 pas python -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​

  1. Le Guide de pypa sur le packaging python : An Overview of Packaging for Python
  2. Un article pour comprendre les wheels : What Are Python Wheels and Why Should You Care?
  3. Une série de trois articles trÚs éclairant sur le fonctionnement du packaging python, écrit par Bernåt Gåbor en 2019 :
  4. Un article à la gloire de setup.cfg sur le regretté site Sam & Max : à propos de setup.cfg
  5. Question stackoverflow : What is pyproject.toml file for