Exercice python : les décorateurs...

Venez relevez le défi...

a marqué ce sujet comme résolu.

Salut à tous,

Il me semble qu'aucun exercice n'a été posté pour le moment, c'est pour ça que je vais en faire un.

Comme dans le titre de ce topic, les exercices vont porter sur les décorateurs.

Pré-requis : Connaitre les décorateurs :D

Exercice 1

Créer un décorateur qui limite l'execution d'une fonction :

  • Quand la fonction est exécuté trop de fois, une exception est levée.
  • Le décorateur doit prend un paramètre qui est le nombre d'execution.
Exercice 2

Créer un décorateur qui contrôle ce que renvoie une fonction : Le décorateur doit une lever une exception si la fonction renvoie un string ou int.

Exercice 3

Un décorateur qui affiche le temps de la fonction (basique)

C'est mon premier topic de ce genre, j'espère que c'est pas trop facile et que tout le monde à compris les consignes ? :-°

Bonne chance à tous ;)

Allez je fais le numéro 1 pour faire démarrer l'exo ;)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
functions = {}

class LimitExcedeed(Exception):
    pass

def deco(n):
    def control(f):
        def wrap(*args, **kwargs):
            name = f.__name__
            if name in functions:
                functions[name] += 1
                if functions[name] > n:
                    raise LimitExcedeed (
                                        "number limit of iteration: {}"\
                                        .format(n)
                                        )
            else:
                functions[name] = 1
            return(f(*args, **kwargs))
        return wrap
    return control

@deco(2)
def f(a):
    return a

for i in range(3):
    print(f(i)) # LimitExcedeed error

functions est un dictionnaire permettant l'utilisation du décorateur deco dans de multiples fonctions, c'est son principal intérêt, sinon je n'aurais pas vu l'intérêt des décorateurs dans cet exercice.

+0 -0

Je suis content de moi, j’ai réussi l’exercice 1 tout seul. Au final ma solution est proche de celle de Fred, je suis un peu déçu j’avais cru être très malin. :p

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
from collections import defaultdict

def CallLimit(limit):
    # "static" dictionary of functions and how many times they have been
    # called so far
    if "call" not in CallLimit.__dict__:
        CallLimit.call = defaultdict(int)
    def CallLimiter(func):
        def limit_func(*args, **kwds):
            CallLimit.call[func] += 1
            if CallLimit.call[func] > limit:
                raise Exception
            return func(*args, **kwds)

        return limit_func
    return CallLimiter

Voici un exemple d’utilisation :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@CallLimit(4)
def f():
    print 'f()'

@CallLimit(3)
def g():
    print 'g()'

for i in range(3):
    f()
    g()

f() # toujours bon
g() # BOOM!

Edit : en fait l’exercice 2 est encore plus simple. J’ai été naïf dans mon test de « type », je ne suis pas certains que ça ait beaucoup de sens en python de toute manière.

1
2
3
4
5
6
7
def NoStringOrInt(func):
    def checked_func(*args, **kwds):
        ret = func(*args, **kwds)
        if isinstance(ret, str) or isinstance(ret, int):
            raise Exception
        return ret
    return checked_func
+0 -0

Voici les 3, dans l'ordre:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import functools

class TooManyExecutions(Exception):
    pass

def limit_exec(n):
    def decorator(f):
        remain_call = n
        @functools.wraps(f)
        def decorated(*args, **kwargs):
            nonlocal remain_call
            if remain_call <= 0:
                raise TooManyExecutions('Too many executions of function {}, max was {}'.format(f.__qualname__, n))
            remain_call -= 1
            return f(*args, **kwargs)
        return decorated
    return decorator


def check_return(types={int, str}):
    def decorator(f):
        @functools.wraps(f)
        def decorated(*args, **kwargs):
            r = f(*args, **kwargs)
            if not any(isinstance(r, t) for t in types):
                raise TypeError('Function {} returns {} but one of {} was expected'.format(f.__qualname__, type(r), types))
            return r
        return decorated
    return decorator


def time_exec(f):
    @functools.wraps(f)
    def decorated(*args, **kwargs):
        start = time.time()
        r = f(*args, **kwargs)
        print('Took {}s'.format(time.time() - start))
        return r
    return decorated

EDIT: Utilisation d'une variable nonlocal plutôt que d'ajouter un attribut à la fonction pour le 1er exercice.

