Хватит дублировать валидацию в Django: как я подружил Pydantic с ORM и перестал страдать

от автора

Типизированный, унифицированный и async-first инструментарий для Django

Типизированный, унифицированный и async-first инструментарий для Django

Представь: ты пишешь научный сервис. Есть модель исследователя, у которой h_index не может быть отрицательным. Ты, как добросовестный разработчик, описываешь это правило в Pydantic-схеме красиво, строго, типизированно. А потом начинается ад: те же самые «не может быть отрицательным» ты вынужден повторять в DRF-сериализаторе, в Django-форме, а если ещё и админку кастомизируешь то и там. Три, пять, десять мест, где разбросана одна и та же бизнес-логика. Знакомо? У меня эта боль копилась годами, пока я не сказал «хватит» и не написал django-nova  фреймворк, который делает Pydantic единственным источником правды для всей экосистемы Django.

Давай разберёмся, как удалось объединить эти две вселенные без боли, циклических импортов и магии, которая ломается на каждом обновлении Python.

Контекст: почему это важно не только «ленивым»

Обычно дублирование валидации списывают на лень разработчика. Но в корпоративной и научной разработке ставки выше. Представь: данные проходят долгий конвейер вычислений, и где-то между этапами отрицательный h-index тихо просачивается в базу. Недели расчётов насмарку. Silent data corruption вот настоящий враг. Я видел, как это ломало исследовательские проекты: люди доверяли ORM, а правила жили только на уровне API или форм, и при прямом обращении к save() база принимала мусор.

Миссия django-nova проста: любое сохранение модели должно проходить сквозь фильтр Pydantic. Один раз описал больше нигде не повторяешь. И да, это работает даже если ты вызываешь article.save() прямо в shell.

Как это выглядит со стороны: от тройного дублирования к одной строке

В классическом Django + DRF ты вынужден писать так (посмотри, я подожду, пока ты пересчитаешь повторы):

# models.pyclass Researcher(models.Model):    h_index = models.IntegerField(default=0)# 1. Pydantic-схемаclass ResearcherSchema(BaseModel):    h_index: int    @field_validator("h_index")    @classmethod    def check_h_index(cls, v: int) -> int:        if v < 0:            raise ValueError("h_index не может быть отрицательным")        return v# 2. DRF Serializer — повторяемclass ResearcherSerializer(serializers.ModelSerializer):    h_index = serializers.IntegerField()    def validate_h_index(self, value):        if value < 0:            raise serializers.ValidationError("h-index не может быть отрицательным")        return value# 3. Форма — ещё разclass ResearcherForm(forms.ModelForm):    def clean_h_index(self):        if self.cleaned_data["h_index"] < 0:            raise forms.ValidationError("h-index не может быть отрицательным")

Три определения одного и того же правила. А теперь как это выглядит с django-nova:

from nova import NovaModel, NovaConfigfrom pydantic import BaseModel, field_validatorclass ArticleSchema(BaseModel):    title: str    views: int = 0    @field_validator("views")    @classmethod    def check_views(cls, v: int) -> int:        if v < 0:            raise ValueError("Просмотры не могут быть отрицательными")        return vclass Article(NovaModel):    title = models.CharField(max_length=200)    views = models.IntegerField(default=0)    _nova_config = NovaConfig(pydantic_schema=ArticleSchema, strict_validation=True)# И всё! DRF-сериализатор — одной строкой:from nova.ecosystem.drf import to_drf_serializerArticleSerializer = to_drf_serializer(Article)

Никаких ручных validate_*. Никаких дублей. Бизнес-правила живут только в Pydantic, а всё остальное генерируется автоматически.

Под капотом: как мы перехватили ORM и не сломали Django

Архитектура библиотеки строилась не как «ещё одна обёртка», а как хирургическое вмешательство в процесс сохранения. Центральный трюк — переопределённый метод save() у NovaModel:

def save(self, *args, **kwargs):    self._run_validation()  # <-- Вызывает Pydantic прямо здесь    super().save(*args, **kwargs)

