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}}]
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
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}]
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}}]
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
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}]