4.2 Décorateurs
En PHP 8, les attributs (#[Route('/api')]) ressemblent aux dĂ©corateurs Python mais ce sont des mĂ©tadonnĂ©es passives â ils ne font rien par eux-mĂȘmes, c'est le framework qui les lit via la rĂ©flexion. Un dĂ©corateur Python, lui, enveloppe la fonction et change son comportement au moment de l'appel. @property que tu connais dĂ©jĂ , c'est exactement ça : un dĂ©corateur built-in qui transforme une mĂ©thode en getter.
1. Le principe : une fonction qui enveloppe une fonction
Un décorateur est une fonction qui prend une fonction en argument et retourne une nouvelle fonction. La syntaxe @timer est un raccourci pour ma_fonction = timer(ma_fonction).
def timer(func): # Le Décorateur
def wrapper(*args, **kwargs):
import time
debut = time.perf_counter() # AVANT : On lance le chronomĂštre
result = func(*args, **kwargs) # PENDANT : appelle la vraie fonction
duree = time.perf_counter() - debut # APRĂS : On arrĂȘte le chronomĂštre
print(f"[timer] {func.__name__} : {duree:.4f}s")
return result
return wrapper
@timer # sucre syntaxique
def charger_donnees(path: str) -> str:
with open(path) as f:
return f.read()
# Ăquivalent exact de : charger_donnees = timer(charger_donnees)
charger_donnees("scanner.txt")
# [timer] charger_donnees : 0.0003s
*args, **kwargsâ le wrapper accepte n'importe quelle signature- Le
return resultest crucial â sinon la valeur de retour est perdue - Le dĂ©corateur s'applique Ă la dĂ©finition, pas Ă l'appel
2. functools.wraps â ne jamais l'oublier
Sans @functools.wraps, le wrapper écrase le __name__ et le __doc__ de la fonction originale. Les outils de debug et la génération de doc voient alors wrapper partout au lieu du vrai nom.
import functools
def timer(func):
@functools.wraps(func) # préserve __name__, __doc__, __module__
def wrapper(*args, **kwargs):
import time
debut = time.perf_counter()
result = func(*args, **kwargs)
duree = time.perf_counter() - debut
print(f"[timer] {func.__name__} : {duree:.4f}s")
return result
return wrapper
@timer
def parse_pip(raw_data: str) -> list:
"""Parse une sortie pip list."""
...
print(parse_pip.__name__) # parse_pip (et non 'wrapper')
print(parse_pip.__doc__) # Parse une sortie pip list.
3. Décorateur avec paramÚtres
Pour passer des arguments au décorateur (@retry(max=3)), on ajoute un niveau d'enveloppement : une fonction qui retourne le décorateur.
import functools, time
def retry(max_tentatives: int = 3, delai: float = 0.5):
def decorateur(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for tentative in range(1, max_tentatives + 1):
try:
return func(*args, **kwargs)
except Exception as e:
if tentative == max_tentatives:
raise
print(f"[retry] tentative {tentative} échouée : {e}")
time.sleep(delai)
return wrapper
return decorateur
@retry(max_tentatives=3, delai=1.0)
def appel_api(url: str) -> dict:
...
# Ăquivalent : appel_api = retry(max_tentatives=3, delai=1.0)(appel_api)
- 3 niveaux : la factory (
retry), le dĂ©corateur (decorateur), le wrapper (wrapper) - Les paramĂštres du dĂ©corateur vivent dans la closure â accessibles dans
wrapper
=> Le décorateur est une solution d'architecte pour un problÚme de répétition (approche DRY).
4. Application projet : @timer sur les parsers
On crée un module utils/decorators.py et on décore chaque parser. Quand le projet grandit (7 écosystÚmes), on voit immédiatement lequel est lent.
# utils/__init__.py (vide)
# utils/decorators.py
import time
import functools
def timer(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
debut = time.perf_counter()
result = func(*args, **kwargs)
duree = time.perf_counter() - debut
print(f"[timer] {func.__name__:<20} {duree:.4f}s")
return result
return wrapper
# parsers/pip.py
import re, logging
from models.component import Component
from utils.decorators import timer
@timer
def parse_pip(raw_data: str) -> list[Component]:
components = []
if not raw_data:
return components
for line in raw_data.strip().split('\n'):
if "Package" in line or "---" in line:
continue
match = re.search(r"(\S+)\s+([\d\.]+)", line)
if match:
components.append(Component(name=match.group(1), version=match.group(2)))
else:
logging.warning(f"Ligne ignorée : {line}")
return components
[--- pyCycloneFlow ---
[timer] parse_pip 0.0001s
--- Inventaire (3 items) ---
â requests == 2.28.1 â pkg:pypi/requests@2.28.1
â flask == 2.2.2 â pkg:pypi/flask@2.2.2
â cryptography == 38.0.1 â pkg:pypi/cryptography@38.0.1
â sbom.json gĂ©nĂ©rĂ©
La structure du projet aprĂšs ce chapitre :
pyCycloneFlow/
âââ models/ (component.py, sbom.py, __init__.py)
âââ parsers/ (pip.py, __init__.py)
âââ utils/
â âââ __init__.py
â âââ decorators.py
âââ tests/
âââ main.py
5. Impact sur les tests
Les tests existants passent toujours â le dĂ©corateur est transparent sur la valeur de retour. Mais il y a deux choses Ă savoir.
1. @timer pollue la sortie des tests. Chaque appel à parse_pip dans les tests affiche une ligne [timer]. Ce n'est pas un échec, mais c'est du bruit.
....ERROR:root:Fichier introuvable : fichier_imaginaire.txt
..[timer] parse_pip 0.0000s
.[timer] parse_pip 0.0001s
.[timer] parse_pip 0.0000s
..
----------------------------------------------------------------------
Ran 10 tests in 0.002s
OK
Pour tester la logique pure sans l'effet de bord du timer, functools.wraps expose l'attribut __wrapped__ qui pointe vers la fonction originale :
from parsers.pip import parse_pip
# Appel normal â passe par @timer, affiche "[timer] parse_pip 0.0001s"
result = parse_pip("requests 2.28.1")
# Appel Ă la fonction brute â bypasse le dĂ©corateur, pas de sortie timer
result = parse_pip.__wrapped__("requests 2.28.1")
2. Tester le dĂ©corateur lui-mĂȘme. On peut vĂ©rifier que @timer n'altĂšre pas le rĂ©sultat et que __wrapped__ est bien prĂ©sent :
def test_timer_preserve_resultat(self):
"""@timer ne doit pas altérer la valeur de retour."""
result = parse_pip("requests 2.28.1")
self.assertEqual(len(result), 1)
self.assertEqual(result[0].name, "requests")
def test_timer_wrapped_accessible(self):
"""functools.wraps doit exposer __wrapped__."""
self.assertTrue(hasattr(parse_pip, "__wrapped__"))
__wrapped__est ajoutĂ© automatiquement parfunctools.wrapsâ sans lui, l'attribut n'existe pas- En production, ne jamais appeler
__wrapped__directement â c'est rĂ©servĂ© aux tests et aux outils d'introspection