Monday, 28 April 2025

Passing parameters to Metaclass (method_added 2)

In my previous post I showed how to simulate Ruby's method_added hook in Python. The most interesting part is making the hook work with methods added to the class ater the class is defined (expandos/monkey-patching). If your remember I use a metaclass defining __setattr__, create a class inheriting from our main class and with that metaclass as metaclass and set in our class the function to execute whan a new method gets added (wrapper_creator).



class WrapMeta(type):
    def __setattr__(cls, name, value):
        if inspect.isfunction(value):
            value = cls._wrapper_creator(value)
        return super().__setattr__(name, value)

def wrap_methods_dynamic(wrapper_creator: Callable):
    def _wrap_methods(cls):
        # wrap method that exist when the class is provided
        members = inspect.getmembers(cls, predicate=inspect.isfunction)
        for name, fn in members:
            setattr(cls, name, wrapper_creator(fn))
        # and use a metaclass for methods that will be added in the future
        # create a new class extending the main one and using as metaclass WrapMeta
        class A(cls, metaclass=WrapMeta):
            pass
        type.__setattr__(A, "_wrapper_creator", wrapper_creator)      
        A.__name__ = cls.__name__ 
        return A
    return _wrap_methods 


There's a "dark" metaclass feature (well, everything related to metaclasses can feel a bit gloomy sometimes) that can make the above code more elegant (and maybe more complex to grasp also). Metaclasses accept parameters. Those parameters can be passed to the metaclass when a class using that metaclass is defined (as keyword arguments that we place in the class declaration after the metaclass keyword argument), and the metaclass can make use of them in its __init__ method. We can leverage this to pass to our metaclass the wrapper_creator function and move into the metaclass the code that sets that function as a "private" attribute of the class. So the code looks like this:



class WrapMeta(type):
    # when a class of this metaclass is created, we provide the additional "wrapper_creator" argument
    # as explained in https://stackoverflow.com/questions/13762231/how-to-pass-arguments-to-the-metaclass-from-the-class-definition
    # we have to declare this custom __new__  to prevent a "__init_subclass__() takes no keyword arguments" Error
    def __new__(metacls, name, bases, namespace, **kwargs):
        return super().__new__(metacls, name, bases, namespace)
     
    def __init__(cls, name, bases, namespace, wrapper_creator):
        super().__init__(name, bases, namespace)
        # this one fails cause it invokes __setattr__ that tries to access the wrapper_creator that we are trying to set
        #cls.wrapper_creator = wrapper_creator
        super().__setattr__("_wrapper_creator", wrapper_creator)
		#the above is just equivalent to: 
        # type.__setattr__(cls, "_wrapper_creator", wrapper_creator)

    def __setattr__(cls, name, value):
        if inspect.isfunction(value):
            value = cls._wrapper_creator(value)
        return super().__setattr__(name, value)

def wrap_methods_dynamic(wrapper_creator: Callable):
    def _wrap_methods(cls):
        # wrap method that exist when the class is provided
        members = inspect.getmembers(cls, predicate=inspect.isfunction)
        for name, fn in members:
            setattr(cls, name, wrapper_creator(fn))
        # and use a metaclass for methods that will be added in the future
        # create a new class extending the main one and using as metaclass  WrapMeta
        # furthermore, we pass an argument to the class  
        class A(cls, metaclass=WrapMeta, wrapper_creator=wrapper_creator):
            pass
        A.__name__ = cls.__name__
        return A
    return _wrap_methods 


@wrap_methods_dynamic(log_wrapper_creator)
class User:
    def __init__(self, name):
        self.name = name

    def say_hi(self, sm: str):
        return f"{self.name} says hi to {sm}"

    def walk(self):
        return f"{self.name} is walking"


u1 = User("Iyan")
print(u1.say_hi("Francois"))
print(u1.walk())
User.do_work = lambda self, a, b: f"{self.name} is working on {a} and {b}"
print(u1.do_work("aaa", "bbb"))

# __init__ started
# __init__ finished
# say_hi started
# say_hi finished
# Iyan says hi to Francois
# walk started
# walk finished
# Iyan is walking
#  started
#  finished
# Iyan is working on aaa and bbb


These custom parameters that I define in __new__ just as **kwargs (I'm not using them there other than for discarding them in the call to the parent __new__) and in __init__ as wrapper_creator, correspond to the **kwargs in the second signature for type.__call__ that we saw in this previous post.



#when constructing a class (an instance of a metaclass)
#type.__call__ would be:
def __call__(metacls, name, bases, namespace, **kwargs):
    cls = metacls.__new__(metacls, name, bases, namespace, **kwargs)
    if isinstance(cls, metacls):
        metacls.__init__(cls, name, bases, namespace, **kwargs)
    return cls

Notice that the 3 statements for creating the Auxiliar class that inherits from cls and has WrapMeta as metaclass, setting its name and returning it:


        class A(cls, metaclass=WrapMeta, wrapper_creator=wrapper_creator):
            pass
        A.__name__ = cls.__name__
        return A

Can be condensed into a single one:


	return WrapMeta(cls.__name__, (cls,), {}, wrapper_creator=wrapper_creator)

No comments:

Post a Comment