14. Décorateurs et Contexte

Fonctionnement interne, @wraps, décorateurs paramétrés, stacking, décorateurs de classe, functools (cache, singledispatch, partial), @contextmanager, contextlib avancé.


14.1 Principe fondamental

Un décorateur est un callable qui reçoit une fonction et retourne une fonction modifiée.

# Syntaxe
@décorateur
def f():
    pass
 
# Équivaut à
f = décorateur(f)

14.2 Décorateur basique

def logger(f):
    def wrapper(*args, **kwargs):
        print(f"Appel de {f.__name__}")
        return f(*args, **kwargs)
    return wrapper
 
@logger
def addition(a, b):
    return a + b
 
addition(2, 3)
# Appel de addition
# 5

14.3 functools.wraps — préserver les métadonnées

Sans @wraps, les métadonnées de f sont perdues (nom, docstring, signature) :

from functools import wraps
 
def logger(f):
    @wraps(f)                     # copie __name__, __doc__, __module__, __annotations__
    def wrapper(*args, **kwargs):
        print(f"Appel de {f.__name__}")
        return f(*args, **kwargs)
    return wrapper
 
@logger
def addition(a, b):
    """Additionne deux nombres."""
    return a + b
 
addition.__name__    # "addition" (pas "wrapper")
addition.__doc__     # "Additionne deux nombres."

@wraps copie aussi __dict__ et __wrapped__ (ce dernier permet l’introspection).

14.4 Décorateurs avec paramètres

Syntaxe à deux niveaux

def répéter(n: int):
    """Décorateur paramétré."""
    def décorateur(f):
        @wraps(f)
        def wrapper(*args, **kwargs):
            for _ in range(n):
                f(*args, **kwargs)
        return wrapper
    return décorateur
 
@répéter(3)
def saluer(nom):
    print(f"Bonjour {nom}")
 
saluer("Alice")
# Bonjour Alice
# Bonjour Alice
# Bonjour Alice

Mécanique : @répéter(3) appelle d’abord répéter(3) qui retourne décorateur, puis @décorateur s’applique normalement.

Décorateur utilisable avec ou sans parenthèses

Pattern avancé : un décorateur qui marche les deux manières :

import functools
 
def répéter(f=None, *, n=1):
    def décorateur(f):
        @wraps(f)
        def wrapper(*args, **kwargs):
            for _ in range(n):
                f(*args, **kwargs)
        return wrapper
 
    if f is None:
        return décorateur
    return décorateur(f)
 
@répéter           # sans parenthèses
def a(): pass
 
@répéter(n=3)      # avec paramètre
def b(): pass

14.5 Stacking — empiler les décorateurs

L’ordre d’application est de bas en haut :

@a
@b
@c
def f():
    pass
 
# f = a(b(c(f)))

Exemple concret :

@logger
@timer
def calcul():
    return sum(range(1_000_000))
 
# timer.__wrapped__ = calcul (wrapper timer)
# logger.__wrapped__ = timer wrapper

Ordre : le plus proche de la fonction s’exécute en premier. Ici timer encapsule calcul, puis logger encapsule le résultat.

14.6 Décorateurs avec état

Un décorateur peut maintenir un état mutable :

from functools import wraps
 
