Search code examples
pythonpython-3.xclasspython-3.6python-decorators

Using a class as a decorator


This is my first time trying this, so...

I am following: https://www.python-course.eu/python3_decorators.php

The goal is that when I initialize an instance of Foo like this:

f = Foo(10, [1, 2, 3, 4, 5])

The output from print(f) needs to look like this:

Instance of Foo, vars = {'x': 10, 'y': [1, 2, 3, 4, 5]}

The decorator class is to function as a kind of automatic repr.

I have this:

class ClassDecorator(object):
    def __init__(self, cls):
        self.cls = cls
        print("An instance of Foo was initialized")

    def __call__(self, cls, *args, **kwargs):
        print("Instance of Foo, vars = ", kwargs)

@ClassDecorator
class Foo(object):
    def __init__(self, x, y):
        self.x = x
        self.y = y

f = Foo(10, [1, 2, 3, 4, 5])
print(f)

Which yields:

An instance of Foo was initialized
Instance of Foo, vars =  {}
None

I don't think I want *args, but without it my program errors out with:

Traceback (most recent call last):
An instance of Foo was initialized
  File "C:/Users/Mark/PycharmProjects/main/main.py", line 17, in <module>
    f = Foo(10, [1, 2, 3, 4, 5])
TypeError: __call__() takes 2 positional arguments but 3 were given

I understand the missing argument issue, but if 10 is to be 'x' and [1, 2, 3, 4, 5] is to be 'y', then I am just not understanding why **kwargs is insufficient.


Solution

  • I think you're missing a few different fundamental concepts here.


    First, when you print an object, like print(f), what that does is call the __str__ method on the type of f. If nobody has defined __str__, the default object.__str__ gets called, which just returns f.__repr__.

    So, if you want to change what happens when you do a print(f), you have to give f's type a __str__ (or __repr__) method that does what you want. Nothing you do anywhere else is going to have any effect.


    Your decorator's __init__ doesn't get called when an instance of Foo is initialized; it gets called when the class gets initialized. (Or, rather, when the decorator gets called on the class, right after class creation) It's the __call__ that happens when you use that to create an instance.

    Also, that __call__ isn't going to get an extra cls parameter from anywhere, it's just going to get self, like any other normal method. That's why we had to store the decorated class as self.cls in the first place.


    The point of a decorator is to return an object that can be used like the decorated object. Your ClassDecorator can be called like a class—but it returns nothing when you do so, instead of returning an instance. That means it's impossible to create instances; if you try, you just get None. Which is why print(f) prints None.


    Finally, forwarding arguments usually requires both *args and **kwargs. *args collects any unexpected positional arguments into a tuple, and **kwargs collects any unexpected keyword arguments into a dict. When someone calls Foo(10, [1, 2, 3, 4, 5]), there are two positional arguments and no keyword arguments. So args will be (10, [1, 2, 3, 4, 5]), and kwargs will be {}. If you didn't have a *args, then you'd get a TypeError, because you don't have any explicit positional-or-keyword parameters to match those positional arguments with.


    So, here's an example that fixes all of that. I'll do it by modifying the decorated class, but remember that this isn't the only way to do it, and there are pros and cons of each approach.

    First, I'll show how to write a decorator that only does what you want for classes whose instance variables are x and y.

    class ClassDecorator(object):
        def __init__(self, cls):
            def _str(self):
                typ = type(self).__name__
                vars = {'x': self.x, 'y': self.y}
                return(f"Instance of {typ}, vars = {vars}")
            cls.__str__ = _str
            self.cls = cls
            print(f"The class {cls.__name__} was created")
    
        def __call__(self, *args, **kwargs):
            print(f"An instance of {self.cls.__name__} was created with args {args} and kwargs {kwargs}")
            return self.cls(*args, **kwargs)
    

    Now, when you use it, it does what you wanted:

    >>> @ClassDecorator
    ... class Foo(object):
    ...     def __init__(self, x, y):
    ...         self.x = x
    ...         self.y = y
    The class Foo was created
    >>> f = Foo(10, [1, 2, 3, 4, 5])
    An instance of Foo was created with args (10, 20) and kwargs {}
    >>> print(f)
    Instance of Foo, vars = {'x': 10, 'y': 20}
    >>> f
    <__main__.Foo at 0x127ecdcf8>
    

    Of course if you want to override that last thing, you want to define __repr__ the same way as __str__.


    So, what if we want to use this on any class, without knowing in advance what its attributes will be? We could use the inspect module to find the attributes:

    def _str(self):
        typ = type(self).__name__
        vars = {name: value for name, value in inspect.getmembers(self) if not name.startswith('_')}
        return(f"Instance of {typ}, vars = {vars}")