Search code examples
pythondecoratorpython-decoratorsmetaclass

Decorator and inheritance: what happens when the decorator is applied to parent class


Need some help to understand a decorator behavior...

Here's some code:

import random

class MyDecorator(object):
    """ Logger decorator, adds a 'logger' attribute to the class """
    def __init__(self, *args, **kwargs):
      print(random.randint(1, 100), *args, **kwargs)
      
      self.cls = args[0]

    def __call__(self, *args, **kwargs):
      # Do something, inject some attribute
      setattr(self.cls, 'x', 1234)
      
      # Return an instance of the class
      return self.cls(*args[1:], **kwargs)

@MyDecorator
class A:
  def __init__(self):
    print(f'A {self.x}')
  
@MyDecorator
class B:
  """ Parent class """
  def __init__(self):
    print(f'B {self.x}')


class B1(B):
  """ Child class """
  def __init__(self):
    super().__init__()
    
    self.logger.info('Class B1 __init__()')
    

# Here the decorator is applied directly to the class that is going to be instantiated
# Decorator's __init__() receives the class as arg[0], so I can store it and use it when __call__()'ed
a = A()

# Here the decorator is not applied to the class being instantiated, rather to its parent class
# It looks like the decorator's __init__() is being called twice:
# - first time it do recceives the class to which it is applied (in this case, B)
# - second time it receives 3 arguments: a string containing the name of the child class, a tuple containing an instance of the decorator class itself and then some dict containing internal Python controls (I think)
b1 = B1()
    

Output:

82 <class '__main__.A'>
47 <class '__main__.B'>
52 B1 (<__main__.MyDecorator object at 0x7fd4ea9b0860>,) {'__module__': '__main__', '__qualname__': 'B1', '__doc__': ' Child class ', '__init__': <function B1.__init__ at 0x7fd4e9785a60>, '__classcell__': <cell at 0x7fd4eaa0f828: empty>}
A 1234
Traceback (most recent call last):
  File "main.py", line 39, in <module>
    b1 = B1()
  File "main.py", line 12, in __call__
    setattr(self.cls, 'x', 1234)
AttributeError: 'str' object has no attribute 'x'

So, my questions are:

  1. What happens when I apply the decorator to the parent class, but not to its children? Looks like the decorator is being called for both parent/child, and is passing different set of parameters in each case
  2. How would I solve my "class instantiation thorugh a decorator" in this scenario? (everything is working in cases like A, where the decorator is being applied directly to the class being instantiated)
  3. Should I really be returning and instance of the class? What would happen if I needed to chain some decorators? what should my __call__ method look like in this scenario
@MyDecorator1
@MyDecorator2
class A():

Thank you for your help!


Solution

  • So, when you do:

    @MyDecorator
    class A:
      def __init__(self):
        print(f'A {self.x}')
    

    You can think of this as is syntactic sugar for

    class B:
      def __init__(self):
        print(f'B {self.x}')
    
    B = MyDecorator(B)
    

    The problem is, though, that now B is an instance of MyDecorator, i.e., now B doesn't refer to a class. So, when you inherit from B in:

    class B1(B):
        pass
    

    Weird things happen. The relevant weird thing -- the one that causes the error ultimately -- is that when deciding on the metaclass to use:

    3.3.3.3. Determining the appropriate metaclass

    • if no bases and no explicit metaclass are given, then type() is used;

    • if an explicit metaclass is given and it is not an instance of type(), then it is used directly as the metaclass;

    • if an instance of type() is given as the explicit metaclass, or bases are defined, then the most derived metaclass is used.

    The most derived metaclass is selected from the explicitly specified metaclass (if any) and the metaclasses (i.e. type(cls)) of all specified base classes. The most derived metaclass is one which is a subtype of all of these candidate metaclasses.

    i.e. the third bullet-point happens, in this case, type(instance_of_my_decorator) is used as the metaclass, i.e. MyDecorator. So ultimately, and through a different route, B1 now also refers to (another) instance of MyDecorator, one which got the string "B1" passed as it's first argument to the constructor, hence in MyDecorator.__init__:

     self.cls = args[0]
    

    Is assigning that string, so when you reach:

    setattr(self.cls, 'x', 1234)
    

    In __call__, it fails with the error above.

    Since all you want to do is assign an attribute to the class, simplest solution is not to use a class-based approach, instead, use a function:

    def MyDecorator(cls):
        setattr(self.cls, 'x', 1234)
        return cls # important
    

    This, of course, seems a bit of overkill. You should probably just set the attribute as a class attribute like you normally would, as part of the class definition statement, but if you insist on using a decorator, the above approach would work. Of course, this won't get inherited.