Каждый раз, когда ты делаешь .save(), данные прогоняются через Pydantic-схему. Если валидация провалилась исключение выбрасывается до того, как запрос уйдёт в базу. Это работает как для DRF, так и для форм, потому что все они в конечном счёте дёргают тот же save().

Но чтобы это работало без сюрпризов, пришлось решить несколько фундаментальных проблем. Давай заглянем в самые интересные.

Битва с AppRegistryNotReady: как мы усмирили циклический импорт

Первая же интеграция в INSTALLED_APPS обернулась классической головной болью: Django требует, чтобы модели были импортированы до завершения инициализации, но наследование от models.Model внутри нашего пакета вызывало инициализацию, которая требовала эти самые модели. Замкнутый круг.

Решение пришло из PEP 562 ленивые импорты на уровне модуля. В init.py пакета nova мы не импортируем NovaModel напрямую, а объявляем функцию getattr:

def __getattr__(name: str):    if name == "NovaModel":        from nova.typing.models import NovaModel        return NovaModel    raise AttributeError(f"module 'nova' has no attribute {name}")

Теперь импорт NovaModel происходит только в тот момент, когда кто-то реально обращается к nova.NovaModel. Это позволяет библиотеке без проблем работать с manage.py на Python 3.14 alpha и Django 5.2. Никаких хаков с AppConfig.ready(), только чистая магия модулей.

Ловушка hasattr(): почему isinstance спас нам генерацию схемы

При автоматическом построении DRF-сериализатора нужно отличать поля-связи (ForeignKey) от обычных. Первая наивная реализация использовала hasattr(field, "related_model"). И тут нас ждал сюрприз: у CharField тоже есть атрибут related_model, только он равен Nonehasattr честно возвращал True, и мы считали каждое текстовое поле — связью. Генерация схемы ломалась с дикими ошибками.

Переписали на явную проверку типа:

from django.db.models.fields.related import RelatedFieldis_relation = isinstance(field, RelatedField)

Этот урок стоил мне пары седых волос, но теперь всё работает чётко. И да, это один из тех 15+ edge cases, которые мы отловили благодаря жёсткому TDD (о тестах расскажу ниже).

Кеш с O(1) инвалидацией: прощай, перебор хешей

Библиотека включает умный кеш для QuerySet’ов, чтобы не генерировать схемы и не дёргать Pydantic повторно для одинаковых запросов. Первая версия пыталась искать имя модели внутри SHA256-хеша SQL-запроса. Как ты понимаешь, из хеша нельзя достать оригинальную строку это математически невозможно. Кеш работал, пока не требовалось сбросить данные конкретной модели.

Мы выкинули этот подход и построили реверсивный индекс:

class QuerySetCache(Generic[ModelT]):    def __init__(self):        self._cache: TTLCache[str, list[ModelT]] = TTLCache(maxsize=1000, ttl=120)        self._model_keys: dict[str, set[str]] = {}  # O(1) индекс    def get_or_set(self, queryset: QuerySet[ModelT]) -> list[ModelT]:        key, model_name = self._generate_key(queryset)        cached = self._cache.get(key)        if cached is not None:            return cached        result = list(queryset)        self._cache[key] = result        self._model_keys.setdefault(model_name, set()).add(key)        return result    def invalidate_model(self, model_name: str) -> int:        keys_to_remove = self._model_keys.pop(model_name, set())        for key in keys_to_remove:            del self._cache[key]        return len(keys_to_remove)

Теперь при изменении модели ты вызываешь cache.invalidate_model("Article"), и она мгновенно находит все ключи, связанные с этой моделью, без перебора. Сложность O(1) вместо O(n) на проде это заметно.

Интеграция с DRF: прозрачная конвертация ошибок

С DRF я поступил хитро. to_drf_serializer в реальном времени создаёт класс ModelSerializer через type(), но добавляет в него собственный метод validate, который вызывает Pydantic после проверок типов DRF, но до сохранения. Если Pydantic ругается, мы парсим его ошибки и превращаем в DRF-совместимый формат:

