Mypy / Pep-484 Поддержка отображений ORM¶
Поддержка аннотаций типизации PEP 484, а также инструмента проверки типов MyPy.
Установка¶
Плагин Mypy зависит от новых заглушек для SQLAlchemy, упакованных по адресу sqlalchemy2-stubs. Эти заглушки обязательно полностью заменяют предыдущие аннотации типизации sqlalchemy-stubs
, опубликованные Dropbox, поскольку они занимают то же самое пространство имен sqlalchemy-stubs
, что и указанное в PEP 561. Сам пакет Mypy также является зависимостью.
Оба пакета могут быть установлены с помощью дополнительного крючка «mypy» с помощью pip:
pip install sqlalchemy[mypy]
Сам плагин настраивается, как описано в Configuring mypy to use Plugins, используя имя модуля sqlalchemy.ext.mypy.plugin
, например, внутри setup.cfg
:
[mypy]
plugins = sqlalchemy.ext.mypy.plugin
Что делает плагин¶
Основная цель плагина Mypy заключается в перехвате и изменении статического определения классов SQLAlchemy declarative mappings таким образом, чтобы они соответствовали тому, как они структурированы после того, как они были instrumented своими Mapper
объектами. Это позволяет как самой структуре класса, так и коду, использующему класс, быть понятным для инструмента Mypy, что в противном случае было бы невозможно на основе того, как в настоящее время функционируют декларативные сопоставления. Плагин не отличается от аналогичных плагинов, необходимых для библиотек типа dataclasses, которые изменяют классы динамически во время выполнения.
Чтобы охватить основные области, где это происходит, рассмотрим следующее отображение ORM, используя типичный пример класса User
:
from sqlalchemy import Column, Integer, String, select
from sqlalchemy.orm import declarative_base
# "Base" is a class that is created dynamically from the
# declarative_base() function
Base = declarative_base()
class User(Base):
__tablename__ = "user"
id = Column(Integer, primary_key=True)
name = Column(String)
# "some_user" is an instance of the User class, which
# accepts "id" and "name" kwargs based on the mapping
some_user = User(id=5, name="user")
# it has an attribute called .name that's a string
print(f"Username: {some_user.name}")
# a select() construct makes use of SQL expressions derived from the
# User class itself
select_stmt = select(User).where(User.id.in_([3, 4, 5])).where(User.name.contains("s"))
Выше перечислены действия, которые может выполнять расширение Mypy:
Интерпретация динамического класса
Base
, порожденногоdeclarative_base()
, таким образом, чтобы классы, наследующие от него, были известны как сопоставленные. В нем также можно применить подход декоратора класса, описанный в Декларативное отображение с использованием декоратора (без декларативной базы).Вывод типа для ORM сопоставленных атрибутов, которые определены в декларативном стиле «inline», в приведенном выше примере атрибуты
id
иname
классаUser
. Это включает, что экземплярUser
будет использоватьint
дляid
иstr
дляname
. Сюда же относится то, что при обращении к атрибутам уровня классаUser.id
иUser.name
, как это сделано выше в оператореselect()
, они совместимы с поведением SQL-выражений, которые получены из класса дескрипторов атрибутовInstrumentedAttribute
.Применение метода
__init__()
к сопоставленным классам, которые еще не содержат явного конструктора, принимающего аргументы ключевых слов определенных типов для всех обнаруженных сопоставленных атрибутов.
Когда плагин Mypy обрабатывает вышеуказанный файл, результирующее статическое определение класса и код Python, переданный инструменту Mypy, эквивалентен следующему:
from sqlalchemy import Column, Integer, String, select
from sqlalchemy.orm import Mapped
from sqlalchemy.orm.decl_api import DeclarativeMeta
class Base(metaclass=DeclarativeMeta):
__abstract__ = True
class User(Base):
__tablename__ = "user"
id: Mapped[Optional[int]] = Mapped._special_method(
Column(Integer, primary_key=True)
)
name: Mapped[Optional[str]] = Mapped._special_method(Column(String))
def __init__(self, id: Optional[int] = ..., name: Optional[str] = ...) -> None:
...
some_user = User(id=5, name="user")
print(f"Username: {some_user.name}")
select_stmt = select(User).where(User.id.in_([3, 4, 5])).where(User.name.contains("s"))
Основные шаги, которые были предприняты выше, включают:
Класс
Base
теперь определяется в терминах классаDeclarativeMeta
явно, а не является динамическим классом.Атрибуты
id
иname
определены в терминах классаMapped
, который представляет собой дескриптор Python, демонстрирующий различное поведение на уровне класса и экземпляра. КлассMapped
теперь является базовым классом для классаInstrumentedAttribute
, который используется для всех атрибутов, сопоставленных с ORM.В
sqlalchemy2-stubs
Mapped
определен как общий класс для произвольных типов Python, то есть конкретные вхожденияMapped
связаны с конкретным типом Python, как, например,Mapped[Optional[int]]
иMapped[Optional[str]]
выше.Правая часть декларативных сопоставленных назначений атрибутов удалена, поскольку это напоминает операцию, которую обычно выполняет класс
Mapper
, то есть он заменяет эти атрибуты конкретными экземплярамиInstrumentedAttribute
. Исходное выражение перемещается в вызов функции, что позволяет ему по-прежнему проверяться на соответствие типу, не конфликтуя с левой частью выражения. Для целей Mypy достаточно аннотации левого типа, чтобы поведение атрибута было понятным.Добавлена заглушка типа для метода
User.__init__()
, которая включает правильные ключевые слова и типы данных.
Использование¶
В следующих подразделах будут рассмотрены отдельные случаи использования, которые до сих пор рассматривались для соответствия стандарту pep-484.
Интроспекция столбцов на основе TypeEngine¶
Для сопоставленных столбцов, которые включают явный тип данных, когда они сопоставлены как встроенные атрибуты, сопоставленный тип будет автоматически интроспективно определен:
class MyClass(Base):
# ...
id = Column(Integer, primary_key=True)
name = Column("employee_name", String(50), nullable=False)
other_name = Column(String(50))
Выше, конечные типы данных уровня класса id
, name
и other_name
будут интроспективно рассматриваться как Mapped[Optional[int]]
, Mapped[Optional[str]]
и Mapped[Optional[str]]
. По умолчанию типы всегда считаются Optional
, даже для первичного ключа и столбца без нуля. Причина в том, что хотя столбцы базы данных «id» и «name» не могут быть NULL, атрибуты Python id
и name
наверняка могут быть None
без явного конструктора:
>>> m1 = MyClass()
>>> m1.id
None
Типы вышеуказанных столбцов могут быть указаны явно, что дает два преимущества: более четкая самодокументация, а также возможность контролировать, какие типы являются необязательными:
class MyClass(Base):
# ...
id: int = Column(Integer, primary_key=True)
name: str = Column("employee_name", String(50), nullable=False)
other_name: Optional[str] = Column(String(50))
Плагин Mypy будет принимать приведенные выше int
, str
и Optional[str]
и преобразовывать их, чтобы включить окружающий их тип Mapped[]
. Конструкция Mapped[]
также может быть использована явно:
from sqlalchemy.orm import Mapped
class MyClass(Base):
# ...
id: Mapped[int] = Column(Integer, primary_key=True)
name: Mapped[str] = Column("employee_name", String(50), nullable=False)
other_name: Mapped[Optional[str]] = Column(String(50))
Когда тип является неопциональным, это просто означает, что атрибут, доступ к которому осуществляется из экземпляра MyClass
, будет рассматриваться как не-None:
mc = MyClass(...)
# will pass mypy --strict
name: str = mc.name
Для необязательных атрибутов Mypy считает, что тип должен включать None или иначе быть Optional
:
mc = MyClass(...)
# will pass mypy --strict
other_name: Optional[str] = mc.name
Независимо от того, набран ли сопоставленный атрибут как Optional
, генерация метода __init__()
будет по-прежнему считать все ключевые слова необязательными. Это снова соответствует тому, что на самом деле делает SQLAlchemy ORM, когда создает конструктор, и не должно путаться с поведением системы проверки, такой как Python dataclasses
, которая будет генерировать конструктор, соответствующий аннотации в плане необязательных и обязательных атрибутов.
Совет
В приведенных выше примерах типы данных Integer
и String
являются подклассами TypeEngine
. В sqlalchemy2-stubs
объект Column
является generic, который подписывается на тип, например, выше типы столбцов Column[Integer]
, Column[String]
и Column[String]
. Классы Integer
и String
в свою очередь являются общими подписчиками типов Python, которым они соответствуют, т.е. Integer(TypeEngine[int])
, String(TypeEngine[str])
.
Колонки, не имеющие явного типа¶
Колонки, включающие модификатор ForeignKey
, не нуждаются в указании типа данных в декларативном отображении SQLAlchemy. Для этого типа атрибутов плагин Mypy сообщит пользователю, что ему нужен явный тип для отправки:
# .. other imports
from sqlalchemy.sql.schema import ForeignKey
Base = declarative_base()
class User(Base):
__tablename__ = "user"
id = Column(Integer, primary_key=True)
name = Column(String)
class Address(Base):
__tablename__ = "address"
id = Column(Integer, primary_key=True)
user_id = Column(ForeignKey("user.id"))
Плагин будет доставлять сообщение следующим образом:
$ mypy test3.py --strict
test3.py:20: error: [SQLAlchemy Mypy plugin] Can't infer type from
ORM mapped expression assigned to attribute 'user_id'; please specify a
Python type or Mapped[<python type>] on the left hand side.
Found 1 error in 1 file (checked 1 source file)
Чтобы решить эту проблему, примените явную аннотацию типа к столбцу Address.user_id
:
class Address(Base):
__tablename__ = "address"
id = Column(Integer, primary_key=True)
user_id: int = Column(ForeignKey("user.id"))
Сопоставление столбцов с таблицей Imperative Table¶
В imperative table style определения Column
даются внутри конструкции Table
, которая отделена от самих отображаемых атрибутов. Плагин Mypy не считает это Table
, но вместо этого поддерживает, что атрибуты могут быть явно указаны с полной аннотацией, которая должна использовать класс Mapped
для идентификации их как сопоставленных атрибутов:
class MyClass(Base):
__table__ = Table(
"mytable",
Base.metadata,
Column(Integer, primary_key=True),
Column("employee_name", String(50), nullable=False),
Column(String(50)),
)
id: Mapped[int]
name: Mapped[str]
other_name: Mapped[Optional[str]]
Приведенные выше аннотации Mapped
считаются отображенными колонками и будут включены в конструктор по умолчанию, а также обеспечат правильный профиль типизации для MyClass
как на уровне класса, так и на уровне экземпляра.
Составление карты взаимоотношений¶
Плагин имеет ограниченную поддержку использования вывода типов для определения типов отношений. Во всех случаях, когда он не может определить тип, он выдает информативное сообщение об ошибке, и во всех случаях соответствующий тип может быть указан явно, либо с помощью класса Mapped
, либо без него для встроенного объявления. Плагин также должен определить, относится ли отношение к коллекции или скаляру, и для этого он полагается на явное значение параметров relationship.uselist
и/или relationship.collection_class
. Явный тип необходим, если ни один из этих параметров не присутствует, а также если целевой тип relationship()
является строкой или callable, а не классом:
class User(Base):
__tablename__ = "user"
id = Column(Integer, primary_key=True)
name = Column(String)
class Address(Base):
__tablename__ = "address"
id = Column(Integer, primary_key=True)
user_id: int = Column(ForeignKey("user.id"))
user = relationship(User)
Приведенное выше отображение приведет к следующей ошибке:
test3.py:22: error: [SQLAlchemy Mypy plugin] Can't infer scalar or
collection for ORM mapped expression assigned to attribute 'user'
if both 'uselist' and 'collection_class' arguments are absent from the
relationship(); please specify a type annotation on the left hand side.
Found 1 error in 1 file (checked 1 source file)
Ошибка может быть устранена либо с помощью relationship(User, uselist=False)
, либо путем указания типа, в данном случае скалярного User
объекта:
class Address(Base):
__tablename__ = "address"
id = Column(Integer, primary_key=True)
user_id: int = Column(ForeignKey("user.id"))
user: User = relationship(User)
Для коллекций действует аналогичная схема, когда при отсутствии uselist=True
или relationship.collection_class
может использоваться аннотация коллекции, такая как List
. Также вполне уместно использовать строковое имя класса в аннотации, как это поддерживается в pep-484, обеспечивая импорт класса с помощью TYPE_CHECKING block в соответствующих случаях:
from typing import TYPE_CHECKING, List
from .mymodel import Base
if TYPE_CHECKING:
# if the target of the relationship is in another module
# that cannot normally be imported at runtime
from .myaddressmodel import Address
class User(Base):
__tablename__ = "user"
id = Column(Integer, primary_key=True)
name = Column(String)
addresses: List["Address"] = relationship("Address")
Как и в случае со столбцами, класс Mapped
также может применяться явно:
class User(Base):
__tablename__ = "user"
id = Column(Integer, primary_key=True)
name = Column(String)
addresses: Mapped[List["Address"]] = relationship("Address", back_populates="user")
class Address(Base):
__tablename__ = "address"
id = Column(Integer, primary_key=True)
user_id: int = Column(ForeignKey("user.id"))
user: Mapped[User] = relationship(User, back_populates="addresses")
Использование @declared_attr и декларативных миксинов¶
Класс declared_attr
позволяет объявлять атрибуты декларативного отображения в функциях уровня класса и особенно полезен при использовании declarative mixins. Для этих функций возвращаемый тип функции должен быть аннотирован либо с помощью конструкции Mapped[]
, либо путем указания точного типа объекта, возвращаемого функцией. Кроме того, классы «mixin», которые не отображены иным образом (т.е. не расширяются из класса declarative_base()
и не отображаются методом, таким как registry.mapped()
), должны быть украшены декоратором declarative_mixin()
, который дает подсказку плагину Mypy, что конкретный класс намерен служить в качестве декларативного mixin:
from sqlalchemy.orm import declarative_mixin, declared_attr
@declarative_mixin
class HasUpdatedAt:
@declared_attr
def updated_at(cls) -> Column[DateTime]: # uses Column
return Column(DateTime)
@declarative_mixin
class HasCompany:
@declared_attr
def company_id(cls) -> Mapped[int]: # uses Mapped
return Column(ForeignKey("company.id"))
@declared_attr
def company(cls) -> Mapped["Company"]:
return relationship("Company")
class Employee(HasUpdatedAt, HasCompany, Base):
__tablename__ = "employee"
id = Column(Integer, primary_key=True)
name = Column(String)
Обратите внимание на несоответствие между фактическим возвращаемым типом метода типа HasCompany.company
и аннотированным. Плагин Mypy преобразует все функции @declared_attr
в простые аннотированные атрибуты, чтобы избежать этой сложности:
# what Mypy sees
class HasCompany:
company_id: Mapped[int]
company: Mapped["Company"]
Объединение с классами данных или другими системами атрибутов, чувствительных к типу¶
Примеры интеграции Python dataclasses в Применение отображений ORM к существующему классу данных представляют проблему; Python dataclasses ожидает явного типа, который он будет использовать для построения класса, и значение, указанное в каждом операторе присваивания, является значимым. То есть, чтобы класс был принят dataclasses, он должен быть указан именно так, как он есть:
mapper_registry: registry = registry()
@mapper_registry.mapped
@dataclass
class User:
__table__ = Table(
"user",
mapper_registry.metadata,
Column("id", Integer, primary_key=True),
Column("name", String(50)),
Column("fullname", String(50)),
Column("nickname", String(12)),
)
id: int = field(init=False)
name: Optional[str] = None
fullname: Optional[str] = None
nickname: Optional[str] = None
addresses: List[Address] = field(default_factory=list)
__mapper_args__ = { # type: ignore
"properties": {"addresses": relationship("Address")}
}
Мы не можем применить наши типы Mapped[]
к атрибутам id
, name
и т.д., потому что они будут отвергнуты декоратором @dataclass
. Кроме того, в Mypy есть еще один плагин для классов данных в явном виде, который также может помешать нашим действиям.
Приведенный выше класс действительно пройдет проверку типов Mypy без проблем; единственное, чего нам не хватает, это возможности использовать атрибуты User
в SQL-выражениях, таких как:
stmt = select(User.name).where(User.id.in_([1, 2, 3]))
Для решения этой проблемы в плагине Mypy есть дополнительная возможность указать дополнительный атрибут _mypy_mapped_attrs
, который представляет собой список, заключающий в себе объекты уровня класса или их строковые имена. Этот атрибут может быть условным в переменной TYPE_CHECKING
:
@mapper_registry.mapped
@dataclass
class User:
__table__ = Table(
"user",
mapper_registry.metadata,
Column("id", Integer, primary_key=True),
Column("name", String(50)),
Column("fullname", String(50)),
Column("nickname", String(12)),
)
id: int = field(init=False)
name: Optional[str] = None
fullname: Optional[str]
nickname: Optional[str]
addresses: List[Address] = field(default_factory=list)
if TYPE_CHECKING:
_mypy_mapped_attrs = [id, name, "fullname", "nickname", addresses]
__mapper_args__ = { # type: ignore
"properties": {"addresses": relationship("Address")}
}
С помощью приведенного выше рецепта атрибуты, перечисленные в _mypy_mapped_attrs
, будут применены вместе с информацией о типизации Mapped
, так что класс User
будет вести себя как сопоставленный класс SQLAlchemy при использовании в контексте, связанном с классом.