Décorateurs en Python : du basique aux paramétrés

1. Aperçu

Le décorateur est l’une des fonctionnalités les plus « magiques » de Python, mais elle peut aussi prêter à confusion si vous vous contentez de copier-coller sans en comprendre l’essence. Cet article progresse du concept de « first-class citizen » pour les fonctions, aux décorateurs basiques, aux décorateurs qui préservent les métadonnées avec functools.wraps, jusqu’aux décorateurs paramétrés et aux décorateurs basés sur une classe. À la fin, vous serez capable d’écrire vos propres décorateurs de logging, timing ou retry pour vos projets sans perdre le nom de la fonction d’origine.

2. Les fonctions comme citoyens de première classe

Avant d’apprendre les décorateurs, vous devez comprendre un fait fondamental : en Python, une fonction est aussi un objet. Vous pouvez assigner une fonction à une variable, passer une fonction en paramètre, ou retourner une fonction à partir d’une autre fonction.

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

say_hello = greet
print(say_hello("Alice"))
Hello, Alice!

say_hello n’est qu’un autre nom pointant vers le même objet fonction greet. Grâce à cette caractéristique, Python vous permet d’écrire des fonctions qui reçoivent une fonction en entrée et en retournent une autre en sortie — c’est précisément la base des décorateurs.

3. Décorateur basique : une fonction qui en enveloppe une autre

Un décorateur est simplement une fonction qui reçoit une autre fonction et retourne une nouvelle fonction qui « enveloppe » (wrap) le comportement de la fonction d’origine.

def log_call(func):
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__} with args={args}, kwargs={kwargs}")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

def add(a: int, b: int) -> int:
    return a + b

add = log_call(add)
print(add(2, 3))
Calling add with args=(2, 3), kwargs={}
add returned 5
5
  • log_call reçoit func (la fonction d’origine add) et définit wrapper — une fonction interne qui rappelle func mais ajoute du logging avant et après.
  • log_call(add) retourne wrapper, et on le réassigne à add. À partir de là, appeler add(...) revient en réalité à appeler wrapper(...).

3.1. La syntaxe @ — du sucre syntaxique

Écrire func = decorator(func) manuellement est plutôt verbeux. Python fournit la syntaxe @decorator pour accomplir exactement la même chose, mais de manière plus concise :

@log_call
def multiply(a: int, b: int) -> int:
    return a * b

print(multiply(4, 5))
Calling multiply with args=(4, 5), kwargs={}
multiply returned 20
20

@log_call placé juste avant def multiply équivaut à multiply = log_call(multiply). C’est simplement du sucre syntaxique — il n’y a pas d’autre magie cachée.

4. Le problème de la perte de métadonnées et functools.wraps

Quand un décorateur « remplace » la fonction d’origine par wrapper, vous perdez les attributs tels que __name__ et __doc__ de la fonction d’origine.

@log_call
def subtract(a: int, b: int) -> int:
    """Soustraire deux nombres."""
    return a - b

print(subtract.__name__)
print(subtract.__doc__)
wrapper
None

C’est une erreur courante lors du débogage ou l’utilisation d’outils de génération de documentation — vous pensez appeler subtract mais Python rapporte que le nom est wrapper. Remédier à cela avec functools.wraps :

import functools

