
Представь: ты пишешь научный сервис. Есть модель исследователя, у которой 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.py class 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, NovaConfig from pydantic import BaseModel, field_validator class ArticleSchema(BaseModel): title: str views: int = 0 @field_validator("views") @classmethod def check_views(cls, v: int) -> int: if v < 0: raise ValueError("Просмотры не могут быть отрицательными") return v class 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_serializer ArticleSerializer = 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, только он равен None. hasattr честно возвращал True, и мы считали каждое текстовое поле — связью. Генерация схемы ломалась с дикими ошибками.
Переписали на явную проверку типа:
from django.db.models.fields.related import RelatedField is_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_serializer ArticleSerializer = to_drf_serializer(Article)
FastAPI-роутер добавляется в приложение так же элементарно:
from nova.ecosystem.fastapi import to_fastapi_router app.include_router(to_fastapi_router(Article, prefix="/api/articles"))
И Swagger уже знает все поля.
Репозиторий открыт: Artem7898/django-nova,
PyPI-пакет django-nova,
DOI: 10.5281/zenodo. Приходите с issue и pull-реквестами.
Если статья была полезной подписывайся, дальше буду разбирать ещё более смелые архитектурные решения. Лайк и комментарий помогают двигаться дальше и показывают, что такие глубокие технические разборы нужны. Пиши в комментариях, обсудим реальные кейсы.
