Tutorial#

Installation#

Just use pip to install the library

pip install adaptix==3.0.0b5

Integrations with 3-rd party libraries are turned on automatically, but you can install adaptix with extras to check that versions are compatible.

There are two variants of extras. The first one checks that the version is the same or newer than the last supported, the second (strict) additionally checks that the version same or older than the last tested version.

Extras

Versions bound

attrs

attrs >= 21.3.0

attrs-strict

attrs >= 21.3.0, <= 23.2.0

sqlalchemy

sqlalchemy >= 2.0.0

sqlalchemy-strict

sqlalchemy >= 2.0.0, <= 2.0.29

pydantic

pydantic >= 2.0.0

pydantic-strict

pydantic >= 2.0.0, <= 2.7.0

Extras are specified inside square brackets, separating by comma.

So, this is valid installation variants:

pip install adaptix[attrs-strict]==3.0.0b5
pip install adaptix[attrs, sqlalchemy-strict]==3.0.0b5

Introduction#

Building an easily maintainable application requires you to split the code into layers. Data between layers should be passed using special data structures. It requires creating many converter functions transforming one model into another.

Adaptix helps you avoid writing boilerplate code by generating conversion functions for you.

from dataclasses import dataclass

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

from adaptix.conversion import get_converter


class Base(DeclarativeBase):
    pass


class Book(Base):
    __tablename__ = "books"

    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str]
    price: Mapped[int]


@dataclass
class BookDTO:
    id: int
    title: str
    price: int


convert_book_to_dto = get_converter(Book, BookDTO)

assert (
    convert_book_to_dto(Book(id=183, title="Fahrenheit 451", price=100))
    ==
    BookDTO(id=183, title="Fahrenheit 451", price=100)
)

The actual signature of convert_book_to_dto is automatically derived by any type checker and any IDE.

Adaptix can transform between any of the supported models, see Supported model kinds for exact list of models and known limitations.

How it works? Adaptix scans each field of the destination model and matches it with the field of the source model. By default, only fields with the same name are matched. You can override this behavior.

Also, it works for nested models.

from dataclasses import dataclass

from adaptix.conversion import get_converter


@dataclass
class Person:
    name: str


@dataclass
class Book:
    title: str
    price: int
    author: Person


@dataclass
class PersonDTO:
    name: str


@dataclass
class BookDTO:
    title: str
    price: int
    author: PersonDTO


convert_book_to_dto = get_converter(Book, BookDTO)

assert (
    convert_book_to_dto(
        Book(title="Fahrenheit 451", price=100, author=Person("Ray Bradbury")),
    )
    ==
    BookDTO(title="Fahrenheit 451", price=100, author=PersonDTO("Ray Bradbury"))
)

Furthermore, there is conversion.convert that can directly convert one model to another, but it is quite limited and can not configured, so it won’t be considered onwards.

Usage of conversion.convert
from dataclasses import dataclass

from adaptix.conversion import convert


@dataclass
class Person:
    name: str


@dataclass
class Book:
    title: str
    price: int
    author: Person


@dataclass
class PersonDTO:
    name: str


@dataclass
class BookDTO:
    title: str
    price: int
    author: PersonDTO


assert (
    convert(
        Book(title="Fahrenheit 451", price=100, author=Person("Ray Bradbury")),
        BookDTO,
    )
    ==
    BookDTO(title="Fahrenheit 451", price=100, author=PersonDTO("Ray Bradbury"))
)

Upcasting#

All source model additional fields not found in the destination model are simply ignored.

from dataclasses import dataclass
from datetime import date

from adaptix.conversion import get_converter


@dataclass
class Book:
    title: str
    price: int
    author: str
    release_date: date
    page_count: int
    isbn: str


@dataclass
class BookDTO:
    title: str
    price: int
    author: str


convert_book_to_dto = get_converter(Book, BookDTO)

