~/wiki

Cheatsheets — vue longue

retour à la liste

Toutes les pages concaténées sur un seul document, pour un Ctrl-F direct.

Bash & shell

page dédiée →

Les commandes qu'on tape sans réfléchir, et celles qu'on cherche à chaque fois.

Fichiers et dossiers

mkdir -p projet/src/utils    # -p : crée les parents, ne râle pas si ça existe
touch projet/src/main.py
ls -la projet
total 0
drwxr-xr-x  3 edouard  staff   96 Aug  7 14:02 .
drwxr-xr-x  8 edouard  staff  256 Aug  7 14:02 ..
drwxr-xr-x  4 edouard  staff  128 Aug  7 14:02 src
cp -r src/ backup/           # -r : récursif, indispensable pour un dossier
mv ancien.py nouveau.py
rm -rf build/                # -f : pas de confirmation. Vérifier le chemin AVANT

ls utile : -l long, -a cachés, -h tailles lisibles, -t tri par date, -S par taille.

Trouver

find . -name "*.py" -not -path "*/node_modules/*"
find . -type f -size +100M              # les gros fichiers
find . -name "*.log" -mtime +7 -delete  # modifiés il y a plus de 7 jours
rg "requests.post" --type py     # ripgrep : rapide, respecte .gitignore
rg -i "todo" -A 2                # -i insensible à la casse, -A 2 lignes après
rg -l "FastAPI"                  # -l : juste les noms de fichiers
rg -c "import"                   # -c : compte par fichier

rg bat grep sur tous les tableaux. Si grep est imposé : grep -rn "motif" .

Inspecter un fichier

head -20 gros.csv        # 20 premières lignes
tail -f app.log          # -f : suit le fichier en direct
wc -l data.csv           # compte les lignes
du -sh *                 # taille de chaque entrée du dossier
df -h                    # espace disque restant
du -sh * | sort -rh | head -5
1.2G	node_modules
340M	.venv
 12M	data
2.1M	src
 48K	README.md

Pipes et redirections

commande > fichier      # écrase
commande >> fichier     # ajoute
commande 2> erreurs.log # stderr seulement
commande &> tout.log    # stdout + stderr
commande 2>/dev/null    # jette les erreurs
commande | tee log.txt  # affiche ET écrit

curl -s ... | bash télécharge un script et l'exécute directement. Pratique pour les installeurs officiels, dangereux pour tout le reste : lire d'abord avec curl -s <url> | less.

Processus et ports

lsof -i :3000            # qui occupe le port 3000
kill <PID>               # demande poliment
kill -9 <PID>            # force, en dernier recours
kill -9 $(lsof -t -i :3000)
ps aux | rg python
lsof -i :3000
COMMAND   PID     USER   FD   TYPE DEVICE  NODE NAME
node    88008 edouard   24u  IPv6 0x9f2a   TCP *:hbci (LISTEN)

Variables d'environnement

export API_KEY="sk-..."     # pour cette session
echo $API_KEY
env | rg API                # lister
unset API_KEY

set -a; source .env; set +a  # charger un .env dans le shell

$VAR non défini vaut chaîne vide sans erreur. ${VAR:?message} échoue explicitement, ${VAR:-defaut} fournit une valeur de repli.

Enchaîner

a && b     # b seulement si a réussit  (exit code 0)
a || b     # b seulement si a échoue
a ; b      # b dans tous les cas
a &        # a en tâche de fond
echo $?    # code de sortie de la dernière commande

Boucles et substitution

for f in *.md; do echo "-- $f"; wc -l "$f"; done

for i in {1..5}; do curl -s "https://api.example.com/p/$i" > "p$i.json"; done

NOW=$(date +%Y-%m-%d)        # substitution de commande
echo "backup-$NOW.tar.gz"

Toujours guillemeter "$f" : sans guillemets, un nom de fichier avec un espace se découpe en deux arguments.

Archives et transferts

tar -czf archive.tar.gz dossier/    # c créer, z gzip, f fichier
tar -xzf archive.tar.gz             # x extraire
zip -r archive.zip dossier/
scp fichier user@host:/chemin/
rsync -avz --progress src/ user@host:/dest/

Raccourcis qui font gagner du temps

Touches Effet
Ctrl-R recherche dans l'historique
Ctrl-A / Ctrl-E début / fin de ligne
Ctrl-W supprime le mot précédent
Ctrl-U supprime jusqu'au début
Ctrl-C interrompt
Ctrl-D fin d'entrée / quitte le shell
!! dernière commande (sudo !!)
cd - dossier précédent

See also

Les flags qui couvrent 90 % des cas

Flag Effet
-X POST méthode (inutile avec -d, qui implique POST)
-H "K: V" un header, répétable
-d '{"a":1}' corps de la requête
-G envoie les -d en query string au lieu du corps
--data-urlencode "q=a b" encode la valeur
-s silencieux, pas de barre de progression
-S affiche quand même les erreurs (à coupler avec -s)
-i inclut les headers de réponse
-I headers seulement, requête HEAD
-L suit les redirections
-o fichier écrit dans un fichier
-O garde le nom distant
-u user:pass authentification basique
--max-time 30 timeout global
-w '%{http_code}' formate une sortie après coup

POST JSON, le cas de référence

curl -sS -X POST "https://api.example.com/v1/sessions" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"agent_id": "a_123", "task": "trouver le produit"}'
{"id":"s_88fe21","status":"queued","created_at":"2026-08-07T14:12:03Z"}

Sans Content-Type: application/json, beaucoup d'API répondent 415 Unsupported Media Type. C'est l'oubli le plus fréquent.

Corps trop long pour la ligne de commande : -d @payload.json lit un fichier.

GET avec des paramètres

curl -sS -G "https://api.example.com/v1/items" \
  --data-urlencode "q=chaussure noire" \
  --data-urlencode "page=2"

-G évite d'encoder soi-même les espaces et les accents.

Lire la réponse

curl -s https://api.github.com/repos/astral-sh/uv | jq '.stargazers_count, .license.name'
62841
"Apache License 2.0"
curl -s https://api.example.com/items | jq '.items[] | {id, name}'
curl -s https://api.example.com/items | jq -r '.items[].name'   # -r : sans guillemets

Debug

curl -i https://example.com          # headers + corps
curl -I https://example.com          # headers seuls
curl -v https://example.com          # tout le dialogue, TLS compris
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" https://example.com
200 0.184s

-w sert à mesurer sans polluer la sortie. Autres variables utiles : %{size_download}, %{redirect_url}, %{content_type}.

Fichiers

curl -O https://example.com/data.csv           # garde le nom
curl -o local.csv https://example.com/data.csv
curl -L -o file.zip https://example.com/dl     # suit la redirection
curl -F "file=@photo.jpg" -F "note=test" https://api.example.com/upload

-F fait du multipart, -d fait du form-urlencoded ou du brut : ce ne sont pas les mêmes requêtes.

Le pipe vers bash

curl -LsSf https://astral.sh/uv/install.sh | sh

Correct pour un installeur officiel. Pour tout le reste, lire d'abord :

curl -sSL https://exemple.com/install.sh | less

Équivalences avec Python

curl requests
-X POST requests.post(...)
-H "K: V" headers={"K": "V"}
-d '{"a":1}' json={"a": 1}
-G --data-urlencode "q=x" params={"q": "x"}
-u user:pass auth=("user", "pass")
--max-time 30 timeout=30
-L allow_redirects=True (défaut)

Astuce navigateur : dans l'onglet Réseau des DevTools, clic droit sur une requête puis « Copy as cURL » reproduit l'appel complet, cookies et headers inclus.

See also

Le cycle quotidien

docker build -t monapp:dev .
docker run --rm -p 8000:8000 --env-file .env monapp:dev
docker ps
docker logs -f <container>
docker exec -it <container> bash
docker stop <container>
docker ps
CONTAINER ID   IMAGE        COMMAND         STATUS         PORTS                    NAMES
a3f1c9e21b04   monapp:dev   "uvicorn ap…"   Up 2 minutes   0.0.0.0:8000->8000/tcp   brave_liskov

docker ps -a inclut les conteneurs arrêtés. --rm supprime le conteneur à sa sortie, ce qui évite d'en accumuler des dizaines.

Flags de run à connaître

Flag Effet
-p 8000:8000 port hôte:conteneur
-v $(pwd):/app monte le dossier courant, code à chaud
--env-file .env charge les variables
-e KEY=value une variable à la volée
-d détaché, en arrière-plan
-it interactif + tty, pour un shell
--rm nettoie à la sortie
--name api nom fixe au lieu d'un nom généré

Dockerfile Python typique

FROM python:3.12-slim

WORKDIR /app

# Couche de dépendances séparée : elle n'est reconstruite que si les
# requirements changent, pas à chaque édition du code.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

--host 0.0.0.0 est obligatoire : sur 127.0.0.1 le serveur n'écoute que l'intérieur du conteneur et le port publié ne sert à rien.

Un .dockerignore évite d'envoyer 1 Go de contexte au démon :

.venv
node_modules
.git
__pycache__
*.pyc
.env

Compose

