Mypy / Pep-484 Поддержка отображений ORM¶
Поддержка аннотаций типизации PEP 484, а также инструмента проверки типов MyPy при использовании отображений SQLAlchemy declarative, которые ссылаются на объект Column
напрямую, а не на конструкцию mapped_column()
, введенную в SQLAlchemy 2.0.
Установка¶
Только для SQLAlchemy 2.0: Не следует устанавливать заглушки, а пакеты sqlalchemy-stubs и sqlalchemy2-stubs должны быть полностью деинсталлированы.
Сам пакет Mypy является зависимостью.
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.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
, которая сгенерирует конструктор, соответствующий аннотациям в плане необязательных и обязательных атрибутов.
Столбцы, не имеющие явного типа¶
Для столбцов, включающих модификатор 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()
является строка или вызываемый объект, а не класс:
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[]
, либо путем указания точного вида объекта, возвращаемого функцией. Кроме того, классы-«миксины», которые не отображаются иным образом (т.е. не расширяются из класса declarative_base()
и не отображаются методом registry.mapped()
), должны быть украшены декоратором declarative_mixin()
, который дает плагину Mypy подсказку о том, что данный класс должен служить декларативным миксином:
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 в Применение отображений ORM к существующему классу данных (использование унаследованного класса данных) возникает проблема: датаклассы Python ожидают явного типа, который будет использоваться для построения класса, и значение, указанное в каждом операторе присваивания, является значимым. То есть для того, чтобы класс был воспринят 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 при использовании в контексте с привязкой к классу.