Du code, du communisme

Comprendre les décorateurs Python pas à pas (partie 2)

Dans la partie 1, nous avons vu comment fonctionnaient les décorateurs. Mais dans leur usage quotidien vous allez rencontrer des cas particuliers:

  • Comment faire si la fonction décorée attend des arguments ?
  • Comment changer le comportement d’un décorateur en lui passant des paramètres ?
  • Comment préserver l’introspection ?

Introspection

Un des grands avantages de Python, c’est qu’il permet une très forte introspection, c’est à dire qu’on peut accéder à énormément d’informations sur le code lui-même.
Par exemple, si vous mettez une docstring à une fonction:

def ma_fonction():
    """
        C'est une super fonction
    """
    pass

Vous pouvez ensuite récupérer la docstring très facilement:

>>> ma_fonction.__doc__
"\n            C'est une super fonction\n        "

Et vous pouvez la lire dans l’aide:

>>> help(ma_fonction)
Help on function ma_fonction in module __main__:
ma_fonction()
    C'est une super fonction
(END)

L’autocompletion, la liste des attributs, le nom de la classe, etc. Toutes ces choses sont rendues accessibles grâce à l’introspection.
Mais quand vous décorez une fonction, vous l’enrobez dans une autre, détruisant ces informations:

def decorateur_inutile(func):
    def wrapper():
        func()
    return wrapper
@decorateur_inutile
def ma_fonction():
    """
        C'est une super fonction
    """
    pass
>>> print(ma_fonction.__doc__)
None
>>> help(ma_fonction)
Help on function wrapper in module __main__:
wrapper()

En effet, ma_fonction contient maitenant wrapper et non la fonction initiale. Heureusement le module functool possède des outils pour y pallier.
Le plus utile est le décorateur @wraps, qui copie littéralement toutes les infos d’une fonction sur son wrapper:

from functools import wraps
def decorateur_inutile(func):
    @wraps(func) # il suffit de décorer le wrapper
    def wrapper():
        func()
    return wrapper
@decorateur_inutile
def ma_fonction():
    """
        C'est une super fonction
    """
    pass

Et tout s’arrange:

>>> ma_fonction.__doc__
"\n        C'est une super fonction\n    "

Fonction avec arguments

Jusqu’ici les fonctions que nous avons décorées n’attendaient pas d’arguments. Il faut en effet faire un petit effort supplémentaire pour les supporter.

# Pas de magie noire, c'est le wrapper qui passe l'argument:
def un_decorateur_passant_un_argument(fonction_a_decorer):
    def un_wrapper_acceptant_des_arguments(arg1, arg2):
        print("J'ai des arguments regarde :", arg1, arg2)
        fonction_a_decorer(arg1, arg2)
    return un_wrapper_acceptant_des_arguments
# Puisqu'on appelle en fait un_wrapper_acceptant_des_arguments(),
# il accepte les arguments, et les passe à la fonctions décorée
@un_decorateur_passant_un_argument
def afficher_nom(nom, prenom):
    print("Mon nom est", nom, prenom)
afficher_nom("Peter", "Venkman")
# output:
#J'ai des arguments regarde : Peter Venkman
#My name is Peter Venkman

Du coup pour décorer une méthode, il suffit d’accepter que le décorateur accepte self. Le moyen le plus simple est encore d’accepter *args, **kwargs, comme ça on est paré pour tous les cas.
Mais attention, si vous acceptez *args, **kwargs, la liste des arguments ne sera plus disponible pour l’introspection. C’est quelque chose que @wraps ne peut pas changer. La plupart du temps, c’est un compromis acceptable.

Passer un argument au décorateur lui-même

Le problème d’un décorateur, c’est qu’il doit accepter une fonction en paramètre. Pourtant, vous avez bien vu que @wraps accepte lui même un argument. C’est qu’il existe donc un moyen de passer un argument au décorateur lui-même.
La solution est tordue: créer un décorateur à la volée. En fait ce decorateur ne sera plus le décorateur, mais le créateur de décorateur. Il y aura donc 3 niveaux d’imbrication… C’est parti pour une session de vaudou :

def createur_de_decorateur():
    print("Je fabrique des décorateurs. Je suis éxécuté une seule fois :" +
           "à la création du décorateur")
    def mon_decorateur(func):
        print("Je suis un décorateur, je suis éxécuté une seule fois quand on décore la fonction")
        def wrapper():
            print("Je suis le wrapper autour de la fonction décorée. "
                  "Je suis appelé quand on appelle la fonction décorée. "
                  "En tant que wrapper, je retourne le RESULTAT de la fonction décorée.")
            return func()
        print("En tant que décorateur, je retourne le wrapper")
        return wrapper
    print("En tant que créateur de décorateur, je retourne un décorateur")
    return mon_decorateur
