Décorateurs

Dans ce chapitre nous allons découvrir les décorateurs et leur utilisation.

Le nom de décorateur provient du patron de conception décorateur (pattern decorator). Un décorateur permet de se soustraire à un objet pour en modifier le comportement.

D&CO, une semaine pour tout changer

En Python, vous en avez peut-être déjà croisé, les décorateurs se repèrent au caractère @.

Le principe de la décoration en Python est d’appliquer un décorateur à une fonction, afin de retourner un nouvel objet (généralement une fonction). On peut donc voir le décorateur comme une fonction prenant une fonction en paramètre, et retournant une nouvelle fonction1

1
2
3
def decorator(f): # decorator est un décorateur
    print(f.__name__)
    return f

Pour appliquer un décorateur, on précède la ligne de définition de la fonction à décorer par une ligne comportant un @ puis le nom du décorateur à appliquer, par exemple :

1
2
3
4
5
>>> @decorator
... def addition(a, b):
...     return a + b
...
addition

Cela a pour effet de remplacer addition par le retour de la fonction decorator appelée avec addition en paramètre, ce qui est strictement équivalent à :

1
2
3
4
def addition(a, b):
    return a + b

addition = decorator(addition)

On voit donc bien que le décorateur est appliqué au moment de la définition de la fonction, et non lors de ses appels. Nous utilisons ici un décorateur très simple qui retourne la même fonction, mais il se pourrait très bien qu’il en retourne une autre, qui serait par exemple créée à la volée.

Disons que nous aimerions modifier notre fonction addition pour afficher les opérandes puis le résultat, sans toucher au corps de notre fonction. Nous pouvons réaliser un décorateur qui retournera une nouvelle fonction se chargeant d’afficher les paramètres, d’appeler notre fonction originale, puis d’afficher le retour et de le retourner (afin de conserver le comportement original).

Ainsi, notre décorateur devient :

1
2
3
4
5
6
7
def print_decorator(function):
    def new_function(a, b): # Nouvelle fonction se comportant comme la fonction à décorer
        print('Addition des nombres {} et {}'.format(a, b))
        ret = function(a, b) # Appel de la fonction originale
        print('Retour: {}'.format(ret))
        return ret
    return new_function # Ne pas oublier de retourner notre nouvelle fonction

Si on applique maintenant ce décorateur à notre fonction d’addition :

1
2
3
4
5
6
7
8
>>> @print_decorator
... def addition(a, b):
...     return a + b
...
>>> addition(1, 2)
Addition des nombres 1 et 2
Retour: 3
3

Mais notre décorateur est ici très spécialisé, il ne fonctionne qu’avec les fonctions prenant deux paramètres, et affichera « Addition » dans tous les cas. Nous pouvons le modifier pour le rendre plus générique (souvenez vous d’*args et **kwargs).

1
2
3
4
5
6
7
8
def print_decorator(function):
    def new_function(*args, **kwargs):
        print('Appel de la fonction {} avec args={} et kwargs={}'.format(
            function.__name__, args, kwargs))
        ret = function(*args, **kwargs)
        print('Retour: {}'.format(ret))
        return ret
    return new_function

Je vous laisse l’essayer sur des fonctions différentes pour vous rendre compte de sa généricité.

Les définitions de fonctions ne sont pas limitées à un seul décorateur : il est possible d’en spécifier autant que vous le souhaitez, en les plaçant les uns à la suite des autres.

1
2
3
4
@decorator
@print_decoration
def useless():
    pass

L’ordre dans lequel ils sont spécifiés est important, le code précédent équivaut à :

1
2
3
def useless():
    pass
useless = decorator(print_decorator(useless))

On voit donc que les décorateurs spécifiés en premiers sont ceux qui seront appliqués en derniers.

J’ai dit plus haut que les décorateurs s’appliquaient aux fonctions. C’est aussi valable pour les fonctions définies à l’intérieur de classes (les méthodes, donc). Mais sachez enfin que les décorateurs s’étendent aux déclarations de classes.

1
2
3
4
5
@print_decorator
class MyClass:
    @decorator
    def method(self):
        pass

  1. Bien que la définition soit plus large que cela. Le décorateur est un callable prenant un callable en paramètre, et pouvant retourner tout type d’objet. 

Décorateurs paramétrés