def pydantic_validate(self, attrs: dict) -> dict:    try:        pydantic_schema.model_validate(attrs)    except PydanticValidationError as exc:        drf_errors = {}        for err in exc.errors():            loc = err.get("loc", ("non_field_errors",))            field_name = loc[0] if loc and loc[0] != "__root__" else "non_field_errors"            drf_errors.setdefault(field_name, []).append(err.get("msg"))        raise serializers.ValidationError(drf_errors)

Таким образом, фронтенд получает красивые ошибки вида {"h_index": ["h-index не может быть отрицательным"]}, а ты не написал ни строчки валидации для DRF.

FastAPI + OpenAPI: как я обманул строковые аннотации

С FastAPI задача ещё интереснее. Чтобы автоматически сгенерировать роутер с валидацией через Pydantic, нужно подсунуть фреймворку реальный класс схемы. Но PEP 563 (from future import annotations) превращает аннотации в строки, и FastAPI не может их разрезолвить. Я решил это через inspect.Signature: создаём поддельную функцию с правильными аннотациями-классами и привязываем её к endpoint’у:

create_item.__signature__ = inspect.Signature(    parameters=[        Parameter(            name="data",            kind=Parameter.POSITIONAL_OR_KEYWORD,            annotation=pydantic_schema,  # Реальный класс, не строка        )    ],    return_annotation=dict[str, Any])

FastAPI видит pydantic_schema и рисует идеальный Swagger с описанием всех полей, а ты получаешь автоматическую валидацию входных данных.

Админка: «глупый» триггер для умных ошибок

Чтобы показать в кастомном UI текст ошибки «h-index не может быть отрицательным», я не стал парсить внутренние структуры Pydantic. Вместо этого реализовали Dummy Trigger Pattern: скармливаем валидатору заведомо невалидное значение (например, -1), перехватываем исключение и возвращаем сообщение об ошибке как строку. Грубо? Да. Надёжно? Абсолютно. И никакой зависимости от внутренностей Pydantic.

Тесты и сухие цифры: почему этому можно доверять

Разработка шла строго по TDD. На момент релиза библиотека прошла 42 теста, покрывающих все критические сценарии. Время прогона — 1.00 секунда. Я гонял её на bleeding-edge стеке: Python 3.14 alpha, Django 5.2. За время разработки было поймано и исправлено более 15 edge cases: от циклических импортов и изменений в API Django 5.x до багов в парсинге ошибок Pydantic V2 и пропавших типов в Python 3.14.

Библиотека полностью типобезопасна: проходит pyright --strict благодаря использованию синтаксиса PEP 695 (class Cache[T]:). А неиспользуемые фичи (трассировка, structlog) подгружаются только при первом обращении — нулевой оверхед.

Как начать прямо сейчас

Установка проста:

pip install django-nova
pip install django-nova[drf,fastapi]

Дальше в models.py наследуешься от NovaModel и указываешь novaconfig. Сериализатор для DRF генерируется одной строкой:

from nova.ecosystem.drf import to_drf_serializerArticleSerializer = to_drf_serializer(Article)

FastAPI-роутер добавляется в приложение так же элементарно:

from nova.ecosystem.fastapi import to_fastapi_routerapp.include_router(to_fastapi_router(Article, prefix="/api/articles"))

И Swagger уже знает все поля.

Репозиторий открыт: Artem7898/django-nova,

PyPI-пакет django-nova,

DOI: 10.5281/zenodo. Приходите с issue и pull-реквестами.

Если статья была полезной подписывайся, дальше буду разбирать ещё более смелые архитектурные решения. Лайк и комментарий помогают двигаться дальше и показывают, что такие глубокие технические разборы нужны. Пиши в комментариях, обсудим реальные кейсы. 

 

ссылка на оригинал статьи https://habr.com/ru/articles/1046880/