3

While there are plenty of resources about using classes as decorators, I haven't been able to find any that deal with the problem of decorating methods. The goal of this question is to fix that. I will post my own solution, but of course everyone else is invited to post theirs as well.


Why the "standard" implementation doesn't work

The problem with the standard decorator class implementation is that python will not create a bound method of the decorated function:

class Deco:
    def __init__(self, func):
        self.func= func
    
    def __call__(self, *args):
        self.func(*args)

class Class:
    @Deco
    def hello(self):
        print('hello world')

Class().hello() # throws TypeError: hello() missing 1 required positional argument: 'self'

A method decorator needs to overcome this hurdle.


Requirements

Taking the classes from the previous example, the following things are expected to work:

>>> i= Class()
>>> i.hello()
hello world
>>> i.hello
<__main__.Deco object at 0x7f4ae8b518d0>
>>> Class.hello is Class().hello
False
>>> Class().hello is Class().hello
False
>>> i.hello is i.hello
True

Ideally, the function's __doc__ and signature and similar attributes are preserved as well.

Community
  • 1
  • 1
Aran-Fey
  • 39,665
  • 11
  • 104
  • 149
  • Also relevant: [Python decorator best practice, using a class vs a function](http://stackoverflow.com/questions/10294014/python-decorator-best-practice-using-a-class-vs-a-function) – Aran-Fey Mar 08 '17 at 11:54
  • Why do you require it to be a class? What's wrong with the decorator just being a function? – Paul Rooney Mar 08 '17 at 22:23
  • @PaulRooney In my particular case (I'm writing a GUI library), I want to store a bunch of attributes on the function (like keyboard hotkey, description, category, etc.) and also a bunch of functions (like `.start_in_new_thread()`, `.update_status()`). Instead of forcing all these attributes onto the function, I'd rather write a wrapper class and replace the function altogether. – Aran-Fey Mar 08 '17 at 22:30

1 Answers1

5

Usually when a method is accessed as some_instance.some_method(), python's descriptor protocol kicks in and calls some_method.__get__(), which returns a bound method. However, because the method has been replaced with an instance of the Deco class, that does not happen - because Deco is not a descriptor. In order to make Deco work as expected, it must implement a __get__ method that returns a bound copy of itself.

Implementation

Here's basic "do nothing" decorator class:

import inspect
import functools
from copy import copy


class Deco(object):
    def __init__(self, func):
        self.__self__ = None # "__self__" is also used by bound methods

        self.__wrapped__ = func
        functools.update_wrapper(self, func)

    def __call__(self, *args, **kwargs):
        # if bound to an object, pass it as the first argument
        if self.__self__ is not None:
            args = (self.__self__,) + args

        #== change the following line to make the decorator do something ==
        return self.__wrapped__(*args, **kwargs)

    def __get__(self, instance, owner):
        if instance is None:
            return self

        # create a bound copy
        bound = copy(self)
        bound.__self__ = instance

        # update __doc__ and similar attributes
        functools.update_wrapper(bound, self.__wrapped__)

        # add the bound instance to the object's dict so that
        # __get__ won't be called a 2nd time
        setattr(instance, self.__wrapped__.__name__, bound)

        return bound

To make the decorator do something, add your code in the __call__ method.


Here's one that takes parameters:

class DecoWithArgs(object):
    #== change the constructor's parameters to fit your needs ==
    def __init__(self, *args):
        self.args = args

        self.__wrapped__ = None
        self.__self__ = None

    def __call__(self, *args, **kwargs):
        if self.__wrapped__ is None:
            return self.__wrap(*args, **kwargs)
        else:
            return self.__call_wrapped_function(*args, **kwargs)

    def __wrap(self, func):
        # update __doc__ and similar attributes
        functools.update_wrapper(self, func)

        return self

    def __call_wrapped_function(self, *args, **kwargs):
        # if bound to an object, pass it as the first argument
        if self.__self__ is not None:
            args = (self.__self__,) + args

        #== change the following line to make the decorator do something ==
        return self.__wrapped__(*args, **kwargs)

    def __get__(self, instance, owner):
        if instance is None:
            return self

        # create a bound copy of this object
        bound = copy(self)
        bound.__self__ = instance
        bound.__wrap(self.__wrapped__)

        # add the bound decorator to the object's dict so that
        # __get__ won't be called a 2nd time
        setattr(instance, self.__wrapped__.__name__, bound)
        return bound

An implementation like this lets us use the decorator on methods as well as functions, so I think it should be considered good practice.

Aran-Fey
  • 39,665
  • 11
  • 104
  • 149