Extended usage

This section continues the tutorial to illuminate some more complex topics.

Dealing with if TYPE_CHECKING

Sometimes you want to split interdependent models into several files. This results in some imports being visible only to type checkers. Analysis of such type hints is not available at runtime.

Let’s imagine that we have two files:

File chat.py
from dataclasses import dataclass
from typing import TYPE_CHECKING, List

if TYPE_CHECKING:
    from .message import Message


@dataclass
class Chat:
    id: int
    name: str
    messages: List["Message"]
File message.py
from dataclasses import dataclass
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from .chat import Chat


@dataclass
class Message:
    id: int
    text: str
    chat: "Chat"

If you try to get type hints at runtime, you will fail:

from typing import get_type_hints

from .chat import Chat
from .message import Message

try:
    get_type_hints(Chat)
except NameError as e:
    assert str(e) == "name 'Message' is not defined"


try:
    get_type_hints(Message)
except NameError as e:
    assert str(e) == "name 'Chat' is not defined"

At runtime, these imports are not executed, so the builtin analysis function can not resolve forward refs.

adaptix can overcome this via type_tools.exec_type_checking. It extracts code fragments defined under if TYPE_CHECKING and if typing.TYPE_CHECKING constructs and then executes them in the context of module. As a result, the module namespace is filled with missing names, and any introspection function can acquire types.

You should call exec_type_checking after all required modules can be imported. Usually, it must be at main module.

File main.py
from typing import List, get_type_hints

from adaptix.type_tools import exec_type_checking

from . import chat, message

# You pass the module object
exec_type_checking(chat)
exec_type_checking(message)

# After these types can be extracted
assert get_type_hints(chat.Chat) == {
    "id": int,
    "name": str,
    "messages": List[message.Message],
}
assert get_type_hints(chat.Message) == {
    "id": int,
    "text": str,
    "chat": chat.Chat,
}

Using default value for fields

By default, all fields of the destination model must be linked to something even if field is not required (has a default value).

Hint

Such policy prevents bugs in converters. If you forget to link two same fields with different names, an error will occur.

You can control this policy via conversion.allow_unlinked_optional and conversion.forbid_unlinked_optional.

from dataclasses import dataclass

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


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


@dataclass
class BookDTO:
    title: str
    price: int
    author: str
    collection_id: int | None = None


convert_book_to_dto = get_converter(
    Book,
    BookDTO,
    recipe=[
        allow_unlinked_optional(P[BookDTO].collection_id),
    ],
)

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

Each parameter of these functions is predicate defining the target scope of the policy. You can use them without arguments to apply new policies to all fields.

Redefine policy globally (for all fields)
from dataclasses import dataclass, field

from adaptix.conversion import allow_unlinked_optional, get_converter


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


@dataclass
class BookDTO:
    title: str
    price: int
    author: str
    collection_id: int | None = None
    bookmarks_ids: list[str] = field(default_factory=list)


convert_book_to_dto = get_converter(
    Book,
    BookDTO,
    recipe=[
        allow_unlinked_optional(),
    ],
)

assert (
    convert_book_to_dto(
        Book(
            title="Fahrenheit 451",
            price=100,
            author="Ray Bradbury",
        ),
    )
    ==
    BookDTO(
        title="Fahrenheit 451",
        price=100,
        author="Ray Bradbury",
        collection_id=None,
        bookmarks_ids=[],
    )
)

What is a recipe really?

The recipe is the main concept of adaptix configuration. It consists of objects defining (or redefining) some piece of behavior. Each of these objects is called a provider.

Recipe system implements chain-of-responsibility design pattern.

Let’s explore the scenario of creating links for the destination field. Initially, adaptix filters providers skipping that can’t make linking. Subsequently, adaptix scans the remaining recipe and applies the predicates of each provider. The first match will be used, causing initial providers to potentially overlap with subsequent ones.

Eliminating recipe duplication via ConversionRetort

Object holding recipe is called retort. You can use conversion.ConversionRetort that exposes all high-level converting functions as methods (convert, get_converter and impl_converter).

from dataclasses import dataclass
from datetime import date
from uuid import UUID

from adaptix.conversion import ConversionRetort, coercer


@dataclass
class Book:
    id: UUID
    title: str
    release_at: date


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


retort = ConversionRetort(
    recipe=[
        coercer(UUID, str, str),
        coercer(date, str, lambda x: x.strftime("%d.%m.%Y")),
    ],
)

convert_book_to_dto = retort.get_converter(Book, BookDTO)

assert (
    convert_book_to_dto(
        Book(
            id=UUID("87000388-94e6-49a4-b51b-320e38577bd9"),
            title="Fahrenheit 451",
            release_at=date(1953, 10, 19),
        ),
    )
    ==
    BookDTO(
        id="87000388-94e6-49a4-b51b-320e38577bd9",
        title="Fahrenheit 451",
        release_at="19.10.1953",
    )
)

This allows reusing your configuration recipe. Parameter recipe of get_converter and impl_converter methods inserts new providers into the beginning of the recipe, so you can override previously defined behavior.

No changes can be made after the retort creation. You can only make a new retort object based on the existing one.

Using .extend method you can add items to the recipe beginning.

from dataclasses import dataclass
from datetime import date
from uuid import UUID

from adaptix.conversion import ConversionRetort, coercer

base_retort = ConversionRetort(
    recipe=[
        coercer(UUID, str, str),
        coercer(date, str, lambda x: x.strftime("%d.%m.%Y")),
    ],
)

# another module

retort = base_retort.extend(
    recipe=[
        coercer(date, str, lambda x: x.strftime("%d-%m-%Y")),
    ],
)


@dataclass
class Book:
    id: UUID
    title: str
    release_at: date


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


convert_book_to_dto = retort.get_converter(Book, BookDTO)

assert (
    convert_book_to_dto(
        Book(
            id=UUID("87000388-94e6-49a4-b51b-320e38577bd9"),
            title="Fahrenheit 451",
            release_at=date(1953, 10, 19),
        ),
    )
    ==
    BookDTO(
        id="87000388-94e6-49a4-b51b-320e38577bd9",
        title="Fahrenheit 451",
        release_at="19-10-1953",
    )
)