Control flow & pattern matching
TL;DR — Le contrôle de flux en Python moderne, ce n'est plus seulement
if/elif/elseet des boucles : depuis Python 3.10,match/case(structural pattern matching) déstructure des objets, des dicts et des dataclasses en une seule expression lisible, avec capture de variables et gardes. Pour quelqu'un qui vient de TS, le réflexe «switch= chaîne de===» est un piège :matchne compare pas, il filtre une forme. C'est exactement l'outil dont vous avez besoin pour la boucle tool-use d'un agent LLM, où vous devez router des blocs hétérogènes (text,tool_use,thinking) renvoyés par l'API Anthropic. Cette leçon vous fait passer duifdéfensif au dispatch par forme, avec le code runnable d'une vraie boucle agent.
🧠 Mental model
Venant de PHP/TS, votre modèle mental de switch est : « j'évalue une valeur, je la compare à des constantes ». Le match de Python n'est pas ça. C'est un moteur de destructuring + filtrage de forme, plus proche du pattern matching de Rust ou de la déstructuration des paramètres en TS — mais en plus puissant, parce qu'il peut lier des variables au passage.
Pensez à un trieur postal automatique. Un if/elif classique, c'est un employé qui prend chaque colis et lit une étiquette : « code postal == 75 ? sinon == 69 ? sinon… ». Un match, c'est un tapis roulant qui regarde la forme du colis : « une boîte longue avec une étiquette tool_use et un champ name → dirige vers le quai des outils, et tant qu'à faire extrais-moi le name et l'input ». Le filtre et l'extraction se font dans le même geste.
Valeur entrante (un bloc de réponse de l'API)
│
▼
┌──────────────┐
│ match │ ← ne compare pas, il "essaie de faire correspondre une forme"
└──────┬───────┘
│
essaie chaque case DANS L'ORDRE, s'arrête à la première qui matche
│
├── case TextBlock(text=t): → t est lié, on l'imprime
├── case ToolUseBlock(name=n, input=i):→ n, i liés, on appelle l'outil
├── case ThinkingBlock(): → on ignore
└── case _: → fallback (le "default")La distinction qui change tout : dans case ToolUseBlock(name=n), n n'est pas une comparaison avec une variable n existante — c'est une capture. Python lie le champ name du bloc à une nouvelle variable n. C'est la source d'erreur n°1 des gens qui découvrent match (voir « En production »).
Le contrôle de flux moderne, rapidement
Avant le pattern matching, posons les briques en Python 3.12 idiomatique. Vous les connaissez, mais les idiomes diffèrent de TS/PHP.
if/elif/else et les expressions conditionnelles
Pas de switch historique en Python (avant 3.10). L'idiome de base reste if/elif/else, et l'expression ternaire s'écrit dans l'ordre « valeur d'abord » :
# Python — l'ordre est: <si_vrai> if <condition> else <si_faux>
status = "active" if user.is_enabled else "suspended"Pour les chaînes de comparaisons, Python autorise le chaînage, ce qui n'existe ni en TS ni en PHP :
# idiomatique
if 0 <= score <= 100:
...
# au lieu de (faux ami venant de TS)
if score >= 0 and score <= 100:
...Boucles : for, else, enumerate, et le piège du range
En Python on itère sur l'objet, pas sur un index. Si vous écrivez for i in range(len(xs)), c'est presque toujours un code smell.
items: list[str] = ["alpha", "beta", "gamma"]
# ❌ Réflexe d'ancien dev C/Java/PHP
for i in range(len(items)):
print(i, items[i])
# ✅ Idiomatique
for index, value in enumerate(items):
print(index, value)Particularité méconnue : la clause for/else. Le bloc else s'exécute si la boucle se termine sans break. Utile pour les recherches :
def find_admin(users: list[dict]) -> dict | None:
for user in users:
if user["role"] == "admin":
return user
else:
# exécuté uniquement si AUCUN break/return n'a interrompu la boucle
return Nonematch/case : la vraie nouveauté
match prend un sujet et le confronte à des patterns (pas des valeurs). Les types de patterns que vous utiliserez en pratique :
def describe(command: object) -> str:
match command:
case "quit": # literal pattern (valeur exacte)
return "fin"
case ["move", ("up" | "down") as dir]: # sequence + OR + capture nommée
return f"déplacement {dir}"
case {"action": "pay", "amount": int(n)}: # mapping + capture typée
return f"paiement de {n}"
case Point(x=0, y=0): # class pattern (positionnel/keyword)
return "origine"
case _: # wildcard (le "default")
return "inconnu"Points à retenir, chacun étant un faux ami pour un dev TS :
case {"action": "pay"}matche un dict qui contient la cléaction == "pay"— les clés supplémentaires sont tolérées. Ce n'est pas une égalité stricte de dict.case int(n)n'appelle pas le constructeurint: c'est un pattern qui dit « si c'est une instance deint, lie-la àn».case "up" | "down"est un OR pattern, pas un opérateur bit-à-bit.- L'ordre compte. Le premier
casequi matche gagne. Mettez le plus spécifique en haut.
Pattern matching appliqué : router la réponse d'un agent LLM
Voici où la théorie devient un outil de production. Quand vous appelez l'API Anthropic avec des outils, la réponse n'est pas une chaîne : c'est une liste de blocs hétérogènes (TextBlock, ToolUseBlock, et des blocs de raisonnement). Vous devez router chaque bloc. C'est exactement le travail de match.
Posons d'abord les dataclasses (Python 3.12, entièrement typées) pour montrer le pattern matching sur des objets, indépendamment du SDK :
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class TextBlock:
text: str
@dataclass(frozen=True, slots=True)
class ToolUseBlock:
id: str
name: str
input: dict[str, object]
@dataclass(frozen=True, slots=True)
class ThinkingBlock:
thinking: str
Block = TextBlock | ToolUseBlock | ThinkingBlockLa bonne façon : dispatch par forme
def handle_block(block: Block) -> str | None:
match block:
case TextBlock(text=t):
return f"[texte] {t}"
case ToolUseBlock(name="get_weather", input={"city": str(city)}):
# On peut filtrer ET déstructurer en profondeur dans un seul case.
return f"[outil] météo pour {city}"
case ToolUseBlock(name=name, input=args):
return f"[outil] {name} avec {args}"
case ThinkingBlock():
return None # on n'affiche pas le raisonnement brut
case _:
raise ValueError(f"bloc inconnu: {block!r}")Remarquez ToolUseBlock(name="get_weather", input={"city": str(city)}) : on combine un literal pattern (name="get_weather"), un mapping pattern imbriqué, et une capture typée (str(city)) — le tout en une ligne, sans une seule assertion défensive. C'est ça, le gain de lisibilité.
La mauvaise façon (le « common wrong way »)
Le réflexe d'un dev qui débarque de TS, c'est de reconstruire un switch avec des isinstance et des if imbriqués :
# ❌ Verbeux, fragile, et facile à casser en oubliant un cas
def handle_block_bad(block: Block) -> str | None:
if isinstance(block, TextBlock):
return f"[texte] {block.text}"
elif isinstance(block, ToolUseBlock):
if block.name == "get_weather":
city = block.input.get("city")
if isinstance(city, str): # validation manuelle, oubliable
return f"[outil] météo pour {city}"
else:
return None
else:
return f"[outil] {block.name} avec {block.input}"
elif isinstance(block, ThinkingBlock):
return None
# ⚠️ pas de else final → retourne None silencieusement sur un bloc inconnuLes deux font « la même chose », mais la version if :
- répète
block.partout (bruit) ; - mélange le filtrage (
isinstance) et l'extraction (.input.get) en deux étapes ; - n'a pas de cas par défaut explicite, donc un nouveau type de bloc passe en silence — un bug qui ne se voit qu'en prod.
L'autre piège classique du match est plus subtil — un nom nu capture toujours, il ne compare jamais :
WEATHER = "get_weather"
# ❌ BUG: `WEATHER` ici n'est PAS comparé à la constante.
# C'est une capture: ce case matche N'IMPORTE QUEL bloc et écrase WEATHER.
match block:
case ToolUseBlock(name=WEATHER): # capture, pas comparaison !
...
# ✅ Pour comparer à une constante, il faut un nom QUALIFIÉ (avec un point)
import constants
match block:
case ToolUseBlock(name=constants.WEATHER): # value pattern → comparaison
...C'est le piège n°1 en revue de code. Un identifiant nu dans un case est une cible de capture. Pour matcher contre une valeur, elle doit être accessible par attribut (module.NOM, Enum.MEMBRE) ou être un littéral.
La boucle tool-use complète avec le SDK Anthropic
Maintenant, le vrai code. On sert un agent : on envoie une requête, on streame la réponse (obligatoire dès que max_tokens est élevé, pour éviter les timeouts HTTP), on route les blocs avec match, on exécute les outils, et on reboucle jusqu'à ce que l'agent ait fini. On utilise AsyncAnthropic, le thinking adaptatif, et le paramètre effort.
from __future__ import annotations
import asyncio
import json
import anthropic
from anthropic import AsyncAnthropic
from anthropic.types import Message, ToolUseBlock
MODEL = "claude-opus-4-8"
TOOLS: list[dict[str, object]] = [
{
"name": "get_weather",
"description": "Donne la météo actuelle. À appeler dès que l'utilisateur "
"demande le temps qu'il fait dans une ville.",
"input_schema": {
"type": "object",
"properties": {"city": {"type": "string", "description": "Nom de la ville"}},
"required": ["city"],
},
}
]
async def run_tool(name: str, args: dict[str, object]) -> str:
"""Exécute un outil côté client. C'est VOTRE code, pas celui d'Anthropic."""
match name:
case "get_weather":
match args:
case {"city": str(city)}:
# ... ici un vrai appel HTTP vers une API météo ...
return f"22°C et ensoleillé à {city}"
case _:
return "erreur: paramètre 'city' manquant ou invalide"
case _:
return f"erreur: outil inconnu {name!r}"
async def agent_turn(client: AsyncAnthropic, user_input: str) -> str:
"""Une conversation agentique complète: boucle jusqu'à end_turn."""
messages: list[dict[str, object]] = [{"role": "user", "content": user_input}]
while True:
# On streame: get_final_message() reconstruit le Message complet pour nous.
async with client.messages.stream(
model=MODEL,
max_tokens=4096,
thinking={"type": "adaptive"}, # le modèle décide quand/combien réfléchir
output_config={"effort": "high"}, # low | medium | high | xhigh | max
tools=TOOLS,
messages=messages,
) as stream:
response: Message = await stream.get_final_message()
# On route le stop_reason — encore un match.
match response.stop_reason:
case "end_turn":
# Réponse finale: on extrait le texte et on rend la main.
return "".join(
block.text for block in response.content if block.type == "text"
)
case "tool_use":
# L'agent veut appeler des outils. On exécute, on renvoie les résultats.
messages.append({"role": "assistant", "content": response.content})
tool_results: list[dict[str, object]] = []
for block in response.content:
# Chaque ToolUseBlock est déstructuré ici.
match block:
case ToolUseBlock(id=tool_id, name=name, input=args):
output = await run_tool(name, args)
tool_results.append(
{
"type": "tool_result",
"tool_use_id": tool_id,
"content": output,
}
)
case _:
pass # text/thinking dans une réponse tool_use: on ignore
messages.append({"role": "user", "content": tool_results})
continue # on reboucle: l'agent voit les résultats et continue
case "max_tokens":
return "[tronqué: augmentez max_tokens]"
case "refusal":
# Claude a refusé pour raison de sécurité. NE PAS relancer tel quel.
return "[refus de sécurité]"
case other:
raise RuntimeError(f"stop_reason non géré: {other}")
async def main() -> None:
client = AsyncAnthropic() # lit ANTHROPIC_API_KEY depuis l'environnement
answer = await agent_turn(client, "Quel temps fait-il à Tunis ?")
print(answer)
if __name__ == "__main__":
asyncio.run(main())Deux endroits où match porte tout le poids de la logique :
response.stop_reason— un dispatch sur une string, où chaque branche a une sémantique radicalement différente (boucler, retourner, lever). Unmatchrend l'exhaustivité visible.- Chaque bloc de
response.content— déstructuration deToolUseBlockpour en sortirid,name,inputd'un coup.
⚠️ Note importante sur le modèle
claude-opus-4-8: on utilisethinking={"type": "adaptive"}etoutput_config={"effort": ...}. Lebudget_tokens(ancienne façon de cadrer le « thinking ») renvoie une 400 sur ce modèle. De même,temperature/top_p/top_ksont retirés. Si vous voyez ces paramètres dans un vieux code, ce sont des bugs en puissance.
Sorties structurées : éviter le pattern matching fragile sur du JSON
Parfois vous ne voulez pas router des blocs, vous voulez un objet typé garanti. Plutôt que de matcher défensivement un dict que le modèle « a probablement » bien formé, utilisez les sorties structurées natives via messages.parse() avec un modèle Pydantic v2 :
from __future__ import annotations
import asyncio
from anthropic import AsyncAnthropic
from pydantic import BaseModel, Field
class Ticket(BaseModel):
title: str
priority: int = Field(ge=1, le=5)
tags: list[str]
async def classify(client: AsyncAnthropic, text: str) -> Ticket:
response = await client.messages.parse(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{"role": "user", "content": f"Classe ce ticket: {text}"}],
output_config={"format": Ticket}, # le SDK contraint et valide la sortie
)
parsed = response.parsed_output
if parsed is None:
raise ValueError("le modèle n'a pas produit de sortie conforme au schéma")
return parsed # déjà un Ticket validé — pas de match à écrire
async def main() -> None:
client = AsyncAnthropic()
ticket = await classify(client, "Le paiement échoue en production depuis 2h, urgent")
# Ici, match a un sens: router selon la priorité validée.
match ticket.priority:
case 5 | 4:
print("→ escalade immédiate")
case 3:
print("→ file standard")
case _:
print("→ backlog")
if __name__ == "__main__":
asyncio.run(main())Le principe senior : n'utilisez match sur du JSON brut que là où la forme est réellement incertaine (les blocs de l'API, eux, le sont). Quand vous contrôlez le schéma de sortie, faites-le valider par messages.parse() + Pydantic et matchez ensuite sur des champs déjà typés. Le pattern matching sur un dict non validé déplace juste le risque.
⚙️ En production
Modes de défaillance
Le
casenon exhaustif silencieux. Contrairement à Rust,matchn'impose pas l'exhaustivité au compile-time. Si aucuncasene matche et qu'il n'y a pas decase _, lematchne fait rien et continue — pas d'erreur. Pour les unions fermées (unstop_reason, un type de bloc), ajoutez toujours uncase _: raiseou unassert_never. C'est votre filet :pythonfrom typing import assert_never match block: case TextBlock(): ... case ToolUseBlock(): ... case ThinkingBlock(): ... case _ as unreachable: assert_never(unreachable) # mypy/pyright hurleront si un cas manqueassert_nevertransforme une omission en erreur de typage statique — c'est ce qui rapprochematchde l'exhaustivité d'unswitchTS bien typé.La capture qui écrase une constante. Vu plus haut :
case STATUS_OKcapture au lieu de comparer. En revue, traquez tout identifiant nu dans uncase. Réflexe : les comparaisons passent parEnum.MEMBREoumodule.CONSTANTE.Le refus de l'agent. Sur
claude-opus-4-8, unstop_reason: "refusal"arrive avec un HTTP 200. Du code qui litresponse.content[0].textsans vérifierstop_reasonplantera (contenu vide) ou affichera un partiel. Routezstop_reasonavant de touchercontent— exactement ce que fait la boucle ci-dessus.Les boucles agent infinies. Une boucle tool-use sans garde-fou peut tourner indéfiniment si le modèle re-demande des outils en boucle. Ajoutez un compteur d'itérations max (
for _ in range(MAX_TURNS)) et un budget.
Performance
matchsur des dataclasses avecslots=Trueet un class pattern positionnel est rapide et alloue moins. Préférezfrozen=True, slots=Truepour vos blocs de domaine.- Un
matchlong n'est pas un saut de table O(1) comme unswitchC sur des entiers : il essaie les cases dans l'ordre. Mettez les cas fréquents en haut. Pour des dizaines de littéraux purs, undictde dispatch ({clé: handler}) reste plus rapide et plus plat. - Côté LLM, le vrai coût n'est pas le
matchmais les tokens. Streamez (messages.stream) au-delà demax_tokensélevé pour ne pas heurter les timeouts HTTP du SDK, et activez le prompt caching sur le prompt système + la liste d'outils (stables) pour payer ~0,1× sur les requêtes répétées de la boucle.
Sécurité
- Validez les
inputd'outils. UnToolUseBlock.inputvient du modèle, donc indirectement de l'utilisateur. Le mapping pattern{"city": str(city)}rejette déjà les formes invalides, mais pour un outil à effet de bord (suppression, paiement, requête SQL), re-validez avec Pydantic et exigez une confirmation humaine sur les actions irréversibles. Ne faites jamaiseval/os.systemsur un champ d'outil. - Ne mettez jamais de secret dans le system prompt ou les messages pour le faire « voir » à un outil — ils persistent dans l'historique.
Observabilité
- Loggez
response.usage(input_tokens,output_tokens,cache_read_input_tokens) à chaque tour de boucle pour suivre le coût réel de l'agent. - Loggez le
namede chaque outil appelé et la latence d'exécution. Lecaseest l'endroit naturel pour instrumenter.
Le tradeoff senior
match est un outil de lisibilité et d'exhaustivité, pas de performance. Sortez-le quand vous routez des formes hétérogènes (blocs LLM, AST, events, messages d'un protocole). Restez sur un dict de dispatch quand vous routez des clés homogènes vers des handlers. Et restez sur if/elif quand il n'y a que deux ou trois conditions booléennes — un match à deux cases est de la sur-ingénierie.
🏋️ Exercices
Exercice 1 — Du if au match (implémenter)
Objectif. Réécrire avec match une fonction qui route un event de webhook représenté par un dict, en capturant les champs utiles. L'entrée a la forme {"type": "push", "ref": "refs/heads/main", "commits": [...]} ou {"type": "issue", "action": "opened", "number": 42}.
Écrivez def route(event: dict) -> str qui retourne :
"déploiement"pour un push surrefs/heads/main;"ci"pour un push sur une autre branche ;"triage #42"pour une issueopened(avec le numéro réel) ;"ignoré"sinon.
Indice/Solution. Imbriquez mapping patterns et captures :
def route(event: dict) -> str:
match event:
case {"type": "push", "ref": "refs/heads/main"}:
return "déploiement"
case {"type": "push"}:
return "ci"
case {"type": "issue", "action": "opened", "number": int(n)}:
return f"triage #{n}"
case _:
return "ignoré"L'ordre est crucial : le push sur main doit précéder le push générique.
Exercice 2 — Rendre exhaustif (production-grade)
Objectif. Reprendre handle_block de la leçon, l'annoter pour que pyright/mypy détectent un type de bloc oublié. Ajoutez un quatrième type ImageBlock à l'union Block sans mettre à jour handle_block, et faites en sorte que le type-checker échoue.
Indice/Solution. Remplacez le case _ par une capture + assert_never (voir « En production »). Tant que tous les cas ne sont pas couverts, assert_never reçoit un type non-Never et le type-checker signale l'erreur. C'est la transformation d'un bug runtime en erreur statique.
Exercice 3 — Boucle tool-use avec garde-fou (production-grade)
Objectif. Partir de agent_turn et la durcir : (a) limiter à 6 tours max, (b) gérer un outil qui lève une exception sans casser la boucle, (c) router stop_reason avec assert_never.
Indice/Solution. Entourez la boucle d'un for turn in range(6): et levez si on en sort sans end_turn. Dans run_tool, attrapez l'exception et renvoyez un tool_result avec "is_error": True — l'agent voit l'erreur et peut s'adapter. Pour stop_reason, importez assert_never et placez-le dans le case _.
Exercice 4 — Casser puis réparer : la capture traîtresse (break-then-fix)
Objectif. On vous donne ce code qui « marche » mais route toujours vers la première branche :
ADMIN = "admin"
def access(user: dict) -> str:
match user:
case {"role": ADMIN}: # 🐛
return "accès total"
case {"role": "viewer"}:
return "lecture seule"
case _:
return "refusé"access({"role": "viewer"}) retourne "accès total". Expliquez pourquoi, puis corrigez.
Indice/Solution. ADMIN est un identifiant nu → c'est une capture, pas une comparaison. {"role": ADMIN} matche n'importe quel dict ayant une clé role, et lie sa valeur à ADMIN. Corrigez en qualifiant la constante (case {"role": roles.ADMIN} avec ADMIN dans un module/Enum), ou utilisez un littéral (case {"role": "admin"}).
Exercice 5 — Dispatch sur du JSON LLM non fiable (break-then-fix)
Objectif. Un collègue parse la sortie d'un agent en matchant directement le dict renvoyé par le modèle, et ça casse une fois sur dix quand le modèle omet un champ. Reproduisez le bug avec un dict incomplet, puis remplacez le match fragile par une validation messages.parse() + Pydantic, et ne matchez qu'ensuite.
Indice/Solution. Le match {"priority": int(p)} échoue silencieusement (tombe dans _) si priority est une string "5" ou absent. La solution n'est pas d'ajouter des cases défensifs à l'infini, mais de contraindre la sortie en amont : définissez un BaseModel (comme Ticket), passez-le à output_config={"format": ...}, et matchez sur les champs typés et validés. Le pattern matching n'est pas un validateur.
🎤 En entretien
Q : Quelle est la différence fondamentale entre match en Python et switch en C/TS ? R : switch compare une valeur à des constantes ; match filtre une forme et capture des variables au passage (déstructuration de séquences, mappings, classes) — c'est du structural pattern matching, plus proche de Rust que de C.
Q : Dans case Point(x=0, y=val), qu'est-ce que 0 et qu'est-ce que val ? R : 0 est un value pattern (le champ x doit valoir 0) ; val est un capture pattern (le champ y est lié à une nouvelle variable val). Un identifiant nu capture toujours ; pour comparer à une constante il faut un nom qualifié ou un littéral.
Q : match garantit-il l'exhaustivité comme un switch exhaustif en TS ? R : Non, pas au runtime — un match sans case correspondant ne fait rien. On obtient l'exhaustivité statique avec typing.assert_never dans le case _, que mypy/pyright vérifient sur une union fermée.
Q : Pourquoi router stop_reason avec match plutôt que des if dans une boucle agent ? R : Parce que chaque valeur (end_turn, tool_use, max_tokens, refusal) impose une action sémantiquement différente (retourner, boucler, tronquer, gérer un refus) ; un match + assert_never rend le routage exhaustif et explicite, et empêche qu'un nouveau stop_reason passe en silence — typiquement le refus de sécurité qu'on lirait à tort comme une réponse vide.