Packaging (uv)
TL;DR —
uv(par Astral, les auteurs de Ruff) est un gestionnaire de projet et de paquets Python écrit en Rust qui remplace d'un couppip,pip-tools,virtualenv,pyenv,pipxet une bonne partie de Poetry — le tout 10 à 100× plus vite. Tu déclares ton projet dans unpyproject.tomlstandard (PEP 621),uvrésout les dépendances dans unuv.lockuniversel et reproductible, gère le venv.venv/automatiquement, installe la bonne version de Python pour toi, etuv runexécute n'importe quelle commande dans cet environnement synchronisé. En venant de Node, pensepyproject.toml=package.json,uv.lock=package-lock.json,uv add=npm install,uv run=npm run— sauf que c'est plus rapide et que ça gère aussi le runtime lui-même.
🧠 Mental model
Quand tu venais de Node/NestJS, tout l'outillage était fusionné : npm/pnpm gère les dépendances et le lockfile et les scripts, et nvm gère les versions de Node à côté. Côté Python historique, c'était l'inverse : un outil par responsabilité, mal intégrés entre eux.
Le monde Python "à la main" (l'ancien enfer)
─────────────────────────────────────────────
pyenv → installe les versions de Python
python -m venv → crée l'environnement isolé
pip → installe des paquets (sans résolution déterministe)
pip-tools → compile requirements.in → requirements.txt (pin)
pipx → installe des outils CLI globaux isolés
build/twine → construit et publie sur PyPI
uv : un seul binaire Rust pour tout ça
───────────────────────────────────────
┌─────────────────────────────┐
pyproject.toml│ uv : résolveur + installeur │ uv.lock
(ce que je veux)│ + venv + python │(ce qui est figé)
└─────────────────────────────┘
↑ tu édites ↓ uv génère/applique
uv add/remove uv sync / uv runL'analogie la plus juste : uv est à Python ce que pnpm + nvm réunis sont à Node, plus la partie publication. Tu décris l'intention dans pyproject.toml, uv produit un lockfile reproductible, et uv run garantit que tu exécutes toujours dans l'environnement exact décrit par ce lock. Tu ne fais plus jamais source .venv/bin/activate à la main : l'activation est implicite.
Distinction clé à garder en tête, parce qu'elle pilote toutes tes commandes :
pyproject.toml = contrat de dépendances (ranges, "anthropic>=0.40")
uv.lock = état figé et résolu (anthropic==0.69.0 + hash + sous-deps)
.venv/ = matérialisation locale du lock (jetable, jamais commité)Bootstrapping : de zéro à un projet qui tourne
Installer uv lui-même
uv est un binaire unique, sans dépendance à un Python existant (c'est tout l'intérêt : il peut installer Python).
# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# ou via Homebrew
brew install uv
# vérifier
uv --versionCréer un projet
# Crée pyproject.toml, README, .python-version, .gitignore, dossier src/
uv init my-agent --package
cd my-agentLe flag --package (vs le mode "application" par défaut) crée un layout src/ installable, ce que tu veux pour quasiment tout sauf un script jetable. Tu obtiens :
my-agent/
├── pyproject.toml
├── README.md
├── .python-version # ex: "3.12" — pinne le runtime
├── uv.lock # apparaît au premier add/sync
└── src/
└── my_agent/
└── __init__.pyLe pyproject.toml généré, en PEP 621 pur :
[project]
name = "my-agent"
version = "0.1.0"
description = "An LLM agent service"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"Ajouter des dépendances
# Dépendances runtime → écrites dans [project.dependencies]
uv add "fastapi[standard]" anthropic httpx
# Dépendances de dev → écrites dans un groupe dédié, PAS livrées
uv add --dev pytest pytest-asyncio ruff mypy
# Épingler une borne
uv add "pydantic>=2.7,<3"Chaque uv add fait trois choses atomiquement : édite pyproject.toml, met à jour uv.lock, et synchronise .venv/. C'est l'équivalent exact d'un npm install --save. Le résultat :
[project]
dependencies = [
"anthropic>=0.69.0",
"fastapi[standard]>=0.115.0",
"httpx>=0.28.0",
"pydantic>=2.7,<3",
]
[dependency-groups] # PEP 735 — standard, pas un truc maison uv
dev = [
"mypy>=1.13.0",
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"ruff>=0.8.0",
]Note senior :
[dependency-groups](PEP 735) est le standard moderne pour les deps non publiées (dev, test, docs, lint). Ne confonds pas avec[project.optional-dependencies](lesextras, ex.mypackage[redis]), qui eux sont publiés sur PyPI et destinés à l'utilisateur final de ton paquet.
Exécuter quelque chose
C'est ici que uv brille et change tes habitudes. Tu ne lances jamais python nu.
# uv run garantit que .venv est synchronisé avec uv.lock, PUIS exécute
uv run python -m my_agent
uv run pytest
uv run uvicorn my_agent.api:app --reload
# Lancer un outil one-shot SANS l'installer dans le projet (= npx / pipx run)
uvx ruff check .
uv tool run ruff check . # forme longue équivalenteAvant chaque uv run, uv revérifie que .venv/ correspond au lock (en quelques millisecondes grâce à son cache) et le met à jour si besoin. Conséquence pratique : il est impossible d'avoir un venv "qui a dérivé". C'est la fin du grand classique "ça marche chez moi" causé par un pip install oublié.
La façon idiomatique vs la façon piège
❌ La mauvaise façon (réflexes hérités de pip/venv)
# Anti-pattern 1 : activer le venv et pip install à la main
source .venv/bin/activate
pip install anthropic # → installé dans le venv mais ABSENT de pyproject/lock
python script.py # marche chez toi, casse en CI et chez le collègue
# Anti-pattern 2 : freeze pour "verrouiller"
pip freeze > requirements.txt # capture l'état du venv, pas l'intention,
# à plat sans hash ni résolution cross-plateforme
# Anti-pattern 3 : commiter le .venv
git add .venv # jamais. C'est du build, pas de la source.Le problème de fond : pip install muté l'environnement sans toucher au contrat (pyproject.toml) ni au lock. L'environnement devient une source de vérité fantôme que personne d'autre ne peut reconstituer.
✅ La façon idiomatique
uv add anthropic # contrat + lock + venv, en une transaction
uv run python script.py # exécution toujours synchronisée
uv sync # reconstruit .venv EXACTEMENT depuis uv.lock
uv sync --frozen # idem mais ÉCHOUE si le lock est obsolète (→ CI)La règle mentale : pyproject.toml et uv.lock sont la vérité ; .venv/ est jetable. Si tu doutes de ton environnement, rm -rf .venv && uv sync le reconstruit à l'identique en quelques secondes.
uv sync vs uv lock vs uv run — la nuance qui sépare junior et senior
uv lock → résout les deps et (ré)écrit uv.lock. NE touche PAS au venv.
uv sync → applique uv.lock au .venv (installe/désinstalle pour matcher).
uv run → uv sync implicite + exécute la commande.
uv add → édite pyproject + uv lock + uv sync, en un coup.Les flags qui comptent en pratique :
| Flag | Effet | Quand |
|---|---|---|
uv sync --frozen | Échoue si uv.lock ne reflète pas pyproject.toml | CI : détecte un lock pas commité |
uv sync --locked | Vérifie que le lock est à jour sans le modifier | CI stricte |
uv sync --no-dev | Exclut les [dependency-groups] dev | Image Docker de prod |
uv lock --upgrade | Re-résout tout vers les dernières versions compatibles | Mise à jour volontaire |
uv lock --upgrade-package anthropic | Bump une seule dépendance | Mise à jour ciblée |
Le lockfile universel : la vraie killer feature
Le uv.lock n'est pas un pip freeze glorifié. C'est une résolution multi-plateformes et multi-versions de Python dans un seul fichier. Le même lock décrit ce qu'il faut installer sur macOS arm64 / Python 3.12 et sur Linux x86_64 / Python 3.13. C'est ce qui rend ton image Docker bit-pour-bit identique à ta machine.
# extrait simplifié de uv.lock — généré, JAMAIS édité à la main
[[package]]
name = "anthropic"
version = "0.69.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "pydantic" },
# ...
]
wheels = [
{ url = "https://files.pythonhosted.org/.../anthropic-0.69.0-py3-none-any.whl",
hash = "sha256:abc123..." },
]Les hashes (sha256) sont vérifiés à l'installation : un paquet altéré sur le miroir est rejeté. C'est ta défense de base contre les attaques de supply-chain. Commit uv.lock pour une application (tu veux la reproductibilité), ne le commit pas pour une bibliothèque publiée (tu veux laisser tes consommateurs résoudre eux-mêmes).
Cas concret : servir un agent LLM avec FastAPI + Anthropic
Mets tout ça en pratique sur le projet qui t'intéresse — un service FastAPI qui expose un agent Claude en streaming. Voilà le pyproject.toml cible :
[project]
name = "my-agent"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"anthropic>=0.69.0",
"fastapi[standard]>=0.115.0",
"pydantic>=2.7",
"pydantic-settings>=2.6",
]
[dependency-groups]
dev = ["pytest>=8.3", "pytest-asyncio>=0.24", "mypy>=1.13", "ruff>=0.8"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.uv]
# Empêche uv d'installer le projet lui-même comme un paquet
# si tu fais juste un service (pas une lib publiée) :
# package = false # ← décommente pour un service pur
[tool.ruff]
line-length = 100
[tool.mypy]
strict = trueLe code du service, entièrement typé (Python 3.12, Pydantic v2, async, DI FastAPI) :
# src/my_agent/settings.py
from functools import lru_cache
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="AGENT_")
anthropic_api_key: SecretStr
model: str = "claude-sonnet-4-6"
max_tokens: int = 2048
@lru_cache
def get_settings() -> Settings:
return Settings() # type: ignore[call-arg] # rempli par l'env# src/my_agent/api.py
from collections.abc import AsyncIterator
from typing import Annotated
from anthropic import AsyncAnthropic
from fastapi import Depends, FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from my_agent.settings import Settings, get_settings
app = FastAPI(title="my-agent")
# DI : un seul client async réutilisé (pool de connexions httpx sous le capot)
def get_client(
settings: Annotated[Settings, Depends(get_settings)],
) -> AsyncAnthropic:
return AsyncAnthropic(api_key=settings.anthropic_api_key.get_secret_value())
class ChatRequest(BaseModel):
prompt: str = Field(min_length=1, max_length=8_000)
async def stream_tokens(
client: AsyncAnthropic, settings: Settings, prompt: str
) -> AsyncIterator[str]:
# messages.stream → context manager async, gère les events SSE proprement
async with client.messages.stream(
model=settings.model,
max_tokens=settings.max_tokens,
messages=[{"role": "user", "content": prompt}],
) as stream:
async for text in stream.text_stream:
# format SSE : chaque token devient un event "data:"
yield f"data: {text}\n\n"
yield "data: [DONE]\n\n"
@app.post("/chat")
async def chat(
body: ChatRequest,
client: Annotated[AsyncAnthropic, Depends(get_client)],
settings: Annotated[Settings, Depends(get_settings)],
) -> StreamingResponse:
return StreamingResponse(
stream_tokens(client, settings, body.prompt),
media_type="text/event-stream",
)Le point qui relie ça au sujet de la leçon : tout ce service est reproductible parce que uv.lock fige anthropic==0.69.0, fastapi, httpx et leurs sous-dépendances avec des hashes. Ton collègue, ta CI et ton image Docker installent exactement le même SDK Anthropic — donc le même comportement de streaming, les mêmes exceptions typées (anthropic.APIStatusError, RateLimitError), la même logique de retry intégrée. Un drift de version du SDK (ex. un changement dans messages.stream) ne peut pas te surprendre en prod si le lock est respecté.
Pour aller plus loin sur l'appel LLM lui-même (structured outputs via messages.parse, prompt caching via cache_control, boucle tool-use, output_config pour l'effort de réflexion), réfère-toi aux leçons 06-ai/. Ici, l'essentiel côté packaging : épingle anthropic avec une borne basse explicite (>=0.69.0) et laisse uv.lock figer la version exacte.
⚙️ En production
Docker multi-stage : l'image minimale et cachée
C'est le payoff concret de uv en prod. Le pattern de référence exploite le cache de couches Docker en installant les dépendances avant de copier ton code :
# Stage 1 : builder avec uv
FROM python:3.12-slim AS builder
# Le binaire uv vient d'une image officielle, pas de pip install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never
WORKDIR /app
# 1) Installer SEULEMENT les deps (couche cachée tant que le lock ne change pas)
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-project --no-dev
# 2) Copier le code et installer le projet lui-même
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
# Stage 2 : runtime nu, sans uv ni cache
FROM python:3.12-slim
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
WORKDIR /app
CMD ["uvicorn", "my_agent.api:app", "--host", "0.0.0.0", "--port", "8000"]Les leviers seniors ici :
--no-install-projectau stage 1 → les deps sont leur propre couche, recachée seulement quanduv.lockchange. Modifier ton code ne réinstalle pasanthropicà chaque build.--frozen→ le build échoue si le lock est obsolète. Pas de résolution surprise en CI.--no-dev→ pytest/ruff/mypy n'entrent pas dans l'image de prod.UV_COMPILE_BYTECODE=1→ précompile les.pyc, démarrage plus rapide du conteneur.- Stage 2 sans
uv→ l'image finale ne contient que.venv/+ ton code.PATHpointe vers le venv, donc plus besoin deuv run.
Failure modes à connaître
- Lock pas commité / pas synchronisé. Quelqu'un
uv addmais oublie de commiteruv.lock. La CI doit utiliseruv sync --frozen(ou--locked) pour échouer bruyamment au lieu de résoudre silencieusement une version différente. - Drift entre
requires-pythonet la version réelle. Sipyproject.tomldit>=3.12mais ta CI tourne en 3.11, le lock universel peut couvrir des résolutions incompatibles. Pinne via.python-versionetuv python install. - Dépendances non packagées (git, chemins locaux).
uv add "mylib @ git+https://..."fonctionne, mais une URL git non pinnée à un commit est non reproductible. Pinne lerev. uvxqui télécharge à l'exécution. Pratique en dev, jamais en prod : un outil exécuté viauvxn'est pas dans ton lock. En CI, déclare-le en dev-dependency.- Hash mismatch. Un miroir PyPI interne mal configuré renvoie un wheel sans hash →
uvrefuse. C'est le comportement voulu (sécurité), à fixer côté index.
Performance
Le résolveur en Rust + le cache global (déduppliqué par hash, partagé entre tous tes projets via hardlinks) font qu'un uv sync à froid d'un projet déjà vu en cache prend des secondes, pas des minutes. En CI, monte ~/.cache/uv en cache persistant (le --mount=type=cache Docker ci-dessus, ou actions/cache sur GitHub). C'est l'accélération la plus visible vs pip.
Sécurité
- Les hashes du lock sont ta première ligne contre la supply-chain.
SecretStrdans Pydantic Settings (cf. code) empêche de logger une clé API par accident.- En prod, ne mets jamais la clé Anthropic dans
pyproject.tomlni dans l'image : injecte-la par variable d'environnement (AGENT_ANTHROPIC_API_KEY). uv sync --no-devréduit la surface d'attaque de l'image (moins de paquets installés).
Observabilité
uv n'est pas un outil de runtime, mais : versionne uv.lock dans Git pour avoir un diff lisible de chaque changement de dépendance (audit), et logge uv --version + le hash du lock au démarrage du service pour corréler un incident de prod à un état de dépendances précis.
🏋️ Exercices
Exercice 1 — Reproductibilité de bout en bout (implémenter)
Objectif : prouver que ton projet est reproductible. Crée un projet uv init --package, ajoute fastapi[standard] et anthropic, commit pyproject.toml + uv.lock. Puis : rm -rf .venv et reconstruis avec uv sync --frozen. Vérifie que la version d'anthropic installée est identique avant/après via uv pip list. Indice/Solution : uv sync --frozen doit réussir et installer exactement la version du lock. Si tu modifies pyproject.toml (ex. bump une borne) sans relocker, --frozen doit échouer — c'est le test qui valide que la garantie fonctionne.
Exercice 2 — Groupes de dépendances et image de prod (production-grade)
Objectif : sépare proprement dev et runtime, puis construis une image Docker qui n'embarque pas les outils de dev. Mets pytest, mypy, ruff dans [dependency-groups].dev. Écris le Dockerfile multi-stage. Compare la taille de l'image avec --no-dev vs sans. Indice/Solution : utilise le Dockerfile de la section « En production ». Vérifie l'absence de pytest dans l'image finale : docker run --rm my-agent uv pip list | grep pytest ne doit rien renvoyer (et uv ne devrait même pas être présent au stage runtime).
Exercice 3 — Mise à jour ciblée sans tout casser (production-grade)
Objectif : bump uniquement anthropic vers la dernière version compatible sans toucher au reste du lock. Observe le diff de uv.lock. Indice/Solution : uv lock --upgrade-package anthropic, puis git diff uv.lock. Seules les lignes d'anthropic (et éventuellement ses sous-deps si nécessaire) changent. Contraste avec uv lock --upgrade qui re-résout tout — utile à connaître pour ne pas faire un bump massif par accident.
Exercice 4 — Casser puis réparer un drift d'environnement (break-then-fix)
Objectif : simule le bug classique « ça marche chez moi ». Active le venv à la main (source .venv/bin/activate) et fais pip install requests directement. Constate que import requests marche mais que requests n'est ni dans pyproject.toml ni dans uv.lock. Puis répare proprement. Indice/Solution : requests est dans le venv fantôme mais invisible au lock. La réparation : pip uninstall requests puis uv add requests (ou rm -rf .venv && uv sync pour repartir d'un état propre, puis uv add requests). La leçon : ne jamais pip install dans un projet géré par uv.
Exercice 5 — Service agent reproductible + smoke test (synthèse)
Objectif : reprends le service FastAPI/Anthropic de la leçon, écris un test pytest-asyncio qui mocke AsyncAnthropic.messages.stream et vérifie que /chat renvoie bien des events SSE data: terminés par [DONE]. Fais tourner ce test via uv run pytest dans une CI qui utilise uv sync --frozen. Indice/Solution : injecte un faux client via app.dependency_overrides[get_client] retournant un mock dont messages.stream est un async context manager exposant un text_stream. Le test confirme que le contrat de streaming tient — et le --frozen en CI confirme que le SDK Anthropic est figé.
Exercice 6 — Workspace multi-paquets (hard, optionnel)
Objectif : transforme le projet en workspace uv avec deux membres — agent-core (lib) et agent-api (service qui dépend de core) — partageant un seul uv.lock. Indice/Solution : ajoute [tool.uv.workspace] members = ["packages/*"] à la racine, et dans agent-api déclare agent-core en dépendance avec [tool.uv.sources] agent-core = { workspace = true }. Un seul uv sync à la racine résout les deux. C'est l'équivalent d'un monorepo pnpm avec workspaces.
🎤 En entretien
Q : Pourquoi commiter uv.lock pour une application mais pas pour une bibliothèque ? R : Une application veut un déploiement reproductible bit-pour-bit, donc on fige tout dans le lock ; une bibliothèque doit laisser ses consommateurs résoudre les versions dans leur contexte, donc on ne publie que les bornes du pyproject.toml et on ignore le lock.
Q : Quelle est la différence entre uv sync, uv lock et uv run ? R : uv lock re-résout et écrit uv.lock sans toucher au venv, uv sync applique le lock au .venv, et uv run fait un sync implicite avant d'exécuter la commande — donc en pratique tu lances toujours via uv run pour ne jamais dériver.
Q : Pourquoi uv est-il beaucoup plus rapide que pip + pip-tools ? R : Résolveur écrit en Rust, parallélisme massif au téléchargement, et un cache global dédupliqué par hash partagé entre projets via hardlinks — un paquet déjà vu n'est ni retéléchargé ni recopié.
Q : Comment garantis-tu en CI qu'un dev n'a pas oublié de commiter le lock après un uv add ? R : uv sync --frozen (ou --locked) en CI : la commande échoue si uv.lock ne reflète pas exactement pyproject.toml, ce qui transforme un oubli silencieux en build rouge immédiat.