J'arrive un peu tard mais j'ai commencé à galérer un max sur ces histoires de décorateurs (cause niveau Python médiocre !) avant de trouver un excellent article sur le sujet de Simeon Franklin article. Je le recommande vivement à ceux qui seraient dans mon cas : bien qu'en anglais, il explique bien les mécanismes en oeuvre dans les appels de fonctions, en particulier les 'closures' rarement expliquées dans les cours que j'ai pu voir. Après ça, on est mieux armé pour comprendre les solutions proposées en particulier pour les exos 2 et 3.

  • pour l'exercice 1 (limiteur d'appels), le hic, c'est la mémorisation du(des) compteur(s) d'appel (un seul compteur si on limite une seule fonction décorée, un compteur par fonction si on peut limiter plusieurs fonctions).

Fred1599 solutionne en utilisant un dictionnaire global 'functions' : c'est aussi ce qui m'est venu à l'esprit et ça marche aussi si on remplace le dictionnaire par un seul compteur.

simbilou utilise une variable 'CallLimit.call' qui est un dictionnaire local à CallLimit, donc du scope de la fonction englobant CallLimiter, et ça marche parce qu'on utilise un mutable (dictionnaire 'CallLimit.call'). Si on limitait une seule fonction, donc avec un seul compteur (un int) initialisé dans la fonction englobante CallLimit au lieu de 'CallLimit.call', ça ne marche plus : l'int n'est pas mutable et c'est sa valeur à la définition de la fonction interne qui est dans la closure : compteur n'est plus accessible en mise à jour dans la fonction interne ! (accessoirement, je n'ai pas compris le pourquoi du test : if "call" not in allLimit.dict: ? c'est superflu ou il y a qq chose qui m'échappe ?)

entwanne stocke le compteur dans un attribut 'remain_call', initialisé à la valeur limite, ajouté à la fonction décorée par setattr(f, 'remain_call', n) dans la fonction 'decorator'. L'attribut de la fonction décorée f reste ainsi accessible de partout y compris dans la fonction interne 'decorated' et le mecanisme marche évidemment aussi bien avec un compteur unique.

A partir de tout ça, voici une 4eme solution voisine de celle de entwanne mais utilisant un attribut de la fonction interne 'wrapper' :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def limit_decorator(func,n):
    def wrapper():
        wrapper.counts[func.__name__] -= 1
        if wrapper.counts[func.__name__] <= 0:
            print(" ... the function {} was called too many times".format(func.__name__))
        else : func()
    wrapper.counts = {}
    wrapper.counts[func.__name__] = n
    return wrapper

def a_function():
    print ("from 'a' func : I do my job.")
def b_function():
    print("hello, this is 'b' func!")

a_modified = limit_decorator(a_function, 3)
b_modified = limit_decorator(b_function, 4)
for i in range(4):
    a_modified()
    b_modified()

En sortie :

1
2
3
4
5
6
7
8
from 'a' func : I do my job.
hello, this is 'b' func!
from 'a' func : I do my job.
hello, this is 'b' func!
 ... the function a_function was called too many times
hello, this is 'b' func!
 ... the function a_function was called too many times
 ... the function b_function was called too many times

Voilà, voilà … c'est ce que j'ai compris et j'aimerais avoir l'avis de pros pour savoir si je me trompe. En tout cas, merci à tous pour les exos et des solutions intéressantes.

+0 -0

