Mypy: правильная типизация класса-миксина Django при обращении к методу super()
У Django есть причуда, когда он не проверяет модели по умолчанию перед записью в базу данных. Неидеальная ситуация, которую разработчики пытаются обойти, создавая, например, класс Mixin: https://www.xormedia.com/django-model-validation-on-save/
Идея этого обходного пути заключается в том, что вы можете наследовать этот миксин для своих пользовательских моделей Django, когда захотите добавить в них валидацию при сохранении.
Этот подход работает, но я пытаюсь обновить эти старые примеры до правильно типизированного примера. Ниже приведен мой текущий код:
from typing import Any, TypeVar
from django.db import models
DjangoModel = TypeVar('DjangoModel', bound=models.Model)
class ValidateModelMixin:
"""Use this mixing to make model.save() call model.full_clean()
Django's model.save() doesn't call full_clean() by default. More info:
* "Why doesn't django's model.save() call full clean?"
http://stackoverflow.com/questions/4441539/
* "Model docs imply that ModelForm will call Model.full_clean(),
but it won't."
https://code.djangoproject.com/ticket/13100
"""
def save(
self: DjangoModel,
force_insert:bool=False,
force_update:bool=False,
*args: bool | str | None,
**kwargs: bool | str | None,
) -> None:
"""Override the save method to call full_clean before saving the model.
Takes into account the force_insert and force_update flags, as they
are passed to the save method when trying to skip the validation.
Also passes on any positional and keyword arguments that were passed
at the original call-site of the method.
"""
# Only validate the model if the force-flags are not enabled
if not (force_insert or force_update):
self.full_clean()
# Then save the model, passing in the original arguments
super().save(force_insert, force_update, *args, **kwargs)
Mypy выдает следующую ошибку для приведенного выше кода:
Ошибка: Неподдерживаемый аргумент 2 для "super" [misc]
Я думаю, что это Mypy не нравятся аргументы, которые я передаю в super().save()
. Но эти аргументы, похоже, совпадают с аргументами models.Model.save
от Django:
https://docs.djangoproject.com/en/5.0/ref/models/instances/#django.db.models.Model.save
Я полагаю, что, возможно, я не задаю правильный тип для аргумента self, но я не уверен, как мне следует набирать этот код вместо него.
В конце концов этот другой вопрос помог мне. А именно:
mypy рекомендует реализовывать миксины через протокол
Итак, я реализовал протокол для определения типа для DjangoModel, и это позволило пройти проверку типа. Полный код, который я в итоге использовал:
from typing import Iterable, Protocol, Self
class DjangoModel(Protocol):
def save(
self: Self,
force_insert: bool = False,
force_update: bool = False,
using: str | None = "default",
update_fields: Iterable[str] | None = None,
) -> None: ...
def full_clean(
self,
exclude: Iterable[str] | None = None,
validate_unique: bool = True,
validate_constraints: bool = True,
) -> None: ...
class ValidateModelMixin:
"""Make model.save() call model.full_clean()
Django's model.save() doesn't call full_clean() by default. More info:
* "Why doesn't django's model.save() call full clean?"
http://stackoverflow.com/questions/4441539/
* "Model docs imply that ModelForm will call Model.full_clean(),
but it won't."
https://code.djangoproject.com/ticket/13100
"""
def save(
self: DjangoModel,
force_insert: bool = False,
force_update: bool = False,
using: str | None = "default", # DEFAULT_DB_ALIAS
update_fields: Iterable[str] | None = None,
) -> None:
"""Override the save method to call full_clean before saving the model.
Takes into account the force_insert and force_update flags, as they
are passed to the save method when trying to skip the validation.
Also passes on any positional and keyword arguments that were passed
at the original call-site of the method.
"""
# Only validate the model if the force-flags are not enabled
if not (force_insert or force_update):
self.full_clean()
# Type ignore below, because even though mypy is correct that calling
# save on super() here directly is not safe, we don't do that
# for this usecase. Instead it's called on the instance inheriting from
# both the Mixin and Django's models.Model. Ideally we'd inherit Django's
# models.Model directly, instead of using a mixin, so we can guarantee
# super.save exists. But Django's tabel queries rely on the first
# class inheriting models.Model, which would then be the mixin.
# In other words: direct inheritance would break Django's queries.
super().save(force_insert, force_update, using, update_fields) # type: ignore[safe-super]
Edit: К сожалению, в последней версии Mypy мне все равно пришлось использовать type-ignore[safe-super]
. Как и в случае с этим решением, вы могли бы вызвать ValidateModelMixin.save
напрямую, что привело бы к сбою во время выполнения, поскольку сам миксин не наследуется от модели Django models.Model (на которую мы ссылаемся в super().save).
Я не смог найти способ исправить это, потому что:
- Вы не можете позволить классу mixin наследоваться от
models.Model
, потому что запросы к таблицам в Django полагаются на наследование первого классаmodels.Model
, поэтому запросы будут пытаться запросить<app_name>_validatemixinmodel
. Поэтому прямое наследование, похоже, не является вариантом. - Если сделать миксин классом AbstractBaseClass (чтобы избежать создания экземпляров и вызова ValidateModelMixin.save() на этих экземплярах), то это приведет к следующей ошибке в Django:
TypeError: metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its bases
Вывод: приведенный выше код обеспечивает большую безопасность типов, чем отсутствие типов вообще, но вы должны помнить, что не следует вызывать .save
на ValidateModelMixin
или его экземплярах напрямую, потому что с type-ignore mypy не предупредит вас об этом. Для меня это нормально, так как делать это все равно не имеет смысла, но если кто-то предложит лучший подход, то я могу изменить принятый ответ на этот вопрос.