Packager son code python

·

14 min read

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

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.

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 :

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>=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.

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 :

```python title="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 :

[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.

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