Faire marcher un class decorator Python avec self

Pour un projet Python, je dois benchmarker plusieurs éléments et une de ces mesures est de chronométrer le temps mis pour avoir une réponse. Dans mon code, cela se traduit par chronométrer combien de temps se passe entre deux appels de deux méthodes différentes d’un même objet. Apprenant le Python en même temps, je me suis dit qu’une façon élégante de le faire serait de faire un class decorator.

Je ne sais pas si vous savez ce qu’est et comment fonctionne un class decorator, si non je vous conseille le chapitre 38.4 « Coding Class Decorators » du livre Learning Python par Mark Lutz et si vous ne savez même pas ce qu’est un decorator, honte à vous è_é! Ahah, non, aucune honte bien sur, dans ce cas lisez simplement le chaptire 38.2.

En bref, un decorator permet de remplacer une fonction (ou une classe) par une autre en écrivant simplement l’annotation @nomDuDecorator avant cette fonction/classe. Dans ma situation, en ajoutant l’annotation @TwoMethodsTimer(« requestMedia », « startPlayback ») juste avant la déclaration de ma classe, je vais récolter des statistiques sur le temps que met un média à commencer sa lecture après avoir été demandé. Voila une première version du decorator faite après avoir lu le chapitre 38.4 en plus de quelques blogs et fils sur Stack Overflow:

import time
def TwoMethodsTimer(func1, func2): # On @ decorator
def ClassBuilder(aClass):
class Wrapper:
def __init__(self, *args, **kargs): # On instance creation
# stores the begining timestamp when timing
self.startTime = 0
# to store all the latencies, for statistics purpose
self.latencies = []
self.wrapped = aClass(*args, **kargs) # Use enclosing scope name
def startTimer(self):
self.startTime = time.time()
def stopTimer(self):
if self.startTime != 0:
totalTime = time.time() - self.startTime
self.latencies.append(totalTime)
print("Took "+str(totalTime)+" seconds for "+ str(self.wrapped.getID()) + " Average: "+str(sum(self.latencies)/float(len(self.latencies))))
self.startTime = 0
# by overloading this function, we can intercept each call to each method
def __getattr__(self, attrname):
if attrname is func1: # calling func1 will trigger the timer
self.startTimer()
if attrname is func2: # calling func2 will stop the timer and store the time
self.stopTimer()
return getattr(self.wrapped, attrname) # Delegate to wrapped obj
return Wrapper
return ClassBuilder

Pour l’utiliser sur n’importe quelle classe, voila un exemple:
@TwoMethodsTimer("methodA", "methodB")
class MyClass:
name = ""
def __init__(self, name):
self.name = name
def methodA(self):
#doing stuff
def methodB(self):
#other logic stuff
mc = MyClass("class")
mc.methodA() # the startTimer will be triggered
time.sleep(2)
mc.methodB() # stopTimer will be triggered and print that it took ~2 seconds

Dans cet exemple, on mesure combien de temps s’écoule entre l’appel de methodA e methodB, ce qui sera environ 2 secondes. Ça marche, on est content, jusqu’à essayer quelque chose de nouveau:
@TwoMethodsTimer("methodA", "methodB")
class MyClass:
name = ""
def __init__(self, name):
self.name = name
def methodA(self):
#doing stuff
def methodB(self):
#other logic stuff
def otherMethod(self):
#stuff code
self.methodB() # with the first version of the decorator, this does not trigger the stopTimer!
#code, again, use your imagination
mc = MyClass("class")
mc.methodA() # the startTimer will be triggered
time.sleep(2)
mc.otherMethod() # stopTimer will be not be triggered for v1, but it will for v2

On peut observer ici un appel interne à methodB (self.methodB()) et que cet appel ne déclenche pas stopTimer! Après enquête, il s’avère que l’appel de méthodes sur self, donc à l’intérieur d’un objet, ne fait pas appel à __getattr__ pour récupérer la méthode. J’ai donc essayé d’intercepter l’appel dans __getattribute__ mais cela s’avère difficile et quand je ne finis pas par des appels récursifs infinis, impossible de voir passer la méthode qui m’intéresse et donc de déclencher mon timer.

Pour résoudre ce problème, à la place de déterminer quelle méthode a été appelée en interceptant __getattr__, je modifie les attributs de l’objet juste après sa création. Voila le code:

import time
def TwoMethodsTimer(func1, func2): # On @ decorator
def ClassBuilder(aClass):
class Wrapper:
def __init__(self, *args, **kargs): # On instance creation
# stores the begining timestamp when timing
self.startTime = 0
# to start all the latencies, for statistics purpose
self.latencies = []
self.wrapped = aClass(*args, **kargs) # Use enclosing scope name"é
self.oldFunc1 = self.wrapped.__getattribute__(func1)
self.oldFunc2 = self.wrapped.__getattribute__(func2)
self.wrapped.__setattr__(func1, self.newFunc1)
self.wrapped.__setattr__(func2, self.newFunc2)
def startTimer(self):
self.startTime = time.time()
def stopTimer(self):
if self.startTime != 0:
totalTime = time.time() - self.startTime
self.latencies.append(totalTime)
print("Took "+str(totalTime)+" seconds for "+ str(self.wrapped.getID()) + " Average: "+str(sum(self.latencies)/float(len(self.latencies))))
self.startTime = 0
def callOrig(self):
getattr(self.wrapped, func2+'_old')()
def newFunc1(self, *args, **kargs):
self.startTimer()
return self.oldFunc1( *args, **kargs)
def newFunc2(self, *args, **kargs):
self.stopTimer()
return self.oldFunc2( *args, **kargs)
def __getattr__(self, attrname):
# to keep the original methods working
return getattr(self.wrapped, attrname) # Delegate to wrapped obj
return Wrapper
return ClassBuilder

Juste après l’instanciation de l’objet, on modifie l’association des deux méthodes qui nous intéressent pour que ces méthodes « pointent » vers nos propres méthodes. On stock les anciennes méthodes quelque part pour pouvoir s’en resservir par la suite. Cette astuce permet le fonctionnement du decorator à la fois sur les appels externes et internes.