Empaqueter et publier un package Python sur PyPI
1. Aperçu
Vous avez écrit un module Python utile et voulez que d’autres puissent l’installer avec pip install. Pour cela, vous devez empaqueter votre code en une distribution et la publier sur PyPI (Python Package Index).
Cet article vous guide à travers l’ensemble du processus. Vous construirez une structure de projet standard, déclarerez les métadonnées dans pyproject.toml, générerez une wheel et un sdist, testerez sur TestPyPI, puis publierez réellement. La fin de l’article couvre l’automatisation de la publication avec GitHub Actions et Trusted Publishing.
2. Structure du projet
L’exemple utilisé tout au long est une petite bibliothèque nonla-order-tools, conçue pour calculer le total des commandes. Nous utilisons la disposition src :
nonla-order-tools/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│ └── nonla_order_tools/
│ ├── __init__.py
│ ├── pricing.py
│ └── cli.py
└── tests/
└── test_pricing.py
Le code se trouve dans src/ au lieu de la racine du répertoire. L’avantage principal est que lors de l’exécution des tests, Python ne peut pas accidentellement importer le code du répertoire de travail. Il est obligé d’importer la version installée, ce qui vous permet de détecter rapidement les erreurs d’oubli de fichiers lors de l’empaquetage.
Vous devez distinguer deux noms :
- Le nom de distribution sur PyPI :
nonla-order-tools. C’est le nom que les utilisateurs tapent avecpip install. - Le nom du package à importer :
nonla_order_tools. Utilisé dans les instructionsimport, sans tirets.
2.1. Code de la bibliothèque
# src/nonla_order_tools/pricing.py
from dataclasses import dataclass, field
from decimal import Decimal
@dataclass(frozen=True)
class OrderLine:
product: str
unit_price: Decimal
quantity: int
@dataclass
class Order:
customer: str
lines: list[OrderLine] = field(default_factory=list)
def add(self, product: str, unit_price: str, quantity: int = 1) -> None:
if quantity <= 0:
raise ValueError("quantity must be positive")
self.lines.append(OrderLine(product, Decimal(unit_price), quantity))
def calculate_total(order: Order, vat_rate: Decimal = Decimal("0.10")) -> Decimal:
subtotal = sum((l.unit_price * l.quantity for l in order.lines), Decimal("0"))
return (subtotal * (1 + vat_rate)).quantize(Decimal("1"))
Order contient une liste de OrderLine. calculate_total additionne le montant de chaque ligne, applique la TVA (par défaut 10%) puis arrondit à l’unité monétaire. Le code utilise Decimal au lieu de float pour éviter les erreurs d’arrondi lors des calculs monétaires.
# src/nonla_order_tools/__init__.py
from importlib.metadata import version
from .pricing import Order, OrderLine, calculate_total
__all__ = ["Order", "OrderLine", "calculate_total"]
__version__ = version("nonla-order-tools")
__version__ est lu à partir des métadonnées du package installé. De cette façon, la version n’est déclarée qu’à un seul endroit : pyproject.toml.
Ajoutons une petite commande de ligne :
# src/nonla_order_tools/cli.py
import sys
from decimal import Decimal
from .pricing import Order, calculate_total
def main() -> None:
# Usage: order-total <prix> <quantité> [<prix> <quantité> ...]
args = sys.argv[1:]
order = Order(customer="cli")
for price, qty in zip(args[::2], args[1::2]):
order.add("item", price, int(qty))
print(calculate_total(order, Decimal("0.10")))
3. Déclaration des métadonnées dans pyproject.toml
pyproject.toml est le seul endroit qui décrit comment construire et les informations du package. Vous n’avez pas besoin de setup.py pour un nouveau projet.
[build-system]
requires = ["hatchling>=1.27"]
build-backend = "hatchling.build"
[project]
name = "nonla-order-tools"
version = "0.1.0"
description = "Calculer le total des commandes avec TVA"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Nón Lá Dev", email = "dev@nonla.example" }]
keywords = ["order", "pricing", "vat"]
classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
]
dependencies = []
[project.optional-dependencies]
test = ["pytest>=8"]
[project.scripts]
order-total = "nonla_order_tools.cli:main"
Explication de chaque partie :
[build-system]indique àpipetbuildquel build backend utiliser. Ici, c’esthatchling. Vous pouvez aussi utilisersetuptools,flit-coreoupdm-backend. Les métadonnées dans[project]restent inchangées.nameetversionsont obligatoires. La version doit suivre le formatMAJOR.MINOR.PATCH.readmedeviendra la page de description sur PyPI.requires-pythonempêche l’installation sur les versions de Python non supportées.license = "MIT"est une expression SPDX selon le nouveau standard. Ce standard nécessitehatchling>=1.27, d’où la version minimale spécifiée ci-dessus.dependenciesénumère les bibliothèques obligatoires à l’exécution, par exemple["httpx>=0.27"].optional-dependenciescrée des “extras”. Les utilisateurs peuvent installer avecpip install "nonla-order-tools[test]".[project.scripts]crée une commandeorder-totaldans lePATHlors de l’installation du package. Cette commande appelle la fonctionmaindu modulecli.
Hatchling trouve automatiquement src/nonla_order_tools car le nom du répertoire correspond au nom du projet normalisé (tirets deviennent underscores). Si les noms diffèrent, vous devez le déclarer manuellement :
[tool.hatch.build.targets.wheel]
packages = ["src/nonla_order_tools"]
4. Écrire des tests et installer en mode editable
Avant de publier, assurez-vous que le comportement principal est correct :
# tests/test_pricing.py
from decimal import Decimal
import pytest
from nonla_order_tools import Order, calculate_total
def test_total_includes_vat():
order = Order(customer="Lan")
order.add("Café", "25000", 2)
order.add("Baguette", "20000")
assert calculate_total(order) == Decimal("77000")
def test_rejects_non_positive_quantity():
order = Order(customer="Minh")
with pytest.raises(ValueError, match="positive"):
order.add("Thé", "15000", 0)
Le premier test calcule (25000 × 2 + 20000) × 1.1 = 77000. Le deuxième test vérifie qu’une quantité égale à 0 est rejetée.
Installez le projet en mode editable puis exécutez les tests :
python -m venv .venv
source .venv/bin/activate
pip install -e ".[test]"
pytest -q
.. [100%]
2 passed in 0.02s
L’option -e lie le package installé au répertoire src/. Si vous modifiez le code, l’effet est immédiat, sans avoir besoin de réinstaller.
5. Construire une wheel et un sdist
Installez l’outil build puis exécutez-le dans le répertoire racine :
pip install --upgrade build twine
python -m build
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
- hatchling>=1.27
* Building sdist...
* Building wheel from sdist
Successfully built nonla_order_tools-0.1.0.tar.gz and nonla_order_tools-0.1.0-py3-none-any.whl
Le répertoire dist/ contient désormais deux fichiers :
- sdist (
.tar.gz) : code source avec métadonnées. Utilisé lorsqu’aucune wheel compatible n’existe. - wheel (
.whl) : version déjà construite. Le suffixepy3-none-anysignifie qu’elle fonctionne sur n’importe quel système d’exploitation avec Python 3, car le package est du Python pur.
Vérifiez que la wheel contient tous les fichiers :
python -m zipfile -l dist/nonla_order_tools-0.1.0-py3-none-any.whl
File Name Modified Size
nonla_order_tools/__init__.py 2020-02-02 00:00:00 209
nonla_order_tools/cli.py 2020-02-02 00:00:00 363
nonla_order_tools/pricing.py 2020-02-02 00:00:00 786
nonla_order_tools-0.1.0.dist-info/METADATA 2020-02-02 00:00:00 1012
nonla_order_tools-0.1.0.dist-info/WHEEL 2020-02-02 00:00:00 87
nonla_order_tools-0.1.0.dist-info/entry_points.txt 2020-02-02 00:00:00 58
...
Les dates et heures sont fixes intentionnellement : hatchling crée des builds reproductibles. La taille des fichiers sur votre machine peut varier légèrement.
Ensuite, utilisez twine pour vérifier les métadonnées, en particulier que le README s’affiche correctement :
twine check dist/*
Checking dist/nonla_order_tools-0.1.0-py3-none-any.whl: PASSED
Checking dist/nonla_order_tools-0.1.0.tar.gz: PASSED
L’étape finale avant de télécharger consiste à installer la wheel dans un environnement propre et à essayer d’importer :
python -m venv /tmp/verify && /tmp/verify/bin/pip install dist/*.whl
/tmp/verify/bin/python -c "import nonla_order_tools as n; print(n.__version__)"
/tmp/verify/bin/order-total 25000 2 20000 1
0.1.0
77000
Si cette étape échoue avec ModuleNotFoundError, vous avez probablement oublié d’empaqueter un fichier ou déclaré un chemin de package incorrect.
6. Tester d’abord sur TestPyPI
TestPyPI est une copie indépendante de PyPI, réservée aux tests. Les deux ont des comptes et des tokens séparés.
- Créez un compte sur TestPyPI et activez l’authentification à deux facteurs (2FA).
- Allez dans Account settings → API tokens et créez un token. Le token commence par
pypi-. - Téléchargez :
twine upload --repository testpypi dist/*
Quand on vous le demande, entrez le nom d’utilisateur __token__ et le mot de passe comme chaîne de token. Pour éviter de le retaper, vous pouvez le sauvegarder dans ~/.pypirc :
[testpypi]
username = __token__
password = pypi-AgENdGVzdC5weXBpLm9yZwIk...
[pypi]
username = __token__
password = pypi-AgEIcHlwaS5vcmcCJ...
Définissez les permissions chmod 600 ~/.pypirc pour que les autres utilisateurs de la machine ne puissent pas lire le fichier. Ensuite, installez pour tester à partir de TestPyPI :
pip install \
--index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
nonla-order-tools
L’option --extra-index-url permet à pip de récupérer les dépendances depuis PyPI réel, car TestPyPI n’a généralement pas assez de packages de dépendances.
7. Publier sur PyPI
Une fois que tout est prêt, le processus sur PyPI réel est identique, sauf qu’on omet --repository :
twine upload dist/*
Uploading distributions to https://upload.pypi.org/legacy/
Uploading nonla_order_tools-0.1.0-py3-none-any.whl
Uploading nonla_order_tools-0.1.0.tar.gz
View at:
https://pypi.org/project/nonla-order-tools/0.1.0/
À partir de maintenant, n’importe qui peut exécuter pip install nonla-order-tools.
Pour les futures versions, suivez ces étapes :
- Augmentez
versiondanspyproject.toml, par exemple à0.1.1. - Supprimez l’ancien répertoire
dist/:rm -rf dist. - Relancez
python -m build,twine check, puistwine upload.
Attention : PyPI ne permet pas de re-télécharger un fichier avec un nom de version identique déjà existant, même si vous avez supprimé cette version. Si vous publiez accidentellement une version bugguée, vous ne pouvez que la yank et publier une nouvelle version. C’est pourquoi il faut toujours tester d’abord sur TestPyPI.
8. Automatiser la publication avec Trusted Publishing
Stocker un token de longue durée dans la CI présente des risques de fuite. Trusted Publishing élimine complètement le token. PyPI fait confiance à un workflow GitHub Actions spécifique via OIDC et émet un token de courte durée à chaque exécution.
Configuration sur PyPI : allez dans Publishing de votre projet, ou utilisez “pending publisher” si le projet n’existe pas encore. Ici, déclarez le propriétaire, le nom du référentiel, le nom du fichier workflow publish.yml et l’environnement pypi.
# .github/workflows/publish.yml
name: Publish to PyPI
on:
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install build
- run: python -m build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
publish:
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
Voici comment fonctionne le workflow :
- Le workflow s’exécute chaque fois que vous créez une GitHub Release.
- Le job
buildgénère les fichiers dansdist/et les sauvegarde en tant qu’artifact. - Le job
publishest isolé et ne dispose que de la permissionid-token: write. Cette permission permet de demander un token OIDC, et l’actionpypa/gh-action-pypi-publishutilise ce token pour télécharger. Vous n’avez besoin d’aucun secret. - La séparation des deux jobs garantit que l’étape de build, qui exécute votre code et les dépendances de build, ne peut jamais accéder aux permissions de publication.
9. Erreurs courantes
- Nom déjà pris : PyPI signale
403ouThe name is too similar to an existing project. Vérifiez le nom à l’avance et ajoutez un préfixe unique commenonla-. File already exists: vous avez oublié d’augmenter la version, ou le répertoiredist/contient des fichiers anciens. Supprimez toujoursdist/avant de construire.- Fichiers de données manquants (JSON, templates…) : les fichiers qui ne sont pas dans le répertoire du package ne sont pas empaquetés. Placez-les dans
src/nonla_order_tools/et lisez-les avecimportlib.resources. - Les tests passent localement mais l’import échoue après installation : c’est exactement l’erreur que la disposition src vous aide à éviter. Testez toujours l’installation de la wheel dans un venv propre comme à la section 5.
- Dépendances déclarées trop strictement : une bibliothèque ne doit pas épingler les versions comme
requests==2.32.3, car cela causera des conflits avec les projets des utilisateurs. Spécifiez seulement une limite inférieure, commerequests>=2.31. L’épinglage strict convient uniquement aux applications, et doit se faire via un fichier de verrouillage. - Token exposé : ne commitez jamais
~/.pypircou les tokens dans le référentiel. Utilisez des tokens limités par projet, ou mieux encore, Trusted Publishing.
Conseil : Pas tout le code mérite d’être sur PyPI. Les bibliothèques internes de l’entreprise doivent utiliser un index privé (comme Artifactory ou GitLab Package Registry) ou s’installer directement depuis Git. PyPI est un espace public, et les noms occupés sont très difficiles à récupérer.
10. Conclusion
Le processus moderne d’empaquetage Python est assez simple. Tout tourne autour d’un seul fichier pyproject.toml, d’un build backend comme hatchling, de la commande python -m build pour générer la wheel et le sdist, et de twine pour vérifier et télécharger.
Habituez-vous à suivre cet ordre : exécutez les tests, construisez, installez dans un venv propre, testez sur TestPyPI, puis publiez sur PyPI. Une fois que le projet est stable, basculez vers Trusted Publishing avec GitHub Actions. À partir de là, chaque publication devient simplement la création d’une Release, et vous n’avez plus à gérer les tokens.