services:
  api:
    build: .
    ports: ["8000:8000"]
    env_file: .env
    volumes: ["./app:/app/app"]      # code à chaud en dev
    depends_on: [db]

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: dev
    ports: ["5432:5432"]
    volumes: ["pgdata:/var/lib/postgresql/data"]

volumes:
  pgdata:
docker compose up              # au premier plan
docker compose up -d --build   # reconstruit puis détache
docker compose logs -f api
docker compose exec api bash
docker compose down            # -v pour supprimer aussi les volumes

--build n'est nécessaire que si le Dockerfile ou les dépendances changent. Avec un volume monté et un reload actif, le code est déjà à jour sans rebuild.

Nettoyer

docker system df               # où part la place
docker system prune -f         # conteneurs et réseaux inutilisés
docker system prune -a -f      # + toutes les images non utilisées
docker builder prune -f        # cache de build
docker system df
TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          24        3         18.4GB    15.2GB (82%)
Containers      5         1         120MB     98MB (81%)
Local Volumes   7         2         2.1GB     1.4GB (66%)
Build Cache     183       0         9.8GB     9.8GB

Les erreurs qu'on rencontre vraiment

port is already allocated — un autre processus tient le port.

lsof -i :8000
kill -9 $(lsof -t -i :8000)

no space left on device pendant un build — le disque de la VM Docker est plein, faire un prune progressif avant d'envisager un reset.

Le conteneur démarre puis s'arrête aussitôt — le processus principal a rendu la main. docker logs <container> donne toujours la raison.

Build lent à chaque fois — l'ordre des couches est mauvais : COPY . . avant pip install invalide le cache à la moindre modification de code.

Inspecter

docker images
docker inspect <container> | rg -i "ipaddress|mounts" -A 5
docker stats                   # CPU / RAM en direct
docker history monapp:dev      # poids par couche

See also

Interfaces de démo pour modèles ML. Contrairement à streamlit, le modèle est événementiel : on déclare des composants et on branche des fonctions dessus, sans réexécution complète du script.

uv add gradio
python app.py          # sert sur http://127.0.0.1:7860

Le plus court chemin

import gradio as gr

def check(site: str, produit: str) -> str:
    return f"{produit} trouvé sur {site} en 3 clics"

gr.Interface(
    fn=check,
    inputs=["text", "text"],
    outputs="text",
    title="Findability checker",
).launch()
* Running on local URL:  http://127.0.0.1:7860
* To create a public link, set `share=True` in `launch()`.

share=True ouvre un tunnel public valable 72 h — pratique pour montrer une démo à un client sans rien déployer.

Blocks, pour tout le reste

Interface couvre le cas « une fonction, des entrées, des sorties ». Dès qu'il faut de la mise en page ou plusieurs interactions, passer à Blocks.

with gr.Blocks(title="Findability") as demo:
    gr.Markdown("## Test d'accessibilité produit")

    with gr.Row():
        site = gr.Textbox(label="Site", value="versace.com")
        produit = gr.Textbox(label="Produit")

    with gr.Row():
        budget = gr.Slider(10, 200, value=60, step=10, label="Budget d'étapes")
        mode = gr.Radio(["fast", "default"], value="default", label="Mode")

    run = gr.Button("Lancer", variant="primary")
    out = gr.JSON(label="Résultat")
    gallery = gr.Gallery(label="Captures", columns=3)

    run.click(fn=check, inputs=[site, produit, budget, mode], outputs=[out, gallery])

demo.launch()

inputs et outputs sont des listes de composants, et la fonction doit renvoyer autant de valeurs qu'il y a de sorties, dans le même ordre.

Composants courants

Composant Usage
gr.Textbox(lines=5) texte court ou long
gr.Number, gr.Slider valeurs numériques
gr.Dropdown(choices=[...], multiselect=True) listes
gr.Checkbox, gr.Radio booléens et choix exclusifs
gr.Image(type="pil") image en entrée ou sortie
gr.File, gr.Audio, gr.Video fichiers
gr.Dataframe tableau éditable
gr.JSON, gr.Label sorties structurées
gr.Plot figure matplotlib ou plotly

Événements

run.click(fn, inputs=..., outputs=...)
site.change(fn, inputs=site, outputs=out)
site.submit(fn, ...)          # touche Entrée
demo.load(fn, ...)            # au chargement de la page

Enchaîner des étapes en gardant l'interface réactive :

run.click(prepare, [site], [status]).then(execute, [site], [out])

Streaming

def stream(prompt):
    partial = ""
    for chunk in llm.stream(prompt):
        partial += chunk
        yield partial            # un yield = une mise à jour de l'UI

gr.Interface(fn=stream, inputs="text", outputs="text").launch()

Un générateur suffit : chaque yield pousse une nouvelle valeur au composant de sortie.

Chat

def respond(message, history):
    return f"Reçu : {message}"

gr.ChatInterface(respond, type="messages").launch()

history arrive en liste de dicts {"role", "content"} avec type="messages". C'est le format aligné sur les API LLM, à préférer à l'ancien format en tuples.

État par session

with gr.Blocks() as demo:
    state = gr.State([])          # propre à chaque visiteur

    def add(item, current):
        current = current + [item]
        return current, current

    box = gr.Textbox()
    out = gr.JSON()
    box.submit(add, [box, state], [state, out])

gr.State n'est jamais rendu, il transporte des données Python entre événements.

Files d'attente et progression

def long_task(x, progress=gr.Progress()):
    for i in progress.tqdm(range(10), desc="Sessions"):
        run(i)
    return "ok"

demo.queue(max_size=20).launch()

queue() est nécessaire dès que plusieurs personnes utilisent la démo en même temps.

Déployer sur Hugging Face Spaces

Un repo avec app.py, requirements.txt, et un README.md à en-tête :

---
title: Findability Checker
sdk: gradio
sdk_version: "5.0.0"
app_file: app.py
---

Les secrets se règlent dans les paramètres du Space et se lisent par os.environ.

Gradio ou Streamlit

Gradio pour exposer un modèle ou une fonction, surtout avec image, audio ou chat, et pour publier sur Spaces. Streamlit pour un tableau de bord de données avec des filtres. Les deux sont des outils de démo : au-delà, une vraie application.

See also

Keras & TensorFlow

page dédiée →

Keras 3 fonctionne au-dessus de TensorFlow, JAX ou PyTorch. Le backend se choisit avant l'import.

import os
os.environ["KERAS_BACKEND"] = "tensorflow"   # ou "jax", "torch"
import keras

Construire un modèle

from keras import layers

model = keras.Sequential([
    keras.Input(shape=(128,)),
    layers.Dense(256, activation="relu"),
    layers.Dropout(0.2),
    layers.Dense(10, activation="softmax"),
])
model.summary()
Model: "sequential"
┌─────────────────────────────────┬────────────────────────┬───────────────┐
│ Layer (type)                    │ Output Shape           │       Param # │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense (Dense)                   │ (None, 256)            │        33,024 │
│ dropout (Dropout)               │ (None, 256)            │             0 │
│ dense_1 (Dense)                 │ (None, 10)             │         2,570 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 35,594 (139.04 KB)

summary() est le premier outil de debug : une forme de sortie inattendue s'y voit immédiatement.

API fonctionnelle, dès qu'il y a plusieurs entrées ou une branche :

inp = keras.Input(shape=(128,))
x = layers.Dense(256, activation="relu")(inp)
x = layers.Dropout(0.2)(x)
out = layers.Dense(10, activation="softmax")(x)
model = keras.Model(inp, out)

Compiler et entraîner

model.compile(
    optimizer=keras.optimizers.AdamW(learning_rate=3e-4),
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
)

history = model.fit(
    X_train, y_train,
    validation_data=(X_val, y_val),
    epochs=20,
    batch_size=32,
    callbacks=[
        keras.callbacks.EarlyStopping(patience=3, restore_best_weights=True),
        keras.callbacks.ReduceLROnPlateau(factor=0.5, patience=2),
        keras.callbacks.ModelCheckpoint("best.keras", save_best_only=True),
    ],
)
Epoch 1/20
188/188 ━━━━━━━━━━━━━━━━━━━━ 2s 7ms/step - accuracy: 0.4123 - loss: 1.8342 - val_accuracy: 0.6210
Epoch 2/20
188/188 ━━━━━━━━━━━━━━━━━━━━ 1s 6ms/step - accuracy: 0.6890 - loss: 1.0021 - val_accuracy: 0.7455

Choisir la bonne perte

Cible Perte Dernière couche
Entiers de classe (0, 1, 2) sparse_categorical_crossentropy Dense(n, softmax)
One-hot categorical_crossentropy Dense(n, softmax)
Binaire binary_crossentropy Dense(1, sigmoid)
Régression mse / mae Dense(1) sans activation

Confondre sparse_ et la version one-hot est l'erreur la plus fréquente : elle produit un message sur les formes plutôt qu'un mauvais score, donc elle se repère vite.

Évaluer et prédire

loss, acc = model.evaluate(X_test, y_test, verbose=0)
proba = model.predict(X_test)
classes = proba.argmax(axis=1)

Courbes d'apprentissage

import matplotlib.pyplot as plt

plt.plot(history.history["loss"], label="train")
plt.plot(history.history["val_loss"], label="val")
plt.legend(); plt.xlabel("epoch"); plt.ylabel("loss")

