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

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)

=> 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
Résultat console avec @timer :
[--- 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__"))
Chapitre précédent Chapitre suivant : Générateurs