Не пишите ни одной строчки кода, пока не увидите эти прорывные функции Pydantic V2

Осторожно, дата-инженеры: революционные обновления Pydantic V2 уже здесь!

Введение

Pydantic — это библиотека Python для проверки данных. С примерно 20 миллионами загрузок в неделю он входит в 100 лучших библиотек Python.

Недавно вышел Pydantic V2 (с ржавчиной) — он в 5–50 раз быстрее, чем V1! В выпуске также представлено много новых прорывных функций, о которых я буду говорить.

💡 DISCLAIMER:
The LLM robot helped me write the 
clickbaity title for this article.

В настоящее время используете Pydantic V1?

Следуйте официальному руководству по миграции. Это видео тоже может быть полезным:

1. Проверка функций

Pydantic V2 позволяет проверять любые аргументы функции! (Да, включая методы класса.)

Пример функции со строгими требованиями к типу ввода показан ниже:

from pydantic import validate_call
from pydantic.types import conint


@validate_call
def echo_hello(n_times: conint(gt=0, lt=11), name: str, loud: bool):
    """
    Greets someone with an echo.

    Args:
        n_times: How many echos. Min value is 1, max is 10.
        name: Name to greet
        loud: Do you want the greeting to be loud?
    """
    greeting = f"Hello {name}!"

    if loud:
        greeting = greeting.upper() + "!!"

    for i in range(n_times):
        print(greeting)


# Call this function
echo_hello(n_times=1, name="Yaakov", loud=True)   # Valid
echo_hello(n_times=10, name="Yaakov", loud=True)  # Valid

# The following will raise an error:
echo_hello(n_times=20, name="Yaakov", loud=True)  # Invalid!
echo_hello(n_times=1, name=1234, loud=True)       # Invalid!

💡 ПРИМЕЧАНИЕ. Эта функция позволяет удалить проверку аргументов из вызовов функций — благо для написания более аккуратного и простого кода.

2. Дискриминированные союзы

Дискриминированные объединения — это операторы объединения, которые определяют, какую модель или значение использовать, на основе поля дискриминатора. Это особенно полезно для разработчиков FastAPI, которым может потребоваться вернуть разные модели из конечной точки API с учетом разных параметров запроса.

Пример, демонстрирующий, как выполняются размеченные объединения, показан ниже:

from typing import Union, Literal, List

from pydantic import BaseModel, Field


class ModelA(BaseModel):
    d_type: Literal["single"]
    value: int = Field(default=0)


class ModelB(ModelA):
    """Inherits from ModelA, making the union challenging"""
    d_type: Literal["many"]
    values: List[int] = Field(default_factory=list)


class ModelC(BaseModel):
    v: Union[ModelA, ModelB] = Field(discriminator="d_type")


# Populate with extra fields, see what happens
m_1 = ModelC(v={"value": 123, "values": [123], "d_type": "single"})
m_2 = ModelC(v={"value": 123, "values": [123], "d_type": "many"})

print(m_1, m_2, sep="\n")
# v=ModelA(d_type='single', value=123)
# v=ModelB(d_type='many', value=123, values=[123])

💡 ПРИМЕЧАНИЕ. Дискриминированные объединения значительно упрощают случаи наследования моделей.

3. Валидированные типы с аннотированными валидаторами

Валидацию больше не нужно привязывать к модели! С помощью аннотированных валидаторов вы можете напрямую создать валидатор для поля (т. е. типа). (Неявные декораторы, прочь!)

Это полезно, если ваша проверка специфична для поля — или если вы хотите разделить эту проверку между модулями.

Вот простой пример пользовательского типа PositiveNumber, который принимает на вход только положительные числа:

from typing_extensions import Annotated
from pydantic.functional_validators import AfterValidator


def validate(v: int):
    assert v > 0

PositiveNumber = Annotated[int, AfterValidator(validate)]

Более подробный пример пользовательского типа Price показан ниже:

from typing import Any
from typing_extensions import Annotated

from pydantic import BaseModel
from pydantic.functional_validators import AfterValidator, BeforeValidator


def remove_currency(v: Any) -> int:
    """Remove currency symbol from any input"""
    if isinstance(v, str):
        v = v.replace('$', '')
    return v