La val_loss qui remonte pendant que la loss descend est la signature du surapprentissage. EarlyStopping(restore_best_weights=True) récupère automatiquement le meilleur état.

Sauvegarder

model.save("model.keras")                 # format natif, tout inclus
model = keras.models.load_model("model.keras")

model.save_weights("poids.weights.h5")    # poids seuls
model.load_weights("poids.weights.h5")

Pipeline de données

import tensorflow as tf

ds = (
    tf.data.Dataset.from_tensor_slices((X, y))
    .shuffle(10_000)
    .batch(32)
    .prefetch(tf.data.AUTOTUNE)
)
model.fit(ds, epochs=10)

prefetch(AUTOTUNE) recouvre la préparation des données et le calcul — souvent le gain le plus simple quand le GPU attend.

Transfer learning

base = keras.applications.EfficientNetB0(include_top=False, weights="imagenet", pooling="avg")
base.trainable = False                    # gel

model = keras.Sequential([base, layers.Dense(5, activation="softmax")])
model.compile(optimizer=keras.optimizers.Adam(1e-3), loss="sparse_categorical_crossentropy")
model.fit(train_ds, epochs=5)

base.trainable = True                     # dégel pour le fine-tuning
model.compile(optimizer=keras.optimizers.Adam(1e-5), loss="sparse_categorical_crossentropy")
model.fit(train_ds, epochs=5)

Le second compile avec un learning rate cent fois plus petit est obligatoire : dégeler sans le baisser détruit les poids pré-entraînés dès le premier batch.

GPU

print(tf.config.list_physical_devices("GPU"))
[PhysicalDevice(name='/physical_device:GPU:0', device_type='GPU')]

Liste vide alors qu'un GPU existe : c'est presque toujours une incompatibilité entre les versions de TensorFlow, CUDA et cuDNN.

See also

API en mouvement rapide. Ce qui suit couvre le cœur stable — LCEL, modèles de chat, prompts, parsers, retrievers. Vérifier contre docs.langchain.com avant de s'appuyer sur un détail.

Installation

uv add langchain langchain-openai langchain-anthropic langchain-community

Les intégrations sont dans des paquets séparés depuis la 0.1 : le cœur ne dépend d'aucun fournisseur.

Appeler un modèle

from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage, SystemMessage

llm = ChatAnthropic(model="claude-sonnet-4-5", temperature=0, max_tokens=1024)

resp = llm.invoke([
    SystemMessage("Tu réponds en une phrase."),
    HumanMessage("Qu'est-ce qu'un agent computer use ?"),
])
print(resp.content)
Un agent computer use est un système qui perçoit l'écran et agit via souris et clavier
pour accomplir des tâches à la place d'un utilisateur.

Quatre méthodes sur tout composant : invoke, batch, stream, et leurs variantes asynchrones ainvoke, abatch, astream.

LCEL, l'opérateur |

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_messages([
    ("system", "Tu es un analyste e-commerce concis."),
    ("human", "Le produit {produit} est-il trouvable sur {site} ?"),
])

chain = prompt | llm | StrOutputParser()
print(chain.invoke({"produit": "Najim 100 ml", "site": "versace.com"}))

Chaque maillon reçoit la sortie du précédent. StrOutputParser extrait .content du message, sinon on manipule un objet AIMessage.

Sortie structurée

from pydantic import BaseModel, Field

class Findability(BaseModel):
    findable: bool = Field(description="Produit atteignable par la navigation")
    clicks: int = Field(description="Nombre de clics jusqu'à la fiche produit")
    path: list[str] = Field(default_factory=list)

structured = llm.with_structured_output(Findability)
result = structured.invoke("Sur versace.com, combien de clics pour Najim 100 ml ?")
print(result.clicks, result.findable)
3 True

with_structured_output s'appuie sur le function calling natif du fournisseur. C'est plus fiable qu'un JsonOutputParser derrière un prompt qui supplie de rendre du JSON.

Tools et agents

from langchain_core.tools import tool

@tool
def get_price(sku: str) -> float:
    """Retourne le prix TTC d'un SKU."""
    return 129.0

llm_with_tools = llm.bind_tools([get_price])
msg = llm_with_tools.invoke("Quel est le prix du SKU AB-12 ?")
print(msg.tool_calls)
[{'name': 'get_price', 'args': {'sku': 'AB-12'}, 'id': 'toolu_01X…', 'type': 'tool_call'}]

Le décorateur @tool dérive le schéma des annotations de type et la description de la docstring — donc la docstring est un élément fonctionnel, pas un commentaire.

bind_tools ne fait que proposer l'appel : c'est à la boucle d'exécuter l'outil et de renvoyer un ToolMessage. Pour la boucle complète, passer à langgraph.

RAG minimal

from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import FAISS

docs = PyPDFLoader("manuel.pdf").load()

splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=150)
chunks = splitter.split_documents(docs)

store = FAISS.from_documents(chunks, OpenAIEmbeddings(model="text-embedding-3-small"))
retriever = store.as_retriever(search_kwargs={"k": 4})

RecursiveCharacterTextSplitter coupe d'abord sur les paragraphes, puis les phrases, puis les mots. C'est le défaut raisonnable ; chunk_overlap évite de trancher une idée en deux.

from langchain_core.runnables import RunnablePassthrough

template = ChatPromptTemplate.from_template(
    "Réponds uniquement à partir du contexte.\n\nContexte:\n{context}\n\nQuestion: {question}"
)

def format_docs(docs):
    return "\n\n".join(d.page_content for d in docs)

rag = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | template
    | llm
    | StrOutputParser()
)
print(rag.invoke("Quelle est la garantie ?"))

RunnablePassthrough laisse passer l'entrée telle quelle pendant que l'autre branche va chercher les documents. Les deux branches du dict s'exécutent en parallèle.

Streaming

for chunk in chain.stream({"produit": "Najim", "site": "versace.com"}):
    print(chunk, end="", flush=True)

Observabilité

export LANGCHAIN_TRACING_V2=true
export LANGCHAIN_API_KEY=ls__...
export LANGCHAIN_PROJECT=mon-projet

Toutes les exécutions apparaissent alors dans LangSmith, avec les prompts réels, les latences et les tokens. C'est le principal argument pour rester dans l'écosystème.

Quand ne pas l'utiliser

Pour un simple appel à un modèle avec un prompt, le SDK du fournisseur suffit et se debugge mieux. LangChain se justifie quand on veut changer de fournisseur sans réécrire, brancher des retrievers existants, ou tracer dans LangSmith.

See also

API en mouvement. Le cœur — StateGraph, nœuds, arêtes conditionnelles, checkpointer — est stable. Vérifier les détails sur docs.langchain.com.

Orchestration d'agents comme graphe d'états. Là où une chaîne LCEL est un pipeline linéaire, LangGraph autorise les cycles, les branchements et la reprise après interruption.

L'idée

Un état partagé, des nœuds qui le transforment, des arêtes qui décident du nœud suivant. Chaque nœud reçoit l'état et renvoie les clés qu'il modifie, pas l'état entier.

uv add langgraph langchain-anthropic

Graphe minimal

from typing import Annotated, TypedDict
from operator import add
from langgraph.graph import StateGraph, START, END

class State(TypedDict):
    site: str
    steps: Annotated[list[str], add]     # les mises à jour s'accumulent
    verdict: str | None

def navigate(state: State) -> dict:
    return {"steps": [f"ouverture de {state['site']}"]}

def judge(state: State) -> dict:
    return {"verdict": "findable" if len(state["steps"]) < 5 else "unreachable"}

builder = StateGraph(State)
builder.add_node("navigate", navigate)
builder.add_node("judge", judge)
builder.add_edge(START, "navigate")
builder.add_edge("navigate", "judge")
builder.add_edge("judge", END)

graph = builder.compile()
print(graph.invoke({"site": "versace.com", "steps": [], "verdict": None}))
{'site': 'versace.com', 'steps': ['ouverture de versace.com'], 'verdict': 'findable'}

Annotated[list, add] est le mécanisme central : sans le réducteur, chaque nœud écraserait steps. Avec, les listes se concatènent. Pour les messages, langgraph.graph.message.add_messages gère aussi la déduplication par id.

Branchement conditionnel

def should_retry(state: State) -> str:
    if state["verdict"] == "unreachable" and len(state["steps"]) < 20:
        return "navigate"
    return END

builder.add_conditional_edges("judge", should_retry, ["navigate", END])

La fonction renvoie le nom du nœud suivant. C'est ce qui crée les cycles, et donc les boucles d'agent.

Boucle agent avec outils

from langgraph.prebuilt import create_react_agent
from langchain_core.tools import tool

@tool
def click(selector: str) -> str:
    """Clique sur un élément et retourne le nouvel état de la page."""
    return f"cliqué sur {selector}"

agent = create_react_agent(llm, tools=[click])
out = agent.invoke({"messages": [("user", "Trouve Najim 100 ml sur versace.com")]})
print(out["messages"][-1].content)

create_react_agent monte la boucle standard : le modèle propose un appel, un nœud ToolNode l'exécute, le résultat repart au modèle, jusqu'à une réponse sans tool call. Pour tout contrôle fin — budget d'étapes, vérification, sous-agents — écrire le graphe à la main.

