Tuesday 2 July 2024

Python Modules and Inheritance

We can say that Python is a "very object oriented language" because not only functions are objects, but also modules are objects. Each module is an instance of the module class. That's one of those builtin types that can be referenced through the types module, using types.ModuleType in this case.


import types

# get a reference to the current module
m1 = sys.modules[__name__]

print(f"module type: {type(m1)}, name: {__name__}")
#module type: class 'module', name: module1


print(type(m1) is types.ModuleType) #True
print(m1.__class__ is types.ModuleType) #True


With modules being objects one could think about having in them features that we have in "normal objects", like intercepting attribute access, being callable... These features are based on dunder methods that are defined not in the object itself, but in its class (__getattr__ for the "missing attribute functionality", __setattr__ and __getattribute__ for intercepting attribute setting or getting, __call__ for callables), but modules are just instances of types.ModuleType not of custom classes where we can define those dunder methods, so it seems like there's not much we can do...

Well, indeed there is. First, PEP-562 was implemented some years ago to provide the __getattr__ and __dir__ functionality to modules. You can define these methods directly in your module and it will work. So it seems like the standard behaviour of these two dunder methods has been modified so that for module objects they work just being in the object itself rather than in its class.

There was a proposal, PEP-726) to add that same support for __setattr__ and __delattr__, but it was rejected. But hopefully, reading the PEP itself we find a simple alternative for it, that leverages the beautiful python dynamism. If we want a module with "super-powers", we can just define in our module a class that inherits from module and provides those dunder methods we need, and make our module inherit from it just by setting the __class__ attribute. Cool! We can use this technique not only for dunder methods, but also for adding getters-setters (descriptors) to our module. Let's see an example of a callable module with also a get descriptor:


# module2.py
import sys
import types

def format_msg(msg: str) -> str:
    return f"[[{msg.upper()}]]"

# define a new Module type (inheriting from the standard Module)
class MyModule(types.ModuleType):
    # make our module callable
    def __call__(self, msg):
        return self.format_msg(msg)
    
    # define a property
    @property
    def formatted_name(self):
        return f"-||{self.__name__}||-"

# set the class of the current module to the new MyModule Module type    
sys.modules[__name__].__class__ = MyModule


###########################

# main.py
import module2

print(module2.format_msg("aaa"))

# our module is callable:
print(module2("aaa"))

# property (getter)
print(module2.formatted_name)


It's important to notice something that is mentioned in the PEP regarding performance and this technique:

But this variant is slower (~2x) than the proposed solution. More importantly, it also brings a noticeable speed regression (~2-3x) for attribute access.

No comments:

Post a Comment