Saving the arguments of a function each time it is called, using a decorator defined in a parent class

Question:

I have a series of parent-children classes A(), B(A),… possibly in different modules. A user will import one of these classes and define its own child class Z(X) deriving either from X = A, B,…. Then he will initialize his instance with an initialize method defined in the parent class X, or in his overriden initialize in Z. I want the initialize function to save its arguments each time it is called and I have prepared in A() a save_last_arginfo() function that could be called automatically by a decorator of initialize.

I give an example that works with save_last_arginfo being explicitely called from Z.initialize(). How to replace this call by the decorator defined in A? I am not sure

  • how to code this decorator
  • how to treat self in the decorator definition and @decorator syntax
  • how to refer to the decorator (should I specify A.decorator or is the filiation enough?)
  • whether the relevant frame level for determining the arguments will change with the decorator compared to the direct call, in getouterframes(inspect.currentframe())[1][0]
   import inspect
   import functools

   class A():
        
      lastargsinfo = None
    
      #@decorator   
      def initialize(self, *args, **kwargs):
         pass

      def save_last_arginfo(self):
         args1, varargs1, varkw1, defaults1, = inspect.getfullargspec(self.initialize)[0:4]
         frame = inspect.getouterframes(inspect.currentframe())[1][0]       # will this work when using the decorator
         args2, varargs2, keywords2, locals2 = inspect.getargvalues(frame)
         self.lastarginfo = [args1, defaults1,  locals2] # I'll do a dictionnary with args and kwargs later
    
      def decorator(self):                  # is this syntax correct?
         @functools.wraps(self.func)
         def wrapper(*args, **kwargs):
            self.save_last_arginfo()
            return self.func(*args, **kwargs)
         return wrapper

        
   class B(A):
      #@decorator   
      def initialize(self, a, b=1):
         pass
        
   class Z(B):
      #@decorator                       # how to correct this syntax? A.decorator? what to do with self?
      def initialize(self, a, b=1, c=2, **kwargs):
         self.save_last_arginfo()       # how to remove this line and use the decorator instead
         return (a+b)*c


   myZ = Z()
   myZ.initialize(0,b=3, c=8, d=12)
   print(myZ.lastarginfo)
   # returns [['self', 'a', 'b', 'c'], (1, 2), {'self': <Z object at 0x000001BBBF135340>, 'a': 0, 'b': 3, 'c': 8, 'kwargs': {'d': 12}}]
Asked By: user3650925

||

Answers:

You could write a decorator for initialize, but this would mean that users who wish to override initialize in their subclass must also use the decorator. If they don’t, their implementation will not call your save_last_arginfo method.

If you want to get around this problem, you can define __init_subclass__ on your base class and perform the decoration for initialize there. That way the decorator is applied to each subclass’ initialize method and users don’t even need to know about the existence of your decorator.

To simplify a bit, I am just going to show how to store the args and kwargs for the last initialize call, and not do extensive introspection. Here is a simplified example of what you described with my suggested approach:

from collections.abc import Callable
from functools import wraps
from typing import Concatenate, ParamSpec, TypeVar


P = ParamSpec("P")
R = TypeVar("R")
T = TypeVar("T", bound="A")


class A:
    last_args: tuple[object, ...]
    last_kwargs: dict[str, object]

    @staticmethod
    def save_last_method_call(
        unbound_method: Callable[Concatenate[T, P], R]
    ) -> Callable[Concatenate[T, P], R]:
        @wraps(unbound_method)
        def wrapper(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
            self.last_args = args
            self.last_kwargs = kwargs
            return unbound_method(self, *args, **kwargs)
        return wrapper

    @classmethod
    def __init_subclass__(cls, **_kwargs: object) -> None:
        setattr(cls, "initialize", cls.save_last_method_call(cls.initialize))

    def initialize(self, a: int, b: int = 1) -> None:
        pass

On the user’s side, we could have this:

class Z(A):
    def initialize(self, a: int, b: int = 1, c: int = 2, **kwargs: int) -> None:
        pass


def main() -> None:
    z = Z()
    z.initialize(0, b=3, c=8, d=12)
    args = z.last_args
    kwargs = z.last_kwargs
    print(f"initialize({args=}, {kwargs=})")


if __name__ == "__main__":
    main()

Output:

initialize(args=(0,), kwargs={'b': 3, 'c': 8, 'd': 12})

I think it makes sense to define the decorator as a staticmethod because on the one hand the class itself is not really needed, but on the other hand it relies on self inside the wrapper to be an instance of A. Therefore the method is intimately related to the A base class.

One advantage of this setup is that you can very easily apply this decorator to any other method of any subclass of A or even to all of them, if you want to. If you do that inside __init_subclass__, it guarantees the decoration of those methods for every subclass. (Unless of course a user overrides __init_subclass__ himself and does not call the parent’s method.)

What exactly you want to do inside save_last_method_call is ultimately up to you. This just demonstrates the concept of applying the decorator automatically. If you want to do more inspection there, I don’t really see an issue with that.


In my code I used type annotations, which pass mypy --strict cecks. But for ParamSpec and Concatenate to be supported, you need Python 3.10+. If you are on an earlier version, it might be enough to simply change the typing imports to this:

...
from typing import TypeVar
from typing_extensions import Concatenate, ParamSpec
Answered By: Daniil Fajnberg

I try to reformulate Daniil answers, keeping it simple for me, and closer to my goals, in order to check whether I have understood and to continue the discussion.

import inspect
import functools

class A:
    def __init__(self):
        self.last_call_info = []
       
    def save_last_call(func):
        @functools.wraps(func)
        def wrapper(self, *args, **kwargs):
            result = func(self, *args, **kwargs)
            self.last_call_info  = [inspect.signature(func), args, kwargs]
            return result
        return wrapper
    
    @save_last_call
    def initialize(self, *args, **kwargs):
        pass
        
    @classmethod
    def __init_subclass__(cls, **kwargs):
        setattr(cls, "initialize", cls.save_last_call(cls.initialize))
    
class B(A):
    def initialize(self, a, x=5, y=8):
        # Do something here
        pass

class Z(B):
    def initialize(self, a, b, x=5, y=8):
        # Do something here
        pass
        
myZ = Z()
myZ.initialize(1,2,x=4)
print(myZ.last_call_info)

outputs:
[<Signature (self, a, b, x=5, y=8)>, (1, 2), {‘x’: 4}]

Note that in the wrapper, I have to call func BEFORE storing its arguments to avoid a bug occuring when the last programmer who overrides initialize in Z choose to reuse X.initialise (by calling super for instance), so that save_last_call is called twice for two different initialize with different arguments. Example:

class Z(B):
   def initialize(self, a, b, x=5, y=9):
      super().initialize(a, x=x ,y=y)
      self.b = b
   
myZ = Z()
myZ.initialize(1,2,x=4)
print(myZ.last_call_info )

outputs now the correct result
[<Signature (self, a, b, x=5, y=9)>, (1, 2), {‘x’: 4}]

Answered By: user3650925