Persistance et reprise

from langgraph.checkpoint.memory import MemorySaver

graph = builder.compile(checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "session-42"}}

graph.invoke({"site": "versace.com", "steps": [], "verdict": None}, config)
graph.invoke({"site": "dior.com"}, config)      # reprend le même fil

print(graph.get_state(config).values["steps"])

Le thread_id identifie une conversation. En production, remplacer MemorySaver par un checkpointer SQLite ou Postgres pour survivre au redémarrage.

Interruption humaine

graph = builder.compile(checkpointer=MemorySaver(), interrupt_before=["judge"])

graph.invoke(initial, config)              # s'arrête avant "judge"
state = graph.get_state(config)
graph.update_state(config, {"verdict": "override"})
graph.invoke(None, config)                 # None = reprendre où on s'est arrêté

C'est le mécanisme des portes d'approbation avant une action irréversible — indispensable dès qu'un agent écrit quelque part.

Suivre l'exécution

for event in graph.stream(initial, config, stream_mode="values"):
    print(event["steps"][-1] if event["steps"] else "…")

stream_mode : values (l'état complet à chaque étape), updates (seulement les deltas), messages (les tokens du LLM).

Budget d'étapes

graph.invoke(initial, {"recursion_limit": 25, **config})

Au-delà, GraphRecursionError. Un agent qui boucle sans progresser est le mode d'échec par défaut — le budget est une sécurité, pas une optimisation.

Visualiser

print(graph.get_graph().draw_mermaid())
graph TD;
	__start__ --> navigate;
	navigate --> judge;
	judge -.-> navigate;
	judge -.-> __end__;

Utile en démo client : le diagramme se colle tel quel dans un document.

LangChain ou LangGraph

Chaîne linéaire, un aller-retour, pas d'état : LCEL suffit. Cycles, outils, reprise après échec, validation humaine : LangGraph. Les deux se composent — un nœud de graphe peut être une chaîne LCEL.

See also

Next.js (App Router)

page dédiée →

Démarrer

npx create-next-app@latest mon-app --ts --tailwind --app --no-src-dir
cd mon-app && npm run dev
▲ Next.js 16.3.0 (Turbopack)
- Local:   http://localhost:3000
✓ Ready in 812ms

Routage par fichiers

app/
  layout.tsx              → enveloppe toutes les pages
  page.tsx                → /
  globals.css
  blog/
    page.tsx              → /blog
    [slug]/page.tsx       → /blog/:slug
  api/
    items/route.ts        → /api/items
  (marketing)/            → groupe, n'apparaît pas dans l'URL
  loading.tsx             → état de chargement automatique
  error.tsx               → frontière d'erreur (doit être "use client")
  not-found.tsx           → 404

Server Components par défaut

Tout composant est serveur sauf mention contraire. Il peut être async, lire le système de fichiers, appeler une base — et son code n'est jamais envoyé au navigateur.

// app/items/page.tsx  — server component
import { readFileSync } from "node:fs";

export default async function Items() {
  const data = JSON.parse(readFileSync("content/items.json", "utf8"));
  return <ul>{data.map((i) => <li key={i.id}>{i.title}</li>)}</ul>;
}

"use client" en première ligne bascule un fichier côté navigateur. Nécessaire dès qu'on utilise useState, useEffect, un gestionnaire d'événement ou une API du DOM.

"use client";
import { useState } from "react";

export function Counter() {
  const [n, setN] = useState(0);
  return <button onClick={() => setN(n + 1)}>{n}</button>;
}

Règle de composition : un composant serveur peut importer un composant client, l'inverse est impossible. Donc on descend "use client" le plus bas possible dans l'arbre.

Params et search params sont asynchrones

export default async function Page({
  params,
  searchParams,
}: {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ tag?: string }>;
}) {
  const { slug } = await params;
  const { tag } = await searchParams;
  return <h1>{slug} {tag}</h1>;
}

C'est le changement qui casse le plus de code venu des anciennes versions : params était un objet simple, il faut maintenant l'attendre.

Génération statique

export function generateStaticParams() {
  return getAllSlugs().map((slug) => ({ slug }));
}

export async function generateMetadata({ params }): Promise<Metadata> {
  const { slug } = await params;
  return { title: `${slug} — Mon site` };
}

Avec generateStaticParams, chaque route est prérendue au build. Sans, elle est rendue à la demande.

Routes API

// app/api/items/route.ts
import { NextResponse } from "next/server";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  return NextResponse.json({ q: searchParams.get("q") });
}

export async function POST(request: Request) {
  const body = await request.json();
  return NextResponse.json({ ok: true, body }, { status: 201 });
}

Server Actions

// app/actions.ts
"use server";

export async function createItem(formData: FormData) {
  const title = formData.get("title") as string;
  await db.insert({ title });
  revalidatePath("/items");
}
import { createItem } from "./actions";

export default function Form() {
  return (
    <form action={createItem}>
      <input name="title" />
      <button type="submit">Créer</button>
    </form>
  );
}

Une mutation serveur appelée depuis le client sans écrire de route API.

import Link from "next/link";
import Image from "next/image";

<Link href="/blog/hello" prefetch>Article</Link>
<Image src="/photo.jpg" alt="" width={800} height={600} priority />
"use client";
import { useRouter, usePathname, useSearchParams } from "next/navigation";

const router = useRouter();
router.push("/blog");
router.refresh();     // recharge les données serveur sans perdre l'état client

next/navigation, pas next/router — ce dernier appartient au Pages Router.

Variables d'environnement

DATABASE_URL=postgres://...      # serveur uniquement
NEXT_PUBLIC_API_URL=https://...  # exposée au navigateur

Seul le préfixe NEXT_PUBLIC_ traverse vers le client. Tout le reste reste serveur — c'est la protection à ne pas contourner par confort.

Build et déploiement

npm run build        # vérifie les types et prérend
npm start            # sert le build de production
npx vercel --prod    # déploie
Route (app)
┌ ○ /                    142 B    102 kB
├ ● /blog/[slug]         1.2 kB   118 kB
└ ƒ /api/items           0 B      0 B

○ Static  ● SSG  ƒ Dynamic

Lire cette table à chaque build : une route passée en ƒ alors qu'elle devrait être statique signale un appel dynamique involontaire — cookies(), headers() ou un fetch non caché.

See also

Créer

import numpy as np

np.array([[1, 2], [3, 4]])
np.zeros((2, 3))
np.ones((2, 3), dtype=np.float32)
np.full((2, 2), 7)
np.eye(3)
np.arange(0, 10, 2)          # 0 2 4 6 8, borne exclue
np.linspace(0, 1, 5)         # 5 points, bornes incluses
rng = np.random.default_rng(42)     # API moderne, préférée à np.random.seed
rng.normal(0, 1, size=(2, 3))
rng.integers(0, 10, size=5)
rng.choice([1, 2, 3], size=4, replace=True)
a = np.arange(6).reshape(2, 3)
print(a)
print(a.shape, a.dtype, a.ndim, a.size)
[[0 1 2]
 [3 4 5]]
(2, 3) int64 2 6

Formes

a.reshape(3, 2)
a.reshape(-1)          # aplatit, -1 = "déduis la dimension"
a.T                    # transposée
a[:, np.newaxis]       # ajoute un axe -> (2, 1, 3)
np.squeeze(a)          # retire les axes de taille 1
np.concatenate([a, a], axis=0)
np.stack([a, a])       # crée un nouvel axe

reshape renvoie une vue quand c'est possible : modifier le résultat modifie l'original. .copy() pour couper le lien.

Indexer

a[0, 1]
a[:, 1]            # toute la colonne 1
a[1:, :2]
a[a > 2]           # masque booléen -> tableau 1D
a[[0, 1], [2, 0]]  # fancy indexing -> éléments (0,2) et (1,0)
a = np.arange(6).reshape(2, 3)
print(a > 2)
print(a[a > 2])
[[False False False]
 [ True  True  True]]
[3 4 5]

Le masque booléen est le pattern à avoir : np.where(cond, x, y) pour choisir élément par élément, a[cond] = valeur pour affecter.

Agréger

a.sum()            # tout
a.sum(axis=0)      # somme des lignes -> un résultat par colonne
a.sum(axis=1)      # un résultat par ligne
a.mean(), a.std(), a.min(), a.max()
a.argmax(), a.argmin()      # position, pas valeur
np.median(a), np.percentile(a, 95)

Le sens d'axis est le piège classique : axis=0 fait disparaître l'axe 0, donc agrège les lignes et laisse une valeur par colonne.

a = np.arange(6).reshape(2, 3)
print(a.sum(axis=0), a.sum(axis=1))
[3 5 7] [ 3 12]

Avec des NaN : np.nanmean, np.nansum, sinon tout devient NaN.

Broadcasting

a = np.ones((3, 4))
b = np.arange(4)          # (4,)
a + b                     # (3, 4) : b est étiré sur les lignes

Règle : on aligne les shapes par la droite, et deux dimensions sont compatibles si elles sont égales ou si l'une vaut 1.

