Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Vous vous êtes déjà demandé comment des projets comme NumPy ou pandas sont organisés en interne ? Comment des centaines de développeurs arrivent à travailler ensemble sans que tout s’écroule ? La réponse commence par une bonne structure de package.

Dans cette première partie, on va poser les fondations: on va écrire du code, le structurer, comprendre ce qui se passe quand on fait import, et construire notre premier package avec pyproject.toml.

Bref, on va voir comment passer d’un simple script Python à une application ayant une bonne structure.

Dans cette partie, nous allons voir comment se structure une application Python en donnant un exemple d’arborescence. Celle-ci n’est pas figée mais est utilisée dans la plupart des projets en calcul scientifique (numpy, pandas, scikit-learn) et autres.

Une application Python est constituée

  • de fichiers Python avec l’extension .py que l’on appelle des modules,

  • de répertoires contenant des fichiers Python que l’on appelle des packages.

Les modules et les packages peuvent être utilisés dans l’interpréteur Python en utilisant la commande import. Lorsque l’on crée sa propre application, il est important de bien comprendre leur fonctionnement afin de définir les comportements que l’on souhaite pour l’utilisateur final lorsqu’il importe l’ensemble ou une partie du package.

Un module

Afin d’illustrer l’utilisation d’un module en Python, nous allons écrire un calculateur qui sait uniquement faire une addition et une soustraction.

Voici le fichier calculator_mod.py.

%%file calculator_mod.py
"""
Calculator module
"""

def add(a, b):
    """
    return a + b
    """
    return a + b

def sub(a, b):
    """
    return a - b
    """
    return a - b
Overwriting calculator_mod.py

Utilisation d’un module

Il existe différentes manières d’importer un module en utilisant le mot-clef import.

  1. On peut importer un module via son nom.

import calculator_mod
calculator_mod.add(1, 2)
3
  1. On peut importer une partie d’un module.

from calculator_mod import sub
sub(1, 2)
-1
  1. On peut importer un module en modifiant son nom d’appel.

import calculator_mod as calc
calc.add(1, 2)
3
  1. On peut importer l’ensemble du module.

from calculator_mod import *
add(1, 2)
3

import définit explicitement certains attributs du module

  • __dict__ : dictionnaire utilisé par le module pour l’espace de noms des attributs

  • __name__ : nom du module

  • __file__ : fichier du module

  • __doc__ : documentation du module

print('file', calculator_mod.__file__)
print('name', calculator_mod.__name__)
print('doc', calculator_mod.__doc__)
file /Users/loic/Formations/packaging/practical_session/calculator_mod.py
name calculator_mod
doc 
Calculator module

Exécution d’un module

On peut ajouter à la fin d’un module le test suivant:

if __name__ == '__main__':
    print(add(1, 2))

On peut à présent exécuter le module.

%%file calculator_mod.py
"""
Calculator module
"""

def add(a, b):
    """
    return a + b
    """
    return a + b

def sub(a, b):
    """
    return a - b
    """
    return a - b

if __name__ == '__main__':
    print(add(1, 2))
Overwriting calculator_mod.py
! python calculator_mod.py
3

Maintenant qu’on sait créer et utiliser un module simple, passons à l’étape suivante. Dans un vrai projet, vous n’allez pas mettre tout votre code dans un seul fichier — ça deviendrait vite illisible. C’est là que les packages entrent en jeu : ils permettent d’organiser votre code en plusieurs modules, comme on range des dossiers dans un classeur.

Un package

Comme dit en introduction, un package est un ensemble de modules Python. Prenons l’arborescence suivante

! tree examples/simple_calculator/
examples/simple_calculator/
├── calculator
│   ├── __init__.py
│   └── operator
│       ├── __init__.py
│       ├── add.py
│       └── sub.py
└── pyproject.toml

3 directories, 5 files

On trouve ici un package appelé calculator et un sous-package appelé operator dans lequel se trouvent deux modules (add.py et sub.py).

Le fichier __init__.py

Prenons l’exemple du fichier calculator/operator/__init__.py

__all__ = ['add', 'sub']

De cette manière, on peut importer add et sub en faisant tout simplement

