Si vous avez utilisé Flask ou FastAPI pendant plus d'une semaine, vous avez déjà utilisé des décorateurs — @app.route, @login_required, @pytest.mark.parametrize. Ils semblent magiques la première fois, puis quelqu'un explique ce qui se passe réellement et ça fait tilt immédiatement. Un décorateur est juste une fonction qui enveloppe une autre fonction. La syntaxe @ est du pur sucre syntaxique — @my_decorator au-dessus d'une définition de fonction est exactement équivalent à écrire func = my_decorator(func) après. C'est le tout le secret. Le reste n'est que des modèles construits sur cette seule idée. Cet article construit le modèle mental depuis zéro, puis parcourt les modèles que vous utiliserez réellement : @functools.wraps, les décorateurs avec arguments, les décorateurs basés sur des classes, et une poignée d'exemples du monde réel incluant @lru_cache, @dataclass, et un @retry correct avec backoff exponentiel.

Les fonctions sont des objets de première classe

Avant que les décorateurs aient du sens, vous devez bien comprendre un fait Python : les fonctions sont des objets. Vous pouvez les assigner à des variables, les passer comme arguments à d'autres fonctions, les retourner depuis des fonctions, et les stocker dans des listes ou des dicts. Rien de spécial ne se passe juste parce que quelque chose est une fonction.

python
def greet(name: str) -> str:
    return f"Hello, {name}"

# Assign to a variable — no () means we're not calling it, just referencing it
say_hello = greet
print(say_hello("Alice"))   # 'Hello, Alice'

# Pass a function as an argument
def run_twice(fn, value):
    return fn(value), fn(value)

run_twice(greet, "Bob")     # ('Hello, Bob', 'Hello, Bob')

# Return a function from another function — this is a "factory"
def make_prefixer(prefix: str):
    def prefixed_greet(name: str) -> str:
        return f"{prefix}, {name}"
    return prefixed_greet

morning_hello = make_prefixer("Good morning")
morning_hello("Carol")      # 'Good morning, Carol'

La fonction interne prefixed_greet "ferme sur" la variable prefix depuis la portée englobante — même après que make_prefixer a retourné, la fonction interne a toujours accès à prefix. C'est une fermeture, et c'est le mécanisme qui fait fonctionner les décorateurs. La documentation Python sur les règles de portée explique cela en détail si vous voulez le tableau complet.

Construire un décorateur depuis zéro

Un décorateur est une fonction qui prend une fonction et retourne une fonction (généralement modifiée). L'exemple classique initial est un décorateur de minuterie — il enveloppe n'importe quelle fonction et enregistre combien de temps elle a mis à s'exécuter.

python
import time
import functools

