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


支持 PEP 484 键入注释以及 使用 SQLAlchemy 时的 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 包本身就是一个依赖项。


Mypy 可以使用 pip 的 “mypy” extras 钩子进行安装:

pip install sqlalchemy[mypy]


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

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


插件的作用


Mypy 插件的主要目的是拦截和更改静态 SQLAlchemy 的定义 声明式映射,以便 它们与它们之后的结构相匹配 由其 Mapper 对象进行检测。这允许两者 类结构本身以及使用该类的代码对 Mypy 工具,否则根据声明性 mappings 当前有效。 该插件与类似的插件没有什么不同 这些库是必需的,例如 data类,它们在运行时动态地改变类。


要涵盖发生这种情况的主要区域,请考虑以下 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 dynamic 类的解释 declarative_base(),以便已知从它继承的类被映射。它还可以适应使用 Decorator 的声明式映射中描述的类修饰器方法(无声明性基)。


  • 以声明性“内联”样式定义的 ORM 映射属性的类型推断,在上面的示例中,是 User 类的 id 和 name 属性。这包括 User 的实例将使用 int 表示 id,str 表示 name。它还包括,当 访问 User.idUser.name 类级属性,如上面的 select() 语句中所示,它们与 SQL 兼容 expression 行为,该行为派生自 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 定义 class 显式地表示,而不是动态类。


  • idname 属性是根据 Mapped 类,它表示一个 Python 描述符,该 在类级别和实例级别表现出不同的行为。 这 Mapped 类现在是 InstrumentedAttribute 的基类 类。


    Mapped 被定义为针对任意 Python 类型的泛型类,这意味着 Mapped 的特定实例与特定的 Python 类型相关联,例如 Mapped[Optional[int]]Mapped[Optional[str]]


  • 声明性映射属性分配的右侧是 removed 的 API作,因为这类似于 Mapper class 通常会执行,也就是说,它将替换这些 具有特定 InstrumentedAttribute 实例的 attributes 来获取。原始表达式被移动到函数调用中,这将允许它仍然进行类型检查,而不会与表达式的左侧冲突。对于 Mypy 目的,左侧键入的 Comments 足以理解 attribute 的行为。


  • 添加了 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,即使对于主键和不可为 null 的列也是如此。原因是虽然数据库列 “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


对于 optional 属性,Mypy 认为类型必须包含 None 或为 Optional

mc = MyClass(...)

# will pass mypy --strict
other_name: Optional[str] = mc.name


无论 mapped 属性是否键入为 Optional__init__() 方法的生成仍将考虑所有关键字 设置为可选。这再次与 SQLAlchemy ORM 在创建构造函数时实际执行的作相匹配,并且不应与验证系统的行为(例如 Python 数据类)混淆,后者将生成一个在可选属性与必需属性方面与 annotation 匹配的构造函数。


没有 Explicit 类型的列


包含 ForeignKey 修饰符的列不需要在 SQLAlchemy 声明性映射中指定数据类型。对于这种类型的 attribute,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 映射列


命令式表格样式中, 定义在 Table 中给出 构造,它与映射的属性本身是分开的。 Mypy 餐厅 plugin 不考虑这个 Table,而是支持 这些属性可以通过一个完整的注解来明确声明,该注解 必须使用 Mapped 类将它们标识为 Map 属性:

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 类允许 Declarative 映射属性 在类级函数中声明,并且在使用 声明式 mixin 中。对于这些函数,函数的返回类型应该使用 Mapped[] construct 或通过指示函数返回的对象的确切类型来构建。 此外,未以其他方式映射的 “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"]


与数据类或其他类型敏感的属性系统相结合


将 ORM 映射应用于现有数据类(使用旧数据类)中的 Python 数据类集成示例 提出了一个问题;Python 数据类需要一个显式类型,它将 use 构建类,每个赋值语句中给出的值 非常重要。 也就是说,必须准确说明以下类 因为它是为了被 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 还有另一个显式的数据类插件,它也可能妨碍我们正在做的事情。


上面的类实际上会毫无问题地通过 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 映射类。