Cheatsheets — vue longue
retour à la listeToutes 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
curl
page dédiée →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
Docker
page dédiée →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
Gradio
page dédiée →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
LangChain
page dédiée →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
LangGraph
page dédiée →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.
Navigation et images
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
NumPy
page dédiée →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
pandas
page dédiée →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
Pydantic v2
page dédiée →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
pyenv
page dédiée →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
PyTorch
page dédiée →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
Streamlit
page dédiée →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
Terraform
page dédiée →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