assert (
    convert_book_to_dto(
        Book(
            title="Fahrenheit 451",
            price=100,
            author="Ray Bradbury",
            release_date=date(1953, 10, 19),
            page_count=158,
            isbn="978-0-7432-4722-1",
        ),
    )
    ==
    BookDTO(
        title="Fahrenheit 451",
        price=100,
        author="Ray Bradbury",
    )
)

Downcasting#

Sometimes you need to add extra data to the source model. For this, you can use a special decorator.

# mypy: disable-error-code="empty-body"
from dataclasses import dataclass

from adaptix.conversion import impl_converter


@dataclass
class Book:
    title: str
    price: int
    author: str


@dataclass
class BookDTO:
    title: str
    price: int
    author: str
    page_count: int


@impl_converter
def convert_book_to_dto(book: Book, page_count: int) -> BookDTO:
    ...


assert (
    convert_book_to_dto(
        book=Book(
            title="Fahrenheit 451",
            price=100,
            author="Ray Bradbury",
        ),
        page_count=158,
    )
    ==
    BookDTO(
        title="Fahrenheit 451",
        price=100,
        author="Ray Bradbury",
        page_count=158,
    )
)

conversion.impl_converter takes an empty function and generates its body by signature.

# mypy: disable-error-code="empty-body" on the top of the file is needed because mypy forbids functions without body. Also, you can set this option at mypy config or supress each error individually via # type: ignore[empty-body].

Fields linking#

If the names of the fields are different, then you have to link them manually.

from dataclasses import dataclass

from adaptix import P
from adaptix.conversion import get_converter, link


@dataclass
class Book:
    name: str
    price: int
    author: str  # same as BookDTO.writer


@dataclass
class BookDTO:
    name: str
    price: int
    writer: str  # same as Book.author


convert_book_to_dto = get_converter(
    src=Book,
    dst=BookDTO,
    recipe=[link(P[Book].author, P[BookDTO].writer)],
)

assert (
    convert_book_to_dto(Book(name="Fahrenheit 451", price=100, author="Ray Bradbury"))
    ==
    BookDTO(name="Fahrenheit 451", price=100, writer="Ray Bradbury")
)

The first parameter of conversion.link is the predicate describing the field of the source model, the second parameter is the pointing to the field of the destination model.

This notation means that the field author of class Book will be linked with the field writer of class BookDTO.

You can use simple strings instead of P construct, but it will match any field with the same name despite of owner class.

By default, additional parameters can replace fields only on the top-level model. If you want to pass this data to a nested model, you should use conversion.from_param predicate factory.

# mypy: disable-error-code="empty-body"
from dataclasses import dataclass

from adaptix import P
from adaptix.conversion import from_param, impl_converter, link


@dataclass
class Person:
    name: str


@dataclass
class Book:
    title: str
    author: Person


@dataclass
class PersonDTO:
    name: str
    rating: float


@dataclass
class BookDTO:
    title: str
    author: PersonDTO


@impl_converter(recipe=[link(from_param("author_rating"), P[PersonDTO].rating)])
def convert_book_to_dto(book: Book, author_rating: float) -> BookDTO:
    ...


assert (
    convert_book_to_dto(
        Book(title="Fahrenheit 451", author=Person("Ray Bradbury")),
        4.8,
    )
    ==
    BookDTO(title="Fahrenheit 451", author=PersonDTO("Ray Bradbury", 4.8))
)

If the field name differs from the parameter name, you also can use conversion.from_param to link them.

Linking algorithm#

The building of the converter is based on a need to construct the destination model.

For each field of the destination model, adaptix searches a corresponding field. Additional parameters are checked (from right to left) before the fields. So, your custom linking looks among the additional parameters too.

By default, fields are matched by exact name equivalence, parameters are matched only for top-level destination model fields.

After fields are matched adaptix tries to create a coercer that transforms data from the source field to the destination type.

Type coercion#

By default, there are no implicit coercions between scalar types.

However, there are cases where type casting involves passing the data as is and adaptix detects its:

  • source type and destination type are the same

  • destination type is Any

  • source type is a subclass of destination type (excluding generics)

  • source union is a subset of destination union (simple == check is using)