Nous avons vu comment appliquer un décorateur à une fonction, nous pourrions cependant vouloir paramétrer le comportement de ce décorateur. Dans notre exemple précédent (print_decorator), nous affichons du texte avant et après l’appel de fonction. Mais que faire si nous souhaitons modifier ce texte (pour en changer la langue, utiliser un autre terme que « fonction ») ?

Nous ne voulons pas avoir à créer un décorateur différent pour chaque phrase possible et imaginable. Nous aimerions pouvoir passer nos chaînes de caractères à notre décorateur pour qu’il s’occupe de les afficher au moment opportun.

En fait, @ ne doit pas nécessairement être suivi d’un nom d’objet, des arguments peuvent aussi s’y ajouter à l’aide de parenthèses (comme on le ferait pour un appel). Mais le comportement peut vous sembler étrange au premier abord.

Par exemple, pour utiliser un tel décorateur paramétré :

1
2
3
@param_print_decorator('call {} with args({}) and kwargs({})', 'ret={}')
def test_func(x):
    return x

Il nous faudra posséder un callable param_print_decorator qui, quand il sera appelé, retournera un décorateur qui pourra ensuite être appliqué à notre fonction. Un décorateur paramétré n’est ainsi qu’un callable retournant un décorateur simple.

Le code de param_print_decorator ressemblerait ainsi à :

1
2
3
4
5
6
7
8
9
def param_print_decorator(before, after): # Décorateur paramétré
    def decorator(function): # Décorateur
        def new_function(*args, **kwargs): # Fonction qui remplacera notre fonction décorée
            print(before.format(function.__name__, args, kwargs))
            ret = function(*args, **kwargs)
            print(after.format(ret))
            return ret
        return new_function
    return decorator

Envelopper une fonction

Une fonction n’est pas seulement un bout de code avec des paramètres. C’est aussi un nom (des noms, avec ceux des paramètres), une documentation (docstring), des annotations, etc. Quand nous décorons une fonction à l’heure actuelle (dans les cas où nous en retournons une nouvelle), nous perdons toutes ces informations annexes.

Un exemple pour nous en rendre compte :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
>>> def decorator(f):
...     def decorated(*args, **kwargs):
...         return f(*args, **kwargs)
...     return decorated
...
>>> @decorator
... def addition(a: int, b: int) -> int:
...     "Cette fonction réalise l'addition des paramètres `a` et `b`"
...     return a + b
...
>>> help(addition)
Help on function decorated in module __main__:

decorated(*args, **kwargs)

Alors, que voit-on ? Pas grand chose. Le nom qui apparaît est celui de decorated, les paramètres sont *args et **kwargs (sans annotations), et nous avons aussi perdu notre docstring. Autant dire qu’il ne reste rien pour comprendre ce que fait la fonction.

Envelopper des fonctions

Plus tôt dans ce cours, je vous parlais du module functools. Il ne nous a pas encore révélé tous ses mystères.

Nous allons ici nous intéresser aux fonctions update_wrapper et wraps. Ces fonctions vont nous permettre de copier les informations d’une fonction vers une nouvelle.

update_wrapper prend en premier paramètre la fonction à laquelle ajouter les informations et celle dans laquelle les puiser en second. Pour reprendre notre exemple précédent, il nous faudrait faire :

1
2
3
4
5
6
7
import functools

def decorator(f):
    def decorated(*args, **kwargs):
        return f(*args, **kwargs)
    functools.update_wrappers(decorated, f) # Nous copions les informations de `f` dans `decorated`
    return decorated

Mais une autre fonction nous sera bien plus utile car plus concise, et recommandée par la documentation Python pour ce cas. Il s’agit de wraps, qui retourne un décorateur lorsqu’appelé avec une fonction.

La fonction décorée par wraps prendra les informations de la fonction passée à l’appel de wraps. Ainsi, nous n’aurons qu’à précéder toutes nos fonctions decorées par @functools.wraps(fonction_a_decorer). Dans notre exemple :

1
2
3
4
5
6
7
import functools

def decorator(f):
    @functools.wraps(f)
    def decorated(*args, **kwargs):
        return f(*args, **kwargs)
    return decorated

Vous pouvez maintenant redéfinir la fonction addition, et tester à nouveau l’appel à help pour constater les différences.

TP : Arguments positionnels