def log_call(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@log_call
def subtract(a: int, b: int) -> int:
    """Soustraire deux nombres."""
    return a - b

print(subtract.__name__)
print(subtract.__doc__)
subtract
Soustraire deux nombres.

functools.wraps(func) est un décorateur-de-décorateur : il copie __name__, __doc__, __module__… de func vers wrapper.

À noter : Utilisez toujours functools.wraps dans vos décorateurs en production. L’oublier rendra le débogage difficile et cassera l’introspection (par exemple help(), les frameworks qui dépendent du nom de la fonction).

5. Décorateurs avec paramètres

Souvent, vous voulez que votre décorateur soit paramétrable, par exemple @retry(times=3). Pour cela, vous avez besoin d’une couche supplémentaire de fonction : une fonction externe qui reçoit les paramètres et retourne le vrai décorateur, qui lui-même reçoit func.

import functools
import time

def retry(times: int = 3, delay: float = 1.0):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as exc:
                    last_exception = exc
                    print(f"Attempt {attempt} failed: {exc}")
                    time.sleep(delay)
            raise last_exception
        return wrapper
    return decorator

@retry(times=2, delay=0)
def call_payment_api(order_id: int) -> str:
    raise ConnectionError(f"Timeout for order {order_id}")
try:
    call_payment_api(101)
except ConnectionError as exc:
    print(f"Giving up: {exc}")
Attempt 1 failed: Timeout for order 101
Attempt 2 failed: Timeout for order 101
Giving up: Timeout for order 101

La structure en trois niveaux à retenir :

  1. retry(times, delay) — fonction factory, reçoit les paramètres de configuration, retourne le decorator.
  2. decorator(func) — le vrai décorateur, reçoit la fonction d’origine.
  3. wrapper(*args, **kwargs) — la fonction exécutée quand on appelle la fonction décorée.

@retry(times=2, delay=0) appelle en réalité d’abord retry(times=2, delay=0) (qui retourne decorator), puis applique ce decorator sur call_payment_api.

5.1. Décorateur utilisable avec ou sans paramètres

Parfois, vous voulez que @retry fonctionne aussi sans passer de paramètres (@retry au lieu de @retry()). La façon la plus élégante est d’utiliser functools.partial combiné avec une vérification callable :

import functools

def retry(func=None, *, times: int = 3):
    if func is None:
        return functools.partial(retry, times=times)

    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        for attempt in range(1, times + 1):
            try:
                return func(*args, **kwargs)
            except Exception as exc:
                print(f"Attempt {attempt} failed: {exc}")
        raise RuntimeError("All attempts failed")
    return wrapper

@retry
def task_a():
    return "ok"

@retry(times=5)
def task_b():
    return "ok"

Quand vous utilisez @retry (sans appeler la fonction), func reçoit directement la fonction task_a, et la branche if func is None n’est pas exécutée. Quand vous utilisez @retry(times=5), func est initialement None, la fonction retourne functools.partial(retry, times=5) — une fonction qui attend de recevoir func à l’étape suivante, qui est task_b.

6. Décorateur écrit avec une classe

Au lieu d’utiliser des fonctions imbriquées, vous pouvez écrire un décorateur en tant que classe qui implémente __call__. Cette approche est appropriée quand le décorateur doit maintenir un état complexe.

import functools

class CountCalls:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.call_count = 0

    def __call__(self, *args, **kwargs):
        self.call_count += 1
        print(f"{self.func.__name__} called {self.call_count} time(s)")
        return self.func(*args, **kwargs)

@CountCalls
def process_order(order_id: int) -> None:
    print(f"Processing order {order_id}")

process_order(1)
process_order(2)
print(process_order.call_count)
process_order called 1 time(s)
Processing order 1
process_order called 2 time(s)
Processing order 2
2
  • __init__ stocke la fonction d’origine et initialise call_count = 0.
  • functools.update_wrapper(self, func) — équivalent à functools.wraps mais pour une instance de classe, copie les métadonnées vers l’objet self.
  • __call__ rend l’instance process_order (qui est maintenant un objet CountCalls, pas une fonction) appelable comme une fonction ordinaire.

Remarque : Les décorateurs basés sur une classe ne fonctionnent pas bien quand on décore une méthode dans une autre classe (parce que le self de la classe d’origine et du décorateur peuvent entrer en conflit). Pour les méthodes, privilégiez les fonctions imbriquées ou functools.singledispatchmethod si vous avez besoin de dispatcher.

7. Empiler plusieurs décorateurs (Stacking)

Vous pouvez empiler plusieurs décorateurs sur une fonction. L’ordre d’application va de bas en haut (le décorateur le plus proche de la fonction s’exécute en premier).

@log_call
@retry(times=2, delay=0)
def charge_card(amount: float) -> str:
    if amount <= 0:
        raise ValueError("Amount must be positive")
    return f"Charged {amount}"

print(charge_card(10))
Calling wrapper
Charged 10

Ici, retry(times=2, delay=0) s’applique d’abord, enveloppe charge_card pour en faire une fonction capable de retry ; puis log_call l’enveloppe à nouveau par-dessus. Si vous n’utilisez pas functools.wraps correctement dans retry, vous verrez le log afficher wrapper au lieu de charge_card — une preuve supplémentaire de l’importance de la section 4.

8. Écrire des tests pour les décorateurs

Les décorateurs sont toujours des fonctions Python ordinaires, vous pouvez donc les tester avec pytest comme n’importe quelle autre logique. Installez la bibliothèque si vous ne l’avez pas encore :

pip install pytest

Exemple de test pour le décorateur retry :

import pytest

def test_retry_raises_after_exhausting_attempts():
    call_log = []

    @retry(times=3, delay=0)
    def always_fail():
        call_log.append("called")
        raise ValueError("boom")

    with pytest.raises(ValueError):
        always_fail()

    assert len(call_log) == 3


def test_retry_succeeds_on_second_attempt():
    attempts = {"count": 0}

    @retry(times=3, delay=0)
    def fail_once_then_succeed():
        attempts["count"] += 1
        if attempts["count"] < 2:
            raise ValueError("boom")
        return "ok"

    result = fail_once_then_succeed()

    assert result == "ok"
    assert attempts["count"] == 2

Exécutez les tests :

pytest test_retry.py -v
test_retry.py::test_retry_raises_after_exhausting_attempts PASSED
test_retry.py::test_retry_succeeds_on_second_attempt PASSED

call_log et attempts jouent le rôle d’espion pour vérifier combien de fois la fonction d’origine a été appelée — conforme au comportement de retry attendu, sans avoir besoin de mocker de bibliothèques externes.

9. Erreurs courantes

  • Oublier functools.wraps : la fonction perd son nom/docstring, ce qui rend le débogage difficile et provoque des erreurs quand on l’utilise avec des frameworks qui s’appuient sur __name__ (par exemple le routage dans Flask).
  • Utiliser un argument mutable par défaut dans wrapper : pareil que les erreurs courantes de Python, évitez d’avoir args=[] comme défaut.
  • Le décorateur a des effets secondaires lourds au moment de l’import : si le décorateur effectue une I/O (appeler une API, lire un fichier) directement dans decorator(func) au lieu de dans wrapper, il s’exécutera chaque fois que le module est importé, pas chaque fois que la fonction est appelée.
  • Abuser des décorateurs pour une logique complexe : si le décorateur contient trop de branchements, le code devient plus difficile à lire qu’un simple appel de fonction direct. Quand la logique devient complexe, envisagez plutôt un context manager ou de factoriser dans une classe séparée.

Quand NE PAS utiliser les décorateurs : si le comportement « enveloppe » est utilisé une seule fois et n’est pas réutilisable, écrire un décorateur ne fait que rendre le code plus difficile à tracer qu’un simple appel de fonction directe. Les décorateurs brillent vraiment quand on applique une logique transversale (cross-cutting concern) comme le logging, le caching, le retry ou l’authentification à plusieurs fonctions.

10. Conclusion

Les décorateurs en Python n’ont rien de mystérieux — ce ne sont que des fonctions qui prennent une fonction, retournent une fonction, basées sur le fait que les fonctions sont des citoyens de première classe. À partir d’un décorateur basique, vous ajoutez functools.wraps pour préserver les métadonnées, ajoutez une couche de fonction pour accepter les paramètres de configuration, ou passez à un style basé sur une classe quand vous avez besoin de gérer un état complexe. Une fois que vous maîtrisez ces motifs, vous pouvez écrire vos propres décorateurs de logging, caching, retry ou validation, propres et faciles à tester pour vos projets réels.

pythondecoratorclosurefunctional-programmingclean-code