Skip to content

Types modernes & data model (3.12)

TL;DR — En Python 3.12, le système de types est purement déclaratif : x: int n'est pas vérifié au runtime, c'est un contrat lu par mypy/pyright et 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 :

  1. Annotations (x: int) — contrat lu par les outils statiques et les humains. Zéro effet runtime.
  2. Data model (__dunder__) — ce que l'interpréteur exécute réellement pour faire fonctionner len(x), x[0], x == y, for i in x.
  3. 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 :

python
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 exception

L'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 :

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

python
# ✅ 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 | None remplace Optional[int] (et signale clairement « ce truc peut être None »).
  • list, dict, tuple, set sont génériques directement — plus besoin d'importer leurs alias.
  • X | Y remplace Union[X, Y].

En 3.12, on a aussi la nouvelle syntaxe de génériques (PEP 695) :

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

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

  • @dataclass génère __init__, __repr__, __eq__ pour vous. Vous écrivez les champs une fois.
  • frozen=True rend 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 :

python
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 subtils

u1 == 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.
  • class custom avec __eq__ mais sans __hash__ → l'objet devient unhashable. C'est le piège : vous croyez pouvoir le mettre dans un set, et TypeError au 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).

python
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) devient 8000 (int). Pydantic coerce quand c'est sûr et sans ambiguïté.
  • Contraintes déclaratives : gt=0, le=128_000, min_length. Plus besoin de if x <= 0: raise.
  • Erreurs structurées : une donnée invalide lève ValidationError avec le chemin exact du champ fautif, parfait pour renvoyer un 422 propre.
python
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 character

Dataclass vs Pydantic : le bon arbitrage senior

Critère@dataclasspydantic.BaseModel
Valide au runtime ?NonOui
Coût à la constructionQuasi nulLéger (validation)
Usage idéalObjets internes, value objects, hot pathFrontières : HTTP, DB, sortie LLM
Sérialisation JSONManuelle.model_dump() / .model_dump_json()
Schéma JSON SchemaNon.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).

python
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é → JSON

Ce 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_cache garantit 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 en Sentiment. 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 jamais budget_tokens (retiré, renvoie 400) ; contrôlez la profondeur via output_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.

python
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é.

python
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/pyright en CI, les annotations sont de la documentation décorative. Action senior : mypy --strict (ou pyright) 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 dict issu d'un json.loads qui 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) laisse parsed_output à None. Le code qui fait message.parsed_output.label directement crashe en AttributeError. Action : toujours guard if parsed is None.

Performance.

  • slots=True sur 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 prompt valide peut contenir une injection. Bornez max_length, et ne mettez jamais de secret/clé d'API dans le prompt ou les messages — ils persistent dans l'historique.
  • block.input d'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 dataclass TokenUsage accumulable (__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.
  • Any est le trou noir du typage : un seul Any contamine toute la chaîne en aval. Préférez object (vous force à narrow) ou un union explicite. Réservez Any aux 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 nouveau Cost) et __lt__ (compare self.usd). frozen=True vous donne __eq__ et __hash__ gratuitement. Vérifiez que Cost(0.5) == Cost(0.5) est True (égalité par valeur) et que hash(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(...). Utilisez Field(ge=0) pour total, un validateur ou Literal["USD","EUR","GBP"] pour currency. 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'un except Exception gé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). Laissez BadRequestError/AuthenticationError se 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ève TypeError: unhashable type — définir __eq__ met __hash__ à None. Réparation : ajoutez def __hash__(self): return hash(self.name), en garantissant que a == 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 un match event.type: (ou des if/elif) sur "content_block_delta" (accéder event.delta.text quand event.delta.type == "text_delta"), "message_delta" (usage), "message_stop". Utilisez stream.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 à écrire getattr(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.

Bibliothèque tech perso — Achref