Protocols & duck typing structurel
TL;DR — Un
Protocol(PEP 544) décrit une forme (les méthodes/attributs qu'un objet doit avoir) plutôt qu'une lignée (class X(Base)). C'est le « duck typing » du Python dynamique, mais vérifié statiquement par mypy/pyright : si ça marche comme un canard et que mypy voit le bec, ça passe — sans héritage, sansregister(), sans couplage. Tu l'utilises quand tu veux découpler un appelant de l'implémentation concrète : unLLMClientProtocol te laisse brancher l'AsyncAnthropicréel en prod et un fake en test, injectés via lesDependsde FastAPI, sans qu'aucune classe n'hérite de quoi que ce soit. C'est l'outil idiomatique pour typer les seams (coutures) d'une app d'agents IA.
Tu viens de TypeScript : un Protocol Python, c'est une interface TS — du typage structurel. La différence avec ce que tu connais en NestJS (où une interface TS est effacée au runtime et où tu injectes via des tokens/classes) c'est qu'en Python le Protocol peut aussi être vérifié au runtime (@runtime_checkable) et que tu n'as rien à enregistrer dans un conteneur DI : la conformité est implicite.
🧠 Mental model
L'analogie : le badge vs. l'arbre généalogique
Il y a deux façons de décider si quelqu'un peut entrer dans une salle serveur.
- Typage nominal (héritage classique,
class Worker(Employee)) : « Montre-moi ton arbre généalogique. Es-tu un descendant déclaré deEmployee? » Tu dois explicitement hériter, ou t'enregistrer (ABCMeta.register). - Typage structurel (
Protocol) : « Montre-moi ton badge. As-tu une méthodeopen_door()et un attributclearance_level: int? Peu importe d'où tu viens, peu importe qui t'a fabriqué — si tu as la forme, tu entres. »
NOMINAL (ABC / héritage) STRUCTUREL (Protocol)
Employee (base) Protocol: a .badge() ?
▲ │
│ class Worker(Employee) │ « est-ce que CET objet
Worker ── doit déclarer ──▶ OK │ a la bonne forme ? »
▼
FreelanceConsultant ── n'hérite pas ──▶ ❌ n'importe quel objet
avec .badge() ──▶ ✅Le point clé : avec un Protocol, l'implémentation n'a pas besoin de connaître l'interface. Tu peux écrire un Protocol qui décrit l'API d'une classe d'une librairie tierce (que tu ne peux pas modifier) — c'est impossible avec une ABC, qui exige l'héritage du côté implémentation.
Pourquoi un dev senior s'en soucie
Le typage structurel inverse la dépendance sans le boilerplate du DI. En NestJS tu écris une interface PaymentGateway, tu crées un @Injectable(), tu enregistres un provider avec un token. En Python avec un Protocol, l'appelant définit le contrat, et n'importe quelle classe qui a la bonne forme le satisfait — y compris des classes écrites avant que le Protocol n'existe. C'est le Dependency Inversion Principle réduit à sa plus simple expression.
Le cœur : Protocol en Python 3.12
Première version : un Protocol simple
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None: ...
def shutdown(resource: SupportsClose) -> None:
# On ne sait pas ce qu'est `resource`. On sait juste qu'il a .close().
resource.close()N'importe quel objet avec une méthode close() -> None satisfait SupportsClose. Un fichier, une connexion DB, ta propre classe — aucun n'a besoin d'hériter de quoi que ce soit :
class DatabasePool:
def close(self) -> None:
print("pool drained")
shutdown(DatabasePool()) # ✅ mypy OK : DatabasePool a la forme
shutdown(open("/tmp/x")) # ✅ aussi : un fichier a .close()Le corps
...(Ellipsis) n'est pas du sucre cosmétique : il dit « cette méthode existe, voici sa signature, mais le Protocol ne fournit pas d'implémentation ». Ne mets jamaispassou un vrai corps dans un Protocol pur —...est la convention.
La bonne façon : un Protocol qui découple un service
Disons que tu construis le seam (la couture) entre ta logique métier et un store de données. La manière idiomatique :
from typing import Protocol
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class User:
id: int
email: str
class UserRepository(Protocol):
"""Contrat structurel. Aucune implémentation n'hérite de ça."""
async def get(self, user_id: int) -> User | None: ...
async def save(self, user: User) -> None: ...
# La logique métier dépend du PROTOCOLE, jamais du concret.
class UserService:
def __init__(self, repo: UserRepository) -> None:
self._repo = repo
async def rename(self, user_id: int, new_email: str) -> User:
user = await self._repo.get(user_id)
if user is None:
raise LookupError(f"user {user_id} not found")
updated = User(id=user.id, email=new_email)
await self._repo.save(updated)
return updatedEt les implémentations — Postgres en prod, un dict en test — ne mentionnent jamais UserRepository :
class PostgresUserRepository: # pas d'héritage !
def __init__(self, pool: "asyncpg.Pool") -> None:
self._pool = pool
async def get(self, user_id: int) -> User | None:
row = await self._pool.fetchrow(
"SELECT id, email FROM users WHERE id = $1", user_id
)
return User(id=row["id"], email=row["email"]) if row else None
async def save(self, user: User) -> None:
await self._pool.execute(
"INSERT INTO users (id, email) VALUES ($1, $2) "
"ON CONFLICT (id) DO UPDATE SET email = $2",
user.id,
user.email,
)
class InMemoryUserRepository: # pas d'héritage non plus !
def __init__(self) -> None:
self._data: dict[int, User] = {}
async def get(self, user_id: int) -> User | None:
return self._data.get(user_id)
async def save(self, user: User) -> None:
self._data[user.id] = usermypy vérifie que les deux satisfont UserRepository au point d'appel :
service_prod = UserService(PostgresUserRepository(pool)) # ✅
service_test = UserService(InMemoryUserRepository()) # ✅C'est ça la magie : tu as obtenu l'inversion de dépendance, la testabilité et le polymorphisme sans une seule ligne d'héritage et sans framework DI.
La mauvaise façon (le piège du dev ex-Java/PHP)
Le réflexe nominal, c'est de forcer l'héritage avec une ABC :
from abc import ABC, abstractmethod
class UserRepository(ABC): # ❌ approche nominale
@abstractmethod
async def get(self, user_id: int) -> User | None: ...
@abstractmethod
async def save(self, user: User) -> None: ...
class PostgresUserRepository(UserRepository): # ❌ couplage forcé
...Pourquoi c'est moins bon ici :
- Couplage : chaque implémentation doit
importpuis hériter de la base. Si la base vit dansdomain/et l'implémentation dansinfra/, tu crées une dépendance d'infra→domainau runtime (souvent ce que tu veux, mais pas toujours — un Protocol te laisse le choix). - Tu ne peux pas adapter du code tiers : tu ne peux pas faire hériter
AsyncAnthropic(du SDK Anthropic) d'une de tes ABC. Avec un Protocol, tu décris simplement la forme que le SDK expose déjà. - Tests : avec une ABC, ton fake doit hériter de la base. Avec un Protocol, n'importe quel objet (même un
Mockcorrectement typé) passe.
Règle de senior : ABC quand tu veux partager de l'implémentation (comportement par défaut, template method) ET contrôler la hiérarchie. Protocol quand tu veux juste un contrat structurel au niveau des frontières. La plupart des seams d'une app (repos, gateways, clients) sont des Protocols.
@runtime_checkable : isinstance() au runtime
Par défaut, un Protocol n'existe qu'au moment de la vérification de types. isinstance(x, MyProtocol) lève TypeError. Pour l'autoriser :
from typing import Protocol, runtime_checkable
@runtime_checkable
class Closeable(Protocol):
def close(self) -> None: ...
isinstance(DatabasePool(), Closeable) # ✅ TrueMais attention au piège : @runtime_checkable ne vérifie que la présence des méthodes, pas leurs signatures. Un objet avec une méthode close(self, force: bool, hard: bool) passera isinstance, puis explosera à l'appel.
class Weird:
def close(self, force: bool, hard: bool) -> None: ...
isinstance(Weird(), Closeable) # ✅ True (présence seule)
shutdown(Weird()) # 💥 TypeError au runtime : args manquantsLes Protocols avec des attributs de données sont encore pires au runtime : avant Python 3.12, isinstance contre un Protocol à attributs pouvait être très lent (il fait du hasattr sur chaque membre). Utilise @runtime_checkable avec parcimonie — pour de l'aiguillage léger, pas comme un validateur.
Protocols génériques
Un Protocol peut être paramétré, et la syntaxe 3.12 (PEP 695) est limpide :
from typing import Protocol
class Repository[T](Protocol):
async def get(self, id: int) -> T | None: ...
async def save(self, item: T) -> None: ...
# Utilisable avec n'importe quel T
def make_service(repo: Repository[User]) -> UserService:
return UserService(repo)Pour la variance, tu peux toujours déclarer explicitement avec TypeVar :
from typing import Protocol, TypeVar
T_co = TypeVar("T_co", covariant=True)
class Producer(Protocol[T_co]):
def produce(self) -> T_co: ...Un Producer[Admin] est alors acceptable là où un Producer[User] est attendu (si Admin <: User), parce que produire un Admin c'est produire un User.
Le lien IA : typer le seam d'un agent LLM
C'est ici que les Protocols paient vraiment leur loyer. Tu construis des agents : tu appelles le SDK Anthropic, tu boucles sur du tool-use, tu streames des tokens. Tu ne veux pas que ta logique d'agent dépende en dur de AsyncAnthropic — sinon tes tests touchent le réseau, coûtent de l'argent, et sont flaky.
La solution idiomatique : un Protocol qui décrit juste la part du SDK que tu utilises.
from typing import Protocol
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class LLMReply:
text: str
input_tokens: int
output_tokens: int
stop_reason: str
class ChatModel(Protocol):
"""La forme minimale dont notre agent a besoin. Le vrai SDK la satisfait."""
async def complete(
self,
*,
system: str,
messages: list[dict[str, str]],
max_tokens: int,
) -> LLMReply: ...L'implémentation de prod enveloppe le SDK Anthropic (AsyncAnthropic). On utilise Opus 4.8 (claude-opus-4-8) avec adaptive thinking, comme recommandé :
from anthropic import (
AsyncAnthropic,
RateLimitError,
OverloadedError,
)
MODEL = "claude-opus-4-8" # phare ; 5/25 USD par Mtok, contexte 1M
class AnthropicChatModel:
"""Implémentation concrète. N'hérite PAS de ChatModel — forme structurelle."""
def __init__(self, client: AsyncAnthropic) -> None:
self._client = client
async def complete(
self,
*,
system: str,
messages: list[dict[str, str]],
max_tokens: int,
) -> LLMReply:
# Le SDK retente déjà 429/5xx avec backoff (max_retries=2 par défaut).
# On laisse remonter les exceptions typées pour les gérer en amont.
resp = await self._client.messages.create(
model=MODEL,
max_tokens=max_tokens,
thinking={"type": "adaptive"}, # PAS de budget_tokens sur 4.8
output_config={"effort": "high"}, # low | medium | high | xhigh | max
system=system,
messages=messages,
)
text = "".join(
block.text for block in resp.content if block.type == "text"
)
return LLMReply(
text=text,
input_tokens=resp.usage.input_tokens,
output_tokens=resp.usage.output_tokens,
stop_reason=resp.stop_reason or "end_turn",
)Et le fake de test, déterministe, zéro réseau, zéro coût :
class ScriptedChatModel:
"""Rejoue des réponses préprogrammées. Satisfait ChatModel structurellement."""
def __init__(self, replies: list[str]) -> None:
self._replies = iter(replies)
self.calls: list[list[dict[str, str]]] = []
async def complete(
self, *, system: str, messages: list[dict[str, str]], max_tokens: int
) -> LLMReply:
self.calls.append(messages)
return LLMReply(
text=next(self._replies),
input_tokens=10,
output_tokens=5,
stop_reason="end_turn",
)L'agent dépend du Protocol, jamais du concret :
class SummariserAgent:
def __init__(self, model: ChatModel) -> None:
self._model = model
async def summarise(self, document: str) -> str:
reply = await self._model.complete(
system="Tu résumes des documents en une phrase, en français.",
messages=[{"role": "user", "content": document}],
max_tokens=256,
)
return reply.textTest (synchrone à lire, async à exécuter) :
import pytest
@pytest.mark.asyncio
async def test_summariser_calls_model_once() -> None:
fake = ScriptedChatModel(["Un résumé concis."])
agent = SummariserAgent(fake)
result = await agent.summarise("un long document...")
assert result == "Un résumé concis."
assert len(fake.calls) == 1En prod tu injectes AnthropicChatModel(AsyncAnthropic()). mypy a vérifié, à chaque point d'injection, que les deux ont bien la forme ChatModel — sans qu'aucune n'hérite de quoi que ce soit.
Brancher ça dans FastAPI avec Depends
Le système de DI de FastAPI est fait pour ce pattern. Tu déclares une dépendance qui retourne le Protocol, et tu changes le câblage par environnement.
from contextlib import asynccontextmanager
from typing import Annotated, AsyncIterator
from anthropic import AsyncAnthropic, OverloadedError, RateLimitError
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
# Un seul client AsyncAnthropic partagé : il pool les connexions HTTP.
app.state.anthropic = AsyncAnthropic()
yield
await app.state.anthropic.close()
app = FastAPI(lifespan=lifespan)
def get_chat_model() -> ChatModel:
# Le type de retour est le PROTOCOLE. L'objet concret est un détail.
return AnthropicChatModel(app.state.anthropic)
# Alias propre, réutilisable
ChatModelDep = Annotated[ChatModel, Depends(get_chat_model)]
class SummariseIn(BaseModel):
document: str
class SummariseOut(BaseModel):
summary: str
@app.post("/summarise")
async def summarise(body: SummariseIn, model: ChatModelDep) -> SummariseOut:
agent = SummariserAgent(model)
try:
summary = await agent.summarise(body.document)
except OverloadedError:
raise HTTPException(status_code=503, detail="LLM saturé, réessayez")
except RateLimitError:
raise HTTPException(status_code=429, detail="Quota dépassé")
return SummariseOut(summary=summary)Et le test d'intégration de l'endpoint override la dépendance pour injecter le fake — aucun appel réseau :
from fastapi.testclient import TestClient
def test_summarise_endpoint() -> None:
fake = ScriptedChatModel(["Résumé de test."])
app.dependency_overrides[get_chat_model] = lambda: fake
with TestClient(app) as client:
resp = client.post("/summarise", json={"document": "blah"})
assert resp.status_code == 200
assert resp.json() == {"summary": "Résumé de test."}
app.dependency_overrides.clear()C'est l'équivalent FastAPI de ton overrideProvider(...).useValue(fake) de NestJS — mais le contrat n'est même pas un token, c'est juste une forme.
Streaming SSE : un Protocol pour le canal de sortie
Quand tu streames les tokens d'un agent vers le navigateur (SSE), abstrais le sink derrière un Protocol pour pouvoir tester l'agent sans HTTP :
from typing import Protocol
class TokenSink(Protocol):
async def emit(self, token: str) -> None: ...
async def stream_summary(model: ChatModel, doc: str, sink: TokenSink) -> None:
# (imaginons un complete() streamé ; ici on émet le texte final)
reply = await model.complete(
system="Résume en français.",
messages=[{"role": "user", "content": doc}],
max_tokens=256,
)
for token in reply.text.split():
await sink.emit(token + " ")En prod le sink écrit dans la réponse SSE ; en test c'est une liste. Pour de vrais tokens incrémentaux côté Anthropic, tu utiliserais client.messages.stream(...) et stream.get_final_message() — l'idée du Protocol reste : ton agent ne connaît pas le transport.
⚙️ En production
Modes de défaillance
@runtime_checkablequi ment : il ne vérifie que la présence des membres, pas les signatures. Ne t'en sers jamais comme garde de validation à une frontière de confiance. Pour valider une donnée entrante (JSON d'une requête), utilise Pydantic, pas un Protocol. Le Protocol valide la forme d'un objet de code, pas la structure d'une donnée.Protocols à attributs et
isinstance: coûteux et fragiles. Préfère des Protocols à méthodes pour le runtime checking.Méthodes oubliées détectées trop tard : un Protocol n'est vérifié qu'au point d'utilisation. Si aucune fonction n'accepte ton implémentation comme paramètre typé
MyProtocol, mypy ne signalera jamais qu'elle est non conforme. Astuce : un test de conformité explicite —pythondef _assert_conformance() -> None: _: ChatModel = AnthropicChatModel(...) # mypy hurle ici si la forme casseou un
if TYPE_CHECKING:qui force la vérification.
Performance
- Coût zéro à l'exécution pour les Protocols purs : sans
@runtime_checkable, un Protocol disparaît au runtime — c'est purement de l'information de typage. Aucune surcharge d'appel, aucune table de méthodes virtuelles ajoutée. isinstancecontre un Protocol est toujours plus lent qu'unisinstancenominal (Python doit inspecter les membres). Sur un chemin chaud (boucle d'agent à des milliers d'itérations), évite-le.AsyncAnthropicse partage : un seul client poolé (créé aulifespan) au lieu d'un par requête. Le Protocol ne change rien à ça — mais comme ton agent dépend du Protocol, il se fiche que ce soit un singleton ou pas.
Sécurité
- Le typage structurel ne fait aucune validation de données. Tout ce qui vient du réseau (corps de requête, sortie d'un outil LLM, JSON de tool-use) passe par Pydantic v2 ou
json.loads— jamais par un cast vers un Protocol. Caster ne valide rien au runtime. - Pour le tool-use LLM : ne fais jamais confiance aux
inputd'untool_useblock. Parse-les avecjson.loads()(le SDK les expose déjà comme objets) et valide-les avec un modèle Pydantic avant d'exécuter quoi que ce soit (surtout si l'outil a des effets de bord : DB, mails, paiements).
Observabilité
Le Protocol comme seam est aussi ton point d'instrumentation. Un wrapper
LoggingChatModel(ChatModel)(structurel) qui enveloppe le vrai modèle et loggeinput_tokens/output_tokens/latence se branche sans toucher l'agent :pythonimport time import logging log = logging.getLogger("llm") class LoggingChatModel: # satisfait ChatModel, enveloppe un autre ChatModel def __init__(self, inner: ChatModel) -> None: self._inner = inner async def complete(self, **kwargs: object) -> LLMReply: t0 = time.perf_counter() reply = await self._inner.complete(**kwargs) # type: ignore[arg-type] log.info( "llm_call", extra={ "in_tok": reply.input_tokens, "out_tok": reply.output_tokens, "latency_ms": (time.perf_counter() - t0) * 1000, "stop": reply.stop_reason, }, ) return replyDecorator pattern + typage structurel = télémétrie sans héritage ni couplage.
Les arbitrages de senior
| Tu veux... | Choisis |
|---|---|
| Un contrat à une frontière (repo, client, gateway) | Protocol |
| Partager du comportement par défaut (template method) | ABC |
| Adapter du code tiers que tu ne peux pas modifier | Protocol |
| Forcer/contrôler une hiérarchie de classes | ABC |
| Valider une donnée entrante (réseau, fichier) | Pydantic |
isinstance léger sur tes propres types | @runtime_checkable (méthodes seulement) |
Règle mnémotechnique : ABC = est-un (is-a), Protocol = se-comporte-comme (acts-like), Pydantic = contient (has-the-data).
🏋️ Exercices
Exercice 1 — Le seam de cache (implémenter)
Objectif : Définis un Protocol Cache avec async def get(self, key: str) -> bytes | None et async def set(self, key: str, value: bytes, ttl_seconds: int) -> None. Écris deux implémentations sans héritage : InMemoryCache (avec expiration) et RedisCache (stub redis.asyncio). Écris une fonction cached_complete(model: ChatModel, cache: Cache, prompt: str) -> str qui met en cache les réponses LLM par hash du prompt.
Indice/Solution : Le Protocol vit dans cache.py, les implémentations ailleurs, et aucune n'importe Cache. Pour la clé : hashlib.sha256(prompt.encode()).hexdigest(). Pour l'expiration en mémoire : stocke (value, expires_at) et compare à time.monotonic(). Le point de vérification mypy est cached_complete, qui accepte Cache — c'est là que la conformité des deux impls est prouvée.
Exercice 2 — Variance et covariance (implémenter + raisonner)
Objectif : Crée un Protocol générique Reader[T_co] (covariant) avec def read(self) -> T_co. Montre, avec une annotation explicite, qu'un Reader[Admin] est acceptable là où un Reader[User] est attendu (avec Admin <: User). Puis crée un Writer[T_contra] (contravariant) avec def write(self, item: T_contra) -> None et explique pourquoi Writer[User] est acceptable là où un Writer[Admin] est attendu.
Indice/Solution : Covariant = « je produis du T, donc produire un sous-type est OK ». Contravariant = « je consomme du T, donc consommer un super-type est OK » (un writer qui accepte n'importe quel User peut servir là où on n'écrit que des Admin). Si tu inverses la variance, mypy lèvera une erreur explicite. Utilise TypeVar("T_co", covariant=True) et Protocol[T_co].
Exercice 3 — Le piège @runtime_checkable (casser puis réparer)
Objectif : Écris un Protocol @runtime_checkable Handler avec def handle(self, event: str) -> None. Crée une classe BadHandler dont handle prend (self, event, priority). Montre que isinstance(BadHandler(), Handler) retourne True mais que l'appel explose. Puis répare : remplace le garde isinstance par une vérification mypy au point d'appel (typage statique) et explique pourquoi c'est plus sûr.
Indice/Solution : C'est le cœur de la leçon de prod. La « réparation » n'est pas de rendre isinstance plus malin (impossible — il ne checke que la présence), mais de ne pas s'appuyer dessus : type le paramètre handler: Handler et laisse mypy vérifier la signature statiquement. @runtime_checkable ne sert qu'à de l'aiguillage où la présence suffit.
Exercice 4 — Decorator de retry sur le seam LLM (production-grade)
Objectif : Écris RetryingChatModel (structurel, satisfait ChatModel) qui enveloppe un autre ChatModel et retente sur OverloadedError/RateLimitError avec backoff exponentiel + jitter, plafonné à N essais. Le SDK Anthropic retente déjà en interne (max_retries=2) ; ta couche ajoute une politique applicative au-dessus de l'abstraction. Rends-la testable avec un ScriptedChatModel qui lève une exception aux 2 premiers appels.
Indice/Solution : Le decorator prend inner: ChatModel et expose la même complete. Backoff : base * 2**attempt + random.uniform(0, jitter). Pour tester sans sleep réel, injecte une fonction sleep: Callable[[float], Awaitable[None]] (encore un seam !) que tu remplaces par un no-op en test. Capture les exceptions Anthropic typées, jamais par string-matching.
Exercice 5 — Boucle de tool-use derrière un Protocol (hard, end-to-end)
Objectif : Définis un Protocol ToolRunner exposant async def run_turn(self, messages: list[dict]) -> ToolUseOrText où l'agent boucle : appel modèle → si stop_reason == "tool_use", exécute l'outil, ré-injecte le tool_result, recommence → jusqu'à end_turn. Implémente la vraie version sur AsyncAnthropic et une version scriptée. Teste qu'un scénario « le modèle appelle get_weather puis répond » fait exactement deux tours.
Indice/Solution : Modélise ToolUseOrText comme un dataclass ou une union discriminée. La boucle manuelle : append response.content complet aux messages (pour préserver les tool_use blocks), puis append un message user avec un tool_result portant le bon tool_use_id. Parse tool_use.input avec json.loads puis valide-le par Pydantic avant exécution. Le Protocol ToolRunner te permet de tester toute la mécanique de boucle sans réseau. Plafonne le nombre de tours (anti-boucle-infinie).
Exercice 6 — Casser le contrat silencieusement (casser puis détecter)
Objectif : Prends ton UserRepository Protocol. Modifie la signature de save dans le Protocol (ajoute un paramètre *, audit: bool). Constate que PostgresUserRepository ne compile plus — mais seulement au point d'injection. Ajoute un test de conformité explicite (_: UserRepository = PostgresUserRepository(...)) pour que mypy détecte la dérive même si aucun appelant n'existe encore.
Indice/Solution : C'est le mode de défaillance « vérifié seulement au point d'utilisation ». La protection : un module conformance.py (ou un bloc if TYPE_CHECKING:) qui assigne chaque impl concrète à une variable typée par le Protocol. Ces assignations ne font rien au runtime mais forcent mypy à vérifier la forme. Intègre-les à ta CI mypy.
🎤 En entretien
Q : Quelle est la différence entre un Protocol et une ABC en Python, et quand choisis-tu l'un plutôt que l'autre ? R : ABC = typage nominal (l'implémentation doit hériter/register, et peut partager du comportement) ; Protocol = typage structurel (conformité implicite par la forme, idéal aux frontières et pour adapter du code tiers). Choisis ABC pour partager de l'implémentation ou contrôler une hiérarchie, Protocol pour un simple contrat à une couture.
Q : Que vérifie réellement @runtime_checkable, et pourquoi est-ce un piège ? R : Uniquement la présence des membres, pas leurs signatures ni les types des attributs — donc un objet avec une méthode de même nom mais signature incompatible passera isinstance puis explosera à l'appel. C'est de l'aiguillage léger, jamais de la validation ; pour valider une donnée, on utilise Pydantic.
Q : Quand un Protocol est-il vérifié, et quel est le risque qui en découle ? R : Au point d'utilisation (quand un objet est passé à un paramètre typé par le Protocol), au moment de l'analyse statique — pas à la définition de la classe. Risque : une implémentation non conforme reste invisible si aucun appelant ne l'exerce ; on ajoute donc des assignations de conformité explicites (_: MyProto = MyImpl()) en CI.
Q : Pourquoi typer un client LLM (Anthropic) derrière un Protocol dans une app d'agents ? R : Pour découpler la logique d'agent du SDK concret : tests déterministes sans réseau ni coût (fake scripté), décorateurs transparents pour le retry/logging/cache (pattern decorator structurel), et swap d'implémentation par environnement via les Depends overridables de FastAPI — le tout sans héritage ni token DI.