pythonpython-decorators

How to deduce whether a classmethod is called on an instance or on a class


I'm writing a logger decorator that can be applied to, among others, classmethods (and theoretically any kind of function or method). My problem is that their parametrization changes according to whether a function is

An example code that works but all but elegant and can be tricked by parametrization:

def some_decorator(func):
    def wrapper(*argsWrap, **kwargsWrap):
        if isinstance(func, classmethod):
            if isinstance(argsWrap[0], SomeClass):
                # Called by some_instance.some_method as the first parameter is SomeClass 
                print(argsWrap[1])
                return func.__func__(*argsWrap, **kwargsWrap)
            else:
                # Called by SomeClass.some_method
                print(argsWrap[0])
                return func.__func__(SomeClass, *argsWrap, **kwargsWrap)
        else:
            return func(*argsWrap, **kwargsWrap)
    return wrapper


class SomeClass:
    @some_decorator
    @classmethod
    def some_method(cls, some_var: str):
        print(some_var)


if __name__ == '__main__':
    SomeClass.some_method('Test')
    some_instance = SomeClass()
    some_instance.some_method('Test2')

Question is simple: is it possible to make a cleaner and safer decision regarding just the parametrization?

Note that for a general purpose decorator (used by others) one cannot say that this must be put below the @classmethod, not above etc.

Also note that this solution is a "bad" workaround in a sense that one can conveniently imagine functions that use more instances of the same class (for example, a typical overloaded operator, like __eq__ works like this).

Also note that the real problem, as I narrowed it, is that a classmethod called upon a class or called upon an instance of a class passes different parametrization (the first parameter being the first arg for a class-bound function and the class reference for an instance-bound function).


