Проверка типов запросов¶
Плагин mypy hare.contrib.mypy сверяет запросы с моделями проекта, пока mypy проверяет код. Он находит
ключ фильтра, которого у модели нет, оператор, которого нет у поля, значение не того типа, опечатку в
имени в order_by() или only(), поле, которого не знает конструктор. Без плагина всё это обнаружилось
бы только при выполнении запроса. Кроме того, плагин даёт строкам values() и values_list() настоящие
типы вместо dict[str, Any] и tuple[Any, ...].
Плагин читает модели так же, как hare во время работы, — через Model._meta.get_lookup_info() (Описание
фильтров и сортировок). Поэтому он принимает ровно то, что принимает запрос,
его тексты ошибок совпадают с теми, что бросил бы запрос, а операторы, преобразования и поля, которые
проект регистрирует сам, проверяются так же, как встроенные.
Подключение¶
Плагин включается в конфигурации mypy. Ему нужно знать, где конфигурация hare, — это та же настройка
[tool.hare] hare_orm, которую читает команда hare (или переменная окружения HARE_ORM):
# pyproject.toml
[tool.mypy]
plugins = ["hare.contrib.mypy"]
[tool.hare]
hare_orm = "library.settings.HARE_CONFIG"
В начале каждого запуска mypy плагин импортирует конфигурацию и связывает её модели без базы данных
(Hare.bind_models()): связи, заменяемые модели, операторы фильтров. Для импорта в путь Python
добавляется папка файла конфигурации mypy. Операторы каждой модели сверяются с диалектом её
default_connection.
Операторы, зарегистрированные вне моделей¶
Оператор, преобразование или класс поля, зарегистрированные при импорте модулей моделей, плагин видит сразу. Если регистрация происходит в другом месте — например, в коде запуска приложения, — этот модуль нужно назвать, и плагин импортирует его до связывания моделей:
mypy_imports — список имён модулей.
В редакторе¶
Плагин работает везде, где работает mypy: в командной строке, в dmypy и в редакторах, которые запускают
mypy. В VS Code это расширение Mypy Type
Checker, оно читает тот
же pyproject.toml.
Что проверяется¶
У каждой ошибки плагина код hare-query, поэтому одну строку можно заглушить через
# type: ignore[hare-query].
Фильтры¶
Проверяются именованные аргументы filter(), exclude(), get(), get_or_create() и
update_or_create() — у Model.objects, у менеджера связи и у QuerySet собственного класса:
Book.objects.filter(title__icontains="war", author__born__year__gte=1800)
Book.objects.filter(titel="War")
# error: Unknown filter param 'titel': Book has no field 'titel' [hare-query]
Book.objects.filter(title__nope="War")
# error: Unknown filter param 'title__nope': Book.title has no lookup 'nope' [hare-query]
Book.objects.filter(published__year="1869")
# error: Argument "published__year" to "filter" of "QuerySet" has incompatible type "str";
# expected "int | Expression | Term | QuerySpecification[Any]" [arg-type]
Какое значение принимает ключ:
| Ключ | Значение |
|---|---|
поле (title) и оператор, сравнивающий его значение (title__gte, title__icontains) |
тип поля, как его объявляет модель; поле перечисления (CharEnumField(Status)) принимает член перечисления или его значение ("open") |
типизированное поле JSON (JSONField[Address]) |
объявленный тип для равенства (address={...}); остальные операторы (address__contains) принимают любую часть документа |
оператор списка (title__in) |
итерируемое значений этого типа |
__range |
кортеж или список из двух значений; граница None оставляет эту сторону открытой |
__isnull |
bool |
часть даты (published__year), __len |
int |
published__date, published__time |
date, time |
прямая связь (author, author__in) |
её ключ или экземпляр связанной модели |
связь на составной ключ, pk составного ключа |
кортеж типов ключа или экземпляр |
обобщённая связь (subject) |
экземпляр одной из её моделей или словарь {"type": ..., "id": ...} |
subject__type |
одно из имён веток — Literal["book", "author"] |
путь в JSON (data__owner__name) |
что угодно |
поле адреса (IPAddressField, InetField и CidrField PostgreSQL) |
его текст или объект ipaddress |
поле диапазона (period, period__overlap, period__contained_by) |
Range типа границ или его границы (lower, upper) (None — открытая сторона) |
period__contains |
значение типа границ, Range или его границы (lower, upper) |
путь диапазона (period__startswith, period__endswith, period__isempty, period__lower_inc, ...) |
тип границ; bool для признака |
dwithin и операторы расстояния поля геометрии (point__distance_lte) |
(geometry, distance) |
relate поля геометрии |
(geometry, pattern) |
остальные пространственные отношения поля геометрии (area__contains) |
геометрия |
| зарегистрированный оператор | по его value_shape и value_type (при None — тип поля) |
зарегистрированное преобразование (rating__as_text__startswith) |
тип поля, которое читает преобразование |
| аннотация | её тип (см. ниже) |
Равенство (title=None) принимает и None, если значение может быть NULL: поле допускает NULL или ключ
проходит через связь, допускающую NULL, или через связь со многими строками. Любой ключ принимает также
выражение (F(), функцию, агрегат), терм или подзапрос (QuerySet).
Сообщается и об операторе, который существует, но не выполняется на базе модели:
Author.objects.filter(rating__within_on_postgresql=(1, 2))
# error: Filter param 'rating__within_on_postgresql': Author.rating has no lookup
# 'within_on_postgresql' on the sqlite database [hare-query]
И о поле, для которого у базы нет типа колонки, — ArrayField модели на SQLite:
Book.objects.filter(keywords=["a"])
# error: Filter param 'keywords': Book.keywords has no column type on the sqlite database [hare-query]
Строки values() и values_list()¶
rows = await Book.objects.values("id", "title", "author__name", "shelf__label")
reveal_type(rows[0])
# TypedDict({'id': int, 'title': str, 'author__name': str, 'shelf__label': str | None})
pairs = await Book.objects.values_list("id", "title") # list[tuple[int, str]]
titles = await Book.objects.values_list("title", flat=True) # list[str]
first = await Book.objects.values_list("title", flat=True).first() # str | None
values()без аргументов — все хранимые поля модели (связь — по её колонке,author_id) и все аннотации, кроме.alias().values(name="title")выбирает путь под этим именем,values(total=Sum("price"))— выражение с типом аннотации.- Значение, прошедшее через связь, допускающую NULL, или через связь со многими строками, может быть None.
- Поле-контейнер даёт своё значение целиком —
list[str]уArrayField(TextField()),dict[str, int]уMapField,tuple[int, str]уTupleField,Rangeу поля диапазона, — хотя его фильтр на равенство принимает другое (элементы массива). - Имя, которое не является путём поля или является оператором (
title__icontains), — ошибка. annotate()послеvalues()добавляет свои имена в строку.get(),first(),last()и перебор дают тип строки.- Строка
values_list(named=True)остаётсяAny: её класс создаётся во время работы.
Аннотации¶
annotate() и alias() добавляют свои имена в тип QuerySet — дальше их можно указывать в ключах
фильтров, сортировках и именах values():
books = Book.objects.annotate(chapter_count=Count("chapters"), total=Sum("price"))
books.filter(chapter_count__gte=10).order_by("-total")
| Выражение | Тип |
|---|---|
Count, Length |
int |
Exists |
bool |
Sum, Min, Max |
тип поля или None (строк нет) |
F("path") |
тип пути |
Value(x) |
тип x |
| остальное | Any |
Имена и типы хранятся в третьем параметре типа QuerySet в виде TypedDict —
QuerySet[Book, Book, TypedDict({'chapter_count': int, ...})]. Параметр ковариантный, поэтому QuerySet с
аннотациями остаётся QuerySet[Book]. Собственный класс QuerySet, который не передаёт этот параметр
дальше (class BookQuerySet(QuerySet[Book])), хранит аннотации скрыто от глаз — вместе со своими
методами.
Функция, принимающая QuerySet с аннотациями, называет их в третьем параметре или ставит там Any — тогда
любое имя считается возможной аннотацией:
def popular(books: QuerySet[Book, Book, Any]) -> QuerySet[Book, Book, Any]:
return books.filter(chapter_count__gte=10)
Имена¶
Проверяются имена, переданные строковыми литералами:
| Метод | Имя — это |
|---|---|
order_by() |
путь поля или аннотация, можно с -; ? — случайный порядок |
only() |
путь поля или аннотация — не путь, который кончается связью с многими строками |
defer() |
прямое поле модели, не связь |
select_related() |
путь из прямых связей и обратных «один к одному» или обобщённая связь |
bulk_update(fields=...) |
поле строк модели, не связь со многими строками |
Запись¶
Именованные аргументы конструктора модели, create() и update() задают поля: по имени поля, по колонке
связи (author_id) или через pk. Поле перечисления принимает член перечисления или его значение, связь
— экземпляр своей модели (и None, если допускает NULL), обобщённая связь — экземпляр одной из своих
моделей или словарь {"type": ..., "id": ...}, а update() — ещё и выражение:
Book(title="War and Peace", author=tolstoy)
await Book.objects.filter(id=1).update(price=F("price") * 2)
Book(titel="War and Peace")
# error: Book has no field 'titel' to set [hare-query]
Book(chapters=[chapter])
# error: Book.chapters is a relation holding many rows - it can't be set [hare-query]
Атрибуты экземпляра¶
Атрибуты экземпляра типизируются по полям модели, что бы ни объявлял класс:
- значение поля, с None, если поле допускает NULL, —
TextField(null=True)даётstr | None; - значение поля-контейнера целиком —
list[str]уArrayField(TextField()); JSON-поле без объявленного типа —Any(JSONField(default=dict)не требует аннотации); - колонка ключа связи —
author_idимеет типint,int | Noneу связи, допускающей NULL; - объект обобщённого внешнего ключа — экземпляр одной из его моделей; присваивание принимает ещё и строку ветки словарём, и None;
pk— тип ключевого поля (для ключа-связи это ключ связанной модели), у составного ключа кортеж типов его полей; присваивание принимает ещё и None — ключ несохранённой копии.
book.pk # int
edition.pk # tuple[int, int] - pk = CompositePrimaryKey("book", "number")
book.author_id # int
comment.target # Book | Author
book.pk = None
book.pk = "1" # error: Incompatible types in assignment (expression has type "str",
# variable has type "int | None") [assignment]
Методы диалекта¶
Собственные методы QuerySet диалекта — final(), prewhere(), limit_by() ClickHouse и другие (см.
Как написать диалект) — известны mypy для
диалектов соединений проекта: каждый принимает параметры своего вызова и возвращает queryset. Метод,
который есть только у диалекта другой базы, — неизвестный атрибут:
PageView.objects.final() # соединение ClickHouse: ошибки нет
PageView.objects.limit_by() # error: Missing positional argument "limit" in call to "limit_by"
Book.objects.final() # соединение SQLite: error: "QuerySet[...]" has no attribute "final"
hare stubs так же объявляет их у queryset каждой модели; метод, принимающий условие, принимает ключи
фильтров модели.
Что не проверяется¶
Q(...)иF("...")— при создании они не привязаны к модели.Qпроверяется при выполнении запроса, аFв значении фильтра принимается для любого ключа.- Имена, заданные не литералами:
filter(**conditions),values(*names). Queryset, аннотации которого получены черезannotate(**expressions), принимает любое имя как аннотацию. - Написанный вручную SQL.
- Обратные связи — атрибуты, которые mypy видит только если модель их объявляет:
books: fields.ReverseRelation["Book"](Связи).
Когда модели меняются¶
mypy сохраняет результаты запуска для следующего. Плагин передаёт mypy отпечаток всего, что он читает из
моделей: полей, связей, допустимости NULL, зарегистрированных операторов и преобразований, диалектов и их
методов QuerySet. Любое изменение заставляет mypy проверить модули заново.
Когда модели не загружаются¶
Если конфигурацию не удалось найти или импортировать или модуль моделей падает, плагин сообщает об этом один раз в каждом проверяемом модуле, а дальше запросы типизируются так, как без плагина:
error: hare: the models can't be loaded for type checking - ConfigurationError: Cannot import
configuration module 'library.settings' ... [hare-query]
pyright и Pylance: hare stubs¶
pyright — и Pylance, расширение Python для VS Code на его основе, — не запускает плагины. Вместо этого он
читает заглушки (stubs): hare stubs (pip install hare-orm[pyright]) пишет по одной на каждый модуль
моделей в typings/ — каталог, в котором pyright ищет первым:
hare stubs # typings/library/models.pyi, ...
hare stubs --relation-depth 3 # ключи фильтров через связи до 3 уровней (от 0 до 5; по умолчанию 2)
hare stubs --check # код 1, если заглушки нет или она устарела, — для CI
hare stubs --output stubs # другая папка вместо typings/
Заглушка — весь модуль в том виде, в каком его пишет stubgen из mypy (pyright читает заглушку вместо
модуля, поэтому остальные его имена остаются), с типизированными полями каждой модели
(name: Field[str], связь — author: Field[Author]) и QuerySet'ом, который принимает её собственные
ключи и значения:
await Book.objects.filter(titel="x")
# error: No parameter named "titel"
await Book.objects.filter(published__year="2020")
# error: Argument of type "Literal['2020']" cannot be assigned to parameter "published__year"
# of type "int | Expression | Term | QuerySpecification[Unknown]"
await Book.objects.create(title=1)
# error: Argument of type "Literal[1]" cannot be assigned to parameter "title" of type "str"
titles = await Book.objects.values_list("title", flat=True) # list[str]
| Что типизировано | Через что |
|---|---|
filter(), exclude(), get(), get_or_create(), update_or_create() |
<Model>Filters: каждый ключ полей модели и полей моделей, к которым ведут её связи (до --relation-depth), с каждым оператором, который выполняет диалект подключения, и типом его значения — тем же, что у плагина mypy; обобщённый внешний ключ — с __type, __in, __isnull и их отрицаниями. |
create() |
<Model>Writes: каждое поле, у связи — объект или ключ, у обобщённого внешнего ключа — объект или его строка словарём, и pk. |
update() |
<Model>Updates: значения <Model>Writes или выражение (F("price") * 2, функция, подзапрос). |
values_list(name, flat=True) |
Тип выбранного поля. |
| Поля модели | Field[<value>] — поле у класса, его значение у экземпляра; поля абстрактной базы так же. |
objects и другие менеджеры модели |
<Model>QuerySet, построенный на собственном классе QuerySet проекта у менеджера по умолчанию, — его методы остаются; менеджер с другим классом QuerySet — этот класс. |
pk экземпляра |
Тип ключа, кортеж у составного ключа; присваивание принимает ещё и None. |
Объявление, имя которого в модуле уже занято (модель BookFilters рядом с Book), получает в конце
подчёркивание: BookFilters_. Затенённые импортируемые имена, включая встроенные типы, получают
псевдонимы; для самого псевдонима тоже выбирается свободное имя. Типы полей, заданные во время
выполнения, сохраняют вложенные значения Literal (включая элементы enum) и None;
для Annotated используется его базовый тип.
После изменения моделей hare stubs нужно запустить снова; --check в CI
ловит забытую заглушку. Плагин mypy проверяет больше, чем может выразить заглушка: строки values() и
values_list() с несколькими именами, аннотации, имена в order_by()/only(), выражения в записи.
Поле, тип которого задан в классе модели обобщением (JSONField[MyDict]), получает в заглушке тип
значения своего класса поля во время выполнения.