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
# 514.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 AliceMé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(): pass14.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 wrapperOrdre : 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 / bVariante 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 wrapper14.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 1Proprié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) # 125Avec 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():
pass14.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 1Rate 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 # TrueDé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écorateur14.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 sortiecontextlib.chdir (3.11+)
from contextlib import chdir
with chdir("/tmp"):
...
# retour automatiquecontextlib.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
| Concept | Mécanisme | Usage |
|---|---|---|
| Décorateur simple | Callable → callable | Logging, timing, validation |
@wraps | Copie métadonnées | Toujours utiliser |
| Paramétré | 2 niveaux de closure | Retry, rate-limit |
| Stacking | @a @b @c f = a(b(c(f))) | Composition de préoccupations |
| Classe décoratrice | __init__ + __call__ | État persistant |
@cache | lru_cache | Mémoïsation |
@singledispatch | Dispatch par type | Polymorphisme sans classe |
@contextmanager | yield unique | Contexte sans classe |
ExitStack | Pile de contextes | Contextes dynamiques |