Skip to content

Control flow & pattern matching

TL;DR — Le contrôle de flux en Python moderne, ce n'est plus seulement if/elif/else et 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 : match ne 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 du if dé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.

text
  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
# 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 :

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

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

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

match/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 :

python
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 constructeur int : c'est un pattern qui dit « si c'est une instance de int, lie-la à n ».
  • case "up" | "down" est un OR pattern, pas un opérateur bit-à-bit.
  • L'ordre compte. Le premier case qui 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 :

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

La bonne façon : dispatch par forme

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

python
# ❌ 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 inconnu

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

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

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

  1. response.stop_reason — un dispatch sur une string, où chaque branche a une sémantique radicalement différente (boucler, retourner, lever). Un match rend l'exhaustivité visible.
  2. Chaque bloc de response.content — déstructuration de ToolUseBlock pour en sortir id, name, input d'un coup.

⚠️ Note importante sur le modèle claude-opus-4-8 : on utilise thinking={"type": "adaptive"} et output_config={"effort": ...}. Le budget_tokens (ancienne façon de cadrer le « thinking ») renvoie une 400 sur ce modèle. De même, temperature/top_p/top_k sont 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 :

python
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 case non exhaustif silencieux. Contrairement à Rust, match n'impose pas l'exhaustivité au compile-time. Si aucun case ne matche et qu'il n'y a pas de case _, le match ne fait rien et continue — pas d'erreur. Pour les unions fermées (un stop_reason, un type de bloc), ajoutez toujours un case _: raise ou un assert_never. C'est votre filet :

    python
    from typing import assert_never
    
    match block:
        case TextBlock():
            ...
        case ToolUseBlock():
            ...
        case ThinkingBlock():
            ...
        case _ as unreachable:
            assert_never(unreachable)  # mypy/pyright hurleront si un cas manque

    assert_never transforme une omission en erreur de typage statique — c'est ce qui rapproche match de l'exhaustivité d'un switch TS bien typé.

  • La capture qui écrase une constante. Vu plus haut : case STATUS_OK capture au lieu de comparer. En revue, traquez tout identifiant nu dans un case. Réflexe : les comparaisons passent par Enum.MEMBRE ou module.CONSTANTE.

  • Le refus de l'agent. Sur claude-opus-4-8, un stop_reason: "refusal" arrive avec un HTTP 200. Du code qui lit response.content[0].text sans vérifier stop_reason plantera (contenu vide) ou affichera un partiel. Routez stop_reason avant de toucher content — 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

  • match sur des dataclasses avec slots=True et un class pattern positionnel est rapide et alloue moins. Préférez frozen=True, slots=True pour vos blocs de domaine.
  • Un match long n'est pas un saut de table O(1) comme un switch C sur des entiers : il essaie les cases dans l'ordre. Mettez les cas fréquents en haut. Pour des dizaines de littéraux purs, un dict de dispatch ({clé: handler}) reste plus rapide et plus plat.
  • Côté LLM, le vrai coût n'est pas le match mais les tokens. Streamez (messages.stream) au-delà de max_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 input d'outils. Un ToolUseBlock.input vient 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 jamais eval/os.system sur 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 name de chaque outil appelé et la latence d'exécution. Le case est 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 sur refs/heads/main ;
  • "ci" pour un push sur une autre branche ;
  • "triage #42" pour une issue opened (avec le numéro réel) ;
  • "ignoré" sinon.

Indice/Solution. Imbriquez mapping patterns et captures :

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

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

Bibliothèque tech perso — Achref