Types modernes & data model (3.12)
TL;DR — En Python 3.12, le système de types est purement déclaratif :
x: intn'est pas vérifié au runtime, c'est un contrat lu parmypy/pyrightet par les humains. Le vrai contrôle vient d'ailleurs — du data model (les méthodes__dunder__qui définissent comment un objet se comporte) et, pour les frontières (API, LLM, DB), de Pydantic v2 qui valide les données à l'exécution. La règle senior : annotez tout (str | None,list[dict[str, int]]), laissez le typage statique attraper les bugs en CI, et utilisez Pydantic uniquement aux boundaries où des données externes entrent dans votre programme — typiquement la réponse JSON structurée d'un agent Claude. Confondre « hint » et « validation » est l'erreur n°1 des gens qui viennent de TypeScript.
🧠 Mental model
Si vous venez de TypeScript, votre intuition est à recâbler. En TS, les types sont effacés à la compilation mais le compilateur refuse de produire du JS si le typage est cassé : le contrôle est un gate bloquant. En Python, les annotations sont effacées elles aussi — mais il n'y a pas de gate. python script.py tourne quoi qu'il arrive, même si chaque ligne ment sur ses types.
L'analogie qui marche : les annotations Python sont des panneaux de signalisation, pas des barrières.
TypeScript Python 3.12
────────── ───────────
type → compilateur → bloque annotation → mypy (optionnel) → warning
(barrière) (panneau)
RUNTIME RUNTIME
┌──────────────┐ ┌──────────────┐
│ types effacés│ │ types effacés│ ← identique !
│ JS pur │ │ bytecode pur │
└──────────────┘ └──────────────┘Au runtime, Python ne connaît qu'une seule chose : le data model. Tout objet est défini par les méthodes spéciales qu'il implémente — __len__, __iter__, __eq__, __hash__, __getitem__… C'est le vrai « système de types » de Python : il est structurel et comportemental. Un objet « est une séquence » non pas parce qu'il hérite de Sequence, mais parce qu'il implémente __getitem__ et __len__. C'est du duck typing formalisé.
Donc trois couches à tenir distinctes dans votre tête :
- Annotations (
x: int) — contrat lu par les outils statiques et les humains. Zéro effet runtime. - Data model (
__dunder__) — ce que l'interpréteur exécute réellement pour faire fonctionnerlen(x),x[0],x == y,for i in x. - Validation (Pydantic,
dataclasses+ checks) — le pont entre « données externes inconnues » et « objets typés sûrs » à l'intérieur de votre programme.
Pour un dev qui construit des agents : la couche 3 est celle qui vous sauve. Un LLM renvoie du texte ; vous voulez un objet typé. Pydantic est le sas de décontamination.
Les annotations : un contrat, pas une vérification
Commençons par l'erreur fondatrice. Voici du code qui « ment » et que Python exécute sans broncher :
def add(a: int, b: int) -> int:
return a + b
result = add("hello", "world") # mypy hurle ; Python renvoie "helloworld"
print(result) # str, pas int — aucune exceptionL'annotation -> int n'a rien vérifié. C'est mypy ou pyright (en CI, dans votre éditeur) qui aurait signalé l'erreur. Le runtime, lui, a fait de la concaténation de strings.
À l'inverse — et c'est ce qui surprend les gens de TS — les annotations sont introspectables au runtime, ce qui est précisément ce qui permet à Pydantic et FastAPI d'exister :
import inspect
def greet(name: str, times: int = 1) -> str:
return f"{name} " * times
sig = inspect.signature(greet)
print(sig.parameters["name"].annotation) # <class 'str'>
print(greet.__annotations__) # {'name': <class 'str'>, 'times': <class 'int'>, 'return': <class 'str'>}FastAPI et Pydantic lisent __annotations__ pour générer des validateurs et des schémas OpenAPI. L'annotation est inerte pour l'interpréteur mais active pour les frameworks qui choisissent de la lire. Tenez les deux idées en même temps.
Syntaxe moderne (3.10+ / 3.12)
Oubliez typing.List, typing.Optional, typing.Union. Depuis 3.10, on écrit :
# ✅ Idiomatique 3.12
def f(x: int | None, items: list[dict[str, int]]) -> tuple[str, ...]: ...
# ❌ Style legacy (3.8) — fonctionne encore mais ne l'écrivez plus
from typing import List, Dict, Optional, Union, Tuple
def f(x: Optional[int], items: List[Dict[str, int]]) -> Tuple[str, ...]: ...int | NoneremplaceOptional[int](et signale clairement « ce truc peut êtreNone»).list,dict,tuple,setsont génériques directement — plus besoin d'importer leurs alias.X | YremplaceUnion[X, Y].
En 3.12, on a aussi la nouvelle syntaxe de génériques (PEP 695) :
# 3.12 : pas de TypeVar explicite
def first[T](items: list[T]) -> T:
return items[0]
class Box[T]:
def __init__(self, value: T) -> None:
self.value = value
# Avant 3.12, il fallait :
# from typing import TypeVar
# T = TypeVar("T")
# def first(items: list[T]) -> T: ...Le data model : ce que Python exécute vraiment
Voici la partie qui compte au runtime. Un objet Python « se comporte » selon les dunders qu'il implémente. Construisons un type qui se comporte comme un nombre de tokens — utile quand on facture des appels LLM.
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class TokenUsage:
input_tokens: int
output_tokens: int
@property
def total(self) -> int:
return self.input_tokens + self.output_tokens
def __add__(self, other: TokenUsage) -> TokenUsage:
# Permet : usage_a + usage_b
return TokenUsage(
self.input_tokens + other.input_tokens,
self.output_tokens + other.output_tokens,
)
def __lt__(self, other: TokenUsage) -> bool:
# Permet : sorted([...]), min(), max(), a < b
return self.total < other.total
def cost_usd(self, in_per_mtok: float, out_per_mtok: float) -> float:
return (
self.input_tokens / 1_000_000 * in_per_mtok
+ self.output_tokens / 1_000_000 * out_per_mtok
)
# claude-opus-4-8 : 5 USD / 25 USD par Mtok (input/output)
a = TokenUsage(input_tokens=12_000, output_tokens=3_000)
b = TokenUsage(input_tokens=8_000, output_tokens=1_500)
combined = a + b # __add__
print(combined.total) # 24500
print(combined.cost_usd(5.0, 25.0)) # 0.2125
print(max(a, b)) # __lt__ → TokenUsage(12000, 3000)Décortiquons les choix seniors ici :
@dataclassgénère__init__,__repr__,__eq__pour vous. Vous écrivez les champs une fois.frozen=Truerend l'instance immuable (hashable, sûre à partager entre threads/coroutines, utilisable comme clé de dict). Pour un objet « valeur » comme un usage de tokens, l'immuabilité est le bon défaut.slots=True(3.10+) supprime le__dict__par instance : moins de mémoire, accès attribut plus rapide, et interdit l'ajout d'attributs accidentels. Sur des millions d'objets (logs d'agent, par exemple), c'est mesurable.- Les dunders (
__add__,__lt__) branchent votre objet sur la syntaxe et les built-ins (+,<,sorted,max). C'est ça, le système de types de Python au runtime.
La bonne façon vs la mauvaise façon
Mauvaise façon — réinventer l'égalité et la représentation à la main :
class TokenUsageBad:
def __init__(self, input_tokens, output_tokens): # pas d'annotations
self.input_tokens = input_tokens
self.output_tokens = output_tokens
def equals(self, other): # méthode custom au lieu de __eq__
return (self.input_tokens == other.input_tokens
and self.output_tokens == other.output_tokens)
u1 = TokenUsageBad(100, 50)
u2 = TokenUsageBad(100, 50)
print(u1 == u2) # False ! identité par défaut, pas valeur
print(u1) # <__main__.TokenUsageBad object at 0x10...> illisible
{u1} # marche, mais hash par identité → bugs subtilsu1 == u2 renvoie False car sans __eq__, Python compare l'identité (is). Et le __repr__ par défaut est inutile en debug et en logs. Vous réimplémentez mal ce que la dataclass fait correctement.
Bonne façon — la dataclass ci-dessus : __eq__ par valeur, __repr__ lisible (TokenUsage(input_tokens=100, output_tokens=50)), __hash__ cohérent (parce que frozen=True).
__eq__ et __hash__ : le couple à ne jamais casser
Règle du data model que tout senior doit connaître : si vous définissez __eq__, vous devez gérer __hash__. Deux objets égaux doivent avoir le même hash, sinon ils se perdent dans les set/dict.
@dataclass(frozen=True)→ génère un__hash__cohérent automatiquement. ✅@dataclass(mutable, défaut) → met__hash__ = None, l'objet devient unhashable (volontairement : un objet mutable ne devrait pas être clé de dict, car muter le ferait disparaître de la table). ✅ sûr.classcustom avec__eq__mais sans__hash__→ l'objet devient unhashable. C'est le piège : vous croyez pouvoir le mettre dans unset, etTypeErrorau runtime.
Pydantic v2 : la validation aux frontières
Les dataclasses ne valident rien. TokenUsage(input_tokens="beaucoup", output_tokens=None) passe sans broncher — vous obtenez un objet pourri. C'est acceptable pour des données internes que vous contrôlez. Ça ne l'est jamais pour des données qui entrent depuis l'extérieur : payload HTTP, ligne de DB, et surtout la sortie d'un LLM.
C'est là que Pydantic v2 entre. Il lit vos annotations et construit un validateur runtime (en Rust, donc rapide).
from pydantic import BaseModel, Field, field_validator
class AgentRequest(BaseModel):
prompt: str = Field(min_length=1, max_length=200_000)
model: str = "claude-opus-4-8"
max_tokens: int = Field(default=4096, gt=0, le=128_000)
temperature: float | None = None
@field_validator("model")
@classmethod
def known_model(cls, v: str) -> str:
allowed = {"claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5"}
if v not in allowed:
raise ValueError(f"modèle inconnu: {v!r}")
return v
# Données externes (ex: corps de requête HTTP), types « sales »
raw = {"prompt": "Explique-moi les monades", "max_tokens": "8000"}
req = AgentRequest.model_validate(raw)
print(req.max_tokens, type(req.max_tokens)) # 8000 <class 'int'> ← coercition str→int
print(req.model) # claude-opus-4-8 (défaut)Points clés que les ex-TS apprécient :
- Coercition contrôlée :
"8000"(string) devient8000(int). Pydantic coerce quand c'est sûr et sans ambiguïté. - Contraintes déclaratives :
gt=0,le=128_000,min_length. Plus besoin deif x <= 0: raise. - Erreurs structurées : une donnée invalide lève
ValidationErroravec le chemin exact du champ fautif, parfait pour renvoyer un 422 propre.
from pydantic import ValidationError
try:
AgentRequest.model_validate({"prompt": "", "max_tokens": -5})
except ValidationError as e:
print(e.errors()[0]["loc"], e.errors()[0]["msg"])
# ('prompt',) String should have at least 1 characterDataclass vs Pydantic : le bon arbitrage senior
| Critère | @dataclass | pydantic.BaseModel |
|---|---|---|
| Valide au runtime ? | Non | Oui |
| Coût à la construction | Quasi nul | Léger (validation) |
| Usage idéal | Objets internes, value objects, hot path | Frontières : HTTP, DB, sortie LLM |
| Sérialisation JSON | Manuelle | .model_dump() / .model_dump_json() |
| Schéma JSON Schema | Non | .model_json_schema() (gratuit) |
La règle : dataclass à l'intérieur, Pydantic aux bords. Ne validez pas dix fois la même donnée déjà propre — validez une fois à l'entrée, puis circulez avec des objets sûrs.
Servir et appeler un agent Claude : types de bout en bout
Mettons tout ensemble. On construit un endpoint FastAPI qui appelle un agent Claude en streaming, avec une sortie structurée validée par Pydantic, des retries typés, et la boucle tool-use. C'est le cœur de votre métier d'auteur d'agents.
1. Endpoint FastAPI typé avec DI et sortie structurée
FastAPI lit vos annotations pour valider l'entrée, injecter les dépendances, et générer l'OpenAPI. Le modèle Claude flagship est claude-opus-4-8 (5 USD / 25 USD par Mtok, fenêtre 1M).
from __future__ import annotations
from functools import lru_cache
from typing import Annotated
from anthropic import AsyncAnthropic
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI()
@lru_cache
def get_client() -> AsyncAnthropic:
# Singleton : le client gère un pool de connexions httpx.
# Le recréer par requête est un anti-pattern (sockets gaspillées).
return AsyncAnthropic()
# Schéma de sortie structurée — Claude renvoie EXACTEMENT cette forme.
class Sentiment(BaseModel):
label: str = Field(description="positive, negative ou neutral")
confidence: float = Field(ge=0.0, le=1.0)
rationale: str
class AnalyzeIn(BaseModel):
text: str = Field(min_length=1, max_length=100_000)
@app.post("/analyze")
async def analyze(
body: AnalyzeIn,
client: Annotated[AsyncAnthropic, Depends(get_client)],
) -> Sentiment:
try:
# messages.parse : sortie structurée native, validée contre le schéma Pydantic.
# En Python, on passe le modèle Pydantic via output_format= (paramètre de
# convenance du SDK, traduit en output_config.format en interne).
message = await client.messages.parse(
model="claude-opus-4-8",
max_tokens=1024,
thinking={"type": "adaptive"},
output_format=Sentiment,
messages=[
{
"role": "user",
"content": f"Analyse le sentiment de ce texte:\n\n{body.text}",
}
],
)
except Exception as exc: # en prod : attraper les exceptions typées du SDK (voir plus bas)
raise HTTPException(status_code=502, detail=f"upstream LLM error: {exc}") from exc
parsed = message.parsed_output
if parsed is None: # refus ou troncature : parsed_output peut être None
raise HTTPException(status_code=422, detail="réponse non conforme au schéma")
return parsed # FastAPI sérialise le Sentiment validé → JSONCe qui se passe niveau types :
body: AnalyzeIn→ FastAPI valide le corps JSON entrant via Pydantic. Entrée invalide = 422 automatique.Annotated[AsyncAnthropic, Depends(get_client)]→ injection de dépendance typée. Le@lru_cachegarantit un seul client (donc un seul pool httpx).output_format=Sentiment+messages.parse→ Claude est contraint à produire du JSON conforme au schéma, et le SDK le valide enSentiment. Le LLM ne renvoie plus du texte libre : c'est un objet typé. C'est exactement le « sas Pydantic » de la couche 3.-> Sentiment→ FastAPI génère le schéma de réponse OpenAPI et sérialise.
Note : on utilise
thinking={"type": "adaptive"}(adaptive thinking) — c'est le mode recommandé sur Opus 4.8. N'utilisez jamaisbudget_tokens(retiré, renvoie 400) ; contrôlez la profondeur viaoutput_config={"effort": "..."}si besoin.
2. Streaming SSE : pourquoi les types des events comptent
Pour les réponses longues, on stream (sinon risque de timeout HTTP). Le flux d'events est un union discriminé — exactement le genre de chose où le typage vous sauve.
from collections.abc import AsyncIterator
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
async def token_stream(client: AsyncAnthropic, prompt: str) -> AsyncIterator[str]:
# messages.stream gère le SSE ; on itère les events typés.
async with client.messages.stream(
model="claude-opus-4-8",
max_tokens=64_000, # gros max_tokens ⇒ streaming obligatoire
thinking={"type": "adaptive"},
messages=[{"role": "user", "content": prompt}],
) as stream:
async for text in stream.text_stream: # déjà filtré sur les text deltas
yield text
final = await stream.get_final_message() # message complet si besoin (usage, stop_reason)
# final.usage.input_tokens / output_tokens dispo ici pour la facturation
@app.post("/chat/stream")
async def chat_stream(body: AnalyzeIn) -> StreamingResponse:
client = get_client()
return StreamingResponse(
token_stream(client, body.text),
media_type="text/event-stream",
)Le type AsyncIterator[str] est load-bearing : StreamingResponse attend un itérable (sync ou async) qui yield des str/bytes. Si votre générateur yield des dict par erreur, ça pète au runtime — et mypy vous l'aurait dit. Le data model en jeu ici est __aiter__/__anext__ (le protocole async-iterator), la version asynchrone de __iter__.
3. Boucle tool-use : un union discriminé que vous bouclez
Quand l'agent utilise des outils, le stop_reason pilote une boucle. C'est l'archétype du pattern matching sur un type tagué.
from typing import Any
# Définition d'outil : un schéma JSON, lui-même typé via Pydantic si vous voulez
def weather_schema() -> dict[str, Any]:
class WeatherInput(BaseModel):
city: str
return {
"name": "get_weather",
"description": "Météo actuelle d'une ville. À appeler quand l'utilisateur "
"demande la météo ou une température.",
"input_schema": WeatherInput.model_json_schema(), # schéma gratuit depuis Pydantic
}
async def run_agent(client: AsyncAnthropic, user_msg: str) -> str:
tools = [weather_schema()]
messages: list[dict[str, Any]] = [{"role": "user", "content": user_msg}]
while True:
resp = await client.messages.create(
model="claude-opus-4-8",
max_tokens=4096,
thinking={"type": "adaptive"},
tools=tools,
messages=messages,
)
if resp.stop_reason == "end_turn":
# Terminé : on extrait le texte final
return "".join(b.text for b in resp.content if b.type == "text")
if resp.stop_reason == "tool_use":
messages.append({"role": "assistant", "content": resp.content})
tool_results: list[dict[str, Any]] = []
for block in resp.content:
if block.type == "tool_use":
# block.input est déjà parsé en dict par le SDK — JAMAIS de string-match
result = await execute_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
messages.append({"role": "user", "content": tool_results})
continue # on reboucle : Claude voit les résultats et continue
# Filet de sécurité pour les autres stop_reason (refusal, max_tokens, pause_turn)
raise RuntimeError(f"stop_reason non géré: {resp.stop_reason}")
async def execute_tool(name: str, payload: dict[str, Any]) -> str:
if name == "get_weather":
return f"22°C et ensoleillé à {payload['city']}"
return f"outil inconnu: {name}"Le stop_reason est un littéral tagué ("end_turn" | "tool_use" | "max_tokens" | "refusal" | "pause_turn"). Le brancher exhaustivement, c'est exactement traiter un union discriminé — la discipline TS qui se transpose parfaitement. Et block.input est déjà un dict parsé : ne faites jamais de string-matching sur du JSON sérialisé, les modèles récents peuvent échapper l'Unicode différemment.
⚙️ En production
Failure modes liés aux types.
- Le mensonge silencieux. Une annotation fausse ne lève rien. Si votre pipeline n'a pas
mypy/pyrighten CI, les annotations sont de la documentation décorative. Action senior :mypy --strict(oupyright) bloquant en CI. Sans gate, le typage statique est théâtre. - La frontière non validée. Toute donnée externe non passée par Pydantic est une bombe à retardement : un
dictissu d'unjson.loadsqui circule typédict[str, Any]propage l'incertitude partout. Action : valider une fois à l'entrée, puis ne manipuler que des modèles typés. parsed_output is None. En sortie structurée LLM, un refus (stop_reason == "refusal") ou une troncature (max_tokens) laisseparsed_outputàNone. Le code qui faitmessage.parsed_output.labeldirectement crashe enAttributeError. Action : toujours guardif parsed is None.
Performance.
slots=Truesur les dataclasses du hot path (logs d'events d'agent, value objects créés en masse) : mémoire en baisse, accès attribut plus rapide. Sur 1M d'objets, ce n'est pas du détail.- Pydantic v2 est rapide (core en Rust) mais pas gratuit. Ne validez pas en boucle chaude des données déjà propres. Le coût se paie une fois, au boundary.
frozen=True+ hashable permet la mémoïsation (@lru_cache) et l'usage comme clé de cache. Utile pour cacher des résultats d'outils déterministes.
Sécurité.
- Ne faites jamais confiance à la coercition pour la sécurité. Pydantic valide la forme, pas l'intention. Une string
promptvalide peut contenir une injection. Bornezmax_length, et ne mettez jamais de secret/clé d'API dans le prompt ou les messages — ils persistent dans l'historique. block.inputd'un tool-use est une donnée non fiable (le LLM l'a produite). Validez-la avec un modèle Pydantic avant de l'exécuter, surtout pour des outils à effets de bord (shell, DB, requêtes réseau).
Observabilité.
- Le
__repr__propre d'une dataclass (TokenUsage(input_tokens=12000, ...)) rend vos logs lisibles. Le__repr__par défaut (<object at 0x...>) est inutile en incident. - Récupérez
final.usage(input/output tokens) après chaque appel pour tracer le coût réel (claude-opus-4-8: 5/25 USD par Mtok). Une dataclassTokenUsageaccumulable (__add__) est le bon support pour agréger par requête/session.
Tradeoffs seniors.
- Dataclass partout = code rapide mais fragile aux entrées sales. Pydantic partout = code robuste mais sur-validé et plus lent. Le bon design met Pydantic en périphérie, dataclasses au cœur.
Anyest le trou noir du typage : un seulAnycontamine toute la chaîne en aval. Préférezobject(vous force à narrow) ou un union explicite. RéservezAnyaux vraies frontières dynamiques (JSON brut avant validation).
🏋️ Exercices
Exercice 1 — Un value object qui se comporte bien (implémenter)
Objectif : implémenter une dataclass Cost (montant USD) qui supporte +, comparaison, tri, et usage comme clé de dict, le tout immuable et hashable.
- Champs :
usd: float. - Doit supporter :
Cost(0.5) + Cost(0.3) == Cost(0.8),sorted([...]),{Cost(0.5): "opus"}.
Indice/Solution :
@dataclass(frozen=True, slots=True), implémentez__add__(retourne un nouveauCost) et__lt__(compareself.usd).frozen=Truevous donne__eq__et__hash__gratuitement. Vérifiez queCost(0.5) == Cost(0.5)estTrue(égalité par valeur) et quehash(Cost(0.5)) == hash(Cost(0.5)).
Exercice 2 — Le sas de validation LLM (production-grade)
Objectif : écrire un modèle Pydantic ExtractedInvoice (champs : vendor: str, total: float >= 0, currency: str parmi {"USD","EUR","GBP"}, line_items: list[str]) et une fonction parse_invoice(client, text) -> ExtractedInvoice qui appelle claude-opus-4-8 en sortie structurée et gère le cas parsed_output is None.
Indice/Solution :
model="claude-opus-4-8",output_format=ExtractedInvoice,messages.parse(...). UtilisezField(ge=0)pourtotal, un validateur ouLiteral["USD","EUR","GBP"]pourcurrency. Après l'appel,if message.parsed_output is None: raise ValueError(...). Bonus : attrapez les exceptions typées du SDK (anthropic.RateLimitError,anthropic.APIError) plutôt qu'unexcept Exceptiongénérique.
Exercice 3 — Retries typés avec backoff (production-grade)
Objectif : envelopper un appel Claude dans une fonction call_with_retry qui retente uniquement sur les erreurs retryables (RateLimitError, InternalServerError, OverloadedError → HTTP 429/500/529) avec backoff exponentiel, et ne retente jamais sur les erreurs non-retryables (BadRequestError → 400, AuthenticationError → 401).
Indice/Solution : importez les classes typées depuis
anthropic.for attempt in range(max_retries): try: return await client.messages.create(...) except (anthropic.RateLimitError, anthropic.InternalServerError, anthropic.OverloadedError): await asyncio.sleep(2 ** attempt). LaissezBadRequestError/AuthenticationErrorse propager (ne les attrapez pas). Note : le SDK retente déjà 429/5xx automatiquement (max_retries=2) — cet exercice est pour comprendre pourquoi le branchement par type d'exception (et non par parsing de message d'erreur) est la bonne pratique.
Exercice 4 — Casser puis réparer : __eq__ sans __hash__ (break-then-fix)
Objectif : écrire une classe ModelTier non-dataclass avec un __eq__ custom (égalité sur un champ name) mais sans __hash__. Observer le crash quand on la met dans un set. Puis réparer.
Indice/Solution :
{ModelTier("opus"), ModelTier("sonnet")}lèveTypeError: unhashable type— définir__eq__met__hash__àNone. Réparation : ajoutezdef __hash__(self): return hash(self.name), en garantissant quea == b ⟹ hash(a) == hash(b). Discutez : pourquoi@dataclass(frozen=True)rend ce bug impossible par construction ? (Il génère un__hash__cohérent dérivé des champs.)
Exercice 5 — Discriminer un union d'events de stream (HARD)
Objectif : écrire une fonction accumulate(events: AsyncIterator[StreamEvent]) -> tuple[str, TokenUsage] qui consomme un flux d'events Claude (messages.stream), accumule le texte des text_delta, et extrait l'usage final — en branchant exhaustivement sur event.type sans jamais utiliser getattr dynamique ni Any.
Indice/Solution : itérez
async for event in stream, faites unmatch event.type:(ou desif/elif) sur"content_block_delta"(accéderevent.delta.textquandevent.delta.type == "text_delta"),"message_delta"(usage),"message_stop". Utilisezstream.get_final_message()pour l'usage si plus simple. Le point pédagogique : un union discriminé par tag (event.type) se traite comme en TS — chaque branche narrow le type. Si vous vous surprenez à écriregetattr(event, "text", ""), vous avez perdu le bénéfice du typage : revenez au branchement explicite.
🎤 En entretien
Q : Les annotations de type Python sont-elles vérifiées au runtime ? Non. Elles sont effacées pour l'interpréteur (zéro overhead, zéro contrôle) mais introspectables via __annotations__ — c'est ce qui permet à Pydantic et FastAPI de générer validateurs et schémas. Le contrôle vient de mypy/pyright en CI, pas du runtime.
Q : Différence entre @dataclass et pydantic.BaseModel — quand l'un, quand l'autre ? Dataclass : conteneur de données sans validation runtime, idéal pour les objets internes et le hot path (surtout avec slots=True). Pydantic : validation runtime via les annotations, idéal aux frontières (HTTP, DB, sortie LLM). Règle : Pydantic en périphérie, dataclass au cœur — on valide une fois à l'entrée, on circule typé ensuite.
Q : Pourquoi __eq__ et __hash__ doivent-ils être cohérents, et que fait @dataclass à ce sujet ? L'invariant des tables de hachage est a == b ⟹ hash(a) == hash(b) ; le violer fait perdre des objets dans les set/dict. Définir __eq__ sans __hash__ rend l'objet unhashable (Python met __hash__ = None). @dataclass(frozen=True) génère un __hash__ cohérent ; @dataclass mutable rend volontairement l'objet unhashable (car muter une clé la corromprait).
Q : Comment garantir qu'un agent Claude renvoie une structure typée, et comment gérer l'échec ? Sortie structurée native : messages.parse(..., output_format=MonModelePydantic) contraint Claude au schéma et valide la réponse en objet Pydantic. L'échec se gère en vérifiant parsed_output is None (refus ou troncature max_tokens) — c'est exactement le « sas Pydantic » entre le texte du LLM et un objet sûr dans votre programme.