def truncate_max_number(v: int) -> int:
    """Any number greater than 100 will be set at 100"""
    return min(v, 100)


# Create a custom type (importable!)
Price = Annotated[
    int,
    BeforeValidator(remove_currency),
    AfterValidator(truncate_max_number)
]


class Model(BaseModel):
    price: Price


# Instantiate the model to demonstrate
m = Model(price="$12")      # price=12
m = Model(price=12)         # price=12
m = Model(price=101)        # price=100
print(m)

Совместное использование полей между моделями и модулями:

Аннотированные валидаторы разблокируют совместное использование полей между моделями и модулями, поскольку пользовательские типы содержат валидаторы на самом объекте!

Этот декларативный подход является важным усовершенствованием и большим преимуществом для разработчиков Python, работающих с пользовательскими типами.

4. Проверка без BaseModel с использованием TypeAdapter

До сих пор Pydantic был либо BaseModel, либо банкротом. 💢 Проверка была ограничена экземплярами BaseModel.

V2 представляет TypeAdapter, специальный класс, который позволяет трансформировать без создания BaseModel. (Невозможно в версии 1)

TypeAdapter для юнит-тестов — тестовые поля вместо моделей

TypeAdapter особенно полезен для написания модульных тестов для определенных полей. Эта декомпозиция позволяет проводить более аккуратные + меньшие юнит-тесты → более счастливые времена для вас!

Пример использования TypeAdapter для преобразования/проверки объектов в NumberList, настраиваемый тип, показан ниже:

from typing import List, Any
from typing_extensions import Annotated

import pytest

from pydantic import TypeAdapter
from pydantic.functional_validators import BeforeValidator


def coerce_to_list(v: Any) -> List[Any]:
    if isinstance(v, list):
        return v
    else:
        return [v]


NumberList = Annotated[
    List[int],
    BeforeValidator(coerce_to_list)
]


@pytest.mark.parameterize(
    ('v', 'expected'),
    [
        pytest.param(1, [1], id="single to list"),
        pytest.param([1, 2, 3], [1, 2, 3], id="list, no change"),
        pytest.param([1, '2'], [1, 2], id="list with string nums"),
    ]
)
def test_number_list(v: Any, expected: List[int]):
    ta = TypeAdapter(NumberList)
    res = ta.validate_python(v)
    assert res == expected

💡 ПРИМЕЧАНИЕ. Всякий раз, когда я тестирую поведение определенного поля модели, мне нравится писать параметризованные модульные тесты, которые вызывают TypeAdapter. Таким образом, я получаю подробное объяснение того, какие значения оказались ошибочными.

5. Пользовательская сериализация

Версия 2 предлагает новый мощный способ преобразования форматов данных в вашу модель Python и обратно.

Допустим, у вас есть объект datetime, но вы хотите представить его по-разному при экспорте модели. С помощью настраиваемого сериализатора вы можете указать именно это.

Пример пользовательского field_serializer показан ниже:

from datetime import datetime
from pydantic import BaseModel, field_serializer

class BroadwayTicket(BaseModel):
    show_name: str
    show_time: datetime

    @field_serializer("show_time")
    def transform_show_time(v) -> str:
        """Returns human readable show time format"""
        return v.strftime("%b %d, %Y, %I:%M %p")


# Create an object
my_tickets = BroadwayTicket(
    show_name="Parade",
    show_time=datetime(2023, 8, 5, 19)  # August 8, 7:00PM
)

print(my_tickets.model_dump())
# {'show_name': 'Parade', 'show_time': 'Aug 05, 2023, 07:00 PM'}

⚠️ ПРИМЕЧАНИЕ. Сериализация не выполняет «двустороннее преобразование», то есть экспортированный «сериализованный» формат не сериализуется автоматически обратно в тип данных.

Существуют продвинутые методы для завершения этого «двухстороннего преобразования», но на данный момент это не простой путь. Я могу написать дополнительную статью об этом.

Заключение

Pydantic V2 — хорошее время. Это дает вам больше контроля над вашими вещами. Это невероятно быстро (по сравнению с библиотеками Python). Он также предлагает массу новых мощных функций.

Удачного кодирования!