Python3.10 decorator confusion: how to wrap a class to augment a class's __init__ e.g. to track function calls

Question:

I’m getting myself confused with python decorators. Yes, there are a lot of useful resources out there (and I have consulted them prior to posting)

e.g.

I am sure there are better ways to do what I am about to describe, that is because this is just a simple toy example to understand the properties not what I am actually trying to do.

Suppose you have a class

class foo:
    def __init__(self):
        pass

    def call_me(self, arg):
        return arg

and I want to extend foo‘s constructor to take in two keyword arguments and modify the call_me method e.g. the desired end class would be:

class foo:
    def __init__(self, keep_track_bool=False, do_print_bool=True):
        self.keep_track = keep_track
        self.memory = []
        ...

    def call_me(self, arg):
        arg = self.old_call_me(arg)
        
        if self.do_print:
            print('hi')

        if self.keep_track:
             self.memory.append(arg)
       
        return arg

    def old_call_me(self, arg):
        ...
        return arg

Ideally, I would like a decorator so that I can wrap a bunch of classes which I am assuming have a method call_me like

@keep_track(do_print=False) # <--- sets default state
class foo:
   ...

@keep_track(keep_track=True) # <--- sets default state
class bar:
   ...


foo_instance = foo(do_print=True) # <-- I can change behavior of instance 

How can I achieve this?

I have tried defining functions inside the decorator and setting them with

setattr(cls, 'old_call_me', call_me)
def call_me(self, arg):
    # see above
 
setattr(cls, 'call_me', call_me) 

and using functools.wrap

I would appreciate the guidance in this matter.

Asked By: SumNeuron

||

Answers:

I can offer a very, very convoluted, hard to read and only intended for learning purposes idea…

You could leverage the fact that if you try to access an instance’s attribute, Python will first go look for it in the instance attributes (let’s say self.__dict__), and if it doesn’t find it there, it will try to find it in the instance’s class attributes (let’s say self.__class__.__dict__)

So you could have your decorator write the default into the class itself and then accept it as an optional keyword argument to your __init__ method (basically creating only sometimes the do_print instance attribute).

from functools import wraps


def my_decorator(*, do_print):
    def my_decorator_inner(klass):
        print(f"Outer: {do_print}")

        @wraps(klass)
        def wrapper(*args, **kwargs):
            print(f"Inner: {klass} {args}, {kwargs}")
            klass.do_print = do_print
            return klass(*args, **kwargs)

        return wrapper

    return my_decorator_inner


@my_decorator(do_print=False)
class Foo:
    def __init__(self, *, do_print=None):
        if do_print is not None:
            self.do_print = do_print

    def call_me(self):
        if self.do_print:
            print('hi')
        else:
            print("nopes, I ain't printing")


f_no_print = Foo()
f_no_print.call_me()

f_print = Foo(do_print = True)
f_print.call_me()

Note that I added a couple of print statements that might help with what’s being passed to each function.

HOWEVER this solution is very, very convoluted and confusing when what it is really doing is just:

class Foo:
    do_print = False

    def __init__(self, *, do_print=None):
        if do_print is not None:
            self.do_print = do_print

    def call_me(self):
        if self.do_print:
            print('hi')
        else:
            print("nopes, I ain't printing")


f_no_print = Foo()
f_no_print.call_me()

f_print = Foo(do_print=True)
f_print.call_me()

EDIT as per comments below:

In Python everything is an object. Even functions are just… sort of """variables""". And classes are objects too. When you type class Foo:, you are not "just" creating a class definition (well… you are, but…) you are creating an instance called Foo whose type is type. You can alter that through metaclasses (which is where the __new__ method is usually used in). That object Foo of type type can then be called (inst = Foo()) to produce instances of type Foo

So, first things first. The cleaner subclassing method that @Tourelou was suggesting in his comment I imagine would go something like this:

class Foo:
    def call_me(self, arg):
        return arg


class FooPrinting(Foo):
    def __init__(self, keep_track: bool = False, do_print: bool = True):
        self.keep_track = keep_track
        self.do_print = do_print
        self.memory = []

    def call_me(self, arg):
        arg = super().call_me(arg)
        if self.do_print:
            print(f'hi {arg}')
        if self.keep_track:
            self.memory.append(arg)
        return arg


f_no_print = Foo()
f_no_print.call_me(1)

f_print = FooPrinting(do_print=True)
f_print.call_me(2)

That’s good, that’s clean, that’s probably what you’d wanna use…

You mentioned in your comment that

Having only some instances have the keyword arg seems too non-pythonic

Actually, that is the Pythonic way. I understand it can look cluttered at first, but if you want to keep the ability of changing the behavior for some instances, you’ll have to accept the parameter in the constructor somehow. And kwargs with default values are a good way of doing it. True, true: you don’t really have to accept the parameters in the constructor, because you can dynamically add attributes to any instance at any point in your code… but you should. The fact you CAN do something doesn’t mean you SHOULD do it (this last line is also applicable to what comes below):

Now, since you seem to just want to sort of monkey-patch a method, there’s the option of dynamically swapping the class’ .call_me method by another method (new_call_me in the example below) that you have "kept in the freezer" and that you swap in the decorator:

from functools import wraps


def new_call_me(self, arg):
    arg = self.old_call_me(arg)
    if getattr(self, 'do_print', False):
        print(f'swapped hi {arg}')
    if getattr(self, 'keep_track', False):
        self.memory.append(arg)
    return arg


def my_decorator(*, do_print):
    def my_decorator_inner(klass):
        print(f"Outer: {do_print}")

        @wraps(klass)
        def wrapper(*args, **kwargs):
            print(f"Inner: {klass} {args}, {kwargs}")
            klass.do_print = do_print

            if (not (hasattr(klass, 'old_call_me'))  # <- If already swapped, skip
                    and hasattr(klass, 'call_me') and callable(klass.call_me)):
                print(f"Swapping method in class {klass.__name__}")
                klass.old_call_me = klass.call_me
                klass.call_me = new_call_me
            return klass(*args, **kwargs)

        return wrapper

    return my_decorator_inner


@my_decorator(do_print=True)
class Foo:
    def __init__(self, *, do_print=None):
        self.do_print = do_print

    def call_me(self, arg):
        return arg


f_no_print = Foo()
f_no_print.call_me(1)

f_print = Foo(do_print=True)
f_print.call_me(2)

⚠️ HOWEVER: If I ever see this in my company’s codebase, I’d yell. And if I have any saying in the matter, I’d try to get the author of such aberration fired. Ok, fine, FINE!… maybe not so much, ‘cuz I am a softie at heart , but for sure there’d be a conversation (a CON-VER-SA-TION!) with that person.

Answered By: BorrajaX
Categories: questions Tags: ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.