Empaquetage Python
Guide complet pour créer, structurer et distribuer des packages Python en utilisant les outils d'empaquetage modernes, pyproject.toml et la publication sur PyPI.
Quand utiliser cette compétence
- Créer des bibliothèques Python pour la distribution
- Construire des outils en ligne de commande avec des points d'entrée
- Publier des packages sur PyPI ou dans des dépôts privés
- Configurer la structure d'un projet Python
- Créer des packages installables avec des dépendances
- Construire des wheels et des distributions source
- Gérer la versioning et les releases de packages Python
- Créer des namespace packages
- Implémenter les métadonnées et classifieurs de packages
Concepts clés
1. Structure du package
- Source layout :
src/package_name/(recommandé) - Flat layout :
package_name/(plus simple mais moins flexible) - Métadonnées du package : pyproject.toml, setup.py ou setup.cfg
- Formats de distribution : wheel (.whl) et distribution source (.tar.gz)
2. Standards modernes d'empaquetage
- PEP 517/518 : Exigences du système de build
- PEP 621 : Métadonnées dans pyproject.toml
- PEP 660 : Installations éditables
- pyproject.toml : Source unique de configuration
3. Build backends
- setuptools : Traditionnel, largement utilisé
- hatchling : Moderne, affirmé
- flit : Léger, pour du pur Python
- poetry : Gestion des dépendances + empaquetage
4. Distribution
- PyPI : Python Package Index (public)
- TestPyPI : Test avant la production
- Dépôts privés : JFrog, AWS CodeArtifact, etc.
Démarrage rapide
Structure minimale du package
my-package/
├── pyproject.toml
├── README.md
├── LICENSE
├── src/
│ └── my_package/
│ ├── __init__.py
│ └── module.py
└── tests/
└── test_module.py
pyproject.toml minimal
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "my-package"
version = "0.1.0"
description = "A short description"
authors = [{name = "Your Name", email = "you@example.com"}]
readme = "README.md"
requires-python = ">=3.8"
dependencies = [
"requests>=2.28.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0",
"black>=22.0",
]
Modèles de structure du package
Modèle 1 : Source Layout (Recommandé)
my-package/
├── pyproject.toml
├── README.md
├── LICENSE
├── .gitignore
├── src/
│ └── my_package/
│ ├── __init__.py
│ ├── core.py
│ ├── utils.py
│ └── py.typed # Pour les type hints
├── tests/
│ ├── __init__.py
│ ├── test_core.py
│ └── test_utils.py
└── docs/
└── index.md
Avantages :
- Empêche d'importer accidentellement depuis la source
- Imports de tests plus propres
- Meilleure isolation
pyproject.toml pour source layout :
[tool.setuptools.packages.find]
where = ["src"]
Modèle 2 : Flat Layout
my-package/
├── pyproject.toml
├── README.md
├── my_package/
│ ├── __init__.py
│ └── module.py
└── tests/
└── test_module.py
Plus simple, mais :
- Permet d'importer le package sans l'installer
- Moins professionnel pour les bibliothèques
Modèle 3 : Projet multi-package
project/
├── pyproject.toml
├── packages/
│ ├── package-a/
│ │ └── src/
│ │ └── package_a/
│ └── package-b/
│ └── src/
│ └── package_b/
└── tests/
Exemples complets de pyproject.toml
Modèle 4 : pyproject.toml complet
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "my-awesome-package"
version = "1.0.0"
description = "An awesome Python package"
readme = "README.md"
requires-python = ">=3.8"
license = {text = "MIT"}
authors = [
{name = "Your Name", email = "you@example.com"},
]
maintainers = [
{name = "Maintainer Name", email = "maintainer@example.com"},
]
keywords = ["example", "package", "awesome"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"requests>=2.28.0,<3.0.0",
"click>=8.0.0",
"pydantic>=2.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"black>=23.0.0",
"ruff>=0.1.0",
"mypy>=1.0.0",
]
docs = [
"sphinx>=5.0.0",
"sphinx-rtd-theme>=1.0.0",
]
all = [
"my-awesome-package[dev,docs]",
]
[project.urls]
Homepage = "https://github.com/username/my-awesome-package"
Documentation = "https://my-awesome-package.readthedocs.io"
Repository = "https://github.com/username/my-awesome-package"
"Bug Tracker" = "https://github.com/username/my-awesome-package/issues"
Changelog = "https://github.com/username/my-awesome-package/blob/main/CHANGELOG.md"
[project.scripts]
my-cli = "my_package.cli:main"
awesome-tool = "my_package.tools:run"
[project.entry-points."my_package.plugins"]
plugin1 = "my_package.plugins:plugin1"
[tool.setuptools]
package-dir = {"" = "src"}
zip-safe = false
[tool.setuptools.packages.find]
where = ["src"]
include = ["my_package*"]
exclude = ["tests*"]
[tool.setuptools.package-data]
my_package = ["py.typed", "*.pyi", "data/*.json"]
# Configuration Black
[tool.black]
line-length = 100
target-version = ["py38", "py39", "py310", "py311"]
include = '\.pyi?$'
# Configuration Ruff
[tool.ruff]
line-length = 100
target-version = "py38"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"]
# Configuration MyPy
[tool.mypy]
python_version = "3.8"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
# Configuration Pytest
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "-v --cov=my_package --cov-report=term-missing"
# Configuration Coverage
[tool.coverage.run]
source = ["src"]
omit = ["*/tests/*"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise AssertionError",
"raise NotImplementedError",
]
Modèle 5 : Versioning dynamique
[build-system]
requires = ["setuptools>=61.0", "setuptools-scm>=8.0"]
build-backend = "setuptools.build_meta"
[project]
name = "my-package"
dynamic = ["version"]
description = "Package with dynamic version"
[tool.setuptools.dynamic]
version = {attr = "my_package.__version__"}
# Ou utiliser setuptools-scm pour le versioning basé sur git
[tool.setuptools_scm]
write_to = "src/my_package/_version.py"
Dans init.py :
# src/my_package/__init__.py
__version__ = "1.0.0"
# Ou avec setuptools-scm
from importlib.metadata import version
__version__ = version("my-package")
Modèles d'interface en ligne de commande (CLI)
Modèle 6 : CLI avec Click
# src/my_package/cli.py
import click
@click.group()
@click.version_option()
def cli():
"""My awesome CLI tool."""
pass
@cli.command()
@click.argument("name")
@click.option("--greeting", default="Hello", help="Greeting to use")
def greet(name: str, greeting: str):
"""Greet someone."""
click.echo(f"{greeting}, {name}!")
@cli.command()
@click.option("--count", default=1, help="Number of times to repeat")
def repeat(count: int):
"""Repeat a message."""
for i in range(count):
click.echo(f"Message {i + 1}")
def main():
"""Entry point for CLI."""
cli()
if __name__ == "__main__":
main()
Enregistrer dans pyproject.toml :
[project.scripts]
my-tool = "my_package.cli:main"
Utilisation :
pip install -e .
my-tool greet World
my-tool greet Alice --greeting="Hi"
my-tool repeat --count=3
Modèle 7 : CLI avec argparse
# src/my_package/cli.py
import argparse
import sys
def main():
"""Main CLI entry point."""
parser = argparse.ArgumentParser(
description="My awesome tool",
prog="my-tool"
)
parser.add_argument(
"--version",
action="version",
version="%(prog)s 1.0.0"
)
subparsers = parser.add_subparsers(dest="command", help="Commands")
# Add subcommand
process_parser = subparsers.add_parser("process", help="Process data")
process_parser.add_argument("input_file", help="Input file path")
process_parser.add_argument(
"--output", "-o",
default="output.txt",
help="Output file path"
)
args = parser.parse_args()
if args.command == "process":
process_data(args.input_file, args.output)
else:
parser.print_help()
sys.exit(1)
def process_data(input_file: str, output_file: str):
"""Process data from input to output."""
print(f"Processing {input_file} -> {output_file}")
if __name__ == "__main__":
main()
Building et Publishing
Modèle 8 : Construire le package localement
# Installer les outils de build
pip install build twine
# Construire la distribution
python -m build
# Cela crée :
# dist/
# my-package-1.0.0.tar.gz (source distribution)
# my_package-1.0.0-py3-none-any.whl (wheel)
# Vérifier la distribution
twine check dist/*
Modèle 9 : Publier sur PyPI
# Installer les outils de publication
pip install twine
# Tester d'abord sur TestPyPI
twine upload --repository testpypi dist/*
# Installer depuis TestPyPI pour tester
pip install --index-url https://test.pypi.org/simple/ my-package
# Si tout va bien, publier sur PyPI
twine upload dist/*
Utiliser les API tokens (recommandé) :
# Créer ~/.pypirc
[distutils]
index-servers =
pypi
testpypi
[pypi]
username = __token__
password = pypi-...your-token...
[testpypi]
username = __token__
password = pypi-...your-test-token...
Modèle 10 : Publication automatisée avec GitHub Actions
# .github/workflows/publish.yml
name: Publish to PyPI
on:
release:
types: [created]
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: Install dependencies
run: |
pip install build twine
- name: Build package
run: python -m build
- name: Check package
run: twine check dist/*
- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: twine upload dist/*
Pour les modèles avancés incluant les fichiers de données, les namespace packages, les extensions C, la gestion de version, les tests d'installation, les templates de documentation et les workflows de distribution, voir references/advanced-patterns.md