Config (pydantic-settings, 12-factor)
TL;DR — La config d'une app n'est pas du code : c'est l'ensemble des valeurs qui changent entre
local,stagingetprod(URLs de DB, clés API, feature flags, timeouts). Le pattern 12-factor dit : mets tout ça dans l'environnement, jamais en dur dans le code, jamais commité. En Python moderne, on ne lit pasos.environà la main : on utilisepydantic-settings(BaseSettings) qui charge depuis l'environnement +.env, valide et type chaque valeur au démarrage, et fail fast si une clé manque ou est mal formée. On expose ensuite ces settings comme un singleton caché (@lru_cache) qu'on injecte via la DI de FastAPI — y compris la cléANTHROPIC_API_KEY, le choix de modèle et les timeouts de ton agent.
🧠 Mental model
Analogie. Pense à ta config comme au tableau de bord d'une voiture de location. Le moteur (ton code métier) est identique quelle que soit la voiture. Ce qui change, c'est ce que tu règles au démarrage : le siège, les rétros, la langue du GPS, le plein d'essence. Tu ne ressoudes pas le moteur à chaque location — tu lis les réglages depuis l'extérieur et tu refuses de partir si le réservoir est vide (fail fast).
En venant de NestJS, tu connais déjà @nestjs/config et ConfigService — pydantic-settings est l'équivalent Python, en plus strict (validation Pydantic v2 native). En venant de PHP/Laravel, c'est le config/*.php + .env + env(), mais typé et validé au boot au lieu de renvoyer des strings non vérifiées.
Le principe central du 12-factor : strict separation of config from code.
┌─────────────────────────────────────────────────────────┐
│ Sources (par ordre de priorité décroissante) │
│ │
│ 1. Arguments init explicites Settings(db_url=...) │ ← tests
│ 2. Variables d'environnement APP_DB_URL=... │ ← prod (12-factor)
│ 3. Fichier .env APP_DB_URL=... │ ← dev local
│ 4. Secrets (/run/secrets/...) docker secret │ ← prod secrets
│ 5. Valeurs par défaut db_url: str = "sqlite" │ ← fallback
└────────────────────────┬─────────────────────────────────┘
│ pydantic-settings parse + valide
▼
┌───────────────────┐
│ Settings │ ← objet typé, immuable, validé
│ .db_url: PgDsn │ (fail fast si invalide)
│ .effort: Literal │
└─────────┬──────────┘
│ @lru_cache → 1 seule instance
▼
Injecté partout via Depends(get_settings)La règle d'or : la config est lue une fois, au démarrage, validée, puis figée. Tout le reste du code reçoit un objet typé — jamais os.getenv("TIMEOUT") enfoui au fond d'une fonction.
Le cœur : BaseSettings
La mauvaise façon (à ne PLUS jamais faire)
# ❌ La façon "PHP 2010" — éparpillée, non typée, non validée
import os
DATABASE_URL = os.getenv("DATABASE_URL", "postgres://localhost/app")
MAX_RETRIES = int(os.getenv("MAX_RETRIES", "3")) # ValueError si "abc" en prod
DEBUG = os.getenv("DEBUG", "false") == "true" # "True", "1", "yes" → cassés
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY") # peut être None → crash à 3h du matinPourquoi c'est piégeux :
- Aucune validation.
MAX_RETRIES=abcne casse pas au boot, mais au premier appel — souvent en prod, dans un chemin de code rare. - Booléens fragiles.
"false"est truthy en Python ; ton== "true"rate"1","yes","True". - Pas de source unique. Les clés sont disséminées, impossible de savoir ce dont l'app a besoin.
Nonesilencieux. Une clé manquante donneNone, qui se propage jusqu'à unTypeErroropaque dix couches plus loin.
La façon idiomatique (Python 3.12 + Pydantic v2)
# config.py
from functools import lru_cache
from typing import Literal
from pydantic import Field, PostgresDsn, SecretStr, ValidationInfo, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="APP_", # APP_DB_URL → db_url
env_file=".env", # chargé en dev ; absent en prod = OK
env_file_encoding="utf-8",
case_sensitive=False, # APP_DB_URL == app_db_url
extra="forbid", # une clé inconnue dans .env = erreur (anti-typo)
frozen=True, # settings immuables après chargement
)
# --- App ---
env: Literal["local", "staging", "production"] = "local"
debug: bool = False
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
# --- Base de données ---
db_url: PostgresDsn # pas de défaut → REQUIS, fail fast
db_pool_size: int = Field(default=10, ge=1, le=100)
# --- Agent / Anthropic ---
anthropic_api_key: SecretStr # SecretStr : pas loggé en clair
anthropic_model: str = "claude-opus-4-8"
anthropic_effort: Literal["low", "medium", "high", "xhigh", "max"] = "high"
anthropic_max_tokens: int = Field(default=4096, ge=1, le=128_000)
request_timeout_s: float = Field(default=600.0, gt=0)
@field_validator("debug")
@classmethod
def no_debug_in_prod(cls, v: bool, info: ValidationInfo) -> bool:
# garde-fou : DEBUG=true interdit en production
if v and info.data.get("env") == "production":
raise ValueError("debug must be False in production")
return v
@lru_cache
def get_settings() -> Settings:
"""Singleton : construit et validé une seule fois par process."""
return Settings() # type: ignore[call-arg] # les champs viennent de l'envCe que tu gagnes immédiatement :
- Fail fast typé.
db_urlsans valeur →ValidationErrorexplicite au démarrage, avec le nom du champ. L'app refuse de booter plutôt que de mourir en prod. - Coercition correcte.
APP_DEBUG=1,true,yes,on→True; Pydantic gère le parsing booléen pour toi. - Bornes.
Field(ge=1, le=100)rejettedb_pool_size=0ou999. - Secrets protégés.
SecretStrempêche la fuite de la clé dans un log ou unrepr()(str(settings)affiche**********). - Source unique. La classe
Settingsest la documentation de tout ce que l'app consomme.
Le .env correspondant (jamais commité — voir .gitignore) :
# .env (dev local uniquement)
APP_ENV=local
APP_DEBUG=true
APP_DB_URL=postgresql://user:pass@localhost:5432/app
APP_ANTHROPIC_API_KEY=sk-ant-...
APP_ANTHROPIC_EFFORT=medium⚠️ Le
.envest pour le dev, pas pour la prod. En prod (Kubernetes, ECS, Cloud Run…), les variables viennent de l'environnement réel ou d'un secret manager — pas d'un fichier dans l'image. Leenv_fileétant optionnel, le même code marche dans les deux mondes : si.envn'existe pas, pydantic-settings se rabat sur l'environnement.
Champs imbriqués et types composés
Pour une vraie app tu auras des groupes logiques. Pydantic v2 gère l'imbrication via un délimiteur :
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
class RedisSettings(BaseModel):
host: str = "localhost"
port: int = 6379
db: int = 0
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="APP_",
env_nested_delimiter="__", # APP_REDIS__HOST → redis.host
)
redis: RedisSettings = RedisSettings()
cors_origins: list[str] = [] # APP_CORS_ORIGINS='["http://a","http://b"]' (JSON)APP_REDIS__HOST=cache.internal
APP_REDIS__PORT=6380
APP_CORS_ORIGINS=["https://app.example.com","https://admin.example.com"]Les list / dict sont parsés en JSON depuis l'environnement — c'est le piège n°1 des nouveaux venus (APP_CORS_ORIGINS=a,b,c ne marche pas par défaut ; il faut du JSON, ou un validateur custom qui split(",")).
Intégration FastAPI (DI + singleton)
Le réflexe est de Depends(get_settings). Grâce au @lru_cache, get_settings() ne construit l'objet qu'une fois ; tous les handlers reçoivent la même instance.
# main.py
from typing import Annotated
from fastapi import Depends, FastAPI
from .config import Settings, get_settings
app = FastAPI()
# Alias typé réutilisable — propre et DRY
SettingsDep = Annotated[Settings, Depends(get_settings)]
@app.get("/health")
async def health(settings: SettingsDep) -> dict[str, str]:
return {"env": settings.env, "model": settings.anthropic_model}Pourquoi le singleton caché plutôt qu'un import global ?
Tu pourrais faire settings = Settings() au niveau module et l'importer partout. C'est tentant mais ça a deux défauts :
- Effet de bord à l'import. L'objet est construit (et peut lever une
ValidationError) dès que n'importe quoi importe le module — y compris pytest pendant la collecte des tests, avant que tu aies pu monkeypatcher l'environnement. - Intestable. Tu ne peux pas substituer une config de test sans bidouiller l'import.
Avec Depends(get_settings), tu override proprement en test :
# conftest.py
from app.config import Settings, get_settings
from app.main import app
def _test_settings() -> Settings:
return Settings(
env="local",
db_url="postgresql://localhost/test",
anthropic_api_key="sk-ant-test",
anthropic_effort="low",
)
app.dependency_overrides[get_settings] = _test_settingsAucun environnement à patcher, aucune fuite entre tests. C'est l'équivalent FastAPI de l'override de provider dans un Test.createTestingModule() de NestJS.
Servir un agent Anthropic configuré
C'est là que la config devient critique : ta clé API, ton choix de modèle, l'effort et les timeouts ne doivent jamais être en dur. On construit le client AsyncAnthropic une fois, on le partage via le lifespan, et on lit tous ses paramètres depuis Settings.
# agent.py
from contextlib import asynccontextmanager
from typing import Annotated, AsyncIterator
from anthropic import AsyncAnthropic
from fastapi import Depends, FastAPI, Request
from .config import Settings, get_settings
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
settings = get_settings()
# Le SDK lit ANTHROPIC_API_KEY tout seul, mais on injecte explicitement
# depuis Settings pour garder UNE seule source de vérité.
app.state.anthropic = AsyncAnthropic(
api_key=settings.anthropic_api_key.get_secret_value(),
timeout=settings.request_timeout_s,
max_retries=4, # le SDK retry 429/5xx en backoff exponentiel
)
yield
await app.state.anthropic.close()
app = FastAPI(lifespan=lifespan)
SettingsDep = Annotated[Settings, Depends(get_settings)]
def get_client(request: Request) -> AsyncAnthropic:
return request.app.state.anthropic
ClientDep = Annotated[AsyncAnthropic, Depends(get_client)]Streaming des tokens (SSE) piloté par la config
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
router = APIRouter()
class ChatIn(BaseModel):
prompt: str
@router.post("/chat/stream")
async def chat_stream(
body: ChatIn, client: ClientDep, settings: SettingsDep
) -> StreamingResponse:
async def token_gen():
# adaptive thinking + effort lus depuis la config — JAMAIS en dur
async with client.messages.stream(
model=settings.anthropic_model,
max_tokens=settings.anthropic_max_tokens,
thinking={"type": "adaptive"},
output_config={"effort": settings.anthropic_effort},
messages=[{"role": "user", "content": body.prompt}],
) as stream:
async for text in stream.text_stream:
yield f"data: {text}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(token_gen(), media_type="text/event-stream")Le bénéfice config-first est concret : pour passer ton agent de claude-opus-4-8 à claude-haiku-4-5 en staging (moins cher, plus rapide pour les tests E2E), tu changes une variable d'environnement — APP_ANTHROPIC_MODEL=claude-haiku-4-5 — sans toucher au code ni redéployer une image différente. Idem pour APP_ANTHROPIC_EFFORT=low quand tu veux réduire les coûts sur un environnement de démo.
Structured outputs et tool-use : mêmes leviers
Le pattern reste identique pour les sorties structurées (messages.parse()) ou une boucle tool-use. Tout ce qui varie par environnement — modèle, effort, max_tokens, timeout — vient de Settings :
from pydantic import BaseModel
class Ticket(BaseModel):
title: str
severity: Literal["low", "medium", "high"]
async def classify(text: str, client: ClientDep, settings: SettingsDep) -> Ticket:
# output_format (le raccourci) et output_config sont mutuellement exclusifs ;
# pour combiner schéma + effort, on passe les deux dans output_config.
msg = await client.messages.parse(
model=settings.anthropic_model,
max_tokens=settings.anthropic_max_tokens,
output_config={
"format": {"type": "json_schema", "schema": Ticket.model_json_schema()},
"effort": settings.anthropic_effort,
},
messages=[{"role": "user", "content": text}],
)
return msg.parsed_output⚙️ En production
Failure modes (et comment les désamorcer)
| Mode de défaillance | Cause | Désamorçage |
|---|---|---|
| App boote, crash 3h plus tard | clé lue avec os.getenv sans validation | db_url: PostgresDsn sans défaut → fail fast au boot |
.env de dev déployé en prod | fichier dans l'image Docker | .dockerignore + variables d'env réelles ; le .env reste optionnel |
Typo APP_ANTROPIC_KEY ignorée | clé surnuméraire silencieuse | extra="forbid" rejette les clés inconnues |
| Clé API dans les logs | logger.info(settings) | SecretStr masque (**********) ; ne jamais .get_secret_value() dans un log |
| Liste mal parsée | APP_CORS=a,b au lieu de JSON | format JSON, ou validateur split(",") documenté |
| Config divergente entre workers | chaque worker relit l'env | OK si l'env est identique ; un seul secret manager comme source |
Performance
@lru_cacheest obligatoire, pas cosmétique. Sans lui,Settings()reparse.envet l'environnement à chaqueDepends— du disk I/O par requête. Avec, c'est zéro coût après le premier appel.frozen=True: settings immuables. Évite les bugs « quelqu'un a mutésettings.debugau runtime ». La config doit être figée après le boot.
Sécurité
- Jamais de secret en dur, jamais commité. Le
.envva dans.gitignore. Commits un.env.examplesans valeurs comme documentation. SecretStrpartout pour clés API, mots de passe, tokens.get_secret_value()uniquement au point d'usage (construction du client), jamais dans un log ou une réponse HTTP.- Secrets ≠ config. Une URL de DB est de la config ; le mot de passe dedans est un secret. En prod, sors les secrets vers Vault / AWS Secrets Manager / Docker secrets (
secrets_dirde pydantic-settings lit/run/secrets/*). - Validation = surface d'attaque réduite.
Literal["local","staging","production"]rejette une valeur d'env injectée arbitraire.
Observabilité
- Logue la config au démarrage, masquée. Au boot, émets un log INFO avec
settings.model_dump()—SecretStry apparaît masqué — pour savoir exactement avec quelle config le process tourne. Inestimable pour débugger « ça marche en staging pas en prod ». - Expose
envetmodeldans/health(comme plus haut) pour vérifier d'un coup d'œil quel environnement et quel modèle servent.
Les tradeoffs du senior
extra="forbid"vsextra="ignore".forbidattrape les typos mais casse si une plateforme injecte des vars système (PORT,PATH…). La parade : unenv_prefix(APP_) qui isole tes vars du bruit système — alorsforbidne voit que tes clés.- Défaut vs requis. Un défaut rend une clé optionnelle… et masque une mauvaise config. Règle : les secrets et les URLs externes n'ont jamais de défaut (fail fast) ; les réglages de tuning (pool size, timeouts) peuvent en avoir un sensé.
- Un gros
Settingsvs plusieurs. En monolithe, un seulSettingssuffit. Si tu as plusieurs services/workers, sous-classe par contexte (WebSettings,WorkerSettings) partageant une base — chacun ne valide que ce qu'il consomme.
🏋️ Exercices
1. (implement) Le Settings qui fail fast
Objectif. Écris un Settings avec : env (Literal), db_url (PostgresDsn, requis), anthropic_api_key (SecretStr, requis), anthropic_effort (Literal[...], défaut "high"), port (int, 1..65535, défaut 8000). Démontre que lancer le process sans APP_DB_URL lève une ValidationError claire au boot, et qu'un repr ne révèle pas la clé.
Indice/Solution. env_prefix="APP_", pas de défaut sur db_url ni anthropic_api_key. Test : with pytest.raises(ValidationError): Settings(_env_file=None) après avoir vidé l'env. Vérifie "sk-ant" not in repr(settings) et str(settings.anthropic_api_key) == "**********".
2. (implement) Le validateur de liste tolérant
Objectif. Ajoute cors_origins: list[str]. Fais en sorte qu'il accepte et le JSON (["a","b"]) et la forme CSV (a,b,c), pour ne pas piéger les ops.
Indice/Solution. @field_validator("cors_origins", mode="before") : si isinstance(v, str) et que v ne commence pas par [, retourne [s.strip() for s in v.split(",") if s.strip()]. Sinon laisse Pydantic parser le JSON.
3. (production-grade) DI + override en test
Objectif. Branche get_settings (caché) sur un endpoint FastAPI. Écris un test qui override get_settings pour pointer vers une config de test, sans toucher aux variables d'environnement réelles, et vérifie que /health renvoie env="local".
Indice/Solution. app.dependency_overrides[get_settings] = lambda: Settings(env="local", db_url=..., anthropic_api_key="sk-ant-test"). Pense à get_settings.cache_clear() entre tests si tu modifies l'env, et nettoie app.dependency_overrides.clear() en teardown.
4. (production-grade) Secrets séparés de la config
Objectif. Configure Settings pour lire les secrets depuis /run/secrets/ (Docker secrets) en plus de l'environnement, avec la bonne priorité (secret > env > .env > défaut). Démontre qu'un fichier /run/secrets/app_anthropic_api_key est bien pris en compte.
Indice/Solution. SettingsConfigDict(secrets_dir="/run/secrets"). Pour personnaliser la priorité, override settings_customise_sources(cls, ...) et réordonne le tuple (init, env, dotenv, file_secret). En test, pointe secrets_dir vers un tmp_path.
5. (break-then-fix) Le piège du singleton à l'import
Objectif. Pars d'un settings = Settings() au niveau module. Constate qu'un import dans conftest.py lève une ValidationError pendant la collecte des tests (avant tout monkeypatch). Répare en passant à @lru_cache def get_settings().
Indice/Solution. Le bug : l'objet est construit à l'import, donc avant que pytest ait pu poser les env vars. Le fix transforme la construction en appel paresseux — rien n'est validé tant que get_settings() n'est pas effectivement appelé, ce que tu contrôles via la DI/override.
6. (break-then-fix) Le secret qui fuite dans les logs
Objectif. Logue logger.info("config: %s", settings.model_dump()) et observe si la clé fuite. Mets en place une config où même model_dump() masque le secret, mais où le code de construction du client y accède correctement.
Indice/Solution. Avec SecretStr, model_dump() renvoie l'objet masqué (SecretStr('**********')) — sûr à logguer. Mais client = AsyncAnthropic(api_key=settings.anthropic_api_key) passerait l'objet SecretStr, pas la string ! Le fix : .get_secret_value() uniquement à la construction du client, jamais ailleurs. Ajoute un test qui scanne la sortie de log et assert que "sk-ant" n'y apparaît pas.
🎤 En entretien
Q : Pourquoi pydantic-settings plutôt que os.getenv + python-dotenv ? R : Parce que ça valide et type au démarrage (fail fast) au lieu de propager des strings ou des None jusqu'à un crash en prod ; une classe Settings est aussi la source unique et documentée de toute la config consommée.
Q : Comment tu gères les secrets en prod par rapport au dev ? R : En dev, .env (gitignoré) ; en prod, variables d'environnement réelles ou un secret manager (Vault / AWS Secrets Manager / Docker secrets_dir), jamais un fichier dans l'image — SecretStr empêche la fuite dans les logs, et get_secret_value() n'est appelé qu'au point d'usage.
Q : Pourquoi @lru_cache sur get_settings() et pas un global ? R : Le cache donne un singleton paresseux — construit une fois, donc pas de re-parsing du .env à chaque Depends (perf) et pas d'effet de bord à l'import (testabilité) ; et via dependency_overrides, je substitue une config de test sans patcher l'environnement.
Q : Tu sers un agent LLM — où mettre le choix du modèle et l'effort ? R : Dans Settings, lus depuis l'env (APP_ANTHROPIC_MODEL, APP_ANTHROPIC_EFFORT), injectés via la DI — ainsi je passe d'opus-4-8 en prod à haiku-4-5 en staging par une variable d'environnement, sans changer le code ni l'image Docker, et le timeout/max_tokens suivent le même chemin.