import sys
sys.path.append("./examples/simple_calculator/")
from calculator.operator import *

On accède ensuite aux attributs et aux fonctions en faisant

print(add.add(1, 2))
print(sub.sub(1, 2))

Il est également important d’utiliser l’import relatif lorsque l’on utilise des fonctionnalités de notre application dans les différents modules.

Prenons l’example du fichier calculator/__init__.py

from . import operator
from .operator import *
from .operator.add import add

On a alors le comportement suivant

import calculator
calculator.add(1, 2)
calculator.sub.sub(2,3)
Solution to Exercise 1
  1. non

  2. oui

  3. non

  4. oui

Vous avez maintenant une bonne idée de ce qu’est un package et de comment import se comporte. Mais il y a une question sous-jacente qu’on n’a pas encore posée : comment Python sait-il où trouver les modules ?

Recherche de modules et de packages

Pour que Python importe correctement un module, celui-ci doit être dans son PATH. Le module sys permet de connaître la liste des répertoires où Python va rechercher les modules.

import sys
print(sys.path)

Python va donc rechercher dans

  • le répertoire courant

  • dans PYTHONPATH si défini (c’est la même syntaxe que le PATH)

  • dans un répertoire par défaut

On peut également rajouter des répertoires à l’exécution étant donné que sys.path n’est qu’une liste.

sys.path.append("/home/loic/Formations/")
print(sys.path)

Lorsque l’on veut importer foo, voici l’ordre des fichiers recherchés dans sys.path.

  • foo.dll, foo.dylib ou foo.so

  • foo.py

  • foo.pyc

  • foo/__init__.py

À ce stade, vous comprenez ce qu’est un module, un package, et comment Python résout les imports. Mais pour l’instant, votre code n’est utilisable que par vous, sur votre machine. Le vrai enjeu, c’est de le rendre distribuable : que n’importe qui puisse l’installer avec un simple pip install. C’est ce qu’on attaque maintenant.

Réaliser sa première distribution

Le contenu à diffuser peut être de différents types

  • des modules et des sous packages

  • des données

  • des scripts

  • des dépendances

Il est nécessaire d’ajouter un ensemble de fichiers pour pouvoir faire le packaging.

Les indispensables

  • pyproject.toml

  • README.rst ou README.md

  • LICENSE.txt

  • votre package

Les optionnels

  • MANIFEST.in

Voici une arborescence classique d’un package Python

package/
    doc/
    examples/
    package/
        ...
        tests/
    tests/
    LICENSE.txt
    README.rst
    pyproject.toml

Premier exemple de pyproject.toml

Ce fichier est l’élément central de votre application. Il va non seulement décrire la façon de construire le package, de le définir ainsi que ses dépendances à l’installation mais il va également être utilisé pour la configuration de tous les outils qui vont vérifier la qualité de l’application (black, ruff, pytest, ...).

