Meta-programming: Decorating Classes and Retaining Information
In this post, we will be looking into how to dynamically alter class instances, this is somewhat of an alternative — a very good one to be honest — to metaclasses in python. And finally, we will also look into a very important portion of decorators: name-spacing and retaining information about the decorated object.
Decorating Class Instances
Decorating classes became a considerable alternatives for altering classes on the fly in python3 as opposed to using metaclasses — which is still a valid way. Frameworks like flask-restx make use of this pattern.
from flask import Flask
from flask_restx import Resource, Api
app = Flask(__name__)
api = Api(app)
@api.route('/hello') #decorating a class to change it's instance
class HelloWorld(Resource):
def get(self):
return {'hello': 'world'}
if __name__ == '__main__':
app.run(debug=True)
So How Do We Write Decorators for Class Instances?
There are two ways of accomplishing this as well: decorator functions and decorator classes.
With Decorator Classes
import inspectclass DecoratorClass:
def __init__(self, cls):
self.other_class = cls def __call__(self, *cls_ars):
other = self.other_class(*cls_ars) # get all class attributes
for cls_item in inspect.getmembers(other): #cls_item is a tuple of the attribute name and object
name, meth = cls_item #code to alter attribute accessed above print('this class gotdecorated')
return other
@DecoratorClass
class MainClass:
def __init__(self, name):
pass MainClass('maestroinc') #'this class got decorated' will be printed
Let’s look at this code in detail and try to understand what exactly is going on. For the most part so far it looks like typical decorator classes approach.
- The __init__ method takes a class object and assigns it to the self.other_class attribute of DecoratorClass.
- In the __call__ method, it instantiates the class object it took in earlier by calling self.other_class with the needed arguments for class instantiation obtained as cls_args(a parameter in the __call__ method).
- Using the inspect.getmembers with the class instance as an argument, you can obtain all the attributes of the class passed and proceed to alter those as you see fit. Of course there are other ways to go about this step, but this is probably the most convenient way, readability and simplicity is part of the creed of python after all.
- Proceed to use the @ syntax like we have seen in the previous posts of the series — decorator functions and decorator classes.
With Decorator Functions
def class_decorator(original_class): # To prevent recursive calls
orig_init = original_class.__init__ def __init__(self, *args, **kws):
nonlocal orig_init origin_class = orig_init(self, *args, **kws)
for cls_item in inspect.getmembers(original_class):
name, meth = cls_item #code to alter attributes of class instance
print('this class is decorated') original_class.__init__ = __init__
return original_class@class_decorator
class MainClass:
def __init__(self, name):
passMainClass('maestroinc') #'this class got decorated' will be printed
This also works similar to a typical decorator function. So what is going on in this code?
- The original class gets passed into the function and then its __init__ method gets stored in a variable in the first layer of the decorator
- What makes a decorator function a decorator function, a second layer function. Now the object the class’ __init__ method got stored in— in our example orig_init — gets called. This particular step is extremely important, and cannot be skipped, calling the class’ __init__ method directly will result in a stackoverflow error — recursion error. This is because of name-spacing.
original_class.__init__(*args, **kws) #recursion error
3. Using the inspect.getmembers with the class instance as an argument, you can obtain all the attributes of the class passed and proceed to alter those arguments as required.
4. Proceed to use the @ syntax like we have seen in the previous posts of the series — decorator functions and decorator classes.
Name-Spacing using Functools
When you use a decorator, you’re replacing one function with another, or one class with another. If using a decorator always meant losing this information about a function, it would be a serious problem. That’s why we have functools.wraps
.
This takes a function used in a decorator and adds the functionality of copying over the function name, docstring, arguments list, etc. You can even write your decorator to perform the task that functools.wraps does and extend it to perform annotations or the likes that is up to your discretion.
In the case of decorator classes using functools.wraps — which itself is a decorator — will not work . You have to make use of functools.update_wrapper()
. So update_wrapper(self, func)
is what gets the job done rather than functools.wraps
if you are working with decorator classes.
Note: This applies to decorators on all level; both for decorating functions and for decorating classes.
Our Updated Simple Decorator Function Code
import functools# A typical example of a decorator functiondef outer(func): @functools.wraps(func)
def inner(*args, **kwargs):
print("Calling", fn.__name__)
new_value = func(*args, **kwargs)
return new_value + '2'
return inner
Our Updated Simple Decorator Class Code
import functoolsclass Decorator:
def __init__(self, fn):
self.fn = functools.update_wrapper(self, fn) def __call__(self, *args, **kwargs):
new_value = self.fn(*args, **kwargs)
return new_value + '2'
Be sure to always fix the name space problem that comes with the decorator pattern — whether for functions or for class — by making using of the great inbuilt library that is functools.
Conclusion
Now we have come to the end of decorators, in the next post of the series we will be looking into metclasses and how it helps us achieve metaprogramming in python. See you there.