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

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