[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

[project]
name = "calculator"
dynamic = ["version"]
requires-python = ">=3.8"

[tool.setuptools]
packages = ["calculator"]

[tool.setuptools.dynamic]
version = { attr = "calculator.version.__version__" }
  • build-system indique les outils pour construire le package ainsi que le backend utilisé. Nous utiliserons tout au long de cet atelier setuptools mais il en existe bien d’autres[1] (Hatch, PDM, Flit, Whey, ...).

  • project définit les caractéristiques de votre package (son nom, sa version, ses dépendances, sa description, ses auteurs, ...).

Pour installer votre package, il suffit de faire

  1. pip install . pour installer le package dans le répertoire site-packages où se trouve votre version de Python.

  2. pip install . --user pour l’installer dans votre $HOME.

  3. pip install -e . pour installer votre application en mode développement. Chaque fois que vous faites une modification, le package est automatiquement mis à jour (ce n’est en fait qu’un lien symbolique vers votre package).

Gérer les dépendances

Les dépendances sont gérées à travers le fichier pyproject.toml.

pixi est en mesure de mettre à jour ce fichier lorsque vous utilisez la ligne de commande.

Supposons par exemple que votre package a une dépendance à NumPy pour que celui-ci fonctionne. Il vous suffit de faire

pixi add numpy

et vous verrez dans le fichier pyproject.toml

[tool.pixi.dependencies]
numpy = ">=2.0.0,<2.1"

Vous pouvez également l’ajouter à la main de la manière suivante

[project]
...
dependencies = [
  "numpy",
]

De la même manière que ce qu’on a vu pour la définition des environnements de développement, il est possible de spécifier les versions que l’on souhaite.

[project]
...
dependencies = [
  "numpy>=1.0,<1.13",
]

Il est également possible d’ajouter des dépendances optionnelles comme par exemple

[project.optional-dependencies]
test = ["pytest"]
doc = ["sphinx", "nbsphinx"]

On peut ensuite sélectionner ces options lors de l’installation

pip install .[test]
pip install .[test,doc]

Mais on préférera passer par pixi en faisant l’équivalent

pixi add -f test pytest
pixi add -f doc sphinx nbsphinx

ce qui ajoutera les sections suivantes dans le fichier pyproject.toml

[tool.pixi.feature.test.dependencies]
pytest = ">=7.2.0,<7.3"

[tool.pixi.feature.doc.dependencies]
sphinx = ">=7.3.7,<7.4"
nbsphinx = ">=0.9.4,<0.10"

Il est ensuite possible de créer des environnements spécifiques à partir des features à l’aide de la commmande

pixi project environment add test -f test
pixi project environment add doc -f doc

Ce qui aura comme conséquence d’ajouter les lignes suivantes dans le fichier pyproject.toml

[tool.pixi.environments]
test = ["test"]
doc = ["doc"]

Pour utiliser l’environnement, il suffit de le spécifier via l’option -e comme par exemple

pixi run -e test pytest

Nous reverrons les environnements dans les prochains chapitres lorsque nous parlerons de la documentation et des tests.

Ajout de scripts

En plus des sous packages et modules Python, votre application peut avoir des scripts que l’on peut vouloir exécuter en ligne de commande. Ce sont là encore des fichier Python. Ils sont spécifiés dans le fichier pyproject.toml.

[project.scripts]
calculator-script = "calculator.command_line:main"

calculator-script est le nom de l’exécutable.

Ajouter une extension

Un package ne contient pas forcément uniquement des fichiers Python. On peut par exemple avoir des extensions écrites en cython. Ce n’est pas si simple de les prendre en compte avec le fichier pyproject.toml. Si vous souhaitez faire quelque chose de propre, il faut regarder du coté de NumPy ou de pandas. Ils utilisent tous les deux le backend meson pour construire leurs extensions Cython.

Afin de ne pas complexifier l’atelier, nous utiliserons l’ancienne méthode qui se conjugue avec le fichier pyproject.toml. Pour ce faire, nous allons créer le fichier setup.py avec le contenu suivant

from setuptools import setup, find_packages
from setuptools.extension import Extension
from Cython.Build import cythonize

extension = [Extension(name = "calculator.cython_mod",
                       sources = ["calculator/cython_mod.pyx"])
            ]

setup(
    ...
    ext_modules=cythonize(extension),
    ...
)

Il faut également ajouter cython dans les dépendances du build-system.

[build-system]
requires = ["setuptools", "cython"]
build-backend = "setuptools.build_meta"

Exercices

Etape 1

Dans le répertoire step0, vous avez l’arborescence suivante

! tree TPs/1.packaging/step0/

Etape 2

Etape 3

Ce qu’on a accompli

Cette première partie était dense, mais vous avez posé des bases solides. Récapitulons :

  1. Module vs package : un module, c’est un fichier .py ; un package, c’est un dossier avec un __init__.py qui contient plusieurs modules.

  2. Import : vous savez maintenant contrôler finement ce que vos utilisateurs peuvent importer, grâce aux imports relatifs et à la variable __all__.

  3. pyproject.toml : vous avez construit votre premier package avec le standard moderne de packaging Python — plus besoin de setup.py !

  4. Dépendances et scripts : vous savez gérer les dépendances (obligatoires ou optionnelles) avec pixi, et déclarer des points d’entrée en ligne de commande.

Vous avez maintenant une application Python structurée et installable. Dans la partie suivante, on va s’assurer que le code est propre et bien formaté avec des outils comme ruff et pylint.

Footnotes