2

NOTE:
I've got a related question here: How to access variables from a Class Decorator from within the method it's applied on?


I'm planning to write a fairly complicated decorator. Therefore, the decorator itself should be a class of its own. I know this is possible in Python (Python 3.8):

import functools

class MyDecoratorClass:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
    
    def __call__(self, *args, **kwargs):
        # do stuff before
        retval = self.func(*args, **kwargs)
        # do stuff after
        return retval

@MyDecoratorClass
def foo():
    print("foo")

Now my problem starts when I try to apply the decorator on a method instead of just a function - especially if it's a method from another class. Let me show you what I've tried:

 

1. Trial one: identity loss

The decorator MyDecoratorClass below doesn't (or shouldn't) do anything. It's just boilerplate code, ready to be put to use later on. The method foo() from class Foobar prints the object it is called on:

import functools

class MyDecoratorClass:
    def __init__(self, method):
        functools.update_wrapper(self, method)
        self.method = method

    def __call__(self, *args, **kwargs):
        # do stuff before
        retval = self.method(self, *args, **kwargs)
        # do stuff after
        return retval

class Foobar:
    def __init__(self):
        # initialize stuff
        pass

    @MyDecoratorClass
    def foo(self):
        print(f"foo() called on object {self}")
        return

Now what you observe here is that the self in the foo() method gets swapped. It's no longer a Foobar() instance, but a MyDecoratorClass() instance instead:

>>> foobar = Foobar()
>>> foobar.foo()
foo() called from object <__main__.MyDecoratorClass object at 0x000002DAE0B77A60>

In other words, the method foo() loses its original identity. That brings us to the next trial.

 

2. Trial two: keep identity, but crash

I attempt to preserve the original identity of the foo() method:

import functools

class MyDecoratorClass:
    def __init__(self, method):
        functools.update_wrapper(self, method)
        self.method = method

    def __call__(self, *args, **kwargs):
        # do stuff before
        retval = self.method(self.method.__self__, *args, **kwargs)
        # do stuff after
        return retval

class Foobar:
    def __init__(self):
        # initialize stuff
        pass

    @MyDecoratorClass
    def foo(self):
        print(f"foo() called on object {self}")
        return

Now let's test:

>>> foobar = Foobar()
>>> foobar.foo()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 7, in __call__
AttributeError: 'function' object has no attribute '__self__'

Yikes!


EDIT
Thank you @AlexHall and @juanpa.arrivillaga for your solutions. They both work. However, there is a subtle difference between them.

Let's first take a look at this one:

def __get__(self, obj, objtype) -> object:
    temp = type(self)(self.method.__get__(obj, objtype))
    print(temp)
    return temp

I've introduced a temporary variable, just to print what __get__() returns. Each time you access the method foo(), this __get__() function returns a new MyDecoratorClass() instance:

>>> f = Foobar()
>>> func1 = f.foo
>>> func2 = f.foo
>>> print(func1 == func2)
>>> print(func1 is func2)
<__main__.MyDecoratorClass object at 0x000001B7E974D3A0>
<__main__.MyDecoratorClass object at 0x000001B7E96C5520>
False
False

The second approach (from @juanpa.arrivillaga) is different:

def __get__(self, obj, objtype) -> object:
    temp = types.MethodType(self, obj)
    print(temp)
    return temp

The output:

>>> f = Foobar()
>>> func1 = f.foo
>>> func2 = f.foo
>>> print(func1 == func2)
>>> print(func1 is func2)
<bound method Foobar.foo of <__main__.Foobar object at 0x000002824BBEF4C0>>
<bound method Foobar.foo of <__main__.Foobar object at 0x000002824BBEF4C0>>
True
False

There is a subtle difference, but I'm not sure why.

K.Mulier
  • 8,069
  • 15
  • 79
  • 141
  • Because in my version, it returns a method object, which will evaluate to true for `==` but it creats two distinct objects... sort of in the way: `a = [1,2]; b = [1,2]`, then `a == b` but not `a is b` – juanpa.arrivillaga Aug 14 '20 at 19:01

2 Answers2

4

Functions are descriptors and that's what allows them to auto-bind self. The easiest way to deal with this is to implement decorators using functions so that this is handled for you. Otherwise you need to explicitly invoke the descriptor. Here's one way:

import functools


class MyDecoratorClass:
    def __init__(self, method):
        functools.update_wrapper(self, method)
        self.method = method

    def __get__(self, instance, owner):
        return type(self)(self.method.__get__(instance, owner))

    def __call__(self, *args, **kwargs):
        # do stuff before
        retval = self.method(*args, **kwargs)
        # do stuff after
        return retval


class Foobar:
    def __init__(self):
        # initialize stuff
        pass

    @MyDecoratorClass
    def foo(self, x, y):
        print(f"{[self, x, y]=}")


