Skip to content

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, sans register(), sans couplage. Tu l'utilises quand tu veux découpler un appelant de l'implémentation concrète : un LLMClient Protocol te laisse brancher l'AsyncAnthropic réel en prod et un fake en test, injectés via les Depends de 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é de Employee ? » Tu dois explicitement hériter, ou t'enregistrer (ABCMeta.register).
  • Typage structurel (Protocol) : « Montre-moi ton badge. As-tu une méthode open_door() et un attribut clearance_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

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

python
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 jamais pass ou 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 :

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

Et les implémentations — Postgres en prod, un dict en test — ne mentionnent jamais UserRepository :

python
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] = user

mypy vérifie que les deux satisfont UserRepository au point d'appel :

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

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

  1. Couplage : chaque implémentation doit import puis hériter de la base. Si la base vit dans domain/ et l'implémentation dans infra/, tu crées une dépendance d'infradomain au runtime (souvent ce que tu veux, mais pas toujours — un Protocol te laisse le choix).
  2. 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à.
  3. Tests : avec une ABC, ton fake doit hériter de la base. Avec un Protocol, n'importe quel objet (même un Mock correctement 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 :

python
from typing import Protocol, runtime_checkable


@runtime_checkable
class Closeable(Protocol):
    def close(self) -> None: ...


isinstance(DatabasePool(), Closeable)  # ✅ True

Mais 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.

python
class Weird:
    def close(self, force: bool, hard: bool) -> None: ...


isinstance(Weird(), Closeable)  # ✅ True (présence seule)
shutdown(Weird())               # 💥 TypeError au runtime : args manquants

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

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

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

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

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

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

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

Test (synchrone à lire, async à exécuter) :

python
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) == 1

En 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.

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

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

python
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_checkable qui 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 —

    python
    def _assert_conformance() -> None:
        _: ChatModel = AnthropicChatModel(...)  # mypy hurle ici si la forme casse

    ou 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.
  • isinstance contre un Protocol est toujours plus lent qu'un isinstance nominal (Python doit inspecter les membres). Sur un chemin chaud (boucle d'agent à des milliers d'itérations), évite-le.
  • AsyncAnthropic se partage : un seul client poolé (créé au lifespan) 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 input d'un tool_use block. Parse-les avec json.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 logge input_tokens/output_tokens/latence se branche sans toucher l'agent :

    python
    import 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 reply

    Decorator 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 modifierProtocol
Forcer/contrôler une hiérarchie de classesABC
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.

Bibliothèque tech perso — Achref