Also, some compound types can be coerced if corresponding inner types are coercible:

  • source and destination types are models (conversion like top-level models)

  • source and destination types are Optional

  • source and destination types are one of the builtin iterable

  • source and destination types are dict

You can define your own coercion rule.

from dataclasses import dataclass
from uuid import UUID

from adaptix.conversion import coercer, get_converter


@dataclass
class Book:
    id: UUID
    title: str
    author: str


@dataclass
class BookDTO:
    id: str
    title: str
    author: str


convert_book_to_dto = get_converter(
    src=Book,
    dst=BookDTO,
    recipe=[coercer(UUID, str, func=str)],
)

assert (
    convert_book_to_dto(
        Book(
            id=UUID("87000388-94e6-49a4-b51b-320e38577bd9"),
            title="Fahrenheit 451",
            author="Ray Bradbury",
        ),
    )
    ==
    BookDTO(
        id="87000388-94e6-49a4-b51b-320e38577bd9",
        title="Fahrenheit 451",
        author="Ray Bradbury",
    )
)

The first parameter of conversion.coercer is the predicate describing the field of the source model, the second parameter is the pointing to the field of the destination model, the third parameter is the function that casts source data to the destination type.

Usually, only field types are used as predicates here.

Also you can set coercer for specific linking via conversion.link.coercer parameter.

from dataclasses import dataclass
from decimal import Decimal

from adaptix import P
from adaptix.conversion import get_converter, link


@dataclass
class Book:
    name: str
    price: int  # same as BookDTO.cost
    author: str


@dataclass
class BookDTO:
    name: str
    cost: Decimal  # same as Book.price
    author: str


convert_book_to_dto = get_converter(
    src=Book,
    dst=BookDTO,
    recipe=[link(P[Book].price, P[BookDTO].cost, coercer=lambda x: Decimal(x) / 100)],
)

assert (
    convert_book_to_dto(Book(name="Fahrenheit 451", price=100, author="Ray Bradbury"))
    ==
    BookDTO(name="Fahrenheit 451", cost=Decimal("1"), author="Ray Bradbury")
)

This coercer will have higher priority than defined via conversion.coercer function.

Putting together#

Let’s explore complex example collecting all features together.

# mypy: disable-error-code="empty-body"
from dataclasses import dataclass
from datetime import date
from uuid import UUID

from adaptix import P
from adaptix.conversion import coercer, from_param, impl_converter, link


@dataclass
class Author:
    name: str
    surname: str
    birthday: date  # is converted to str


@dataclass
class Book:
    id: UUID  # is converted to str
    title: str
    author: Author  # is renamed to `writer`
    isbn: str  # this field is ignored


@dataclass
class AuthorDTO:
    name: str
    surname: str
    birthday: str


@dataclass
class BookDTO:
    id: str
    title: str
    writer: AuthorDTO
    page_count: int  # is taken from `pages_len` param
    rating: float  # is taken from param with the same name


@impl_converter(
    recipe=[
        link(from_param("pages_len"), P[BookDTO].page_count),
        link(P[Book].author, P[BookDTO].writer),
        coercer(UUID, str, func=str),
        coercer(P[Author].birthday, P[AuthorDTO].birthday, date.isoformat),
    ],
)
def convert_book_to_dto(book: Book, pages_len: int, rating: float) -> BookDTO:
    ...


assert (
    convert_book_to_dto(
        book=Book(
            id=UUID("87000388-94e6-49a4-b51b-320e38577bd9"),
            isbn="978-0-7432-4722-1",
            title="Fahrenheit 451",
            author=Author(name="Ray", surname="Bradbury", birthday=date(1920, 7, 22)),
        ),
        pages_len=158,
        rating=4.8,
    )
    ==
    BookDTO(
        id="87000388-94e6-49a4-b51b-320e38577bd9",
        title="Fahrenheit 451",
        writer=AuthorDTO(name="Ray", surname="Bradbury", birthday="1920-07-22"),
        page_count=158,
        rating=4.8,
    )
)