# Créons un décorateur, c'est juste une fonction après tout.
nouveau_decorateur = createur_de_decorateur()
#ouputs:
#Je fabrique des décorateurs. Je suis éxécuté une seule fois : à la création du décorateur.
#En tant que créateur de décorateur, je retourne un décorateur
# Ensuite décorons la fonction
def fonction_decoree():
    print("Je suis la fonction décorée")
fonction_decoree = nouveau_decorateur(fonction_decoree)
#ouputs:
#Je suis un décorateur, je suis éxécuté une seule fois quand on décore la fonction
#En tant que décorateur, je retourne la fonction décorée
# Appelons la fonction:
fonction_decoree()
#ouputs:
#Je suis le wrapper autour de la fonction décorée. Je suis appelé quand on appelle la fonction décorée.
#En tant que wrapper, je retourne le RESULTAT de la fonction décorée.
#Je suis la fonction décorée

Aucune surprise ici. Faisons EXACTEMENT la même chose, mais en sautant les variables intermédiares.

def fonction_decoree():
    print("Je suis la fonction décorée")
fonction_decoree = createur_de_decorateur()(fonction_decoree)
#ouputs:
#Je fabrique des décorateurs. Je suis éxécuté une seule fois : à la création du décorateur.
#En tant que créateur de décorateur, je retourne un décorateur
#Je suis un décorateur, je suis éxécuté une seule fois quand on décore la fonction
#En tant que décorateur, je retourne la fonction décorée.
# Au final:
fonction_decoree()
#ouputs:
#Je suis le wrapper autour de la fonction décorée. Je suis appelé quand on appelle la fonction décorée.
#En tant que wrapper, je retourne le RESULTAT de la fonction décorée.
#Je suis la fonction décorée

On recommence, en encore plus court::

@createur_de_decorateur()
def fonction_decoree():
    print("Je suis la fonction décorée")
#ouputs:
#Je fabrique des décorateurs. Je suis éxécuté une seule fois : à la création du décorateur.
#En tant que créateur de décorateur, je retourne un décorateur
#Je suis un décorateur, je suis éxécuté une seule fois quand on décore la fonction
#En tant que décorateur, je retourne la fonction décorée.
#Et pour finir:
fonction_decoree()
#ouputs:
#Je suis le wrapper autour de la fonction décorée. Je suis appelé quand on appelle la fonction décorée.
#En tant que wrapper, je retourne le RESULTAT de la fonction décorée.
#Je suis la fonction décorée

Vous noterez qu’on a utilisé la notation @, avec un appel de fonction: @createur_de_decorateur() et non @createur_de_decorateur !
Maintenant que nous pouvons générer des décorateurs à la volée, il suffit de passer des arguments au créateur de décorateur:

def createur_de_decorateur_avec_arguments(decorator_arg1, decorator_arg2):
    print("Je créé des décorateur et j'accepte des arguments:", decorator_arg1, decorator_arg2)
    def mon_decorateur(func):
        print("Je suis un décorateur, vous me passez des arguments:", decorator_arg1, decorator_arg2)
        # Ne pas mélanger les arguments du décorateurs et de la fonction !
        def wrapped(function_arg1, function_arg2) :
            print("Je suis le wrapper autour de la fonction décorée.\n"
                  "Je peux accéder à toutes les variables\n"
                  "\t- du décorateur: {0} {1}\n"
                  "\t- de l'appel de la fonction: {2} {3}\n"
                  "Et je les passe ensuite à la fonction décorée"
                  .format(decorator_arg1, decorator_arg2,
                          function_arg1, function_arg2))
            return func(function_arg1, function_arg2)
        return wrapped
    return mon_decorateur
@createur_de_decorateur_avec_arguments("Leonard", "Sheldon")
def fonction_decoree_avec_arguments(function_arg1, function_arg2):
    print("Je suis une fonctions décorée, je ne me soucie que de mes arguments: {0}"
           " {1}".format(function_arg1, function_arg2))
fonction_decoree_avec_arguments("Rajesh", "Howard")
#output:
#Je crée des décorateurs et j'accepte des arguments: Leonard Sheldon
#Je suis un décorateur, vous me passez des arguments: Leonard Sheldon
#Je suis le wrapper autour de la fonction décorée function.
#Je peux accéder à toutes les variables
#   - du décorateur: Leonard Sheldon
#   - de l'appel de la fonction: Rajesh Howard
#Et je les passe ensuite à la fonction décorée
#Je suis une fonction décorée, je ne me soucie que de mes arguments: Rajesh Howard

mon_decorateur a accès aux variables du scope supérieur car elles sont dans une closure. Vous ne pourrez donc pas les modifier.
Et voilà, un décorateur avec des arguments ! Les arguments peuvent être des
variables:

