Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Inheriting __init_subclass__-parameters

Tags:

python-3.x

Let's say I have a class that requires some arguments via __init_subclass__:

class AbstractCar:
    def __init__(self):
        self.engine = self.engine_class()
        
    def __init_subclass__(cls, *, engine_class, **kwargs):
        super().__init_subclass__(**kwargs)
        cls.engine_class = engine_class
        
class I4Engine:
    pass
class V6Engine:
    pass
    
class Compact(AbstractCar, engine_class=I4Engine):
    pass
class SUV(AbstractCar, engine_class=V6Engine):
    pass

Now I want to derive another class from one of those derived classes:

class RedCompact(Compact):
    pass

The above does not work, because it expects me to re-provide the engine_class parameter. Now, I understand perfectly, why that happens. It is because the Compact inherits __init_subclass__ from AbstractCar, which is then called when RedCompact inherits from Compact and is subsequently missing the expected argument.

I find this behavior rather non-intuitive. After all, Compact specifies all the required arguments for AbstractCar and should be usable as a fully realized class. Am I completely wrong to expect this behavior? Is there some other mechanism that allows me to achieve this kind of behavior?

I already have two solutions but I find both lacking. The first one adds a new __init_subclass__ to Compact:

class Compact(AbstractCar, engine_class=I4Engine):
    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(engine_class=I4Engine, **kwargs)

This works but it shifts responsibility for the correct working of the AbstractCar class from the writer of that class to the user. Also, it violates DRY as the engine specification is now in two places that must be kept in sync.

My second solution overrides __init_subclass__ in derived classes:

class AbstractCar:
    def __init__(self):
        self.engine = self.engine_class()
        
    def __init_subclass__(cls, * , engine_class, **kwargs):
        super().__init_subclass__(**kwargs)
        cls.engine_class=engine_class
        @classmethod
        def evil_black_magic(cls, **kwargs):
            AbstractCar.__init_subclass__(engine_class=engine_class, **kwargs) 
        if '__init_subclass__' not in cls.__dict__:        
            cls.__init_subclass__ = evil_black_magic

While this works fine for now, it is purest black magic and bound to cause trouble down the road. I feel like this cannot be the solution to my problem.

like image 493
SeppJ Avatar asked Jul 02 '26 19:07

SeppJ


1 Answers

Indeed—the way this works in Python is counter-intuitive—I agree with you on your reasoning.

The way to go to fix it is to have some logic in the metaclass. Which is a pity, since avoiding the need for metaclasses is exactly what __init_subclass__ was created for.

Even with metaclasses it would not be an easy thing—one would have to annotate the parameters given to __init_subclass__ somewhere in the class hierarchy, and then insert those back when creating new subclasses.

On second thought, that can work from within __init_subclass__ itself. That is: when __init_subclass__ "perceives" it did not receive a parameter that should have been mandatory, it checks for it in the classes in the mro (mro "method resolution order"—a sequence with all base classes, in order). In this specific case, it can just check for the attribute itself—if it is already defined for at least one class in the mro, just leave it as is, otherwise raises.

If the code in __init_subclass__ should do something more complex than simply annotating the parameter as passed, then, besides that, the parameter should be stored in an attribute in the new class, so that the same check can be performed downstream.

In short, for your code:

class AbstractCar:
    def __init__(self):
        self.engine = self.engine_class()

    def __init_subclass__(cls, *, engine_class=None, **kwargs):
        super().__init_subclass__(**kwargs)
        if engine_class:
            cls.engine_class = engine_class
            return
        for base in cls.__mro__[1:]:
            if getattr(base, "engine_class", False):
                 return
        raise TypeError("parameter 'engine_class' must be supplied as a class named argument")

I think this is a nice solution. It could be made more general with a decorator meant specifically for __init_subclass__ that could store the parameters in a named class attribute and perform this check automatically.

(I wrote the code for such a decorator, but having all the corner cases for named and unamed parameters, even using the inspect model can make things ugly)

like image 121
jsbueno Avatar answered Jul 04 '26 08:07

jsbueno



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!