Solution

  • I use class decorator, __call__ and __get__ to do the trick.

    Here it is:

    class LoggerDecorator:
        def __init__(self, func):
            self.func = func
        
        def __call__(self, *args, **kwargs):
            return self.func(*args, **kwargs)
        
        def __get__(self, instance, owner):
            if not instance:
                return self.func.__get__(owner, owner)
            return self.func.__get__(instance, owner)
    

    Explainations:

    The key idea behind this implementation is to use the __get__ and __call__ methods within a class decorator to handle different types of function call.

    The problem we are facing here is that the decorator is used as a generic way to wrap various functions, but the parameters it receives vary depending on the type of function being called (e.g., classmethod(cls, ), staticmethod(), instance method(self, )). As the author of the topic mentioned, parametrization(e.g. using the type of function's first parameter) as the determining condition for the wrapper function is undoubtedly risky.

    Here, instead of using a function decorator, we use a class decorator. The condition for handling the decorator is shifted from the 'parametrization' of a wrapper helper function to the __get__ descriptor, which determines whether the function is being used by an instantiated instance.

    1. __get__ : The __get__ method is called to get the attribute of the owner class (class attribute access) or of an instance of that class (instance attribute access).

      Let’s take a "classmethod" as an example and compare the differences in the get method when it's called 'directly' (normal usage) versus when it's called by an instantiated instance:

      class LoggerDecorator:
          def __init__(self, func):
              self.func = func
      
          def __get__(self, instance, owner):
              print(f'self:     {self}')
              print(f'instance: {instance}')
              print(f'owner:    {owner}')
              return self.func.__get__(owner, owner)
      
       class SomeClass:
           @LoggerDecorator
           @classmethod
           def class_method(cls, some_var):
               print(f"print some_var: {some_var}")
      
      # Call Directly
      SomeClass.class_method("Call Directly")
      
      # Called by instance.
      s = SomeClass()
      s.class_method("Called by instance")
      
      # OUTPUT
      --- Called by directly ---
      self:     <__main__.LoggerDecorator object at 0x7f36aad05590>
      instance: None
      owner:    <class '__main__.SomeClass'>
      print some_var: Call Directly
      
      --- Called by instance ---
      self:     <__main__.LoggerDecorator object at 0x7f36aad05590>
      instance: <__main__.SomeClass object at 0x7f36aad06150>
      owner:    <class '__main__.SomeClass'>
      print some_var: Called by instance
      

      We can observe that if called directly, the instance will be None. And the result is the same for a static method (an instance is needed to call it). Therefore, we can use whether the instance in __get__ is None as a determining factor.

    2. __call__: The __call__ method is responsible for handling the decoration of "standalone functions" or any function that does not belong to a class. When we decorate a function like this:

      @LoggerDecorator
      def standalone_function():
         return
      

      which is equal to

      LoggerDecorator(standalone_function)
      

      and this triggers the __call__ method.


    Fully Test: Code & Result

    class LoggerDecorator:
        def __init__(self, func):
            self.func = func
     
        def __call__(self, *args, **kwargs):
            return self.func(*args, **kwargs)
     
        def __get__(self, instance, owner): 
            print(f'self:     {self}')
            print(f'instance: {instance}')
            print(f'owner:    {owner}')
    
            if not instance:
                return self.func.__get__(owner, owner)
            return self.func.__get__(instance, owner)
    
    
    class SomeClass:
        @LoggerDecorator
        @classmethod
        def class_method(cls, some_var: str, *args, **kwargs):
            print(f"Class method: {some_var}")
            print(f"Class method args: {args}")
            print(f"Class method kwargs: {kwargs}")
        
        @LoggerDecorator
        def instance_method(self, some_var: str, *args, **kwargs):
            print(f"Instance method: {some_var}")
            print(f"Instance method args: {args}")
            print(f"Instance method kwargs: {kwargs}")
    
        @LoggerDecorator
        @staticmethod
        def static_method(some_var: str, *args, **kwargs):
            print(f"Static method: {some_var}")
            print(f"Static method args: {args}")
            print(f"Static method kwargs: {kwargs}")
    
    @LoggerDecorator
    def standalone_function(some_var: str, *args, **kwargs):
        print(f"Standalone function: {some_var}")
        print(f"Standalone function args: {args}")
        print(f"Standalone function kwargs: {kwargs}")
    
    if __name__ == '__main__':
        ### Class Method ###
        # Call Directly
        print("### Class Method ###")
        print("--- Called by directly ---")
        SomeClass.class_method("Test1", "class_directly", a1="class_a", b1="class_b")
    
        # Called by instance.
        print("")
        print("--- Called by instance ---")
        s = SomeClass()
        s.class_method("Test2", "class_by_instance", a1="class_a", b1="class_b")
     
        ### Static Method ###
        # Call Directly
        print("")
        print("### Static Method ###")
        print("--- Called by directly ---")
        SomeClass.static_method("Test3", "static_directly", a1="static_a", b1="static_b")
     
        # Called by instance.
        print("")
        print("--- Called by instance ---")
        s.static_method("Test4", "static_by_instance", a1="static_a", b1="static_b")
     
        ### Instance Method ###
        # Call only by instance
        print("")
        print("### Instance Method ###")
        s.instance_method("Test5", "instance", a1="instance_a", b1="instance_b")
    
        ### Standalone Function
        print("")
        print("### Standalone Function ###")
        standalone_function("Test6", "standalone", a1="standalone_a", b1="standalone_b")
    
    
    
    ### Class Method ###
    --- Called by directly ---
    self:     <__main__.LoggerDecorator object at 0x7f858c34e390>
    instance: None
    owner:    <class '__main__.SomeClass'>
    Class method: Test1
    Class method args: ('class_directly',)
    Class method kwargs: {'a1': 'class_a', 'b1': 'class_b'}
    
    --- Called by instance ---
    self:     <__main__.LoggerDecorator object at 0x7f858c34e390>
    instance: <__main__.SomeClass object at 0x7f858c34f410>
    owner:    <class '__main__.SomeClass'>
    Class method: Test2
    Class method args: ('class_by_instance',)
    Class method kwargs: {'a1': 'class_a', 'b1': 'class_b'}
    
    ### Static Method ###
    --- Called by directly ---
    self:     <__main__.LoggerDecorator object at 0x7f858c34f0d0>
    instance: None
    owner:    <class '__main__.SomeClass'>
    Static method: Test3
    Static method args: ('static_directly',)
    Static method kwargs: {'a1': 'static_a', 'b1': 'static_b'}
    
    --- Called by instance ---
    self:     <__main__.LoggerDecorator object at 0x7f858c34f0d0>
    instance: <__main__.SomeClass object at 0x7f858c34f410>
    owner:    <class '__main__.SomeClass'>
    Static method: Test4
    Static method args: ('static_by_instance',)
    Static method kwargs: {'a1': 'static_a', 'b1': 'static_b'}
    
    ### Instance Method ###
    self:     <__main__.LoggerDecorator object at 0x7f858c34e510>
    instance: <__main__.SomeClass object at 0x7f858c34f410>
    owner:    <class '__main__.SomeClass'>
    Instance method: Test5
    Instance method args: ('instance',)
    Instance method kwargs: {'a1': 'instance_a', 'b1': 'instance_b'}
    
    ### Standalone Function ###
    Standalone function: Test6
    Standalone function args: ('standalone',)
    Standalone function kwargs: {'a1': 'standalone_a', 'b1': 'standalone_b'}