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 при использовании в контексте с привязкой к классу.

Вернуться на верх