pythonclass

How to tell Python to re-evaluate a parent class method's interface?


Over the years, I can't count how many times I wanted to do something like this in Python:

class A:
    DEFAULT_MESSAGE = "Unspecified Country"

    def do_it(message=DEFAULT_MESSAGE):
        print(message)

class B(A):
    DEFAULT_MESSAGE = "Mexico"

And have this result in:

> A().do_it()
Unspecified Country
> B().do_it()
Mexico

Without having to redefine the method:

class B(A):
    DEFAULT_MESSAGE = "Mexico"
    
    def do_it(message=DEFAULT_MESSAGE):
        super().do_it(message)

Or do an arguably bad design pattern of setting the default message to None for the method, and instead reading the DEFAULT_MESSAGE in the implementation of the method.

Is there a better, more DRY design-pattern that helps with this? Or is there some way to tell Python to re-evaluate the interfaces on parent class methods when defining a new, sub-class?


Solution

  • Here is a sketch of an approach using __init_subclass__, note, this is highly dependent on your particular implementation, to get it to work more genreally, you would have to introspect the function signature, and maybe mark the functions you wish this to apply to somehow. But the idea is:

    def _copy_func(f):
        """
        There may be a better way to do this...
        """
        import types
        import functools
        f_copy = types.FunctionType(f.__code__, f.__globals__, f.__name__, f.__defaults__)
        f_copy = functools.update_wrapper(f_copy, f)
        f_copy.__kwdefaults__ = f.__kwdefaults__ # for keyword-only default arguments
        return f_copy
    
    class A:
        DEFAULT_MESSAGE = 'Unspecified Country'
    
        def do_it(self, message=DEFAULT_MESSAGE):
            print(message)
    
        def __init_subclass__(cls, **kwargs):
            """
            copy `do_it` and replace the default argument with the attribute
            from the newly created class being initialized
            """
            super().__init_subclass__(**kwargs)
            # obviously, this relies on you knowing the name of the func ahead of time
            do_it = _copy_func(cls.do_it)
            # this relies on you knowing ahead of time the signature of the func
            do_it.__defaults__ = (cls.DEFAULT_MESSAGE,)
            cls.do_it = do_it
    
    class B(A):
        DEFAULT_MESSAGE = "Mexico"
    
    A().do_it()
    B().do_it()
    

    This would all be very "magical" so I would probably not use this in production. But if this is really what you want, the above approach offers one solution.