Skip to content

Packaging (uv)

TL;DRuv (par Astral, les auteurs de Ruff) est un gestionnaire de projet et de paquets Python écrit en Rust qui remplace d'un coup pip, pip-tools, virtualenv, pyenv, pipx et une bonne partie de Poetry — le tout 10 à 100× plus vite. Tu déclares ton projet dans un pyproject.toml standard (PEP 621), uv résout les dépendances dans un uv.lock universel et reproductible, gère le venv .venv/ automatiquement, installe la bonne version de Python pour toi, et uv run exécute n'importe quelle commande dans cet environnement synchronisé. En venant de Node, pense pyproject.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 run

L'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).

bash
# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

# ou via Homebrew
brew install uv

# vérifier
uv --version

Créer un projet

bash
# Crée pyproject.toml, README, .python-version, .gitignore, dossier src/
uv init my-agent --package
cd my-agent

Le 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__.py

Le pyproject.toml généré, en PEP 621 pur :

toml
[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

bash
# 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 :

toml
[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] (les extras, 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.

bash
# 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 équivalente

Avant 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)

bash
# 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

bash
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 :

FlagEffetQuand
uv sync --frozenÉchoue si uv.lock ne reflète pas pyproject.tomlCI : détecte un lock pas commité
uv sync --lockedVérifie que le lock est à jour sans le modifierCI stricte
uv sync --no-devExclut les [dependency-groups] devImage Docker de prod
uv lock --upgradeRe-résout tout vers les dernières versions compatiblesMise à jour volontaire
uv lock --upgrade-package anthropicBump une seule dépendanceMise à 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.

toml
# 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 :

toml
[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 = true

Le code du service, entièrement typé (Python 3.12, Pydantic v2, async, DI FastAPI) :

python
# 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
python
# 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 :

dockerfile
# 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-project au stage 1 → les deps sont leur propre couche, recachée seulement quand uv.lock change. Modifier ton code ne réinstalle pas anthropic à 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. PATH pointe vers le venv, donc plus besoin de uv run.

Failure modes à connaître

  1. Lock pas commité / pas synchronisé. Quelqu'un uv add mais oublie de commiter uv.lock. La CI doit utiliser uv sync --frozen (ou --locked) pour échouer bruyamment au lieu de résoudre silencieusement une version différente.
  2. Drift entre requires-python et la version réelle. Si pyproject.toml dit >=3.12 mais ta CI tourne en 3.11, le lock universel peut couvrir des résolutions incompatibles. Pinne via .python-version et uv python install.
  3. 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 le rev.
  4. uvx qui télécharge à l'exécution. Pratique en dev, jamais en prod : un outil exécuté via uvx n'est pas dans ton lock. En CI, déclare-le en dev-dependency.
  5. Hash mismatch. Un miroir PyPI interne mal configuré renvoie un wheel sans hash → uv refuse. 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.
  • SecretStr dans Pydantic Settings (cf. code) empêche de logger une clé API par accident.
  • En prod, ne mets jamais la clé Anthropic dans pyproject.toml ni dans l'image : injecte-la par variable d'environnement (AGENT_ANTHROPIC_API_KEY).
  • uv sync --no-dev ré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.

Bibliothèque tech perso — Achref