def timer(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = fn(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{fn.__name__} finished in {elapsed:.4f}s")
        return result
    return wrapper

@timer
def fetch_user_records(db, user_id: int):
    """Fetch all records for a given user from the database."""
    return db.query("SELECT * FROM records WHERE user_id = ?", user_id)

# Calling fetch_user_records is now: timer(fetch_user_records)(db, 42)
# Which is exactly what @timer desugars to

Quelques choses à remarquer : le wrapper utilise *args, **kwargs pour pouvoir transmettre n'importe quelle combinaison d'arguments à la fonction originale sans connaître sa signature. Il capture la valeur de retour dans result et la retourne, donc la fonction enveloppée se comporte toujours de manière identique depuis la perspective de l'appelant — elle a juste un effet de bord d'impression supplémentaire. Supprimez le minutage, et vous avez le squelette de presque chaque décorateur que vous écrirez jamais.

La règle de désucrage : Chaque fois que vous voyez @some_decorator au-dessus d'une définition de fonction, substituez mentalement fn = some_decorator(fn) écrit immédiatement après le bloc def. Les deux sont exactement équivalents. Il n'y a pas de magie — c'est un appel de fonction.

Pourquoi vous devez utiliser @functools.wraps

Dans l'exemple ci-dessus, il y a une ligne @functools.wraps(fn) sur le wrapper. Cela n'est pas optionnel. Sans cela, votre fonction décorée perd son identité — ses attributs __name__, __doc__, et __qualname__ sont tous remplacés par ceux de la fonction interne wrapper. Cela cause des problèmes subtils dans quelques situations réelles :

  • Les docstrings disparaissent. help(fetch_user_records) affiche la docstring vide du wrapper au lieu de "Fetch all records for a given user...".
  • Les traces de pile mentent. Quand une exception est levée à l'intérieur de la fonction enveloppée, le traceback montre wrapper au lieu du vrai nom de la fonction — difficile à déboguer.
  • L'introspection se brise. Des outils comme pytest, le système de routage de Flask, et inspect.signature() reposent tous sur __name__ et __wrapped__. Le routeur de Flask lèvera une exception si deux routes partagent le même nom (wrapper).
  • functools.lru_cache et des outils similaires utilisent l'identité de la fonction pour la clé de cache — sans wraps, vous pouvez obtenir des collisions de cache surprenantes.
python
import functools

# Without @functools.wraps — broken
def bad_timer(fn):
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

@bad_timer
def process_batch(batch_id: int):
    """Process a single batch job."""
    pass

print(process_batch.__name__)   # 'wrapper'   ← wrong
print(process_batch.__doc__)    # None         ← docstring gone

# With @functools.wraps — correct
def good_timer(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

@good_timer
def process_batch(batch_id: int):
    """Process a single batch job."""
    pass

print(process_batch.__name__)   # 'process_batch'  ← correct
print(process_batch.__doc__)    # 'Process a single batch job.'  ← preserved

functools.wraps est lui-même un décorateur — il copie __module__, __name__, __qualname__, __annotations__, __doc__, et définit __wrapped__ sur la fonction originale. Utilisez-le sur chaque fonction wrapper, point final. Il n'y a aucune raison de ne pas le faire.

Décorateurs avec arguments

Un décorateur simple prend une fonction et retourne une fonction. Un décorateur avec arguments nécessite un niveau supplémentaire : une fonction qui prend les arguments et retourne un décorateur. C'est trois niveaux d'imbrication, et cela confond presque tout le monde la première fois qu'ils le voient. Voici un décorateur @retry qui réessaie une fonction jusqu'à max_attempts fois en cas d'exception :

python
import functools
import time

def retry(max_attempts: int = 3, delay: float = 1.0, backoff: float = 2.0):
    """
    Retry a function on exception with exponential backoff.

    Usage:
        @retry(max_attempts=5, delay=0.5, backoff=2.0)
        def call_external_api(endpoint: str) -> dict:
            ...
    """
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            current_delay = delay
            last_exception = None

            for attempt in range(1, max_attempts + 1):
                try:
                    return fn(*args, **kwargs)
                except Exception as exc:
                    last_exception = exc
                    if attempt < max_attempts:
                        print(
                            f"{fn.__name__}: attempt {attempt}/{max_attempts} failed "
                            f"({exc!r}), retrying in {current_delay:.1f}s..."
                        )
                        time.sleep(current_delay)
                        current_delay *= backoff
                    else:
                        print(f"{fn.__name__}: all {max_attempts} attempts failed.")

            raise last_exception

        return wrapper
    return decorator

@retry(max_attempts=4, delay=0.5, backoff=2.0)
def fetch_price_data(ticker: str) -> dict:
    """Fetch stock price data from external API."""
    response = requests.get(f"https://api.example.com/prices/{ticker}", timeout=5)
    response.raise_for_status()
    return response.json()

La chaîne d'appels lorsque Python traite @retry(max_attempts=4, delay=0.5, backoff=2.0) : d'abord retry(max_attempts=4, delay=0.5, backoff=2.0) est appelé et retourne decorator. Ensuite decorator(fetch_price_data) est appelé et retourne wrapper. Finalement fetch_price_data est relié à wrapper. Donc @retry(...) est fetch_price_data = retry(...)(fetch_price_data) — trois appels, deux niveaux d'enveloppement. Une fois que vous voyez ce modèle, les fabriques de décorateurs cessent d'être déroutantes.

Décorateurs basés sur des classes

Vous pouvez aussi implémenter un décorateur comme une classe en définissant __call__. C'est utile quand le décorateur doit maintenir un état entre les appels — un compteur d'appels, un cache, des pools de connexions — parce que les variables d'instance sont un endroit plus naturel pour cet état que les variables de fermeture.

python
import functools
import time

class RateLimiter:
    """
    Decorator that limits how often a function can be called.
    Raises RuntimeError if the function is called within `min_interval` seconds
    of the previous call.
    """

    def __init__(self, min_interval: float):
        self.min_interval = min_interval
        self._last_called: float = 0.0

    def __call__(self, fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            now = time.monotonic()
            since_last = now - self._last_called
            if since_last < self.min_interval:
                wait = self.min_interval - since_last
                raise RuntimeError(
                    f"{fn.__name__} called too soon — "
                    f"wait {wait:.2f}s before calling again."
                )
            self._last_called = now
            return fn(*args, **kwargs)
        return wrapper

# Usage — one call per 2 seconds
@RateLimiter(min_interval=2.0)
def send_sms_alert(phone: str, message: str) -> None:
    """Send an SMS alert via the gateway API."""
    sms_gateway.send(phone, message)

@RateLimiter(min_interval=2.0) fonctionne exactement comme une fabrique de décorateurs : RateLimiter(2.0) construit une instance, puis cette instance est appelée avec send_sms_alert parce qu'elle a __call__. L'instance stocke _last_called comme attribut — pas besoin de jongler avec des variables de fermeture. Voir PEP 318 (la proposition originale de décorateur) pour la raison de conception derrière la syntaxe @, et PEP 614 pour la grammaire de décorateur assouplie arrivée dans Python 3.9.

Décorateurs du monde réel de la bibliothèque standard

Avant d'écrire le vôtre, vérifiez si la stdlib a déjà ce dont vous avez besoin. Trois décorateurs dans functools apparaissent constamment dans le code Python de production.

python
from functools import lru_cache, cache
import requests

# @cache — unbounded memoisation (Python 3.9+)
# Caches every unique set of arguments forever.
# Good for pure functions with a small domain of inputs.
@cache
def get_country_name(country_code: str) -> str:
    """Look up a country name from ISO 3166 code. Cached after first call."""
    response = requests.get(f"https://restcountries.com/v3.1/alpha/{country_code}")
    return response.json()[0]["name"]["common"]

get_country_name("DE")   # hits the API
get_country_name("DE")   # served from cache, no network call

# @lru_cache(maxsize=N) — bounded LRU cache
# Evicts least-recently-used entries once the cache hits `maxsize`.
# Better when the input domain is large and memory is a concern.
@lru_cache(maxsize=256)
def compute_discount(base_price: float, tier: str) -> float:
    """Heavy computation — price varies by tier. Cache the top 256 combinations."""
    discount_table = load_discount_table()          # expensive DB call
    rate = discount_table.get(tier, 0.0)
    return round(base_price * (1 - rate), 2)

# Inspect cache performance
print(compute_discount.cache_info())
# CacheInfo(hits=142, misses=14, maxsize=256, currsize=14)

@dataclass est dans une catégorie différente — c'est un décorateur de classe qui auto-génère __init__, __repr__, et __eq__ à partir de vos annotations de champs. Il réduit une quantité significative de code redondant pour les classes qui contiennent des données :

python
from dataclasses import dataclass, field
from typing import Optional
from datetime import datetime

@dataclass
class WebhookEvent:
    event_type: str
    source_id: int
    payload: dict
    received_at: datetime = field(default_factory=datetime.utcnow)
    retry_count: int = 0
    error_message: Optional[str] = None

# @dataclass generates all of this for free:
# - __init__(self, event_type, source_id, payload, received_at=..., retry_count=0, error_message=None)
# - __repr__ that shows all fields
# - __eq__ that compares field-by-field

event = WebhookEvent(event_type="order.created", source_id=9912, payload={"order_id": 44501})
print(event)
# WebhookEvent(event_type='order.created', source_id=9912, payload={...}, retry_count=0, ...)

Le décorateur @property transforme une méthode en accesseur de style attribut — les appelants lisent user.display_name au lieu de user.get_display_name(). Combinez-le avec @property.setter pour valider lors de l'écriture :

python
class UserProfile:
    def __init__(self, first_name: str, last_name: str, email: str):
        self._first_name = first_name
        self._last_name = last_name
        self._email = email.strip().lower()

    @property
    def display_name(self) -> str:
        return f"{self._first_name} {self._last_name}"

    @property
    def email(self) -> str:
        return self._email

    @email.setter
    def email(self, value: str) -> None:
        if "@" not in value:
            raise ValueError(f"Invalid email address: {value!r}")
        self._email = value.strip().lower()

profile = UserProfile("Alice", "Smith", "  [email protected]  ")
print(profile.display_name)   # 'Alice Smith'
print(profile.email)          # '[email protected]'

profile.email = "[email protected]"    # setter runs validation
profile.email = "not-an-email"           # raises ValueError

Un décorateur @require_auth pour les gestionnaires de routes

Les vérifications d'authentification dans les frameworks web sont un cas d'utilisation typique des décorateurs. Plutôt que de dupliquer la vérification "l'utilisateur est-il connecté ?" en haut de chaque gestionnaire de route, vous l'écrivez une seule fois comme décorateur et l'appliquez là où c'est nécessaire. Voici le modèle, écrit pour fonctionner avec Flask mais transférable à n'importe quel framework :

python
import functools
from flask import request, jsonify, g

def require_auth(fn):
    """
    Decorator that validates a Bearer token before the route handler runs.
    Sets g.current_user on success; returns 401 JSON on failure.
    """
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        auth_header = request.headers.get("Authorization", "")
        if not auth_header.startswith("Bearer "):
            return jsonify({"error": "Missing or malformed Authorization header"}), 401

        token = auth_header[len("Bearer "):]
        user = verify_token(token)          # your token validation logic
        if user is None:
            return jsonify({"error": "Invalid or expired token"}), 401

        g.current_user = user               # available to the route handler
        return fn(*args, **kwargs)
    return wrapper

# Usage — the auth check runs before the handler body
@app.route("/api/v1/reports/<int:report_id>")
@require_auth
def get_report(report_id: int):
    report = Report.query.get_or_404(report_id)
    if report.owner_id != g.current_user.id:
        return jsonify({"error": "Forbidden"}), 403
    return jsonify(report.to_dict())

Notez l'ordre : @app.route va en premier (le plus externe), @require_auth va en second. Cela compte — voir la section suivante. Le modèle s'étend naturellement : vous pourriez ajouter un décorateur @require_role("admin") qui vérifie g.current_user.role après que @require_auth a déjà vérifié que l'utilisateur existe.

Empiler des décorateurs — L'ordre compte

Quand vous empilez plusieurs décorateurs, ils s'appliquent du bas vers le haut (le décorateur le plus proche de la fonction s'applique en premier), mais ils s'exécutent du haut vers le bas quand la fonction est appelée. Cela piège les gens.

python
@decorator_a
@decorator_b
@decorator_c
def my_function():
    pass

# This is exactly equivalent to:
my_function = decorator_a(decorator_b(decorator_c(my_function)))

# When my_function() is called:
# 1. decorator_a's wrapper runs first (outermost)
# 2. decorator_b's wrapper runs second
# 3. decorator_c's wrapper runs third (innermost, closest to the real function)
# 4. The real my_function body runs
# 5. Unwinding: decorator_c → decorator_b → decorator_a
python
# Practical example: order matters for @timer and @retry
# If timer is outermost, it measures total time including retry wait periods.
# If retry is outermost, it measures only the final successful call.

@timer          # measures: total time across all retry attempts + sleep
@retry(max_attempts=3, delay=1.0)
def sync_with_partner_api(partner_id: int) -> dict:
    ...

# vs.

@retry(max_attempts=3, delay=1.0)  # measures: only the final successful call
@timer
def sync_with_partner_api(partner_id: int) -> dict:
    ...

# Usually you want @timer outermost — it tells you the real wall-clock cost of the operation.
# Think about what "calling this function" means to the caller, then wrap in that order.
Astuce pour les traces de pile : Quand une fonction décorée lève une exception, la trace de pile montrera chaque wrapper dans la pile d'appels. Si vous avez utilisé @functools.wraps correctement, les noms refléteront les fonctions originales. Si vous voyez une mer de frames wrapper, quelqu'un a oublié @functools.wraps. Le primer sur les décorateurs Python de Real Python a une bonne présentation sur comment déboguer les décorateurs empilés.

Pour conclure

Le modèle mental à retenir : @decorator est fn = decorator(fn). Tout le reste — les décorateurs avec arguments, les décorateurs basés sur des classes, les décorateurs empilés — est une variation sur cette seule substitution. Utilisez @functools.wraps sur chaque wrapper interne, transmettez toujours *args, **kwargs à la fonction enveloppée, et retournez son résultat. Les décorateurs avec arguments nécessitent trois niveaux d'imbrication : la fonction d'arguments, le décorateur, et le wrapper. Les décorateurs basés sur des classes sont la bonne approche quand votre décorateur doit maintenir un état entre les appels.

Pour aller plus loin : l' entrée du glossaire Python sur les décorateurs est brève mais précise. La proposition originale de décorateur, PEP 318, vaut la peine d'être lue pour le contexte sur pourquoi la syntaxe @ a été choisie plutôt que des alternatives. Si vous utilisez des décorateurs dans une base de code qui fait aussi beaucoup de traitement de données — lecture de fichiers, transformation d'enregistrements — les modèles ici se complètent naturellement avec ce qui est couvert dans Gestion des fichiers Python. Et si vous utilisez des décorateurs pour transformer des collections ou construire des structures de recherche, Compréhensions de listes Python couvre le côté transformation de données de cette image. Si vos fonctions décorées retournent du JSON et que vous voulez l'inspecter rapidement, le Formateur JSON de ce site est pratique pour ça.