(accessoirement, je n'ai pas compris le pourquoi du test : if "call" not in allLimit.dict: ? c'est superflu ou il y a qq chose qui m'échappe ?)

rozo

En effet c’est superflu. En fait on est obligé d’avoir recours à cette technique pour avoir une variable statique façon C pour une fonction normale (c.f. spoiler). Mais comme on a affaire à une closure ce n’est pas nécessaire. Je me suis planté parce que tout ça n’est pas encore tout à fait clair dans ma tête, merci pour l’article du coup.

1
2
3
4
5
def h():
   if "static_i" not in h.__dict__:
       h.static_i = 0
   h.static_i = h.static_i+1
   return h.static_i

+0 -0

@simbilou : "merci pour l’article du coup." … de rien !!

Pour le fun une version du premier exo avec une classe (pour limiter une seule fonction) :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class limiteur(object):
    def __init__(self,  n):
        self.compteur = [n]
    def __call__(self, func):
        def  wrapper(*args):
            if self.compteur[0] <= 0:
                 print("ça suffit !")
            else :
                self.compteur[0] -= 1
                return self.func(*args)
        self.func = func
        return wrapper

@limiteur(3)
def plus(a,b):
    return a+b

#plus=limiteur(plus, 3)
for i in range(5):
    print(plus(i,5))

… mais pb qui me laisse très perplexe : ça marche en utilisant le decorateur @limiteur(), mais pas avec 'plus=limiteur(plus, 3)' ??? Même chose mais inversée avec ma solution précédante :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# version 1 'classique': passage d'argument dans le decorateur
#   et de la fonction décorée par une fonction intermédiaire
# - décoration par @decorator
def decorator(gift):
    def func_wrapper(func):
        def wrapper(*args, **kwargs):
            print ("I don't like this " + gift + " you gave me!")
            return func(gift, *args, **kwargs)
        return wrapper
    return func_wrapper

@decorator("sweater")
def grateful_function(gift):
    print ("I love the " + gift + "! Thank you!")

grateful_function()

#---------------------------
# version 2 'simplifiée': passage d'argument et de fonction décorée via le decorateur
# (la fonction intermédiaire func_wrapper disparait)
# - décoration par réaffectation de la fonction
def decorator(func, gift):
    def wrapper(*args, **kwargs):
        print ("I don't like this " + gift + " you gave me!")
        return func(gift, *args, **kwargs)
    return wrapper

def grateful_function(gift):
    print ("I love the " + gift + "! Thank you!")

grateful_function = decorator(grateful_function, "sweater")
grateful_function()

#--------------------------
# version 3 mixte: même code pour decorator mais décoration par @decorator
# >>>> erreur :
#  @decorator("sweater"): decorator() missing 1 required positional argument: 'gift'
def decorator(func, gift):
    def wrapper(*args, **kwargs):
        print ("I don't like this " + gift + " you gave me!")
        return func(gift, *args, **kwargs)
    return wrapper

@decorator("sweater")
def grateful_function(gift):
    print ("I love the " + gift + "! Thank you!")

grateful_function()

Conclusion : les formes @decorator(n) et func = decorator(func, n) ne sont pas équivalentes, ou ma syntaxe de décoration est mauvaise … le passage d'arguments au décorateur a encore plein de mystères pour moi :-(

+0 -0

Première question: pourquoi fais-tu un self.compteur = [n] et pas simplement n ?

Ensuite, pour l'histoire de syntaxe, le décorateur est un callable qui ne prend qu'un paramètre: la fonction. Quand tu passes des paramètres à un décorateur, c'est plutôt que tu génères un décorateur à la volée en fonction de ces paramètres. Et la syntaxe que tu recherches est decorator(n)(func).

@entwanne : "pourquoi fais-tu un self.compteur = [n] et pas simplement n ?" - exact, j'ai posté un peu vite une version de test (avec un mutable pour le compteur, pour voir si ça changeait qq chose). La bonne version :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class limiteur(object):
    def __init__(self,  n):
        self.compteur = n
    def __call__(self, func):
        def  wrapper(*args):
            if self.compteur <= 0:
                 print("ça suffit !")
            else :
                self.compteur -= 1
                return self.func(*args)
        self.func = func
        return wrapper

Pour le passage de paramètres, en reprenant l'exemple de la décoration de la fn grateful_function ci-dessus, la syntaxe func = decorator(n)(func) est adaptée à la version 1 (avec une fonction 'func_wrapper' intermédiaire), mais pas à la version 2 qui attend deux arguments pour l'appel à decorator et où il faut bien utiliser : func = decorator(func, n) . Il me semble qu'on ne peut pas décorer par la forme @decorator dans la version 2 (qui me plait bien car elle n'a pas besoin de la fonction intermédiaire func_wrapper de la version 1 :-) ) …

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import time


def recall(count):
    if not hasattr(recall, 'registered'):
        recall.registered = {}
    def decorator(func):
        if func not in recall.registered:
            recall.registered[func] = count
        def function(*args, **kwargs):
            if recall.registered[func] <= 0:
                raise OverflowError('Too many function calls')
            recall.registered[func] -= 1
            return func(*args, **kwargs)
        return function
    return decorator

def forbid(*types):
    def decorator(func):
        def function(*args, **kwargs):
            value = func(*args, **kwargs)
            if type(value) in types:
                raise TypeError('Forbidden type returned')
            return value
        return function
    return decorator

