Share
Embed
Transcript
147 slides
-
Adaptix Расскажу про него Сравню с pydantic Расскажу про опыт
в проде Какие ещё возможности есть? -
Adaptix github.com/reagento/adaptix Гибкая и настраиваемая библиотека для преобразования моделей данных
с декларативным описанием правил -
Работает с любой моделью на ваш выбор dataclasses, attrs, msgspec,
Pydantic, TypedDict, NamedTuple, sqlalchemy, т.д , -
Не влияет на определение моделей За лоадинг и дампинг отвечает
объект — Retort -
Для каждого формата вы создаете новую реторту Вы можете расширять
и комбинировать их, чтобы избежать дублирования кода -
Таким образом 1. Доменные модели ничего не знают о механизме
сериализации 2. А реторты знают, как преобразовывать доменные модели -
Таким образом Такое разделение позволяет сохранить чистоту и простоту кода.
Кроме того, вы можете создать один экземпляр реторты для работы с десятками и сотни классов, использующих схожие принципы лоадинга и дампинга -
Опишем данные data = { "title": "ЧИСТАЯ АРХИТЕКТУРА", "price": "432,12",
"author": { "name": " Дядюшка Боб " }, } -
Опишем модели @dataclass class Person: name: str @dataclass class Book:
title: str price: Decimal author: Person -
Опишем правила через 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, )]) -
Слоадим данные 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": "Дядюшка боб"}, } -
Опишем данные data = { “Id”: 16102026, “Name”: “Lubaskinc0de”, “User_Age”:
16, “City”: “NN” } -
Опишем модельку @dataclass class User: id: int name: str age:
int extra: dict[str, Any] -
Настроим Реторту from adaptix import Retort, NameStyle, name_mapping retort =
Retort( recipe=[ name_mapping( User, map={"age": "User_Age"}, name_style=NameStyle.PASCAL, extra_in="extra", ), ], ) -
Лоадим данные assert retort.load(data, User) == User( id=16102026, name="Lubaskinc0de", age=16,
extra={"City": "NN"}, ) -
Опишем модельки @dataclass class Book: title: str price: int created_at:
datetime @dataclass class BookResponse: title: str price: int created_at: float -
Опишем конвертер from adaptix.conversion import get_converter, coercer converter = get_converter(
Book, BookResponse, recipe=[ coercer(datetime, float, datetime.timestamp), ], ) -
Конвертируем assert converter( Book( title="Rust book", price=0, created_at=created_at, ) )
== BookResponse( title="Rust book", price=0, created_at=created_at.timestamp(), ) -
Опишем 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" -
Создадим 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), ) -
Использование 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") -
Использование session.add(AuditLogRecord(data=UserCreated(id=1, name="rasput"))) session.commit() record = session.scalar( select(AuditLogRecord) .where(AuditLogRecord.data["id"].as_integer() ==
1), ) assert record.data == UserCreated(id=1, name="rasput") -
Создавать HTTP клиенты С помощью: 1. descanso — github.com/reagento/descanso 2.
unihttp — github.com/goduni/unihttp -
Опишем модельку @dataclass class Todo: id: int user_id: int title:
str completed: bool -
Создадим RestBuilder from descanso import RestBuilder rest = RestBuilder( request_body_dumper=Retort(),
response_body_loader=Retort(), query_param_dumper=Retort(), ) -
Создадим наш клиент 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: ... -
Юзаем его 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) -
Опишем наш конфиг @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_'" )] -
Лоадим конфиг 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 запрещен!". ), )) -
Получам ошибку +-+---------------- 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] Токен -
Модельки @dataclass class Review: id: int title: str rating: float
content: str # алиас к text @dataclass class Book: id: int name: str reviews: list[Review] # Включает 100 объектов -
TypedDict class User(TypedDict): id: int name: str data = {"id":
1, "name": "lynyx"} user = adaptix.load(data, User) assert user == {"id": 1, "name": "lynyx"} -
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") -
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") -
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") -
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") -
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") -
Создадим наш класс class User: def __init__( self, id: int,
name: str, ) -> None: self.id = id self.name = name -
Настроим и лоадим 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") -
Где используется? 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 -
Есть моделька 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) -
В ней нарушается Нарушен SRP. Модель перегружена ответственностью. Она знает:
1. Как она валидируется 2. Как сериализуется в JSON 3. Как генерируется json-схему -
Лишний парсинг и валидация данных Обычно в доменном слое вам
достаточно статических анализаторов типов для выявления большинства ошибок, а тесты покрывают их полностью -
Рассмотрим на примере 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] -
Замерим создание моделей 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})) -
Замерим 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})) -
Задумайтесь, а нужен ли вам pydantic, там где его может
заменить dataclass? -
Не явные преобразования 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 -
Запутанные алиасы 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 == {} -
Некорректный маппинг классов @dataclass class Person: name: str age: float
class PersonDTO(BaseModel): name: str age: float -
Некорректный маппинг классов person = Person(name="Anna", age=20) person_dto = PersonDTO.model_validate(person,
from_attributes=True) assert person_dto == PersonDTO(name="Anna", age=20) -
Во первых В режиме from_attributes=True используются те же алиасы, что
и при парсинге Нельзя настроить маппинг, не затрагивая логику внешних взаимодействий (например, парсинг JSON). -
Во вторых При маппинге не учитываются подсказки типов из исходного
класса, что приводит к ненужным проверкам типов Например, если в обоих классах есть поля со значениями типа list[str] с сотнями элементов, pydantic проверит тип каждого значения -
В третьих Вы не можете настроить маппинг классов таким образом,
чтобы конвертация отличалась от парсинга Вам придется либо искать обходные решения, либо менять взаимодействие с внешним миром -
В четвертых Нет проверок, которые бы гарантировали корректность маппинга Многие
такие ошибки выявляются только при тщательном тестировании. -
API { “created_at”: “2026-10-10, “changed_at”: “2026-10-10 12:34, “updated_at”: “2026-10-10 12:34:12,
“deleted_at”: “2026-10-10 12:34:12.0000 } -
Моделька class UserStatus(Enum): TROLLER = "TROLLER" @dataclass class User: id:
int name: str status: UserStatus -
Пробуем слоадить data = { "id": 1984, "name": "Pavlik", "status":
" TROLLER } ", user = adaptix.load(data, User) -
Проверяем решение data = { "id": 1984, "name": "Pavlik", "status":
" TROLLER } ", user = retort.load(data, User) assert user == User(1984, "Pavlik", UserStatus.TROLLER) -
Появляется другая моделька class OrderStatus(Enum): PAYMENT = "PAYMENT" @dataclass class
Order: id: int name: str status: OrderStatus -
Пытаемся слоадить её data = { "id": 1234, "name": "Claude
Code (PRO MAX)", "status": " PAYMENT ", } user = retort.load(data, Order) -
Получаем ошибку Т.к наш лоадер работает только для UserStatus, а
не для OrderStatus -
Решение возникшей проблемы retort = Retort( recipe=[ loader( P[UserStatus, OrderStatus],
str.strip, chain=Chain.FIRST, ), ], ) -
Или более универсальное решение from adaptix._internal.provider.loc_stack_filtering import OriginSubclassLSC retort =
Retort(recipe=[ loader( OriginSubclassLSC(Enum), str.strip, chain=Chain.FIRST, ), ]) -
На 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 -
Решение 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, )]) -
Проверка data = { "id": 1234, "name": "Ð\x92анÑ\x8f", "full_name": "Брбрбрблоков"
} user = retort.load(data, User) assert user == User(1234, "Ваня", "Брбрбрблоков") -
На 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 -
Решение 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", -
"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, ) ] ) -
API { “created_at”: “2026-10-10, “changed_at”: “2026-10-10 12:34, “updated_at”: “2026-10-10 12:34:12,
“deleted_at”: “2026-10-10 12:34:12.0000 } -
Создадим лоадер 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) -
Сделаем лоадер 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) -
Применим к моделькам @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) ) -
Создадим лоадер 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) -
Применим к моделькам 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"] ) -
Применим к моделькам 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"] ) -
И 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) -
И 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"] ) -
Чо мы по итогу получили? За пару десятков строк мы
решили все проблемы апишки сразу для 130 моделей Вышло декларативно, аккуратно, и поддерживаемо -
Как я ранее и говорил 1. HTTP клиенты: descanso, unihttp
2. Либы для загрузки конфигов — dature 3. Аналог faker в пару десятков строк -
Как это выглядит @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))