Небольшое введение к тому, как мы собственно до всего этого дошли.

Современная экосистема Python переживает большую трансформацию в подходах к обработке, валидации и (де)сериализации данных. Еще совсем недавно (десять лет назад) в питоне не было аннотаций типов, все использовали ручные проверки типов, да и в принципе мало кто заморачивался с контрактами для данных.

С появлением аннотаций типов в 3.5 версии, все потихоньку начало меняться – аннотации типов начали указывать везде, и сейчас, мало кто представляет жизнь без них. Их появление, собственно, открыло возможность декларативного программирования.

И самая очевидная тема для создания инструментов: создать удобное решение для валидации, (де)сериализации и обработки данных.

Pydantic

Несколько лет назад, начал набирать огромную популярность FastAPI (фреймворк для создания API), и в нем все завязано на pydantic. Декларативное описание схем оказалось настолько удобным, что все начали использовать его повсеместно – от конфигов до ORM-схем, и прочих сущностей в которых содержатся какие-либо данные.

Такой подход в условиях тренда сообщества на чистую архитектуру обнажил одну из главных проблем – нарушение SRP.

Это привело к созданию толстых моделей, которые:

  1. начали сильно перегружать ответственностью и теперь модель знает: как конкретно она валидируется, как сериализуется в JSON, как генерирует json-схему (содержит описание самой себя для внешнего мира)

  2. стали антипаттерном для производительности: каждое создание экземпляра модели проходит весь цикл валидации и требует от 240% до 430% больше времени по сравнению с обычным dataclasses, и за счет хранения дополнительных данных (из пункта 1) требуют большего количества оперативной памяти

  3. могут приводить к проблемам связанным с максимальной лояльностью pydantic к входным данным за счет неявного преобразования, например, если вы передаете float в поле с типом Decimal, что может привести к неточностям для денежных вычислений.

Adaptix

Эта библиотека появилась сравнительно давно (первый коммит был в сентябре 2018 года), и ранее она называлась dataclass-factory.

Adaptix предлагает принципиально иной подход к работе с моделями, разделяя сами модели (модель остается обычным чистым python объектом) и работу с загрузкой данных и их (де)сериализации. Вся логика преобразования, валидации и маппинга инкапсулируется в отдельном объекте – Retort (реторта), которую обычно создают один раз при бутстрапе приложения.

Такой подход решает проблему SRP: остается чистая модель данных и реторта, в которой содержатся рецепты – список провайдеров, которые указывают, как работать с теми или иными случаями.

В библиотеке есть мощная система предикатов, которая позволяет делать рецепты не только к конкретным моделям / типам данных, но и к определенным паттернам. Например, вам в приложении всегда необходимо сериализовывать created_at в timestamp число, рецепт будет выглядеть следующим образом:

from adaptix import Retort, dumper, P

retort = Retort(
	recipe=[
		dumper(P.created_at, lambda x: int(x.timestamp()))
	]
)

Вопрос с производительностью у Adaptix решается иначе: при первом обращении к реторте с определенным типом данных на выгрузку / загрузку, происходит генерация оптимизированной функции, которая в дальнейшем выполняет обработку данных с этим типом данных.

Сравнение Pydantic и Adaptix

Архитектура

Pydantic

Удобно, легко и очень быстро описывать модель – сразу видны все правила работы с данными и хорошо применяется при необходимости работы с данными из внешних систем.

Однако, в больших и долгоживущих приложениях тяжело поддерживать модели за счет загрязнения и необходимости учитывать все существующие детали работы с моделью (алиасы, валидаторы, (де)сериализаторы для внешних и внутренних систем).

Adaptix

Ваша модель данных остается чистым датаклассом, а правила работы с данными выносятся в реторты, которые можно разделять на слои приложения – для инфраструктуры использовать чистую загрузку модели без валидации, для презентационного слоя использовать валидаторы, подстраивать правила (де)сериализации данных в зависимости от требований.