@MyDecoratorClass
def bar(spam):
    print(f"{[spam]=}")


Foobar().foo(1, 2)
bar(3)

Here the __get__ method creates a new instance of MyDecoratorClass with the bound method (previously self.method was just a function since no instance existed yet). Also note that __call__ just calls self.method(*args, **kwargs) - if self.method is now a bound method, the self of FooBar is already implied.

Alex Hall
  • 34,833
  • 5
  • 57
  • 89
  • Awesome! This worked like a charm. Thank you so much @Alex Hall! – K.Mulier Aug 13 '20 at 20:51
  • Hi Alex, please check my **EDIT** at the end of my question. I've noticed a subtle difference between your solution and the one from @juanpa.arrivillaga . Do you have an idea why? – K.Mulier Aug 14 '20 at 18:52
3

You can implement the descriptor protocol, an example of how functions do it (but in pure python) is available in the Descriptor HOWTO, translated to your case:

import functools
import types

class MyDecoratorClass:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func

    def __call__(self, *args, **kwargs):
        # do stuff before
        retval = self.func(*args, **kwargs)
        # do stuff after
        return retval

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return types.MethodType(self, obj)

Note, return types.MethodType(self, obj) is essentially equivalent to

return lambda *args, **kwargs : self.func(obj, *args, **kwargs)

Note from Kristof
Could it be that you meant this:

return types.MethodType(self, obj) is essentially equivalent to

return lambda *args, **kwargs : self(obj, *args, **kwargs)

Note that I replaced self.func(..) with self(..). I tried, and only this way I can ensure that the statements at # do stuff before and # do stuff after actually run.

juanpa.arrivillaga
  • 88,713
  • 10
  • 131
  • 172
  • Thank you @juanpa, this is also a very nice approach to solve the issue :-) – K.Mulier Aug 13 '20 at 20:55
  • Hi Juanpa, please check my **EDIT** at the end of my question. I've noticed a subtle difference between your solution and the one from @AlexHall . Do you have an idea why? – K.Mulier Aug 14 '20 at 18:52
  • @K.Mulier it's not a subtle difference, we took completely different approaches. They both work but in expectedly different ways. Another difference is if you `print(args)` inside `__call__`, this solution will include the FooBar instance (the self argument of foo) while mine won't because it's bound within the new `self.method`. – Alex Hall Aug 14 '20 at 19:01
  • Hi Alex, Thanks for your reply, It's still not entirely clear to me why it's so different. The difference also has an impact on accessing variables from the Decorator Class (see https://stackoverflow.com/questions/63416226/how-to-access-variables-from-a-class-decorator-from-within-the-method-its-appli) – K.Mulier Aug 14 '20 at 19:09
  • @K.Mulier because Alex's approach returns *a `MyDecoratorClass` instance* whereas my approach returns a `MethodType` instance (the same as a regular method). – juanpa.arrivillaga Aug 14 '20 at 19:10
  • Hi @AlexHall and juanpa.arivillaga, I think there is a small mistake in the last part of this answer. Please have a look at the edit I inserted (it was too long for a comment, so I decided to insert an edit instead). – K.Mulier Aug 15 '20 at 10:31
  • @K.Mulier yes I think you're right. Really it's equivalent to `return lambda *args, **kwargs : self(obj, *args, **kwargs)`, and the `.__call__` is implied. Or you could think of it as `return functools.partial(self, obj)`. – Alex Hall Aug 15 '20 at 10:35
  • Hi @AlexHall, thanks for the clarification. Is `return lambda *args, **kwargs : self(obj, *args, **kwargs)` really the same as `return lambda *args, **kwargs : self.__call__(obj, *args, **kwargs)`? You're absolutely 100% positive that there are no subtle differences? – K.Mulier Aug 15 '20 at 11:07
  • @K.Mulier yes, I really just wanted to point out that it was partially applying the object as the first argument. And yes, those are the same. That's what `__call__` does – juanpa.arrivillaga Aug 15 '20 at 11:08
  • @K.Mulier yes, `x(...)` is the same as `x.__call__(...)` for any object `x`. – Alex Hall Aug 15 '20 at 11:08
  • Hi @AlexHall and juanpa.arrivillaga, thanks a lot for the clarifications :-) – K.Mulier Aug 15 '20 at 11:13
  • Hi @AlexHall and juanpa.arrivillaga. First of all a BIG THANK YOU for your help. Based on everything you wrote, I've compiled an overview in this post: https://stackoverflow.com/questions/63416226/how-to-access-variables-from-a-class-decorator-from-within-the-method-its-appli Please have a look :-) – K.Mulier Aug 15 '20 at 14:00