Nous avons vu avec les signatures qu’il existait en Python des paramètres positional-only, c’est-à-dire qui ne peuvent recevoir que des arguments positionnels.

Mais il n’existe à ce jour aucune syntaxe pour écrire en Python une fonction avec des paramètres positional-only. Il est seulement possible, comme nous l’avons fait au TP précédent, de récupérer les arguments positionnels avec *args et d’en extraire les valeurs qui nous intéressent.

Nous allons alors développer un décorateur pour pallier à ce manque. Ce décorateur modifiera la signature de la fonction reçue pour transformer en positional-only ses n premiers paramètres. n sera un paramètre du décorateur.

Python nous permet de redéfinir la signature d’une fonction, en assignant la nouvelle signature à son attribut __signature__. Mais cette redéfinition n’est que cosmétique (elle apparaît par exemple dans l’aide de la fonction). Ici, nous voulons que la modification ait un effet.

Nous créerons donc une fonction wrapper, qui se chargera de vérifier la conformité des arguments avec la nouvelle signature.

Nous allons diviser le travail en deux parties :

  • Dans un premier temps, nous réaliserons une fonction pour réécrire une signature ;
  • Puis dans un second temps, nous écrirons le code du décorateur.

Réécriture de la signature

La première fonction, que nous appellerons signature_set_positional, recevra en paramètres une signature et un nombre n de paramètres à passer en positional-only. La fonction retournera une signature réécrite.

Nous utiliserons donc les méthodes replace de la signature et des paramètres, pour changer le positionnement des paramètres ciblés, et mettre à jour la liste des paramètres de la signature.

La fonction itérera sur les n premiers paramètres, pour les convertir en positional-only.

On distinguera trois cas :

  • Le paramètre est déjà positional-only, il n’y a alors rien à faire ;
  • Le paramètre est positional-or-keyword, il peut être transformé ;
  • Le paramètre est d’un autre type, il ne peut pas être transformé, on lèvera alors une erreur.

Puis une nouvelle signature sera créée et retournée avec cette nouvelle liste de paramètres.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
def signature_set_positional(sig, n):
    params = list(sig.parameters.values()) # Liste des paramètres
    if len(params) < n:
        raise TypeError('Signature does not have enough parameters')
    for i, param in zip(range(n), params): # Itère sur les n premiers paramètres
        if param.kind == param.POSITIONAL_ONLY:
            continue
        elif param.kind == param.POSITIONAL_OR_KEYWORD:
            params[i] = param.replace(kind=param.POSITIONAL_ONLY)
        else:
            raise TypeError('{} parameter cannot be converted to POSITIONAL_ONLY'.format(param.kind))
    return sig.replace(parameters=params)

Décorateur paramétré

Passons maintenant à positional_only, notre décorateur paramétré. Pour rappel, un décorateur paramétré est une fonction qui retourne un décorateur. Et un décorateur est une fonction qui reçoit une fonction et retourne une fonction.

Le décorateur proprement dit se chargera de calculer la nouvelle signature et de l’appliquer à la fonction décorée. Il créera aussi un wrapper à la fonction, lequel se chargera de vérifier la correspondance des arguments avec la signature.

Nous n’oublierons pas d’appliquer functools.wraps à notre wrapper pour récupérer les informations de la fonction initiale.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import functools
import inspect

def positional_only(n):
    def decorator(f):
        sig = signature_set_positional(inspect.signature(f), n)
        @functools.wraps(f)
        def decorated(*args, **kwargs):
            bound = sig.bind(*args, **kwargs)
            return f(*bound.args, **bound.kwargs)
        decorated.__signature__ = sig
        return decorated
    return decorator

Voyons maintenant l’utilisation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
>>> @positional_only(2)
... def addition(a, b):
...     return a + b
...
>>> print(inspect.signature(addition))
(a, b, /)
>>> addition(3, 5)
8
>>> addition(3, b=5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'b' parameter is positional only, but was passed as a keyword
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
>>> @positional_only(1)
... def addition(a, b):
...     return a + b
...
>>> addition(3, 5)
8
>>> addition(3, b=5)
8
>>> addition(a=3, b=5)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'a' parameter is positional only, but was passed as a keyword

Les ressources sur les décorateurs sont peu nombreuses dans la documentation, car il ne s’agit au final que d’un sucre syntaxique. Je vous indique tout de même les deux principales pages qui les évoquent.