def compteur(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        wrapper.appels += 1
        print(f"Appel #{wrapper.appels}")
        return f(*args, **kwargs)
    wrapper.appels = 0
    return wrapper
 
@compteur
def dire(x):
    print(x)
 
dire("a")   # Appel #1 / a
dire("b")   # Appel #2 / b

Variante avec closure :

def compteur(f):
    appels = 0
    @wraps(f)
    def wrapper(*args, **kwargs):
        nonlocal appels
        appels += 1
        print(f"Appel #{appels}")
        return f(*args, **kwargs)
    return wrapper

14.7 Décorateur sous forme de classe

Un décorateur peut être une classe (implémente __call__) :

from functools import wraps
 
class Compteur:
    def __init__(self, f):
        self.f = f
        self.appels = 0
        wraps(f)(self)        # copie les métadonnées sur l'instance
 
    def __call__(self, *args, **kwargs):
        self.appels += 1
        print(f"Appel #{self.appels}")
        return self.f(*args, **kwargs)
 
@Compteur
def dire(x):
    print(x)

Version paramétrée :

class Retry:
    def __init__(self, max_tentatives=3):
        self.max_tentatives = max_tentatives
 
    def __call__(self, f):
        @wraps(f)
        def wrapper(*args, **kwargs):
            for i in range(self.max_tentatives):
                try:
                    return f(*args, **kwargs)
                except Exception as e:
                    if i == self.max_tentatives - 1:
                        raise
                    print(f"Tentative {i+1}/{self.max_tentatives} échouée")
            return None
        return wrapper
 
@Retry(max_tentatives=3)
def requête_instable(url):
    ...

14.8 Décorateurs intégrés dans le langage

@property

@property
def x(self):
    return self._x
 
# __get__ du descripteur property

@staticmethod

@staticmethod
def f():
    pass
 
# Supprime le binding self/cls

@classmethod

@classmethod
def from_json(cls, data):
    return cls(**data)
 
# Passe cls au lieu de self

@dataclass

from dataclasses import dataclass
 
@dataclass
class Point:
    x: float
    y: float
 
# Transforme la classe : génère __init__, __repr__, __eq__, etc.

14.9 functools — décorateurs utilitaires

@lru_cache et @cache

Mémoïsation automatique :

from functools import lru_cache
 
@lru_cache(maxsize=128)
def fib(n):
    if n < 2:
        return n
    return fib(n-1) + fib(n-2)
 
fib(100)  # instantané grâce au cache

@functools.cache (3.9+) — idem, sans limite de taille :

from functools import cache
 
@cache
def factorielle(n):
    return n * factorielle(n-1) if n else 1

Propriétés : les arguments doivent être hashables. Le cache a une méthode .cache_info() et .cache_clear().

@singledispatch — dispatch polymorphe

Surcharge de fonction par type du premier argument :

from functools import singledispatch
 
@singledispatch
def sérialiser(obj):
    raise TypeError(f"Type non supporté: {type(obj)}")
 
@sérialiser.register
def _(obj: int) -> str:
    return f"int:{obj}"
 
@sérialiser.register
def _(obj: str) -> str:
    return f"str:{obj!r}"
 
@sérialiser.register(list)
def _(obj) -> str:
    items = ",".join(sérialiser(x) for x in obj)
    return f"[{items}]"
 
sérialiser(42)      # "int:42"
sérialiser("hello") # "str:'hello'"
sérialiser([1, "a"]) # "[int:1,str:'a']"

@total_ordering

Vue en leçon 10b — génère <=, >, >= à partir de == et <.

@wraps

Préserve les métadonnées de la fonction décorée (vu plus haut).

14.10 functools.partial — dérivé de fonction

Pas un décorateur mais souvent utilisé avec :

from functools import partial
 
def puissance(base, exp):
    return base ** exp
 
carré = partial(puissance, exp=2)
cube = partial(puissance, exp=3)
 
carré(5)   # 25
cube(5)    # 125

Avec décorateur paramétré :

from functools import partial
 
def log(level, f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        print(f"[{level}] {f.__name__}")
        return f(*args, **kwargs)
    return wrapper
 
info = partial(log, "INFO")
warn = partial(log, "WARN")
 
@info
def faire_qqch():
    pass

14.11 Patterns avancés

Validation d’arguments

def valider_entier(f):
    @wraps(f)
    def wrapper(n, *args, **kwargs):
        if not isinstance(n, int) or n < 0:
            raise ValueError(f"Attendu entier positif, reçu {n}")
        return f(n, *args, **kwargs)
    return wrapper
 
@valider_entier
def factorielle(n):
    return n * factorielle(n-1) if n else 1

Rate limiting

import time
from functools import wraps
 
def rate_limit(secondes: float):
    def décorateur(f):
        dernier_appel = 0.0
        @wraps(f)
        def wrapper(*args, **kwargs):
            nonlocal dernier_appel
            écoulé = time.time() - dernier_appel
            if écoulé < secondes:
                time.sleep(secondes - écoulé)
            dernier_appel = time.time()
            return f(*args, **kwargs)
        return wrapper
    return décorateur
 
@rate_limit(0.5)  # max 2 appels par seconde
def api_call():
    ...

Singleton (décorateur de classe)

def singleton(cls):
    instances = {}
    @wraps(cls)
    def wrapper(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    return wrapper
 
@singleton
class Config:
    pass
 
a = Config()
b = Config()
a is b  # True

Décorateur avec mise en cache personnalisée

from functools import wraps
import hashlib, json
 
def cache_fichier(prefix="cache_"):
    import os
    def décorateur(f):
        @wraps(f)
        def wrapper(*args, **kwargs):
            clé = hashlib.md5(
                json.dumps((args, kwargs), sort_keys=True).encode()
            ).hexdigest()
            chemin = f"{prefix}{clé}.pkl"
            if os.path.exists(chemin):
                with open(chemin, "rb") as fh:
                    return pickle.load(fh)
            resultat = f(*args, **kwargs)
            with open(chemin, "wb") as fh:
                pickle.dump(resultat, fh)
            return resultat
        return wrapper
    return décorateur

14.12 Gestionnaires de contexte avancés

Rappel : @contextmanager + contextlib (vu leçon 12). Compléments :

contextlib.nullcontext — contexte vide

from contextlib import nullcontext
 
def traiter(need_db: bool):
    ctx = db.connect() if need_db else nullcontext()
    with ctx as conn:
        ...

contextlib.ExitStack — contextes dynamiques

Nombre inconnu de contextes à l’avance :

from contextlib import ExitStack
 
with ExitStack() as stack:
    fichiers = []
    for nom in ["a.txt", "b.txt"]:
        f = stack.enter_context(open(nom))
        fichiers.append(f)
    # tous fermés automatiquement à la sortie

contextlib.chdir (3.11+)

from contextlib import chdir
 
with chdir("/tmp"):
    ...
# retour automatique

contextlib.closing

from contextlib import closing
from urllib.request import urlopen
 
with closing(urlopen("https://python.org")) as page:
    ...

14.13 Décorateur + contexte combiné

from contextlib import contextmanager
from functools import wraps
 
def benchmark(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        import time
        début = time.perf_counter()
        resultat = f(*args, **kwargs)
        durée = time.perf_counter() - début
        print(f"{f.__name__}: {durée*1000:.2f}ms")
        return resultat
    return wrapper
 
@contextmanager
def chrono(nom: str):
    """Usage : with chrono('bloc'): ..."""
    import time
    début = time.perf_counter()
    yield
    durée = time.perf_counter() - début
    print(f"{nom}: {durée*1000:.2f}ms")

Résumé des notions

ConceptMécanismeUsage
Décorateur simpleCallable → callableLogging, timing, validation
@wrapsCopie métadonnéesToujours utiliser
Paramétré2 niveaux de closureRetry, rate-limit
Stacking@a @b @c f = a(b(c(f)))Composition de préoccupations
Classe décoratrice__init__ + __call__État persistant
@cachelru_cacheMémoïsation
@singledispatchDispatch par typePolymorphisme sans classe
@contextmanageryield uniqueContexte sans classe
ExitStackPile de contextesContextes dynamiques

🔗 ← Retour au cours · ← précédent · Suivant →