def stopwatch(func):
    def function(*args, **kwargs):
        total = time.time()
        value = func(*args, **kwargs)
        total = time.time() - total
        print('{} executed in {} seconds'.format(func, total))
        return value
    return function

… encore moi ! Mais je suis tombé sur un truc bizare : le 'décorateur one_shot' qui décore une seule fois !?

en creusant sur les closure, on tombe sur la déclaration nonlocal typiquement adaptée à notre besoin pour l'exercice 1 (limiteur d'appels pour une fonction) cf PEP3104. Code correspondant par ex.:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def make_counter(n):
    count = n
    def counter(func):
        nonlocal count
        count -= 1            # décremente count de make_counter
        print("count= ",count)
        print("on y va ", end='')
        if count <= 0:
            print("...ça suffit!")
        else:
            return func
    return counter

@make_counter(3)
def myfunc():
    print( "encore une fois ...")

#myfunc = make_counter(3)(myfunc) # - alternative à @make_counter(3): marche aussi
for i in range(5):
    myfunc()

et ça donne :

1
2
3
4
5
6
count=  2
on y va encore une fois ...
encore une fois ...
encore une fois ...
encore une fois ...
encore une fois ...

:-( … le décorateur a bien marché la 1ere fois en décrémentant dans counter la variable count de make_counter, puis l'appel à myfunc … appelle myfunc ??? La décoration a sauté.

Je ne comprends rien à ce mystère : fonctionnement normal ou bug (j'utilise python 3.41) ? Qq'un peut-il éclaircir ça …

+0 -0

C'est parfaitement normal, le décorateur n'est appelé qu'une fois: à la déclaration de ta fonction. Pour rappel, Ajouter @make_counter(3) avant ta fonction revient à insérer my_func = make_counter(3)(my_func) après.

make_counter(3) retourne une fonction (la fonction counter), cette fonction est appelée avec my_func en paramètre, décrémente une variable et affiche des messages, puis retourne func.

En définitive, my_func est toujours my_func, le comportement de la fonction n'est pas changé.

@entwanne : j'ai bien compris tout ça, mais le pb n'est pas là. Pourquoi la fn 'counter' qui sert de wrapper (et non le decorateur make_counter) n'est-elle appelée qu'une fois ? Pour que ça marche, il faut ajouter une fonction wrapper supplementaire entre make_counter et counter :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
def make_counter(n):
    count = n
    def wrapper(func):
        def counter():
            nonlocal count
            print("count= ",count)
            if count <= 0:
                print("...ça suffit!")
            else:
                count -= 1            # décremente count de make_counter
                print("on y va ", end="")
                return func()
        return counter
    return wrapper

@make_counter(3)
def myfunc():
    print( "encore une fois ...")

#myfunc = make_counter(3)(myfunc) # - alternative à @make_counter(3): marche aussi
for i in range(5):
    myfunc()

qui donne ce qui est attendu :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
count=  3
on y va encore une fois ...
count=  2
on y va encore une fois ...
count=  1
on y va encore une fois ...
count=  0
...ça suffit!
count=  0
...ça suffit!

Oui, et c'est tout à fait normal. Je pense que tu n'as pas «bien compris» comme tu le prétends, tu sembles encore avoir du mal à appréhender les «décorateurs à paramètres».

Il faut bien savoir savoir qu'un décorateur ne peut prendre qu'on objet appelable en paramètre et en retourner un autre (ou le même). Il ne peut prendre aucun autre paramètre. Aucun.

Quand un décorateur semble prendre des paramètres, c'est en fait un objet qui renvoie un décorateur quand on l'appelle avec les bons paramètres. make_counter dans ton premier exemple n'est pas un décorateur. make_counter(3), c'est à dire la sous-fonction counter en est un. Le décorateur n'est appelé qu'une fois, à la déclaration de la classe/fonction.

Dans ton second exemple, le décorateur est ici la sous-fonction wrapper, qui plutôt que de retourner func lorsqu'elle est appelée retourne counter. Donc myfunc vaut cette instance de counter. Donc quand tu appelles myfunc, c'est counter qui est appelée.

J'ai tracé pas à pas avec le débugger (merci PyCharm !) et j'ai vu exactement ce qui se passe. Je vais en rester là avec les décorateurs. Merci à tous les contributeurs de ce post et surtout à Cirdo pour avoir proposé ces exercices :-)

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte