Книга: FastAPI: веб-разработка на Python
Назад: Глава 4. Асинхронность, конкурентность и обзор библиотеки Starlette
Дальше: Глава 6. Зависимости

Глава 5. Pydantic, подсказки типов и обзор моделей

Быстрый и расширяемый, Pydantic прекрасно сочетается с вашими линтерами/IDE/brain. Определите, как должны выглядеть данные в чистом, каноническом Python 3.6+. Проверьте их с помощью Pydantic.

Сэмюэл Колвин, разработчик Pydantic

Обзор

FastAPI во многом опирается на пакет Python с названием Pydantic. Для определения структур данных используются модели (объектные классы Python). Они широко применяются в приложениях FastAPI и становятся реальным преимуществом при написании больших приложений.

Подсказки типов данных

Пришло время узнать немного больше о подсказках типов в Python.

В главе 2 упоминалось, что во многих компьютерных языках переменная указывает непосредственно на значение в памяти. Это требует от программиста объявления типа значения, чтобы можно было определить его размер и разрядность. В Python переменные — это просто имена, связанные с объектами, и именно у объектов есть типы.

В стандартном программировании переменная обычно связана с одним и тем же объектом. Если мы свяжем с этой переменной подсказку типа, то сможем избежать некоторых ошибок в программировании. Поэтому Python добавил подсказки типов к языку, в стандартный модуль типизации. Интерпретатор Python игнорирует синтаксис подсказки типа и выполняет программу так, как будто ее нет. Тогда в чем смысл?

В одной строке вы можете рассматривать переменную как строку, а потом забыть и присвоить ей объект другого типа. Компиляторы других языков будут жаловаться, а Python этого не сделает. Стандартный интерпретатор Python отлавливает обычные синтаксические ошибки и исключения времени выполнения, но не смешивает типы переменных. Инструменты-помощники, такие как mypy, обращают внимание на подсказки типов и предупреждают о любых несоответствиях.

Кроме того, подсказки доступны разработчикам Python, которые могут написать инструменты, выполняющие не только проверку ошибок типов. В сле­дующих разделах описывается, как пакет Pydantic был разработан для удовлетворения неочевидных потребностей. Позже вы увидите, как его интеграция с FastAPI значительно упрощает решение многих вопросов веб-разработки.

Кстати, как выглядят подсказки? Существует один синтаксис для переменных и другой — для возвращаемых значений функций.

Подсказки типа переменной могут включать только тип:

name: type

или также инициализировать переменную значением:

name: type = value

Тип может быть одним из стандартных простых типов Python, таких как int или str, или коллекцией, такой как tuple, list или dict:

thing: str = "yeti"

При использовании Python до версии 3.9 необходимо импортировать прописные версии стандартных имен типов из модуля типизации:

from typing import Str

thing: Str = "yeti"

Вот несколько примеров с инициализацией:

physics_magic_number: float = 1.0/137.03599913

hp_lovecraft_noun: str = "ichor"

exploding_sheep: tuple = "sis", "boom", bah!"

responses: dict = {"Marco": "Polo", "answer": 42}

Можно также включать подтипы коллекций:

name: dict[keytype, valtype] = {key1: val1, key2: val2}

Модуль типизации содержит полезные дополнения для подтипов. Наиболее распространенные из них следующие:

Any — любой тип;

Union — любой из указанных типов, например Union[str, int].

В Python, начиная с версии 3.10, можно написать type1 | type2, а не Union[type1, type2].

Примеры определений Pydantic для словарей (dict) в Python включают следующее:

from typing import Any

responses: dict[str, Any] = {"Marco": "Polo", "answer": 42}

Или, если быть более точными:

from typing import Union

responses: dict[str, Union[str, int]] = {"Marco": "Polo", "answer": 42}

либо (в Python 3.10 и более поздних версиях):

responses: dict[str, str | int] = {"Marco": "Polo", "answer": 42}

Обратите внимание на то, что в Python строка переменной с подсказкой типа является верной, а простая строка переменной — нет:

$ python

...

>>> thing0

Traceback (most recent call last):

  File "<stdin>", line 1, in <module>

NameError: name thing0 is not defined

>>> thing0: str

Кроме того, некорректное использование типов не отлавливается обычным интерпретатором Python:

$ python

...

>>> thing1: str = "yeti"

>>> thing1 = 47

Но такие ошибки будут обнаружены mypy. Если у вас еще не установлен этот статический анализатор, наберите команду pip install mypy. Сохраните две предыдущие строки в файле stuff.py, а затем попробуйте выполнить следующие команды:

$ mypy stuff.py

stuff.py:2: error: Incompatible types in assignment

(expression has type "int", variable has type "str")

Found 1 error in 1 file (checked 1 source file)

В подсказке типа возврата функции вместо двоеточия применяется стрелка:

function(args) -> type:

Вот пример возврата функции при использовании Pydantic:

def get_thing() -> str:

    return "yeti"

Можно задействовать любой тип, включая определенные классы или их комбинации. Вы увидите это через несколько страниц.

Группировка данных

Зачастую нам нужно сохранить связанную группу переменных, а не передавать множество отдельных переменных. Как объединить несколько переменных в группу и сохранить подсказки типа?

Давайте оставим в прошлом пример с простым приветствием из предыдущих глав и начнем использовать более богатые данные. Как и в остальных частях этой книги, я буду приводить примеры криптидов (воображаемых существ) и исследователей (тоже воображаемых), которые их ищут. Начальные определения криптидов будут включать в себя только строковые переменные для следующих параметров:

name — ключ;

• country — двухсимвольный код страны согласно стандарту ISO (3166-1 alpha 2) или *, что означает «все»;

• area (необязательный) — штат США или другое территориальное образование страны;

• description — в свободной форме;

aka — обозначает «также известен как…» (also known as…).

А исследователи получат следующие параметры:

name — ключ;

• country — двухсимвольный код страны согласно стандарту ISO;

description — в свободной форме.

Исторические структуры группировки данных в Python (помимо базовых int, string и подобных им) приведены далее:

tuple — неизменяемая последовательность объектов (кортеж);

• list — изменяемая последовательность объектов (список);

• set — изменяемые отдельные объекты (множество);

dict — пары изменяемых объектов «ключ — значение» (ключ должен быть неизменяемого типа) (словарь).

Кортежи (пример 5.1) и списки (пример 5.2) позволяют обращаться к переменной-члену только по ее смещению, поэтому вам придется запоминать, что куда переместилось.

Пример 5.1. Использование кортежа

>>> tuple_thing = ("yeti", "CN", "Himalayas",

    "Hirsute Himalayan", "Abominable Snowman")

>>> print("Name is", tuple_thing[0])

Name is yeti

Пример 5.2. Использование списка

>>> list_thing = ["yeti", "CN", "Himalayas",

    "Hirsute Himalayan", "Abominable Snowman"]

>>> print("Name is", list_thing[0])

Name is yeti

Пример 5.3 показывает, что вы можете получить немного больше объяснений, определив имена для целочисленных смещений.

Пример 5.3. Использование кортежей и именованных смещений

>>> NAME = 0

>>> COUNTRY = 1

>>> AREA = 2

>>> DESCRIPTION = 3

>>> AKA = 4

>>> tuple_thing = ("yeti", "CN", "Himalayas",

    "Hirsute Himalayan", "Abominable Snowman")

>>> print("Name is", tuple_thing[NAME])

Name is yeti

В примере 5.4 словари выглядят немного лучше, предоставляя доступ по описательным ключам.

Пример 5.4. Использование словаря

>>> dict_thing = {"name": "yeti",

...     "country": "CN",

...     "area": "Himalayas",

...     "description": "Hirsute Himalayan",

...     "aka": "Abominable Snowman"}

>>> print("Name is", dict_thing["name"])

Name is yeti

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

В примере 5.5 именованный кортеж — это кортеж, предоставляющий вам доступ по целочисленному смещению или имени.

Пример 5.5. Использование именованного кортежа

>>> from collections import namedtuple

>>> CreatureNamedTuple = namedtuple("CreatureNamedTuple",

...     "name, country, area, description, aka")

>>> namedtuple_thing = CreatureNamedTuple("yeti",

...     "CN",

...     "Himalaya",

...     "Hirsute HImalayan",

...     "Abominable Snowman")

>>> print("Name is", namedtuple_thing[0])

Name is yeti

>>> print("Name is", namedtuple_thing.name)

Name is yeti

Нельзя написать namedtuple_thing["name"]. Это будет tuple, а не dict, поэтому индекс должен быть целым числом.

В примере 5.6 определяется новый класс Python под названием class и добавляются все атрибуты с помощью self. Но для их определения вам придется набрать много текста.

Пример 5.6. Использование стандартного класса

>>> class CreatureClass():

...     def __init__(self,

...       name: str,

...       country: str,

...       area: str,

...       description: str,

...       aka: str):

...       self.name = name

...       self.country = country

...       self.area = area

...       self.description = description

...       self.aka = aka

...

>>> class_thing = CreatureClass(

...     "yeti",

...     "CN",

...     "Himalayas"

...     "Hirsute Himalayan",

...     "Abominable Snowman")

>>> print("Name is", class_thing.name)

Name is yeti

Вы можете подумать: что в этом плохого? В обычном классе можно добавить больше данных (атрибутов), но особенно много поведения (методов). В один безумный день вы можете решить добавить метод для поиска любимых песен исследователя. (Это нельзя применить к существам.) Но в данном случае речь идет о том, чтобы просто без помех перемещать сборки данных между уровнями и проверять их на входе и выходе. Кроме того, методы — это квадратные детали, которые с трудом помещаются в круглые отверстия базы данных.

Есть ли в Python что-то похожее на то, что в других компьютерных языках называется записью (record) или структурой (struct) (группа имен и значений)? Недавно в Python появился класс для хранения данных (dataclass). В примере 5.7 показано, как все эти self-выражения исчезают при использовании классов данных.

Пример 5.7. Применение класса данных dataclass

>>> from dataclasses import dataclass

>>>

>>> @dataclass

... class CreatureDataClass():

...     name: str

...     country: str

...     area: str

...     description: str

...     aka: str

...

>>> dataclass_thing = CreatureDataClass(

...     "yeti",

...     "CN",

...     "Himalayas"

...     "Hirsute Himalayan",

...     "Abominable Snowman")

>>> print("Name is", dataclass_thing.name)

Name is yeti

Это очень хорошо для части описания, связанной с сохранением переменных вместе. Но нам требуется больше, так что давайте попросим у Дедушки Мороза вот что:

объединение возможных альтернативных типов;

• отсутствующие/дополнительные значения;

• значения по умолчанию;

• проверку достоверности данных;

• сериализацию в форматы, такие как JSON, и из них.

Альтернативы

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

• Ключ необязателен?

• Если ключ отсутствует, есть ли значение по умолчанию?

• Существует ли ключ?

• Если да, то относится ли значение ключа к правильному типу?

• Если да, то находится ли значение в нужном диапазоне или соответствует ли оно шаблону?

По крайней мере три решения отвечают хотя бы некоторым из этих требований:

Dataclasses (https://oreil.ly/mxANA) — часть стандартного языка Python;

• attrs (https://www.attrs.org) — сторонний пакет, но содержит супернабор классов данных;

Pydantic (https://docs.pydantic.dev) — тоже сторонний продукт, но интегрированный в FastAPI, поэтому его легко выбрать, если вы уже используете FastAPI. И если вы читаете эту книгу, то вполне вероятно, что это именно так.

Удобное сравнение этих трех вариантов можно посмотреть на YouTube (https://oreil.ly/pkQD3). Одним из выводов является то, что Pydantic выделяется при проверке, а его интеграция с FastAPI позволяет выявить множество потенциальных ошибок в данных. Другое дело, что Pydantic полагается на наследование (от класса BaseModel), а два других используют декораторы Python для определения своих объектов. Это скорее вопрос стиля.

В другом сравнении (https://oreil.ly/gU28a) Pydantic превзошел более старые пакеты проверки, такие как marshmallow (https://marshmallow.readthedocs.io) и библиотека с интригующим названием Voluptuous (https://github.com/alecthomas/voluptuous). Еще один большой плюс Pydantic в том, что он использует стандартный синтаксис подсказок типов Python — более старые библиотеки не применяли подсказки типов и создавали собственные.

В книге я остановился на Pydantic, но вы можете найти применение любой из альтернатив, если не используете FastAPI.

Pydantic предоставляет возможность задать любую комбинацию следующих проверок:

• обязательные и необязательные;

• значение по умолчанию, если не указано, но требуется;

• ожидаемый тип или типы данных;

• ограничения диапазона значений;

• другие проверки на основе функций, если необходимо;

• сериализацию и десериализацию.

Простой пример

Вы уже видели, как передать простую строку в конечную точку веб-приложения через URL, параметр запроса или тело HTTP-запроса. Проблема в том, что обычно вы запрашиваете и получаете группы данных разных типов. Именно здесь в FastAPI впервые появляются модели Pydantic. В начальном примере будут использоваться три файла:

model.py — определяет модель Pydantic;

• data.py — источник фиктивных данных, определяющих экземпляр модели;

web.py — определяет конечную точку веб-приложения FastAPI, возвраща­ющую фиктивные данные.

Для простоты в этой главе сохраним все файлы в одном каталоге. В последующих главах, посвященных более крупным веб-сайтам, мы разделим их на соответствующие уровни. Сначала определим модель существа в примере 5.8.

Пример 5.8. Определение модели существа: model.py

from pydantic import BaseModel

class Creature(BaseModel):

    name: str

    country: str

    area: str

    description: str

    aka: str

thing = Creature(

    name="yeti",

    country="CN",

    area="Himalayas",

    description="Hirsute Himalayan",

    aka="Abominable Snowman")

)

print("Name is", thing.name)

Класс Creature наследуется от класса BaseModel из Pydantic. Часть выражения : str после слов name, country, area, description и aka представляет собой подсказку типа — каждое из значений относится к строковому типу данных Python.

В этом примере все поля обязательны для заполнения. В Pydantic, если слово Optional отсутствует в описании типа, поле должно содержать значение.

В примере 5.9 аргументы передаются в любом порядке, если вы указываете их имена.

Пример 5.9. Создание существа

>>> thing = Creature(

...     name="yeti",

...     country="CN",

...     area="Himalayas"

...     description="Hirsute Himalayan",

...     aka="Abominable Snowman")

>>> print("Name is", thing.name)

Name is yeti

Пока что в примере 5.10 определен небольшой источник данных. В последу­ющих главах этим будут заниматься базы данных. Подсказка типа list[Creature] говорит Python, что это список только объектов Creature.

Пример 5.10. Определение фиктивных данных в файле data.py

from model import Creature

_creatures: list[Creature] = [

    Creature(name="yeti",

        country="CN",

        area="Himalayas",

        description="Hirsute Himalayan",

        aka="Abominable Snowman"

        ),

    Creature(name="sasquatch",

        country="US",

        area="*",

        description="Yeti's Cousin Eddie",

        aka="Bigfoot")

]

def get_creatures() -> list[Creature]:

    return _creatures

(Мы использовали символ "*" для аргумента area объекта Bigfoot, потому что он может жить почти везде.)

Этот код импортирует написанный нами ранее файл model.py. Он немного скрывает данные, вызывая свой список объектов Creature_creatures и предоставляя функцию get_creatures() для их возврата.

В примере 5.11 приведен файл web.py, определяющий конечную точку веб-приложения FastAPI.

Пример 5.11. Определение конечной точки веб-приложения FastAPI: web.py

from model import Creature

from fastapi import FastAPI

app = FastAPI()

@app.get("/creature")

def get_all() -> list[Creature]:

    from data import get_creatures

    return get_creatures()

Теперь запустите этот сервер с одной конечной точкой в примере 5.12.

Пример 5.12. Запуск Uvicorn

$ uvicorn creature:app

INFO:     Started server process [24782]

INFO:     Waiting for application startup.

INFO:     Application startup complete.

INFO:     Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

В другом окне примера 5.13 осуществляется доступ к веб-приложению с по­мощью веб-клиента HTTPie (попробуйте использовать свой браузер или модуль Requests по желанию).

Пример 5.13. Проверка с помощью HTTPie

$ http http://localhost:8000/creature

HTTP/1.1 200 OK

content-length: 183

content-type: application/json

date: Mon, 12 Sep 2022 02:21:15 GMT

server: uvicorn

[

    {

        "aka": "Abominable Snowman",

        "area": "Himalayas",

        "country": "CN",

        "name": "yeti",

        "description": "Hirsute Himalayan"

    },

    {

        "aka": "Bigfoot",

        "country": "US",

        "area": "*",

        "name": "sasquatch",

        "description": "Yeti's Cousin Eddie"

    }

FastAPI и Starlette автоматически преобразуют исходный список объектов модели Creature в строку JSON. Это формат вывода по умолчанию в FastAPI, поэтому нам не нужно его указывать.

Кроме того, в окне, в котором вы первоначально запустили веб-сервер Uvicorn, должна быть выведена строка журнала:

INFO: 127.0.0.1:52375 - "GET /creature HTTP/1.1" 200 OK

Проверка типов

В предыдущем разделе было показано, как сделать следующее:

• применить подсказки типов к переменным и функциям;

• определить и использовать модель Pydantic;

• возвратить список моделей из источника данных;

• возвратить список моделей веб-клиенту, автоматически преобразовав его в JSON.

А теперь действительно применим этот план для проверки данных.

Попробуйте присвоить значение неправильного типа одному или нескольким полям объекта Creature. Для этого воспользуйтесь автономным тестом (Pydantic не применяется ни к какому веб-коду, он относится к данным).

В примере 5.14 показано содержимое файла test1.py.

Пример 5.14. Проверка модели Creature

from model import Creature

dragon = Creature(

    name="dragon",

    description=["incorrect", "string", "list"],

    country="*" ,

    area="*",

    aka="firedrake")

Теперь попробуйте выполнить тест из примера 5.15.

Он показывает, что мы присвоили полю description список строк, а ему нужна обычная строка.

Пример 5.15. Продолжение теста

$ python test1.py

Traceback (most recent call last):

  File ".../test1.py", line 3, in <module>

    dragon = Creature(

  File "pydantic/main.py", line 342, in

    pydantic.main.BaseModel.init

    pydantic.error_wrappers.ValidationError:

    1 validation error for Creature description

  str type expected (type=type_error.str)

Проверка значений

Даже если тип значения соответствует его спецификации в классе Creature, могут потребоваться дополнительные проверки. Некоторые ограничения могут быть наложены на само значение.

• Целочисленное значение (conint) или число с плавающей точкой:

gt — больше чем;

lt — меньше чем;

ge — больше или равно;

le — меньше или равно;

multiple_of — целое число, кратное значению.

• Строковое (constr) значение:

min_length — минимальная длина в символах (не в байтах);

max_length — максимальная длина в символах;

to_upper — преобразование в прописные буквы;

to_lower — преобразование в строчные буквы;

regex — сопоставление с регулярным выражением Python.

• Кортеж, список или множество:

min_items — минимальное количество элементов;

max_items — максимальное количество элементов.

Они указываются в типовых частях модели.

Пример 5.16 позволяет убедиться, что поле name всегда будет содержать не менее двух символов. В противном случае "" (пустая строка) будет считаться допустимой.

Пример 5.16. Просмотр ошибки проверки

>>> from pydantic import BaseModel, constr

>>>

>>> class Creature(BaseModel):

...     name: constr(min_length=2)

...     country: str

...     area: str

...     description: str

...     aka: str

...

>>> bad_creature = Creature(name="!",

...     description="it's a raccoon",

...     area="your attic")

Traceback (most recent call last):

  File "<stdin>", line 1, in <module>

  File "pydantic/main.py", line 342,

  in pydantic.main.BaseModel.__init__

pydantic.error_wrappers.ValidationError:

1 validation error for Creature name

  ensure this value has at least 2 characters

  (type=value_error.any_str.min_length; limit_value=2)

Ключевое слово constr означает ограниченную строку (constrained string). В примере 5.17 используется альтернативный вариант — спецификация Field из библиотеки Pydantic.

Пример 5.17. Еще один сбой проверки, применена функция Field

>>> from pydantic import BaseModel, Field

>>>

>>> class Creature(BaseModel):

...     name: str = Field(..., min_length=2)

...     country: str

...     area: str

...     description: str

...     aka: str

...

>>> bad_creature = Creature(name="!",

...     area="your attic",

...     description="it's a raccoon")

Traceback (most recent call last):

  File "<stdin>", line 1, in <module>

  File "pydantic/main.py", line 342,

  in pydantic.main.BaseModel.__init__

pydantic.error_wrappers.ValidationError:

1 validation error for Creature name

  ensure this value has at least 2 characters

  (type=value_error.any_str.min_length; limit_value=2)

Аргумент ... функции Field() означает, что значение обязательное и значения по умолчанию не предусмотрено.

Это минимальное введение в Pydantic. Главное, что можно сделать, — автоматизировать проверку данных. Вы увидите, насколько это полезно, при получении данных с веб-уровня или уровня данных.

Заключение

Модели предоставляют лучший способ определить данные, передаваемые в вашем веб-приложении. Библиотека Pydantic использует подсказки типов Python для определения моделей, передаваемых в приложении данных. Далее — определение зависимостей для выделения конкретных деталей из общего кода.


Появились сомнения в наличии у меня воображения при именовании? Хм… нет.

За исключением небольшой группы йодлингующих йети (хорошее название для группы).

Voluptuous (англ.) — «чувственный». — Примеч. пер.

Назад: Глава 4. Асинхронность, конкурентность и обзор библиотеки Starlette
Дальше: Глава 6. Зависимости