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:
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"]
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.
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,
}
Link function¶
Using conversion.link_function you can write your functions
that will retrieve the necessary data directly from the model.
Link model¶
Let’s start with an example of code.
from dataclasses import dataclass
from adaptix import P
from adaptix.conversion import get_converter, link_function
@dataclass
class Book:
title: str
sub_title: str
price: int
author: str
@dataclass
class BookDTO:
label: str
price: int
author: str
convert_book_to_dto = get_converter(
Book,
BookDTO,
recipe=[
link_function(lambda book: f"{book.title}. {book.sub_title}", P[BookDTO].label),
],
)
assert (
convert_book_to_dto(
Book(
title="Enchiridion",
sub_title="Handbook of Epictetus",
price=100,
author="Arrian",
),
)
==
BookDTO(
label="Enchiridion. Handbook of Epictetus",
price=100,
author="Arrian",
)
)
The first argument of function receives a model instance. The function has to return the value of the field.
The input and output types are not checked, because there is no runtime tool to ensure that types are compatible.
Structure flattening¶
The default linking only links fields at one nesting level.
You can flatten input structure using conversion.link_function.
from dataclasses import dataclass
from adaptix import P
from adaptix.conversion import get_converter, link_function
@dataclass
class Position:
id: int
title: str
@dataclass
class Employee:
id: int
name: str
last_name: str
position: Position
@dataclass
class OutTrainer:
id: int
name: str
last_name: str
position: str
make_out_trainer = get_converter(
Employee, OutTrainer,
recipe=[
link_function(
lambda trainer: trainer.position.title,
P[OutTrainer].position,
),
],
)
assert (
make_out_trainer(
Employee(
id=354,
name="Name",
last_name="LastName",
position=Position(
id=200,
title="Position",
),
),
)
==
OutTrainer(
id=354,
name="Name",
last_name="LastName",
position="Position",
)
)
Using converter extra parameters¶
Additional parameters of the function are matched with additional parameters of the converter.
# mypy: disable-error-code="empty-body"
from dataclasses import dataclass
from adaptix import P
from adaptix.conversion import impl_converter, link_function
@dataclass
class Book:
title: str
sub_title: str
price: int
author: str
@dataclass
class BookDTO:
label: str
price: int
author: str
@impl_converter(
recipe=[
link_function(
lambda book, page_count: (
f"{book.title}. {book.sub_title} ({page_count} pages)"
),
P[BookDTO].label,
),
],
)
def convert_book_to_dto(book: Book, page_count: int) -> BookDTO:
...
assert (
convert_book_to_dto(
Book(
title="Enchiridion",
sub_title="Handbook of Epictetus",
price=100,
author="Arrian",
),
page_count=23,
)
==
BookDTO(
label="Enchiridion. Handbook of Epictetus (23 pages)",
price=100,
author="Arrian",
)
)
After linking, a default coercing mechanism is applied.
Example with coercing
# mypy: disable-error-code="empty-body"
from dataclasses import dataclass
from datetime import date
from adaptix import P
from adaptix.conversion import coercer, impl_converter, link_function
@dataclass
class Book:
title: str
sub_title: str
price: int
author: str
@dataclass
class BookDTO:
label: str
price: int
author: str
def make_label(book: Book, released_at: str) -> str:
return f"{book.title}. {book.sub_title} ({released_at})"
def format_date(x: date) -> str:
return x.strftime("%d.%m.%Y")
@impl_converter(
recipe=[
link_function(make_label, P[BookDTO].label),
coercer(date, str, format_date),
],
)
def convert_book_to_dto(book: Book, released_at: date) -> BookDTO:
...
assert (
convert_book_to_dto(
Book(
title="Enchiridion",
sub_title="Handbook of Epictetus",
price=100,
author="Arrian",
),
released_at=date(year=1683, month=1, day=1),
)
==
BookDTO(
label="Enchiridion. Handbook of Epictetus (01.01.1683)",
price=100,
author="Arrian",
)
)
Merging several fields¶
You can get fields from the model, but it requires manual type casting. All keyword-only are linked to the model field followed by common type coercing policies.
# mypy: disable-error-code="empty-body"
from dataclasses import dataclass
from datetime import date
from adaptix import P
from adaptix.conversion import coercer, get_converter, link_function
@dataclass
class Book:
title: str
sub_title: str
price: int
author: str
released_at: date
@dataclass
class BookDTO:
label: str
price: int
author: str
def make_label(book: Book, *, released_at: str) -> str:
return f"{book.title}. {book.sub_title} ({released_at})"
def format_date(x: date) -> str:
return x.strftime("%d.%m.%Y")
convert_book_to_dto = get_converter(
Book,
BookDTO,
recipe=[
link_function(make_label, P[BookDTO].label),
coercer(date, str, format_date),
],
)
assert (
convert_book_to_dto(
Book(
title="Enchiridion",
sub_title="Handbook of Epictetus",
price=100,
author="Arrian",
released_at=date(year=1683, month=1, day=1),
),
)
==
BookDTO(
label="Enchiridion. Handbook of Epictetus (01.01.1683)",
price=100,
author="Arrian",
)
)
If the first parameter is keyword-only, it will be matched with the model field.
Hint
conversion.link_function can not call a function with zero arguments
if the function has more than zero parameters.
So, it can not be used with callables like list or dict natively.
You should use conversion.link_constant with factory parameter for this case.
Link constant¶
You can use conversion.link_constant to pass a constant value to the field.
from dataclasses import dataclass
from adaptix import P
from adaptix.conversion import get_converter, link_constant
@dataclass
class Book:
title: str
price: int
author: str
@dataclass
class BookDTO:
title: str
price: int
author: str
collection_id: int | None
bookmarks_ids: list[str]
convert_book_to_dto = get_converter(
Book,
BookDTO,
recipe=[
link_constant(P[BookDTO].collection_id, value=None),
link_constant(P[BookDTO].bookmarks_ids, factory=list),
],
)
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=[],
)
)
To pass mutable objects you can use factory parameter. It takes callable accepting zero arguments.
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",
)
)