(3, 4)  +  (4,)     ->  (3, 4)   ✅
(3, 4)  +  (3,)     ->  erreur   ❌   (3 ≠ 4 sur le dernier axe)
(3, 4)  +  (3, 1)   ->  (3, 4)   ✅

Pour corriger le cas d'erreur : a + b[:, np.newaxis].

Algèbre linéaire

A @ B                       # produit matriciel
A * B                       # produit terme à terme
np.dot(u, v)                # produit scalaire
np.linalg.norm(v)
np.linalg.inv(A)
np.linalg.solve(A, b)       # résout Ax = b, mieux que inv(A) @ b
np.linalg.eig(A)

Vectoriser au lieu de boucler

# lent
out = [x ** 2 + 1 for x in data]

# rapide
out = data ** 2 + 1

Une boucle Python sur un tableau NumPy annule tout l'intérêt de NumPy. Si la logique semble imposer une boucle, chercher du côté de np.where, np.select, des masques, ou d'un reshape + agrégation par axe.

Similarité cosinus, en pratique

def cosine(a: np.ndarray, b: np.ndarray) -> np.ndarray:
    a = a / np.linalg.norm(a, axis=-1, keepdims=True)
    b = b / np.linalg.norm(b, axis=-1, keepdims=True)
    return a @ b.T

keepdims=True conserve la dimension pour que la division broadcaste correctement — sans lui, la shape passe de (n, 1) à (n,) et la division échoue ou donne un résultat faux.

Sauvegarder

np.save("arr.npy", a)
a = np.load("arr.npy")
np.savez("plusieurs.npz", x=a, y=b)
np.savetxt("arr.csv", a, delimiter=",")

See also

Charger et regarder

import pandas as pd

df = pd.read_csv("ventes.csv")
df = pd.read_csv("ventes.csv", sep=";", parse_dates=["date"], dtype={"code": str})
df = pd.read_parquet("ventes.parquet")
df = pd.read_json("ventes.json")
df.head(3)
df.info()
df.describe()
df.shape
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1240 entries, 0 to 1239
Data columns (total 4 columns):
 #   Column   Non-Null Count  Dtype
---  ------   --------------  -----
 0   date     1240 non-null   datetime64[ns]
 1   produit  1240 non-null   object
 2   region   1198 non-null   object
 3   montant  1240 non-null   float64

info() est le premier réflexe : il donne d'un coup les types et les valeurs manquantes. dtype: object sur une colonne censée être numérique signale presque toujours un problème de parsing.

Sélectionner

df["montant"]                 # une Series
df"produit", "montant"    # un DataFrame
df.loc[3, "montant"]          # par label
df.iloc[3, 2]                 # par position
df.loc[df["montant"] > 100, ["produit", "montant"]]

Filtres combinés : parenthèses obligatoires, et & | ~ au lieu de and or not.

df[(df["montant"] > 100) & (df["region"] == "IDF")]
df[df["produit"].isin(["A", "B"])]
df[df["produit"].str.contains("chaise", case=False, na=False)]
df.query("montant > 100 and region == 'IDF'")

Valeurs manquantes

df.isna().sum()
df.dropna(subset=["region"])
df["region"] = df["region"].fillna("inconnue")
df.isna().sum()
date        0
produit     0
region     42
montant     0
dtype: int64

Grouper et agréger

df.groupby("region")["montant"].sum()
df.groupby("region")["montant"].agg(["sum", "mean", "count"])
df.groupby(["region", "produit"], as_index=False).agg(
    total=("montant", "sum"),
    n=("montant", "size"),
)
df.groupby("region")["montant"].agg(["sum", "mean", "count"])
              sum    mean  count
region
IDF      184203.5  312.55    589
PACA      92310.0  287.29    321
Bretagne  61044.2  254.35    240

as_index=False évite d'avoir à faire .reset_index() juste après.

Transformer

df["ttc"] = df["montant"] * 1.2
df["mois"] = df["date"].dt.to_period("M")
df["cat"] = df["montant"].apply(lambda x: "gros" if x > 500 else "petit")

df = df.rename(columns={"montant": "ht"})
df = df.drop(columns=["temp"])
df = df.sort_values("ht", ascending=False)
df = df.astype({"code": "string", "n": "int32"})

apply sur une Series est lent. Préférer les opérations vectorisées, ou np.where / pd.cut pour les cas conditionnels :

import numpy as np
df["cat"] = np.where(df["ht"] > 500, "gros", "petit")

Joindre et empiler

pd.merge(cmd, clients, on="client_id", how="left")
pd.merge(a, b, left_on="id", right_on="ref", how="inner")
pd.concat([df_jan, df_fev], ignore_index=True)     # empile verticalement

how : left, right, inner, outer. Après un merge, vérifier len(df) — une explosion du nombre de lignes révèle une clé non unique.

Pivots

df.pivot_table(index="region", columns="mois", values="ht", aggfunc="sum", fill_value=0)
df.melt(id_vars=["region"], var_name="mois", value_name="ht")   # l'inverse

Le SettingWithCopyWarning

sub = df[df["ht"] > 100]
sub["remise"] = 0.1          # ⚠️ warning : sub peut être une vue

Corriger avec une copie explicite :

sub = df[df["ht"] > 100].copy()
sub["remise"] = 0.1

Ou modifier en place sur l'original : df.loc[df["ht"] > 100, "remise"] = 0.1.

Exporter

df.to_csv("out.csv", index=False)
df.to_parquet("out.parquet")            # plus rapide et typé, à préférer
df.to_json("out.json", orient="records")

index=False presque toujours, sinon on récupère une colonne Unnamed: 0 au rechargement.

Options d'affichage

pd.set_option("display.max_columns", None)
pd.set_option("display.width", 200)
pd.set_option("display.float_format", "{:.2f}".format)

See also

Validation de données par annotations de type. La v2 a un cœur en Rust et une API qui diffère de la v1 sur plusieurs noms.

Modèle de base

from pydantic import BaseModel, Field

class Item(BaseModel):
    id: int
    title: str
    price: float = Field(gt=0, description="Prix TTC en euros")
    tags: list[str] = []

item = Item(id="42", title="Chaise", price=19.9)   # "42" est converti en int
print(item)
print(item.model_dump())
id=42 title='Chaise' price=19.9 tags=[]
{'id': 42, 'title': 'Chaise', 'price': 19.9, 'tags': []}

La coercition est volontaire : "42" devient 42. Pour l'interdire, model_config = ConfigDict(strict=True).

Les méthodes v2, et leurs anciens noms

v2 v1
model_dump() .dict()
model_dump_json() .json()
model_validate(obj) parse_obj()
model_validate_json(s) parse_raw()
model_json_schema() schema()
model_copy(update=...) .copy()

Erreurs de validation

from pydantic import ValidationError

try:
    Item(id="abc", title="Chaise", price=-1)
except ValidationError as exc:
    print(exc)
2 validation errors for Item
id
  Input should be a valid integer, unable to parse string as an integer
    [type=int_parsing, input_value='abc', input_type=str]
price
  Input should be greater than 0
    [type=greater_than, input_value=-1, input_type=int]

exc.errors() renvoie la même chose en liste de dicts — c'est la forme à renvoyer à un modèle quand on valide ses tool calls.

Contraintes utiles

from typing import Literal, Annotated
from pydantic import BaseModel, Field, HttpUrl, EmailStr

class Agent(BaseModel):
    name: Annotated[str, Field(min_length=1, max_length=64)]
    url: HttpUrl
    email: EmailStr | None = None            # pip install pydantic[email]
    mode: Literal["fast", "default"] = "default"
    budget: int = Field(default=60, ge=1, le=500)

Field(gt, ge, lt, le) pour les nombres, min_length / max_length pour chaînes et listes, pattern= pour une regex.

Valeurs par défaut mutables

from pydantic import BaseModel

class Bad(BaseModel):
    tags: list[str] = []          # sûr ici : pydantic copie la valeur par défaut

Contrairement aux dataclasses et aux fonctions Python, un défaut mutable ne fuit pas entre instances. Pour un défaut calculé : Field(default_factory=list).

Validateurs

from pydantic import BaseModel, field_validator, model_validator

class Range(BaseModel):
    low: int
    high: int

    @field_validator("low", "high")
    @classmethod
    def positive(cls, v: int) -> int:
        if v < 0:
            raise ValueError("doit être positif")
        return v

    @model_validator(mode="after")
    def ordered(self):
        if self.low > self.high:
            raise ValueError("low doit être <= high")
        return self

mode="before" s'exécute sur la valeur brute avant conversion, mode="after" sur l'objet déjà validé.

Modèles imbriqués et parsing JSON

class Result(BaseModel):
    findable: bool
    clicks: int
    notes: str | None = None

class Report(BaseModel):
    site: str
    results: list[Result]

payload = '{"site":"versace.com","results":[{"findable":true,"clicks":3}]}'
report = Report.model_validate_json(payload)
print(report.results[0].clicks)
3

C'est le pattern des sorties structurées de LLM : on impose un schéma, on parse, et une réponse hors format lève au lieu de contaminer la suite.

Schéma JSON pour un tool call