c1 = "Penny"
c2 = "Leslie"
@createur_de_decorateur_avec_arguments("Leonard", c1)
def fonction_decoree_avec_arguments(function_arg1, function_arg2):
    print("Je suis une fonctions décorée, je ne me soucie que de mes arguments:"
           " {0} {1}".format(function_arg1, function_arg2))
fonction_decoree_avec_arguments(c2, "Howard")
#output:
#Je créé des décorateurs et j'accepte des arguments: Leonard Penny
#Je suis un décorateur, vous me passez des arguments: Leonard Penny
#Je suis le wrapper autour de la fonction décorée function.
#Je peux accéder à toutes les variables
#   - du décorateur: Leonard Penny
#   - de l'appel de la fonction: Leslie Howard
#Et je les passe ensuite à la fonction décorée
#Je suis une fonctions décorée, je ne me soucie que de mes arguments: Leslie Howard

Comme vous le voyez, on peut passer des arguments au décorateur comme à n’importe quelle fonction en utilisant cette astuce. En fait on peut même utiliser *args, **kwargs. Mais rappelez-vous: les décorateurs sont appelés uniquement une fois, au moment de l’import du script. On ne peut pas changer leurs arguments a posteriori. Quand vous faites from x import ma_fonction, ma_fonction est déjà décorée, et on ne peut rien y changer.

Super, mais ça sert à quoi un décorateur ?

Ca a l’air chouette et tout, mais un exemple d’usage concret, ça aiderait quand même….
Et bien il y a 1000 possibilités. Parmi les usages classiques:

  • étendre la fonction d’une lib externe qu’on ne peut pas modifier;
  • gérer les permissions d’une fonction;
  • réagir aux arguments passés;
  • débugger.

Le principe est la réutilisabilité: on fait un seul code, et on décore plein de fonctions avec.
Exemple:

def benchmark(func):
    """
    Un décorateur qui affiche le temps qu'une fonction met à s'éxécuter
    """
    import time
    def wrapper(*args, **kwargs):
        t = time.clock()
        res = func(*args, **kwargs)
        print(func.__name__, time.clock()-t)
        return res
    return wrapper
def logging(func):
    """
    Un décorateur qui log l'activité d'un script.
    (Ok, en vrai ça fait un print, mais ça pourrait logger !)
    """
    def wrapper(*args, **kwargs):
        res = func(*args, **kwargs)
        print(func.__name__, args, kwargs)
        return res
    return wrapper
def counter(func):
    """
    Compte et affiche le nombre de fois qu'une fonction a été exécutée
    """
    def wrapper(*args, **kwargs):
        wrapper.count = wrapper.count + 1
        res = func(*args, **kwargs)
        print("{0} a été utilisée: {1}x".format(func.__name__, wrapper.count))
        return res
    wrapper.count = 0
    return wrapper
@counter
@benchmark
@logging
def reverse_string(string):
    return string[::-1]
print(reverse_string("Karine alla en Irak"))
print(reverse_string("Sa nana snob porte de trop bons ananas"))
## reverse_string ('Karine alla en Irak',) {}
## wrapper 0.000132
## wrapper a été utilisée: 1x
## karI ne alla eniraK
## reverse_string ('Sa nana snob porte de trop bons ananas',) {}
## wrapper 0.000128
## wrapper a été utilisée: 2x
## sanana snob port ed etrop bons anan aS

Mais bien sur, le plus cool avec les décorateurs, c’est qu’on peut les utiliser immédiatement sans avoir à réécrire quoi ce que soit:

import urllib
@counter
@benchmark
@logging
def citation_de_futurama_au_hasard():
    url = 'http://subfusion.net/cgi-bin/quote.pl?quote=futurama&number=1'
    try:
        res = urllib.request.urlopen(url)
        html = res.read().decode('ISO-8859-1')
        return html.split('
')[3].strip()
    except:
        return "No, I'm ... doesn't!"
print(citation_de_futurama_au_hasard())
print(citation_de_futurama_au_hasard())
#output:
#citation_de_futurama_au_hasard () {}
#wrapper 0.02
#wrapper a été utilisée: 1x
#The laws of science be a harsh mistress.
#citation_de_futurama_au_hasard () {}
#wrapper 0.01
#wrapper a été utilisée: 2x
#Curse you, merciful Poseidon!

Python vient chargé de décorateurs dans la lib standard: property, staticmethod, classmethod, @coroutine, @lru_cache, etc. Django gère les permissions des vues avec les décorateurs. Bottle déclare ses routes avec. Twisted donne l’impression qu’un appel asynchrone est synchrone en les utilisant. On peut faire vraiment tout et n’importe quoi.
Un grand merci à gawel, de l’AFPY, qui m’a, il y a quelques années, donné envie de découvrir les décorateurs.