pythonpython-3.xdjangoprotocolspep

Using Protocols in django models raises metaclass conflict error


Let's say I have PEP-544 protocol called Summable :

class Summable(Protocol):
    @property
    total_amount()-> Decimal:
      ...

And I have model Item that implements the Protocol

class Item(Summable, models.Model):
    discount = models.DecimalField(
        decimal_places=2,
        validators=[MaxValueValidator(1)],
        default=Decimal('0.00'),
        max_digits=10
    )
    price = models.DecimalField(
        decimal_places=4,
        validators=[MinValueValidator(0)],
        max_digits=10
    )

    @property
    def total_amount(self) - > Decimal:
       return self.price - self.price * self.discount

    class Meta:
        ordering = ['id']

I get:

TypeError: metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its bases

The same happens even if I extend Item's Meta from Summable.Meta and models.Model.Meta as well.

I am using python 3.9 Any ideas?


Solution

  • Well, there are many gotchas out there:

    1. You need to create a new metaclass:

    For Example:

    class ModelProtocolMeta(type(Model), type(Protocol)):
         pass
    
    1. You need to place the protocol last in order so that protocol doesn't overwrite the model's constructor with no_init. Protocol's no_init constructor is as follows:
    def _no_init(self, *args, **kwargs):
        if type(self)._is_protocol:
            raise TypeError('Protocols cannot be instantiated')
    

    so it would just overwite constuctor silently without any error since inherited class will have _is_protocol set to False

    (note that super is not called, so we are talking about a complete overwrite)

    so at the end of the day we need the following:

    class Item(models.Model, Summable, metaclass=ModelProtocolMeta):
        discount = models.DecimalField(
            decimal_places=2,
            validators=[MaxValueValidator(1)],
            default=Decimal('0.00'),
            max_digits=10
        )
        price = models.DecimalField(
            decimal_places=4,
            validators=[MinValueValidator(0)],
            max_digits=10
        )
    
        @property
        def total_amount(self) -> Decimal:
           return sel.price - self.price * self.discount