print(Result.model_json_schema())
{'properties': {'findable': {'title': 'Findable', 'type': 'boolean'},
                'clicks': {'title': 'Clicks', 'type': 'integer'},
                'notes': {'anyOf': [{'type': 'string'}, {'type': 'null'}],
                          'default': None, 'title': 'Notes'}},
 'required': ['findable', 'clicks'],
 'title': 'Result',
 'type': 'object'}

C'est exactement ce qu'attend un tools=[...] d'API LLM.

Configuration et settings

from pydantic import ConfigDict
from pydantic_settings import BaseSettings   # pip install pydantic-settings

class Item(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True, strict=False)

class Settings(BaseSettings):
    api_key: str                              # lit API_KEY dans l'environnement
    base_url: str = "https://api.example.com"

settings = Settings()                         # lève si API_KEY manque

extra="forbid" refuse les champs inconnus — précieux pour attraper une faute de frappe dans une config plutôt que de l'ignorer en silence.

See also

Gère des versions de Python, pas des paquets. Fonctionne par shims : de faux exécutables placés très tôt dans le PATH, qui redirigent vers la version active.

Voir et installer

pyenv versions        # installées, l'active marquée d'une étoile
pyenv version         # l'active ICI, et qui l'a décidée
pyenv install --list | rg "^\s*3\.12"
pyenv install 3.12.9
pyenv version
3.12.9 (set by /Users/edouard/code/mon-projet/.python-version)

La deuxième partie est la plus utile : elle dit quel fichier impose la version.

Choisir une version

pyenv global 3.12.9      # défaut de la machine  -> ~/.pyenv/version
pyenv local 3.12.9       # ce dossier            -> ./.python-version
pyenv shell 3.12.9       # ce shell seulement    -> $PYENV_VERSION
pyenv local --unset

Ordre de priorité : shell > local > global.

Environnements nommés (plugin pyenv-virtualenv)

pyenv virtualenv 3.12.9 mon-env    # -> ~/.pyenv/versions/3.12.9/envs/mon-env
pyenv local mon-env                # activation automatique en entrant dans le dossier
pyenv activate mon-env
pyenv deactivate
pyenv virtualenv-delete mon-env

Ils vivent dans ~/.pyenv/versions/, pas dans le projet. C'est la différence de fond avec un .venv local.

venv standard, sans le plugin

pyenv local 3.12.9
python -m venv .venv
source .venv/bin/activate
pip install httpx pydantic
pip freeze > requirements.txt
deactivate

Les deux pièges qui coûtent des minutes

Le shim répond à la place du venv. Un paquet installé dans le venv, et pourtant la commande résolue est celle de pyenv :

which uvicorn
/Users/edouard/.pyenv/versions/lewagon/bin/uvicorn

Contournement immédiat : python -m uvicorn main:app passe par le Python courant et ignore le PATH. Sinon hash -r (zsh : rehash) vide le cache de commandes du shell.

La commande existe ailleurs. Message classique :

pyenv: jupyter: command not found