Минус, на мой взгляд, довольно незначительный – требует большего количества кода для старта работы с моделью.

Производительность

Pydantic

В случае с парсингом тяжелых JSON, выигрывает, за счет осуществления парсинга внутри ядра на Rust.

Независимо от источника данных (бд / внешняя система) требует при каждом создании инстанса модели проходить весь цикл валидации, что сильно замедляет создание инстанса и дальнейшую работу с ним.

Adaptix

Работает с объектами со скоростью нативного питона, создает обычные инстансы моделей (классы, датаклассы, в зависимости от того, что вы используете).

В случае с парсингом JSON, то минусом является необходимость самостоятельно осуществлять парсинг за счет json / ujson / orjson / прочих парсеров.

Возьмем здесь простой бенчмарк из документации adaptix:

from dataclasses import dataclass  
from datetime import datetime  
from timeit import timeit  
from typing import Optional  
  
from pydantic import BaseModel, PositiveInt  
  
  
class UserPydantic(BaseModel):  
    id: int  
    name: str = "John Doe"  
    signup_ts: Optional[datetime]  
    tastes: dict[str, PositiveInt]  
  
  
@dataclass(kw_only=True)  
class UserDataclass:  
    id: int  
    name: str = "John Doe"  
    signup_ts: Optional[datetime]  
    tastes: dict[str, PositiveInt]  
  
  
stmt = """  
User(  
    id=123,    
    signup_ts=datetime(year=2019, month=6, day=1, hour=12, minute=22),    
    tastes={'wine': 9, 'cheese': 7, 'cabbage': '1'},
)  
"""  


print("dataclasses:", timeit(stmt, globals={"User": UserDataclass, "datetime": datetime}))
print("Pydantic (standard):", timeit(stmt, globals={"User": UserPydantic, "datetime": datetime}))  
print("Pydantic (model_construct):", timeit(stmt, globals={"User": UserPydantic.model_construct, "datetime": datetime}))

Мы получаем следующие результаты:

  1. pydantic (standard) – медленнее обычного dataclasses в 2.15 раз

  2. pydantic (model_construct) – медленнее обычного dataclasses в 3.14 раз

model_construct участвует в этом бенчмарке, так как это действие должно ускорять создание инстанса модели в обход валидации.

Гибкость валидации

Pydantic

Валидация описывается внутри самого класса через декоратор @validator и чтобы изменить правило для того же поля, но в другом контексте – необходимо делать класс-наследник и менять уже там.

Adaptix

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

Экосистема и интеграция

Pydantic

FastAPI – поддерживает нативно, IDE (PyCharm / VSCode) – раньше требовались плагины для работы с pydantic моделями, сейчас движутся в сторону нативной работы с pydantic, но моментами бывает, что это работает весьма криво.

Adaptix

Если использовать его в FastAPI, то требует некоторой настройки с участием Dependency Injection, которая заключается в необходимости преобразовывания получаемого JSON в нужную модель данных через реторту. Это можно сделать написав общий лоадер, куда можно передавать тип модели, либо делать отдельные функции и указывать их в качестве зависимостей в контроллерах.

Для обработок ошибок валидации потребуется написать свой exception_handler, который будет перехватывать ошибки adaptix и выдавать их в структурированном ответе.

IDE – полная поддержка за счет того, что библиотека полностью типизирована и работает с обычными датаклассами.

Выводы

Необязательно выбирать что-то одно. Можно использовать Pydantic для работы в API контроллерах, где нужна генерация OpenAPI схемы и быстрый парсинг JSON. Adaptix стоит использовать с чистыми датаклассами внутри самого приложения, чтобы гонять данные между слоев без задержек на все проверки.

Дополнительные материалы:

  1. https://adaptix.readthedocs.io/en/latest/why-not-pydantic.html – почему не Pydantic, от автора самой библиотеки adaptix.