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 |
---|---|
|
|
|
|
|
|
|
|
|
|
|
|
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,
)
)