pyproject.toml, lockfiles et layout src/ — la structure moderne d'un projet
De setup.py à pyproject.toml, builds reproductibles avec uv.lock, __init__.py et layout src/ — et transformer un dossier de scripts à plat en vrai paquet avec une commande.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Le travail de tri des tickets a commencé avec quelques scripts dans un même dossier : clean.py, score.py, run_triage.py. L'auteur se place dans le dossier, lance python run_triage.py, et tout fonctionne. Puis, pendant trois mois :
- Un collègue a rangé un script de rapport dans un sous-dossier
scripts/, et il a échoué avecModuleNotFoundError: No module named 'score'— alors quescore.pyest bien présent dans le dossier parent. - Quelqu'un a créé un fichier
secrets.pydans le dossier pour stocker une clé d'API, et tout à coup le modulesecretsde Python lui-même a « cassé » dans un autre script. - Une nouvelle personne a rejoint l'équipe ; l'ancien
setup.pyet lerequirements.txtlistaient les paquets de deux façons différentes, et personne ne savait laquelle était la bonne.
Le code ne contient aucun bug ; le problème est dans la structure. Dans ce chapitre, nous transformons ce dossier en un vrai paquet Python — un pyproject.toml, un lockfile et un layout src/ — c'est à peu près à quoi ressemble tout dépôt Python moderne dans une entreprise.
À la fin de ce chapitre vous pourrez
- Expliquer pourquoi Python est passé de
setup.pyàpyproject.toml, et lire les sections principales d'unpyproject.toml - Expliquer ce que
uv.lockfige, et utiliseruv lock,uv syncetuv sync --lockedau bon moment - Créer un projet en layout
src/avecuv init --packageet--lib, et expliquer le rôle de__init__.py - Reconnaître les deux pièges d'imports d'un dossier à plat, et montrer pourquoi le layout
src/les détecte tôt - Transformer un dossier de scripts à plat en paquet avec un point d'entrée en ligne de commande
Ce qu'il vous faut d'abord : Git, branches et Pull Requests, ainsi que uv init, uv add et uv run du chapitre précédent.
De setup.py à pyproject.toml
Dans les anciens projets Python, vous croiserez souvent un fichier de ce genre :
from setuptools import find_packages, setup
setup(
name="ticket-triage",
version="0.1.0",
packages=find_packages(),
install_requires=["httpx"],
entry_points={"console_scripts": ["triage=ticket_triage.cli:main"]},
)Et à côté, un requirements.txt, souvent avec une liste différente. Le principal problème de setup.py est que c'est un programme : même pour connaître le nom ou les dépendances du projet, il faut l'exécuter ; et comme il s'agit de Python, n'importe qui peut y mettre n'importe quoi — lire des fichiers, des conditions, même des appels réseau. Les outils ne pouvaient jamais rien affirmer de certain à son sujet.
pyproject.toml contient les mêmes informations, mais sous forme de déclaration — un simple fichier TOML qui est lu, jamais exécuté. C'est désormais le standard Python, et uv, les éditeurs, les outils de test et les linters le lisent tous. Voici ce que produit uv init --package ticket-triage :
[project]
name = "ticket-triage"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Nadia Karim", email = "[email protected]" }
]
requires-python = ">=3.12"
dependencies = []
[project.scripts]
ticket-triage = "ticket_triage:main"
[build-system]
requires = ["uv_build>=0.9.1,<0.10.0"]
build-backend = "uv_build"(uv récupère le nom et l'adresse e-mail de l'auteur dans vos paramètres Git.) Trois sections :
[project]— l'identité du projet et ses dépendances.uv addgère la listedependenciesà votre place.[project.scripts]— les commandes en ligne de commande.ticket-triage = "ticket_triage:main"signifie : créer une commande appeléeticket-triagequi appelle la fonctionmaindu paquetticket_triage.[build-system]— l'outil qui transforme le projet en paquet installable (le « backend de build »).uvutilise son propreuv_build; dans d'autres projets, vous verrez peut-êtrehatchlingousetuptools— le même rôle.
Remarquez que le projet s'appelle ticket-triage (avec un trait d'union), mais que le paquet Python s'appelle ticket_triage (avec un tiret bas). Les traits d'union ne sont pas autorisés dans les noms Python — import ticket-triage serait une soustraction — donc uv fait la conversion pour vous.
Lockfiles : de « ça marche » à « ça marche toujours de la même façon »
Dans pyproject.toml, les dépendances sont écrites sous forme de plages : "httpx>=0.28.1" signifie 0.28.1 ou toute version ultérieure. C'est volontaire — vous dites ce qui fonctionnera. Mais installer aujourd'hui et installer dans trois mois peut donner deux versions différentes, sans parler des dépendances des dépendances.
uv.lock comble cet écart. Il enregistre la version exacte et les empreintes (hashes) de chaque paquet — y compris ceux situés plus bas dans l'arbre. Avec ce fichier dans le dépôt, chaque machine, la CI et le serveur de production installent exactement la même chose. C'est ce qu'on appelle un « build reproductible ».
Quatre commandes, quatre rôles :
uv lock— litpyproject.tomlet crée ou met à jour le lockfile. N'installe rien.uv sync— fait correspondre.venvexactement au lockfile ; met d'abord le lockfile à jour si nécessaire.uv sync --locked— la même chose, mais si le lockfile est périmé, la commande s'arrête au lieu de le mettre à jour.uv lock --check— ne modifie rien ; indique seulement si le lockfile correspond àpyproject.toml.
uv add et uv remove mettent à jour les deux fichiers ensemble, donc dans le travail courant, vous n'appelez pas ces commandes séparément. Les ennuis commencent quand quelqu'un modifie pyproject.toml à la main. Imaginons qu'on remplace dependencies = [] par dependencies = ["httpx>=0.28"] à la main, sans lancer uv lock :
uv sync --locked
Resolved 8 packages in 57ms
The lockfile at `uv.lock` needs to be updated, but `--locked` was provided. To update the lockfile, run `uv lock`.C'est pour cela que la CI lance toujours uv sync --locked : aucune version non relue ne peut s'y glisser discrètement.
Un simpleuv syncne signale aucune erreur dans cette situation. Sans--locked, dans le même cas,uvrecalcule discrètement les versions, réécrituv.locktout seul et poursuit. Sur votre propre machine, c'est pratique ; en CI, cela signifie qu'on teste autre chose que ce qui se trouve dans le dépôt.
Paquets, __init__.py et le layout src/
Un module est un fichier .py. Un paquet est un dossier de modules contenant un __init__.py. __init__.py remplit deux rôles : il marque le dossier comme paquet importable, et c'est le fichier qui s'exécute en premier quand vous écrivez import ticket_triage — c'est donc là que vous pouvez placer ce que vous voulez présenter comme la « porte d'entrée » du paquet.
Trois formes de uv init :
uv init— un projet de script :main.pyetpyproject.toml, sans système de build. Pour les tâches d'un ou deux fichiers et les expérimentations.uv init --package— un paquet d'application : layoutsrc/, un système de build et une commande dans[project.scripts].uv init --lib— une bibliothèque que d'autres importeront : layoutsrc/et un système de build, sans commande, plus un fichierpy.typedvide qui indique aux vérificateurs de types que le paquet fournit des informations de typage.
La structure produite par uv init --package ticket-triage :
ticket-triage/
├── .git/
├── .gitignore
├── .python-version
├── README.md
├── pyproject.toml
└── src/
└── ticket_triage/
└── __init__.pyLe paquet ne se trouve pas à la racine du projet, mais dans un dossier src/. Pour comprendre pourquoi ce niveau supplémentaire existe, regardons d'abord ce qui ne va pas dans un dossier à plat.
Deux pièges dans un dossier à plat
Premier piège : où Python cherche. Quand vous lancez un script, Python cherche d'abord les imports dans le dossier où se trouve le script — et non là où vous vous trouvez dans le terminal. Dans le dossier à plat, from score import priority fonctionne uniquement parce que score.py et le script partagent le même dossier. Le fichier scripts/report.py de votre collègue :
from score import priority
print(priority("Database breach reported"))Placé dans le dossier triage-scripts et lancé avec python scripts/report.py :
Traceback (most recent call last):
File "scripts\report.py", line 1, in <module>
from score import priority
ModuleNotFoundError: No module named 'score'(Sur votre machine, le traceback affiche le chemin complet ; il est raccourci ici.) Python a cherché dans scripts/, et il n'y a pas de score.py là-bas. Qu'un import fonctionne ou non dépend de l'endroit où se trouve le fichier — peu de règles sont aussi fragiles pour un projet.
Deuxième piège : les collisions de noms. À cause de la même règle, les fichiers du dossier du script sont cherchés avant les modules propres à Python. Quelqu'un crée secrets.py dans le dossier pour stocker une clé, et le new_id.py à côté veut le module secrets de la bibliothèque standard :
import secrets
print(len(secrets.token_hex(8)))Traceback (most recent call last):
File "new_id.py", line 3, in <module>
print(len(secrets.token_hex(8)))
^^^^^^^^^^^^^^^^^
AttributeError: module 'secrets' has no attribute 'token_hex'import secrets n'a déclenché aucune erreur — il a trouvé le secrets.py du projet et l'a importé. L'erreur arrive plus tard, quand le code demande quelque chose que ce fichier ne contient pas. Dans les projets d'IA, cela arrive souvent avec des fichiers expérimentaux nommés openai.py, anthropic.py ou httpx.py, qui masquent précisément la bibliothèque que vous essayez d'utiliser.
Comment le layout src/ repère ces erreurs tôt
Deux choses changent dans un layout src/.
D'abord, tout vit sous un nom unique. Les modules ne s'appellent plus clean, score, secrets — ils s'appellent ticket_triage.clean, ticket_triage.score. Aucun fichier du projet ne peut masquer la bibliothèque standard ou un autre paquet, et les imports eux-mêmes montrent quel code vous appartient.
Ensuite, le paquet ne peut pas être importé sans être installé. Essayez depuis la racine du projet avec un simple python :
python -c "import ticket_triage"
Traceback (most recent call last):
File "<string>", line 1, in <module>
ModuleNotFoundError: No module named 'ticket_triage'Cet échec est voulu. Si le paquet se trouvait à la racine du projet, cette commande fonctionnerait — Python trouverait le dossier directement — et vous ne sauriez jamais si la configuration de pyproject.toml est correcte. Dans src/, il n'existe qu'un seul moyen d'atteindre le paquet : l'installer. Et uv run le fait à chaque fois — il installe le projet dans .venv en mode « éditable », ce qui signifie que le paquet installé pointe vers votre dossier src/ ; vous n'avez donc pas à réinstaller après chaque modification du code :
uv run python -c "import ticket_triage; print(ticket_triage.priority('OUTAGE'))"
highAinsi, toute erreur de packaging apparaît sur votre propre machine, dès le premier uv run — et non le jour du déploiement. Par exemple, si le dossier du paquet est mal nommé ou si __init__.py manque :
× Failed to build `ticket-triage @
│ file:///.../ticket-triage`
╰─▶ Expected a Python module at: src\ticket_triage\__init__.py(Le chemin est raccourci.) Le backend de build déduit le nom du paquet du nom du projet — ticket-triage donne src/ticket_triage/ — et s'arrête s'il n'y trouve rien. Si vous voulez un autre nom volontairement, indiquez-le avec module-name sous [tool.uv.build-backend] dans pyproject.toml.
Un exemple complet
Reprenons maintenant le dossier à plat du début du chapitre. Après avoir lancé uv init --package ticket-triage --python 3.12 dans un dossier voisin, puis déplacé les anciens fichiers à l'intérieur du paquet, la structure ressemble à ceci :
ticket-triage/
├── pyproject.toml
├── uv.lock
├── scripts/
│ └── report.py
└── src/
└── ticket_triage/
├── __init__.py
├── clean.py
├── cli.py
└── score.pyUne seule modification dans pyproject.toml — un nom de commande plus court, pointant vers le nouveau module cli :
[project.scripts]
triage = "ticket_triage.cli:main"src/ticket_triage/clean.py — l'ancien clean.py, désormais avec des annotations de type et une docstring :
import re
def clean_text(text: str) -> str:
"""Lowercase the text and collapse every run of whitespace to one space."""
return re.sub(r"\s+", " ", text).strip().lower()src/ticket_triage/score.py — la seule vraie modification est la ligne d'import : from clean import ... devient from ticket_triage.clean import ... :
from ticket_triage.clean import clean_text
URGENT_WORDS = ("outage", "down", "breach")
def priority(text: str) -> str:
words = clean_text(text).split()
return "high" if any(word in URGENT_WORDS for word in words) else "normal"src/ticket_triage/__init__.py — la porte d'entrée du paquet :
from ticket_triage.score import priority
__all__ = ["priority"]scripts/report.py — le script qui a échoué précédemment, qui importe désormais à travers le nom du paquet :
from ticket_triage import priority
print(priority("Database breach reported"))Et src/ticket_triage/cli.py — le point d'entrée en ligne de commande qui remplace l'ancien run_triage.py :
import sys
from ticket_triage.score import priority
DEMO_TICKETS = ["Login page is slow", "Payments DOWN for all users"]
def main() -> None:
tickets = sys.argv[1:] or DEMO_TICKETS
for ticket in tickets:
print(f"{priority(ticket):<6} {ticket}")
if __name__ == "__main__":
main()Lancez depuis la racine du projet :
uv run triage
uv run triage "Service down since morning" "Typo on the pricing page"
uv run scripts/report.py
uv lock --checknormal Login page is slow
high Payments DOWN for all users
high Service down since morning
normal Typo on the pricing page
high
Resolved 1 package in 2msLe tout premier uv run triage affiche quelques lignes avant celles-ci — la création de .venv, puis Building ticket-triage et Built ticket-triage — parce que uv installe le projet pour la première fois ; ensuite, elles n'apparaissent plus. La dernière ligne vient de uv lock --check, et son code de sortie est 0 : le lockfile correspond à pyproject.toml.
Les choix de cette conversion :
Imports absolus (from ticket_triage.clean import ...). Les imports relatifs fonctionnent aussi à l'intérieur d'un paquet — from .clean import clean_text — mais les imports absolus se lisent de la même façon depuis n'importe quel fichier et sont plus faciles à rechercher. Quel que soit votre choix, gardez un seul style par projet.
Seul priority dans __init__.py. Le code extérieur (scripts/report.py, les futurs tests, les autres services) ne connaît que from ticket_triage import priority. Si la répartition entre score.py et clean.py change plus tard, le code extérieur ne casse pas. __all__ écrit explicitement cette liste publique.
Un cli.py séparé. Avec la logique (score.py) et les entrées/sorties (cli.py) séparées, priority peut être appelée depuis n'importe où — un test, une API — sans sys.argv ni print. Quand sys.argv[1:] est vide, les tickets de démonstration s'affichent, si bien que lancer la commande sans argument montre quand même quelque chose.
f"{priority(ticket):<6}". :<6 aligne le texte à gauche sur six caractères, pour que les tickets commencent à la même colonne après high comme après normal.
Une limite mérite d'être connue : clean_text ne découpe les mots que sur les espaces ; donc dans "The site is down.", le mot est down. — avec le point final — et il ne correspond pas à URGENT_WORDS. De meilleures façons d'extraire les mots d'un texte viendront un peu plus loin dans le cours ; ce chapitre porte sur la structure, et comme la structure est bonne, cette amélioration se fera dans clean.py et nulle part ailleurs.
Quand ça casse
ModuleNotFoundError: No module named 'clean' Les fichiers ont été déplacés dans le paquet, mais un fichier contient encore l'ancien import from clean import clean_text. Il n'existe pas de module de premier niveau appelé clean à l'intérieur d'un paquet ; écrivez from ticket_triage.clean import clean_text. Après la conversion, recherchez from clean et from score dans tout le projet.
ModuleNotFoundError: No module named 'ticket_triage' Le script a été lancé avec un simple python au lieu de uv run, donc l'environnement dans lequel le paquet est installé n'a pas été utilisé. Lancez-le avec uv run .... C'est le layout src/ qui fonctionne comme prévu, et non un bug.
Expected a Python module at: src\ticket_triage\__init__.py Le paquet ne se trouve pas dans le dossier que le backend de build attend à partir du nom du projet — le dossier est mal nommé (src/triage/, src/ticket-triage/), ou __init__.py manque. Corrigez le nom du dossier, ajoutez __init__.py, ou indiquez le vrai nom avec module-name.
The lockfile at uv.lock needs to be updated, but --locked was provided. pyproject.toml a été modifié à la main sans lancer uv lock, ou le lockfile n'a pas été commité. Lancez uv lock et committez les deux fichiers ensemble. La prochaine fois, modifiez les dépendances avec uv add et uv remove — ils gardent les deux fichiers en accord.
AttributeError: module 'secrets' has no attribute 'token_hex' Le projet contient un fichier portant le nom d'un module de la bibliothèque standard (secrets.py, random.py, logging.py) qui masque le vrai module. Renommez le fichier — et gardez les secrets dans .env, pas dans un fichier .py.
error: Failed to spawn: triag suivi de Caused by: program not found Le nom donné à uv run diffère de celui de [project.scripts] — ici, une faute de frappe. Vérifiez le nom dans pyproject.toml ; après le renommage, uv run réinstalle le projet tout seul et crée la nouvelle commande.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
Le clean_text du chapitre est appliqué à un ticket désordonné. Qu'est-ce qui s'affiche ?
import re
def clean_text(text: str) -> str:
return re.sub(r"\s+", " ", text).strip().lower()
print(repr(clean_text(" Payments DOWN\tsince 9 ")))- A`' payments down since 9 '`
- B`'payments down since 9'`
- C`'payments down\tsince 9'`
- D`'Payments DOWN since 9'`
Après être passé d'un dossier à plat à un layout src/, uv run triage affiche ModuleNotFoundError: No module named 'clean'. clean.py se trouve juste à côté, à src/ticket_triage/clean.py. Quel est le bug ?
# src/ticket_triage/score.py
from clean import clean_text
URGENT_WORDS = ("outage", "down", "breach")
def priority(text: str) -> str:
words = clean_text(text).split()
return "high" if any(word in URGENT_WORDS for word in words) else "normal"- A`clean.py` doit revenir à la racine du projet, car c'est là que Python cherche
- BL'import est encore celui d'un dossier à plat ; dans le paquet, il doit être `from ticket_triage.clean import clean_text`
- CAvec un `__init__.py` dans `src/ticket_triage/`, les fichiers voisins ne peuvent pas être importés ; il faut le supprimer
- D`uv run triage` ne regarde que `cli.py` ; les imports de `score.py` ne sont jamais exécutés
Dans une PR, quelqu'un a modifié pyproject.toml à la main pour ajouter un paquet. Le uv sync --locked de la CI échoue : The lockfile at uv.lock needs to be updated. Quelle est la bonne correction ?
- ARetirez `--locked` de la CI pour que la CI mette elle-même à jour le lockfile
- BAjoutez `uv.lock` à `.gitignore` pour que chacun construise son propre lockfile
- CLancez `uv lock` en local (ou utilisez `uv add` dès le départ) et committez le `uv.lock` mis à jour dans la même PR
- DSupprimez `uv.lock` : sans lockfile, cette erreur ne peut pas apparaître
Jouer fait partie de Pro
+30 XP vous attendent
Jouez ce chapitre avec Pro
La lecture est gratuite et tout le chapitre est ouvert. Les 5 premiers chapitres de chaque cours gratuit se jouent gratuitement. À partir du chapitre 6, répondre aux questions, gagner des XP et faire grandir votre série fait partie de Pro.
Débloquer le jeu avec ProÀ vous de jouer
Reconstruisez vous-même le dossier à plat du début du chapitre et convertissez-le. Commencez par placer trois fichiers dans un dossier triage-scripts/ : clean.py (la fonction clean_text ci-dessus, sans annotations de type), score.py (commençant par from clean import clean_text), et run_triage.py, qui affiche la priorité de deux tickets de démonstration. Ensuite :
- À côté, créez un nouveau projet avec
uv init --package ticket-triage --python 3.12, déplacez les fichiers danssrc/ticket_triage/, et écrivez chaque import avecticket_triage. - Transformez
run_triage.pyen une fonctionmain()danscli.py, comme dans ce chapitre (avec les mêmes deux tickets de démonstration), et ajouteztriage = "ticket_triage.cli:main"à[project.scripts] - Exposez seulement
prioritydepuis__init__.py, et utilisez-le dansscripts/report.pyavecfrom ticket_triage import priority - Lancez
uv lock --checkpour confirmer que le lockfile correspond, et committezpyproject.toml,uv.lock,src/etscripts/dans un seul commit — sans.venv
Depuis la racine du projet, uv run triage suivi de uv run scripts/report.py doit donner exactement ceci :
normal Login page is slow
high Payments DOWN for all users
highEnsuite, cassez-le volontairement, deux fois. La première fois, remettez l'import de score.py sur from clean import clean_text et observez quelle erreur apparaît, et sur quelle ligne. La seconde fois, créez un secrets.py dans src/ticket_triage/, ajoutez import secrets et print(len(secrets.token_hex(8))) à cli.py, et lancez uv run triage — le piège du dossier à plat fonctionne-t-il encore ici, et pourquoi pas ?
Étape 6 sur 6
Défi — le quiz du chapitre
Dix questions, de la plus facile à la plus difficile. Les dernières sont difficiles exprès.
Jouer avec Pro