Upgrade to Pro — share decks privately, control downloads, hide ads and more …

adaptix_slides.pdf

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest. →
Avatar for Sobolev Nikita Sobolev Nikita
October 21, 2026
4

 adaptix_slides.pdf

Avatar for Sobolev Nikita

Sobolev Nikita

October 21, 2026

More Decks by Sobolev Nikita

Transcript

  1. Adaptix Расскажу про него Сравню с pydantic Расскажу про опыт

    в проде Какие ещё возможности есть?
  2. Для каждого формата вы создаете новую реторту Вы можете расширять

    и комбинировать их, чтобы избежать дублирования кода
  3. Таким образом 1. Доменные модели ничего не знают о механизме

    сериализации 2. А реторты знают, как преобразовывать доменные модели
  4. Таким образом Такое разделение позволяет сохранить чистоту и простоту кода.

    Кроме того, вы можете создать один экземпляр реторты для работы с десятками и сотни классов, использующих схожие принципы лоадинга и дампинга
  5. Опишем правила через adaptix from adaptix import Retort, P, Chain,

    loader retort = Retort(recipe=[ loader(P[Book].title, str.lower), loader(P[Person].name, str.capitalize), loader( P[Book].price, lambda price: price.replace(",", "."), chain=Chain.FIRST, )])
  6. Слоадим данные book = retort.load(data, Book) assert book == Book(

    title="Чистая архитектура", price=Decimal("432.12"), author=Person("Дядюшка боб"), ) assert retort.dump(book) == { "title": "Чистая архитектура", "price": "432.12", "author": {"name": "Дядюшка боб"}, }
  7. Настроим Реторту from adaptix import Retort, NameStyle, name_mapping retort =

    Retort( recipe=[ name_mapping( User, map={"age": "User_Age"}, name_style=NameStyle.PASCAL, extra_in="extra", ), ], )
  8. Опишем модельки @dataclass class Book: title: str price: int created_at:

    datetime @dataclass class BookResponse: title: str price: int created_at: float
  9. Опишем конвертер from adaptix.conversion import get_converter, coercer converter = get_converter(

    Book, BookResponse, recipe=[ coercer(datetime, float, datetime.timestamp), ], )
  10. Конвертируем assert converter( Book( title="Rust book", price=0, created_at=created_at, ) )

    == BookResponse( title="Rust book", price=0, created_at=created_at.timestamp(), )
  11. Опишем JSON моделями @dataclass class UserCreated: id: int name: str

    tag: Literal["user_created"] = "user_created" @dataclass class UserChanged: id: int name: str tag: Literal["user_changed"] = "user_changed"
  12. Создадим ORM модель from adaptix.integrations.sqlalchemy import AdaptixJSON AnyAuditLogRecordData: TypeAlias =

    UserCreated | UserChanged class AuditLogRecord(Base): __tablename__ = "audit_logs" id: Mapped[int] = mapped_column(primary_key=True) data: Mapped[AnyAuditLogRecordData] = mapped_column( AdaptixJSON(Retort(), AnyAuditLogRecordData), )
  13. Использование audit_log_record = session.get(AuditLogRecord, 1) assert audit_log_record.data == UserCreated(id=1, name="K1rL3s")

    audit_log_record = session.get(AuditLogRecord, 2) assert audit_log_record.data == UserChanged(id=1, name="Zen")
  14. Создадим наш клиент from descanso.http.requests import RequestsClient class RealClient(RequestsClient): @rest.get("todos/{id}")

    def get_todo(self, id: int) -> Todo: ... @rest.delete("todos/{id}") def delete_todo(self, id: int) -> None: ... @rest.post("todos") def create_todo(self, body: Todo) -> Todo: ...
  15. Юзаем его from requests import Session client = RealClient( base_url="https://example.com/api",

    session=Session() ) todo = client.create_todo(Todo(...)) assert client.get_todo(todo.id) == todo client.delete_todo(todo.id)
  16. Опишем наш конфиг @dataclass class AppConfig: env: Literal["dev", "stage", "prod"]

    port: Annotated[int, (V >= 0) & (V <= 65535)] api_token: Annotated[str, V.check( lambda v: v.startswith("bot_"), error_message="Токен должен начинаться с 'bot_'" )]
  17. Лоадим конфиг def check_ssl_in_production(config: AppConfig) -> bool: return not config.env

    == "prod" and config.port == 80 config = dature.load( dature.Yaml12Source(file="config.yaml"), schema=AppConfig, root_validators=( V.root( check_ssl_in_production, error_message="В режиме 'prod' порт 80 запрещен!". ), ))
  18. Получам ошибку +-+---------------- 1 ---------------| dature.errors.exceptions.FieldLoadError: 'prod' порт 80 запрещен!

    └── FILE 'config.yaml' +---------------- 2 ---------------| dature.errors.exceptions.FieldLoadError: должен начинаться с 'bot_' ├── api_token: "<REDACTED>" │ ^^^^^^^^^^ └── FILE 'config.yaml', line 3 +------------------------------------ [<root>] В режиме [api_token] Токен
  19. Модельки @dataclass class Review: id: int title: str rating: float

    content: str # алиас к text @dataclass class Book: id: int name: str reviews: list[Review] # Включает 100 объектов
  20. TypedDict class User(TypedDict): id: int name: str data = {"id":

    1, "name": "lynyx"} user = adaptix.load(data, User) assert user == {"id": 1, "name": "lynyx"}
  21. NamedTuple class User(NamedTuple): id: int name: str data = {"id":

    1, "name": "tuum tuum sahur"} user = adaptix.load(data, User) assert user == User(1, "tuum tuum sahur")
  22. attrs class User(NamedTuple): id: int name: str data = {"id":

    1, "name": "abc"} user = adaptix.load(data, User) assert user == User(id=1, name="abc")
  23. sqlalchemy class User(Base): __tablename__ = "user" id: Mapped[int] name: Mapped[str]

    data = {"id": 1, "name": "Nikita Pastuhov"} user = adaptix.load(data, User) assert user == User(id=1, name="Nikita Pastuhov")
  24. pydantic class User(BaseModel): id: int name: str data = {"id":

    1, "name": "Борис zmqtt"} user = adaptix.load(data, User) assert user == User(id=1, name="Борис zmqtt")
  25. msgspec class User(Struct): id: int name: str data = {"id":

    1, "name": "Nikita Sobolev"} user = adaptix.load(data, User) assert user == User(id=1, name="Nikita Sobolev")
  26. Создадим наш класс class User: def __init__( self, id: int,

    name: str, ) -> None: self.id = id self.name = name
  27. Настроим и лоадим from adaptix import Retort, constructor retort =

    Retort( recipe=[ constructor(User, User) ] ) data = {"id": 1, "name": "quantum"} user = retort.load(data, User) assert user == User(1, "quantum")
  28. Где используется? 1. dishka — github.com/reagento/dishka 2. annet — github.com/annetutil/annet

    3. descanso — github.com/reagento/descanso 4. unihttp — github.com/goduni/unihttp 5. dature — github.com/reagento/dature 6. maxo — github.com/K1rL3s/maxo 7. DeepSWE — github.com/datacurve-ai/deep-swe
  29. Есть моделька class User(BaseModel): id: int = Field(alias="Id") name: str

    = Field(alias="FullName", description="...") age: int = Field(alias="Age", json_schema_extra={...}) @field_validator("age") @classmethod def age_validate(cls, age: str) -> int: return int(age)
  30. В ней нарушается Нарушен SRP. Модель перегружена ответственностью. Она знает:

    1. Как она валидируется 2. Как сериализуется в JSON 3. Как генерируется json-схему
  31. Лишний парсинг и валидация данных Обычно в доменном слое вам

    достаточно статических анализаторов типов для выявления большинства ошибок, а тесты покрывают их полностью
  32. Рассмотрим на примере class UserPydantic(BaseModel): id: int name: str =

    "Ёееееееж" signup_ts: datetime | None tastes: dict[str, PositiveInt] @dataclass(kw_only=True) class UserDataclass: id: int name: str = "Ёееееееж" signup_ts: datetime | None tastes: dict[str, PositiveInt]
  33. Замерим создание моделей dt = datetime(year=2019, month=6, day=1, hour=12, minute=22)

    stmt = """User( id=123, signup_ts=dt, tastes={'wine': 9, 'cheese': 7, 'cabbage': 1}, )""" print("pydantic ", timeit(stmt, globals={"User": UserPydantic, "dt": dt})) print("dataclass", timeit(stmt, globals={"User": UserDataclass, "dt": dt}))
  34. Замерим model_construct dt = datetime(year=2019, month=6, day=1, hour=12, minute=22) stmt

    = """User( id=123, signup_ts=dt, tastes={'wine': 9, 'cheese': 7, 'cabbage': '1'}, )""" print("pydantic (model_construct)", timeit(stmt, globals={"User": UserPydantic.model_construct, "dt": dt}))
  35. Не явные преобразования class Product(BaseModel): id: int amount: Decimal p1

    = Product(id=1, amount=14.6) p2 = Product(id=1, amount=Decimal("14.6")) assert p1 == p1
  36. Запутанные алиасы class User(BaseModel): model_config = ConfigDict( populate_by_name=True, extra="allow", )

    name: str = Field(alias="full_name") age: int data = {"name": "name_value", "age": 20} assert User.model_validate(data).model_extra == {}
  37. Во первых В режиме from_attributes=True используются те же алиасы, что

    и при парсинге Нельзя настроить маппинг, не затрагивая логику внешних взаимодействий (например, парсинг JSON).
  38. Во вторых При маппинге не учитываются подсказки типов из исходного

    класса, что приводит к ненужным проверкам типов Например, если в обоих классах есть поля со значениями типа list[str] с сотнями элементов, pydantic проверит тип каждого значения
  39. В третьих Вы не можете настроить маппинг классов таким образом,

    чтобы конвертация отличалась от парсинга Вам придется либо искать обходные решения, либо менять взаимодействие с внешним миром
  40. В четвертых Нет проверок, которые бы гарантировали корректность маппинга Многие

    такие ошибки выявляются только при тщательном тестировании.
  41. Проверяем решение data = { "id": 1984, "name": "Pavlik", "status":

    " TROLLER } ", user = retort.load(data, User) assert user == User(1984, "Pavlik", UserStatus.TROLLER)
  42. Пытаемся слоадить её data = { "id": 1234, "name": "Claude

    Code (PRO MAX)", "status": " PAYMENT ", } user = retort.load(data, Order)
  43. На pydantic class _EnumNormalizer(BaseModel): @model_validator(mode="before") @classmethod def strip_enums(cls, data: Any)

    -> Any: if isinstance(data, dict): for field_name, field_info in cls.model_fields.items(): if (field_name in data and isinstance(data[field_name], str) and issubclass(field_info.annotation, Enum)): data[field_name] = data[field_name].strip() return data class Order(_EnumNormalizer, BaseModel): status: OrderStatus
  44. Решение def normalize_encode(data: Any) -> Any: if not isinstance(data, str):

    return data for encode_from in ["latin-1", "cp1251"]: try: return data.encode(encode_from).decode("utf-8") except (UnicodeEncodeError, UnicodeDecodeError): pass raise LoadError retort = Retort(recipe=[ loader( P[str, datetime, date, time, OriginSubclassLSC(Enum)], normalize_encode, chain=Chain.FIRST, )])
  45. Проверка data = { "id": 1234, "name": "Ð\x92анÑ\x8f", "full_name": "Брбрбрблоков"

    } user = retort.load(data, User) assert user == User(1234, "Ваня", "Брбрбрблоков")
  46. На pydantic +- так class _EncodeNormalizer(BaseModel): @model_validator(mode="before") @classmethod def normalize_encode(cls,

    data: Any) -> Any: if isinstance(data, dict): for field_name, field_info in cls.model_fields.items(): if (field_name in data and isinstance(data[field_name], str) and issubclass(field_info.annotation, (str, datetime.datetime, datetime.date, datetime.time, Enum))): data[field_name] = normalize_encode(data[field_name]) return data class Order(_EncodeNormalizer, BaseModel): status: datetime.datetime
  47. Решение retort = Retort(recipe=[ name_mapping( GetClientVisitsItemResult, name_style=NameStyle.LOWER_SNAKE, ), name_mapping( map={

    "request_id": "Request_id", "client_id": "ClientID", "sort_order": "sortOrder", "document_id": "DocumentID", "ticket_id": "TicketID",
  48. "pre_entry": "preEntry", "service_id": "ServiceID", "appointment_id": "AppointmentID", "club_id": "ClubID", "freeze_id": "FreezeID",

    "item_id": "ItemID", "purchase_id": "PurchaseID", "employee_id": "EmployeeID", "transaction_id": "IDTransaction", "trainer_id": "TrainerID", "room_id": "RoomID", }, name_style=NameStyle.PASCAL, ) ] )
  49. Создадим лоадер def time_loader(pred: Any): def loader_func(data: Any) -> Any:

    if data is None: return None if isinstance(data, str): return time.fromisoformat(data) raise LoadError return loader(pred, loader_func)
  50. Сделаем лоадер def date_loader(format: str, pred: Any): def loader_func(data: Any)

    -> date: if data is None: return None if isinstance(data, str): return datetime.strptime(data, format).date() raise LoadError return loader(pred, loader_func)
  51. Применим к моделькам @dataclass class GetAvailableDatesResult: booking_dates: list[datetime] date_loader( "%Y-%m-%d",

    P[GetTicketsItemResult]["end_date"] | P[GetClientByIdResult, GetClientByPhoneResult]["birthday"] | P[GetAvailableDatesResult]["booking_dates"].generic_arg(0, datetime) )
  52. Создадим лоадер def datetime_loader(format: str, pred: Any): def loader_func(data: Any)

    -> Any: if data is None: return None if isinstance(data, str): return datetime.strptime(data, format) raise LoadError return loader(pred, loader_func)
  53. Применим к моделькам datetime_loader( "%Y-%m-%d %H:%M:%S.%f", P[GetClientVisitsItemResult]["oper_date_from", "oper_date_to"] ) datetime_loader(

    "%Y-%m-%d %H:%M:%S", P[GetAccountPassResult]["valid_thru"] | P[CheckinTime]["start_date_time", "end_date_time"] | P[GetGuestVisitHistoryItemResult]["start_date", "end_date"] )
  54. Применим к моделькам datetime_loader( "%Y-%m-%d %H:%M", P[AppointmentToRate]["start_date"] | P[GetDepositDetailItemResult, GetDebtDetailResult]["date"]

    | P[SetAppointmentResult, GetAvailableTimesItemResult] ["date_time"] | P[ GetScheduleResult, GetVisitHistoryResult, GetVisitHistoryAppointment, GetClientAppointmentsItemResult, ]["start_date", "end_date"] )
  55. И datetime дампер def datetime_dumper(format: str, pred: Any) -> Provider:

    def dumper_func(data: Any) -> Any: if isinstance(data, (date, datetime)): return data.strftime(format) return data return dumper(pred, dumper_func)
  56. И datetime дампер datetime_dumper( "%Y-%m-%d", P[UpdateClient, CreateClient, CreateClientRaw]["birthday"] | P[GetAvailableServices]["start_date_time",

    "end_date_time"] | P[GetAvailableDates, GetAvailableTrainers]["start_date", "end_date"] | P[Defrost, Freezing, GetAvailableTimes, GetAvailableTimesCrm]["date"] ) datetime_dumper( "%Y-%m-%d %H:%M:%S", P[GetVisitHistory, GetClientAppointments, GetGuestVisitHistory, GetDepositDetailItemResult]["timestamp"] ) datetime_dumper( "%Y-%m-%d %H:%M", P[SetAppointment]["date_time"] | P[GetSchedule]["start_date", "end_date"] )
  57. Чо мы по итогу получили? За пару десятков строк мы

    решили все проблемы апишки сразу для 130 моделей Вышло декларативно, аккуратно, и поддерживаемо
  58. Как я ранее и говорил 1. HTTP клиенты: descanso, unihttp

    2. Либы для загрузки конфигов — dature 3. Аналог faker в пару десятков строк
  59. Как это выглядит @dataclass class City: index: int @dataclass class

    User: id: int name: str city: City retort = FakerRetort() for i in range(10): print(retort.fake(User, seed=10))