The `jupyter' command exists in these Python versions:
  3.12.9/envs/lewagon
  3.10.6/envs/taxifare-env

Le shim cherche dans la version active, qui n'a pas le paquet. Soit on active le bon environnement, soit on passe par uv run.

Diagnostic

which -a python python3     # tous les candidats, dans l'ordre du PATH
pyenv which python          # ce que pyenv résoudrait
pyenv doctor                # si le plugin est installé
PYENV_VERSION=system python -V   # contournement ponctuel

Rapport avec uv

uv est indépendant : il télécharge ses propres interpréteurs dans ~/.local/share/uv/python/ et ne consulte jamais pyenv. Seul point de friction : les deux lisent .python-version.

See also

Python requests

page dédiée →

Client HTTP synchrone. Pas de timeout par défaut — toujours le passer explicitement.

Le squelette à écrire de mémoire

import os
import requests

BASE = "https://api.example.com/v1"

r = requests.post(
    f"{BASE}/environments",
    headers={
        "Authorization": f"Bearer {os.environ['API_KEY']}",
        "Content-Type": "application/json",
    },
    json={"name": "demo", "url": "https://example.com"},
    timeout=30,
)
r.raise_for_status()
print(r.status_code, r.json())

json= sérialise le dict et pose Content-Type: application/json tout seul. data= envoie du form-urlencoded ou des bytes bruts — c'est la confusion la plus fréquente.

Réponse

Attribut Contenu
r.status_code entier, 200, 404
r.ok True si status_code < 400
r.json() corps parsé, lève JSONDecodeError si ce n'est pas du JSON
r.text corps brut décodé
r.headers dict insensible à la casse
r.raise_for_status() lève HTTPError sur 4xx/5xx, ne fait rien sinon

Session : réutiliser la connexion et les headers

with requests.Session() as s:
    s.headers.update({"Authorization": f"Bearer {os.environ['API_KEY']}"})
    for page in range(1, 6):
        r = s.get(f"{BASE}/items", params={"page": page}, timeout=30)
        r.raise_for_status()
        print(r.json()["items"])

params= construit la query string, pas besoin de la concaténer à la main.

Polling d'un job asynchrone

import time

def wait_for(session_id: str, timeout_s: int = 300, every_s: int = 3) -> dict:
    deadline = time.time() + timeout_s
    while time.time() < deadline:
        r = requests.get(f"{BASE}/sessions/{session_id}", timeout=30)
        r.raise_for_status()
        payload = r.json()
        if payload["status"] in {"completed", "failed"}:
            return payload
        time.sleep(every_s)
    raise TimeoutError(f"session {session_id} toujours en cours après {timeout_s}s")

Erreurs à distinguer

try:
    r = requests.get(url, timeout=10)
    r.raise_for_status()
except requests.Timeout:              # dépassement du timeout
    ...
except requests.ConnectionError:      # DNS, refus de connexion, réseau
    ...
except requests.HTTPError as exc:     # 4xx / 5xx après raise_for_status
    print(exc.response.status_code, exc.response.text[:200])

Toutes héritent de requests.RequestException.

Charger la clé depuis un .env

from dotenv import load_dotenv   # pip install python-dotenv
load_dotenv()
key = os.environ["API_KEY"]      # KeyError explicite si absente

os.environ["X"] lève si la variable manque, os.getenv("X") renvoie None en silence. Préférer le premier pour une clé obligatoire.

Debug rapide

print(r.request.method, r.request.url)
print(r.request.headers)
print(r.request.body)

Équivalences avec curl

curl requests
-X POST requests.post(...)
-H "K: V" headers={"K": "V"}
-d '{"a":1}' json={"a": 1}
-G --data-urlencode "q=x" params={"q": "x"}
-u user:pass auth=("user", "pass")
--max-time 30 timeout=30

See also

  • httpx — même API, plus l'async et un timeout par défaut

Tenseurs et device

import torch

x = torch.tensor([[1., 2.], [3., 4.]])
torch.zeros(2, 3); torch.ones(2, 3); torch.randn(2, 3)
torch.arange(0, 10, 2); torch.linspace(0, 1, 5)
device = (
    "cuda" if torch.cuda.is_available()
    else "mps" if torch.backends.mps.is_available()
    else "cpu"
)
print(device, torch.__version__)
mps 2.9.1
x = x.to(device)
model = model.to(device)

Erreur la plus fréquente : Expected all tensors to be on the same device. Les données et le modèle doivent être sur le même device, à chaque batch.

Formes

x.shape, x.dtype, x.device
x.view(-1, 4)         # nécessite un tenseur contigu
x.reshape(-1, 4)      # marche toujours, copie si besoin
x.permute(0, 2, 1)    # réordonne les axes
x.unsqueeze(0)        # ajoute un axe -> (1, ...)
x.squeeze()           # retire les axes de taille 1
torch.cat([a, b], dim=0)
torch.stack([a, b])   # crée un nouvel axe

torch.einsum("bij,bjk->bik", a, b) remplace avantageusement les enchaînements de permute + matmul quand la manipulation d'axes devient illisible.

Autograd

w = torch.randn(3, requires_grad=True)
loss = (w ** 2).sum()
loss.backward()
print(w.grad)
tensor([ 1.4832, -0.6210,  2.0044])
with torch.no_grad():        # désactive le graphe : inférence, évaluation
    preds = model(x)

x.detach()                   # coupe un tenseur du graphe

Un modèle

import torch.nn as nn

class MLP(nn.Module):
    def __init__(self, d_in: int, d_hidden: int, d_out: int):
        super().__init__()                    # obligatoire, avant tout le reste
        self.net = nn.Sequential(
            nn.Linear(d_in, d_hidden),
            nn.GELU(),
            nn.Dropout(0.1),
            nn.Linear(d_hidden, d_out),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.net(x)

model = MLP(128, 512, 10).to(device)
print(sum(p.numel() for p in model.parameters()) / 1e6, "M paramètres")
0.13 M paramètres

Boucle d'entraînement

from torch.utils.data import DataLoader, TensorDataset

loader = DataLoader(TensorDataset(X, y), batch_size=32, shuffle=True, num_workers=4)
opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01)
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)
criterion = nn.CrossEntropyLoss()

for epoch in range(epochs):
    model.train()
    for xb, yb in loader:
        xb, yb = xb.to(device), yb.to(device)

        opt.zero_grad(set_to_none=True)     # sinon les gradients s'accumulent
        loss = criterion(model(xb), yb)
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        opt.step()

    sched.step()

    model.eval()
    with torch.no_grad():
        acc = (model(X_val.to(device)).argmax(1) == y_val.to(device)).float().mean()
    print(f"epoch {epoch}  loss {loss.item():.4f}  val_acc {acc:.3f}")
epoch 0  loss 1.8342  val_acc 0.412
epoch 1  loss 1.1907  val_acc 0.638
epoch 2  loss 0.8455  val_acc 0.741

Les quatre oublis classiques : zero_grad absent, model.train() / model.eval() non basculés (dropout et batchnorm se comportent différemment), no_grad manquant en évaluation, et données restées sur le CPU.

Pertes

Tâche Perte Entrée attendue
Classification multi-classe nn.CrossEntropyLoss logits bruts, pas de softmax
Classification binaire nn.BCEWithLogitsLoss logits bruts
Régression nn.MSELoss, nn.L1Loss valeurs

CrossEntropyLoss applique le log-softmax en interne. Y ajouter un softmax dans le modèle est une erreur silencieuse qui dégrade l'apprentissage sans lever.

Précision mixte

scaler = torch.amp.GradScaler(device)

with torch.autocast(device_type=device, dtype=torch.bfloat16):
    loss = criterion(model(xb), yb)

scaler.scale(loss).backward()
scaler.step(opt)
scaler.update()

En bf16 le GradScaler est facultatif ; il reste nécessaire en fp16.

Sauvegarder

torch.save(model.state_dict(), "model.pt")
model.load_state_dict(torch.load("model.pt", map_location=device))
model.eval()

Toujours sauvegarder le state_dict, jamais l'objet modèle : la sérialisation directe casse dès que le code de la classe change.

Checkpoint complet pour reprendre un entraînement :

torch.save({"model": model.state_dict(), "opt": opt.state_dict(), "epoch": epoch}, "ckpt.pt")

Diagnostic mémoire GPU

print(torch.cuda.memory_allocated() / 1e9, "Go")
torch.cuda.empty_cache()

CUDA out of memory : réduire le batch, activer l'accumulation de gradients, passer en précision mixte, ou activer le gradient checkpointing.

Accélérer

model = torch.compile(model)       # gains réels sur GPU récents
torch.backends.cuda.matmul.allow_tf32 = True

See also

scikit-learn

page dédiée →

Une API uniforme : tout estimateur a fit, tout transformateur a transform, tout prédicteur a predict. Le reste en découle.

Découper

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

stratify=y conserve la proportion des classes — indispensable en classification déséquilibrée. random_state rend le découpage reproductible.

Le pipeline, à utiliser systématiquement

from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.ensemble import RandomForestClassifier

numeric = ["age", "montant"]
categorical = ["region", "segment"]

pre = ColumnTransformer([
    ("num", Pipeline([
        ("impute", SimpleImputer(strategy="median")),
        ("scale", StandardScaler()),
    ]), numeric),
    ("cat", Pipeline([
        ("impute", SimpleImputer(strategy="most_frequent")),
        ("onehot", OneHotEncoder(handle_unknown="ignore")),
    ]), categorical),
])

model = Pipeline([("pre", pre), ("clf", RandomForestClassifier(random_state=42))])
model.fit(X_train, y_train)

Le pipeline n'est pas une élégance : il empêche la fuite de données. Un StandardScaler ajusté sur tout le jeu avant le split fait fuiter la moyenne du test dans l'entraînement, et le score devient un mensonge.

handle_unknown="ignore" évite un crash quand une catégorie inconnue apparaît en production.

Évaluer

from sklearn.metrics import classification_report, confusion_matrix, roc_auc_score

y_pred = model.predict(X_test)
print(classification_report(y_test, y_pred))
              precision    recall  f1-score   support

           0       0.91      0.95      0.93       248
           1       0.78      0.66      0.71        72

    accuracy                           0.89       320
   macro avg       0.84      0.80      0.82       320
weighted avg       0.88      0.89      0.88       320

Sur un jeu déséquilibré, l'accuracy ment : ici 89 % semble bon alors que la classe minoritaire n'est rattrapée qu'à 66 %. Regarder le rappel par classe, la matrice de confusion, et l'AUC.

proba = model.predict_proba(X_test)[:, 1]
print(roc_auc_score(y_test, proba))

Validation croisée

from sklearn.model_selection import cross_val_score, StratifiedKFold

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(model, X, y, cv=cv, scoring="f1", n_jobs=-1)
print(scores.mean().round(3), scores.std().round(3))
0.712 0.031

L'écart-type compte autant que la moyenne : un modèle à 0.71 ± 0.03 est utilisable, à 0.71 ± 0.18 il est instable.

Recherche d'hyperparamètres

from sklearn.model_selection import GridSearchCV, RandomizedSearchCV

grid = {
    "clf__n_estimators": [100, 300],
    "clf__max_depth": [None, 10, 20],
    "clf__min_samples_leaf": [1, 5],
}

search = GridSearchCV(model, grid, cv=cv, scoring="f1", n_jobs=-1, verbose=1)
search.fit(X_train, y_train)
print(search.best_params_, search.best_score_)

Le double underscore adresse un paramètre à travers le pipeline : clf__max_depth vise le max_depth de l'étape nommée clf. Au-delà d'une centaine de combinaisons, RandomizedSearchCV(n_iter=50) donne presque le même résultat bien plus vite.

Estimateurs courants

Tâche Point de départ Ensuite
Classification tabulaire LogisticRegression HistGradientBoostingClassifier, RandomForest
Régression tabulaire Ridge HistGradientBoostingRegressor
Clustering KMeans DBSCAN, AgglomerativeClustering
Réduction de dimension PCA TSNE, UMAP (hors sklearn)

HistGradientBoosting* gère les valeurs manquantes nativement et bat presque toujours une forêt aléatoire sur du tabulaire.

Toujours établir une référence triviale avant de comparer :

from sklearn.dummy import DummyClassifier
DummyClassifier(strategy="most_frequent").fit(X_train, y_train).score(X_test, y_test)

Classes déséquilibrées

RandomForestClassifier(class_weight="balanced")
LogisticRegression(class_weight="balanced")

Et ajuster le seuil de décision plutôt que d'accepter 0.5 par défaut :

from sklearn.metrics import precision_recall_curve
p, r, seuils = precision_recall_curve(y_test, proba)

Sauvegarder

import joblib

joblib.dump(model, "model.joblib")
model = joblib.load("model.joblib")

Le pipeline entier est sérialisé, préprocessing compris — c'est le second intérêt majeur du pipeline : le même objet sert en entraînement et en production.

See also

Interface web en Python pur. Le meilleur rapport temps/effet pour une démo client interne.

Le modèle d'exécution, à comprendre en premier

Le script entier est réexécuté de haut en bas à chaque interaction. Pas de callbacks, pas de composants. C'est ce qui rend Streamlit simple et ce qui provoque toutes ses surprises.

Deux conséquences : tout état doit vivre dans st.session_state, et tout calcul coûteux doit être mis en cache.

pip install streamlit
streamlit run app.py
  You can now view your Streamlit app in your browser.

  Local URL: http://localhost:8501
  Network URL: http://192.168.1.24:8501

Afficher

import streamlit as st

st.title("Test d'accessibilité produit")
st.header("Résultats")
st.subheader("Par site")
st.write("Accepte à peu près tout : texte, DataFrame, figure, dict")
st.markdown("**gras**, `code`, [lien](https://example.com)")
st.code("requests.post(url, json=payload)", language="python")
st.json({"findable": True, "clicks": 3})
st.dataframe(df)            # interactif, triable
st.table(df.head())         # statique
st.metric("Clics moyens", 3.2, delta=-0.4)

Saisir

site = st.text_input("Site", value="versace.com")
produit = st.text_area("Produit recherché")
budget = st.slider("Budget d'étapes", 10, 200, 60)
n = st.number_input("Répétitions", min_value=1, max_value=10, value=3)
mode = st.selectbox("Mode", ["fast", "default"])
tags = st.multiselect("Tests", ["navigation", "panier", "checkout"])
strict = st.checkbox("Mode strict")
fichier = st.file_uploader("CSV", type=["csv"])

if st.button("Lancer"):
    ...

Chaque widget renvoie sa valeur courante. Le if st.button(...) n'est vrai que sur le rerun déclenché par le clic.

Mise en page

col1, col2, col3 = st.columns(3)
with col1:
    st.metric("Sessions", 12)

with st.sidebar:
    api_key = st.text_input("Clé API", type="password")

tab1, tab2 = st.tabs(["Résultats", "Logs"])
with tab1:
    st.dataframe(df)

with st.expander("Détails techniques"):
    st.code(trace)

with st.container():
    st.write("bloc regroupé")

État

if "runs" not in st.session_state:
    st.session_state.runs = []

if st.button("Ajouter"):
    st.session_state.runs.append({"site": site})

st.write(f"{len(st.session_state.runs)} exécutions")

Sans session_state, la liste serait recréée vide à chaque interaction.

Cache

@st.cache_data                     # pour des données : DataFrame, JSON, réponses API
def charger(path: str):
    return pd.read_csv(path)

@st.cache_resource                 # pour des objets vivants : client, modèle, connexion
def get_client():
    return SomeClient(api_key=os.environ["API_KEY"])

La distinction compte : cache_data sérialise et renvoie une copie, cache_resource renvoie le même objet partagé entre sessions. Un client HTTP dans cache_data casse.

Retour visuel pendant un traitement long

with st.spinner("Session en cours…"):
    result = run_agent(site, produit)

bar = st.progress(0)
for i, test in enumerate(tests):
    run(test)
    bar.progress((i + 1) / len(tests))

st.success("Terminé")
st.warning("2 tests non concluants")
st.error("Clé API invalide")

with st.status("Navigation…", expanded=True) as s:
    st.write("Ouverture du site")
    st.write("Recherche du produit")
    s.update(label="Terminé", state="complete")

Streaming d'un LLM

def tokens():
    for chunk in client.stream(prompt):
        yield chunk.text

st.write_stream(tokens)

Pour un chat complet, st.chat_message("user" | "assistant") et st.chat_input().

Secrets et configuration

.streamlit/secrets.toml :

API_KEY = "sk-..."
key = st.secrets["API_KEY"]

.streamlit/config.toml pour le thème et le port :

[server]
port = 8501
[theme]
base = "light"

Limites à connaître avant de s'engager

Pas de routing multi-pages fin (juste un dossier pages/), peu de contrôle sur le CSS, et le modèle de rerun devient pénible dès qu'on veut une vraie interactivité. Au-delà de la démo, passer à Next.js avec une API FastAPI derrière.

Déploiement : Streamlit Community Cloud gratuit depuis un repo GitHub, ou un conteneur Docker n'importe où. Pas sur Vercel — Vercel ne fait pas tourner de processus Python long.

See also

Infrastructure décrite en fichiers, appliquée de façon idempotente. On décrit l'état voulu, Terraform calcule les opérations pour y arriver.

Le cycle

terraform init       # télécharge les providers, configure le backend
terraform fmt        # reformate le HCL
terraform validate   # vérifie la syntaxe et les références
terraform plan       # ce qui va changer, sans rien faire
terraform apply      # applique après confirmation
terraform destroy    # supprime tout ce qui est géré
terraform plan
Terraform will perform the following actions:

  # aws_s3_bucket.artifacts will be created
  + resource "aws_s3_bucket" "artifacts" {
      + bucket = "monprojet-artifacts"
      + id     = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Toujours lire le plan avant d'appliquer. La ligne à surveiller est le nombre de destroy : un changement anodin peut provoquer un remplacement de ressource.

Structure d'un projet

main.tf         ressources
variables.tf    entrées
outputs.tf      sorties
providers.tf    providers et versions
terraform.tfvars   valeurs (à ne pas committer si secrets)
terraform {
  required_version = ">= 1.9"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"        # >= 5.0, < 6.0
    }
  }
  backend "s3" {
    bucket = "mon-tfstate"
    key    = "prod/terraform.tfstate"
    region = "eu-west-3"
  }
}

provider "aws" {
  region = var.region
}

Le backend distant est le premier réflexe en équipe : sans lui, le fichier d'état vit sur un poste et deux personnes qui appliquent en même temps se marchent dessus.

Variables et sorties

variable "region" {
  type        = string
  default     = "eu-west-3"
  description = "Région AWS"
}

variable "instance_count" {
  type = number
  validation {
    condition     = var.instance_count > 0
    error_message = "Il faut au moins une instance."
  }
}

output "bucket_url" {
  value = aws_s3_bucket.artifacts.bucket_domain_name
}

output "db_password" {
  value     = random_password.db.result
  sensitive = true      # masqué dans les logs
}
terraform apply -var="region=eu-west-1"
terraform apply -var-file="prod.tfvars"
TF_VAR_region=eu-west-1 terraform apply

Ressources et références

resource "aws_s3_bucket" "artifacts" {
  bucket = "${var.project}-artifacts"

  tags = {
    Environment = var.env
    ManagedBy   = "terraform"
  }
}

resource "aws_s3_bucket_versioning" "artifacts" {
  bucket = aws_s3_bucket.artifacts.id     # crée la dépendance implicite
  versioning_configuration {
    status = "Enabled"
  }
}

Le graphe de dépendances se déduit des références. depends_on ne sert que pour les dépendances invisibles.

Boucles et conditions

resource "aws_instance" "worker" {
  count         = var.instance_count
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
  tags = { Name = "worker-${count.index}" }
}

resource "aws_s3_bucket" "per_env" {
  for_each = toset(["dev", "staging", "prod"])
  bucket   = "monprojet-${each.key}"
}

locals {
  is_prod = var.env == "prod"
  size    = local.is_prod ? "t3.large" : "t3.micro"
}

Préférer for_each à count : avec count, supprimer un élément au milieu décale les index et Terraform détruit puis recrée tout ce qui suit.

Data sources

data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"]
  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
  }
}

État

terraform state list
terraform state show aws_s3_bucket.artifacts
terraform import aws_s3_bucket.artifacts mon-bucket-existant
terraform state rm aws_s3_bucket.artifacts     # oublie sans détruire
terraform refresh

import sert à reprendre la main sur une ressource créée à la main dans la console.

Modules

module "network" {
  source = "./modules/network"
  cidr   = "10.0.0.0/16"
}

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.8.1"
  name    = "prod"
}

# module.network.subnet_ids pour lire une sortie

Cibler et forcer

terraform plan -target=aws_s3_bucket.artifacts     # dépannage, pas une habitude
terraform apply -replace=aws_instance.worker[0]    # recrée une ressource
terraform apply -auto-approve                      # CI uniquement

Pièges

Le .tfstate contient des secrets en clair : jamais dans git, toujours dans un backend chiffré. Un terraform destroy sur le mauvais workspace est irréversible. Et le plan n'est valide qu'à l'instant où il est produit — si quelqu'un modifie l'infra à la main entre-temps, l'apply diverge.

See also

Gestionnaire de projets Python en Rust. Remplace pyenv + venv + pip + pip-tools d'un seul coup, et télécharge ses propres interpréteurs.

Démarrer un projet

uv init --python 3.12 mon-projet
cd mon-projet
uv add httpx pydantic pytest
uv run python -V
Using CPython 3.12.11
Creating virtual environment at: .venv
Python 3.12.11

Toujours passer --python. Sans lui, uv prend sa version par défaut — souvent une 3.13 ou 3.14 — et écrit requires-python = ">=3.13" dans le pyproject.toml.

uv init crée pyproject.toml, .python-version, main.py, README.md, .gitignore et un dépôt git. Le .venv/ n'apparaît qu'au premier add ou run.

Variante Résultat
uv init projet application, fichiers à plat
uv init --package src/<nom>/__init__.py + [build-system]
uv init --lib idem plus py.typed

Le quotidien

uv add httpx                  # ajoute et installe
uv add --dev pytest ruff      # dépendance de dev
uv remove httpx
uv sync                       # aligne .venv sur uv.lock
uv lock                       # regénère le lock sans installer
uv tree                       # arbre des dépendances
uv run <commande>             # exécute dans le venv, sans activation

uv add et uv run synchronisent implicitement. uv sync sert surtout après un clone, ou quand le .venv est cassé.

uv sync désinstalle ce qui n'est pas dans le lock, contrairement à pip install -r.

uv sync
Resolved 100 packages in 4ms
Uninstalled 3 packages in 8ms
 - markdown-it-py==4.2.0
 - mdurl==0.1.2
 - rich==15.0.0

Reprendre un repo existant

git clone <url> && cd <repo> && ls
Ce qu'on trouve Ce qu'on lance
uv.lock uv sync
pyproject.toml seul uv sync (le lock est généré)
requirements.txt uv venv --python 3.12 puis uv pip install -r requirements.txt
poetry.lock poetry install, ou uv pip install -e .
rien uv init --python 3.12

uv venv + uv pip ne modifient aucun fichier du repo — c'est la voie propre pour accélérer l'installation chez un client sans imposer son outillage.

Le piège .python-version

uv et pyenv lisent ce fichier. Si uv y écrit 3.13 et que pyenv n'a pas de 3.13, pyenv affiche une erreur à chaque commande dans ce dossier. C'est du bruit, uv continue de fonctionner, mais c'est pénible.

# corriger requires-python dans pyproject.toml D'ABORD
uv python pin 3.12
Updated `.python-version` from `3.13` -> `3.12`

Dans l'autre ordre, uv refuse : The requested Python version 3.12 is incompatible with the project requires-python value of >=3.13.

Outils globaux

uv tool install ruff          # installé isolément, disponible partout
uv tool list
uvx ruff check .              # exécute sans installer

Diagnostic

uv run python -c "import sys; print(sys.executable)"
/Users/edouard/code/mon-projet/.venv/bin/python3

Le chemin doit contenir /.venv/ et être dans le projet. S'il pointe vers .pyenv/shims, /opt/homebrew ou /usr/bin, on n'est pas où on croit.

uv python list        # interpréteurs connus
uv cache clean        # vider le cache

Pour un live coding sous pression, activer une fois et oublier le préfixe :

source .venv/bin/activate

See also