Mypy / Pep-484 对 ORM 映射的支持

在使用 SQLAlchemy 声明式 映射时,支持 PEP 484 类型注解以及 MyPy 类型检查工具,这些映射直接引用 Column 对象,而不是 SQLAlchemy 2.0 中引入的 mapped_column() 构造。

自版本 2.0 起已弃用: SQLAlchemy Mypy 插件已被弃用,并将在 SQLAlchemy 2.1 版本中移除。我们强烈建议用户尽快迁移。mypy 插件也仅适用于 mypy 版本 1.10.1。版本 1.11.0 及更高版本可能无法正常工作。

此插件无法在不断变化的 mypy 版本中维护,并且无法保证其未来的稳定性。

现代 SQLAlchemy 现在提供了 完全符合 pep-484 标准的映射语法;请参阅链接部分了解迁移详情。

安装

对于 仅 SQLAlchemy 2.0:不应安装存根,并且应完全卸载诸如 sqlalchemy-stubssqlalchemy2-stubs 之类的包。

Mypy 包本身是一个依赖项。

可以使用 pip 使用 “mypy” extras hook 安装 Mypy

pip install sqlalchemy[mypy]

插件本身的配置如 配置 mypy 以使用插件 中所述,使用 sqlalchemy.ext.mypy.plugin 模块名称,例如在 setup.cfg

[mypy]
plugins = sqlalchemy.ext.mypy.plugin

插件的功能

Mypy 插件的主要目的是拦截和更改 SQLAlchemy 声明式映射 的静态定义,使其与它们被 检测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 扩展可以采取的步骤包括

  • 解释由 declarative_base() 生成的 Base 动态类,以便继承自它的类被认为是映射的。它还可以适应 使用装饰器的声明式映射(无声明式基类) 中描述的类装饰器方法。

  • 对在声明式 “内联” 样式中定义的 ORM 映射属性进行类型推断,在上面的示例中,User 类的 idname 属性。这包括 User 的实例将对 id 使用 int,对 name 使用 str。它还包括当访问 User.idUser.name 类级别属性时(如上面的 select() 语句中所示),它们与 SQL 表达式行为兼容,这源自 InstrumentedAttribute 属性描述符类。

  • __init__() 方法应用于尚未包含显式构造函数的映射类,该方法接受检测到的所有映射属性的特定类型的关键字参数。

当 Mypy 插件处理上述文件时,传递给 Mypy 工具的生成的静态类定义和 Python 代码等效于以下内容

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 类显式定义,而不是动态类。

  • idname 属性根据 Mapped 类定义,该类表示在类级别与实例级别表现出不同行为的 Python 描述符。Mapped 类现在是用于所有 ORM 映射属性的 InstrumentedAttribute 类的基类。

    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))

上面,idnameother_name 的最终类级别数据类型将被内省为 Mapped[Optional[int]]Mapped[Optional[str]]Mapped[Optional[str]]。即使对于主键和非空列,这些类型默认情况下始终被认为是 Optional。原因是虽然数据库列 “id” 和 “name” 不能为 NULL,但 Python 属性 idname 当然可以为 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 插件将接受上述 intstrOptional[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"))

使用命令式 Table 映射列

命令式表样式 中,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=Truerelationship.collection_class 的情况下,可以使用集合注解,例如 List。在注解中使用类的字符串名称也是完全合适的,pep-484 支持这样做,确保在 TYPE_CHECKING 块中适当地导入该类

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 和声明式 Mixin

declared_attr 类允许在类级别函数中声明声明式映射属性,并且在使用 声明式 mixin 时特别有用。对于这些函数,应使用 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 mapped_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"]

与 Dataclasses 或其他类型敏感属性系统结合使用

将 ORM 映射应用于现有 dataclass(旧版 dataclass 用法) 中的 Python dataclasses 集成示例提出了一个问题;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[] 类型应用于属性 idname 等,因为它们会被 @dataclass 装饰器拒绝。此外,Mypy 还有一个专门用于 dataclasses 的插件,这也可能会妨碍我们正在做的事情。

上面的类实际上将通过 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 映射类。