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

优质
小牛编辑
139浏览
2023-12-01

支持 PEP 484 键入批注以及 Mypy 类型检查工具。

注解

Mypy插件和键入注释应该被视为 Alpha级别 用于SQLAlChemy的早期1.4版本。该插件尚未在真实场景中进行测试,可能存在许多未处理的情况和错误情况。新类型存根的详细信息还包括 如有更改,可随时更改 在1.4系列期间。

安装

Mypy插件依赖于打包在 sqlalchemy2-stubs 。这些存根必须完全替换以前的 sqlalchemy-stubs 键入Dropbox发布的批注,因为它们占用相同的 sqlalchemy-stubs 指定的命名空间 PEP 561 。这个 Mypy 包本身也是一个依赖项。

这两个软件包都可以通过使用pip::的“mypy”附加挂接进行安装。

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
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import 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映射属性的类型推断,在上面的示例中 idname 对象的属性 User 班级。这包括 User 将使用 intidstrname 。它还包括当 User.idUser.name 访问类级属性,因为它们位于 select() 语句,它们与SQL表达式行为兼容,而SQL表达式行为派生自 InstrumentedAttribute 属性描述符类。

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

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

from sqlalchemy import Column
from sqlalchemy import Integer
from sqlalchemy import String
from sqlalchemy import select
from sqlalchemy.orm import declarative_base
from sqlalchemy.orm.decl_api import DeclarativeMeta
from sqlalchemy.orm import Mapped

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 类现在是 InstrumentedAttribute 用于所有ORM映射属性的类。

    在……里面 sqlalchemy2-stubsMapped 定义为针对任意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”不能为空,但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[] CONTACTION也可以明确使用::

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 将被视为非无::

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 它将生成一个构造函数,该构造函数在可选属性与必需属性方面与注释匹配。

小技巧

在上面的示例中, IntegerString 数据类型都是 TypeEngine 子类。在……里面 sqlalchemy2-stubs ,即 Column 对象是一个 generic 其订阅该类型,例如,在列类型之上是 Column[Integer]Column[String] ,以及 Column[String] 。这个 IntegerString 类又一般订阅它们所对应的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 style ,即 Column 定义在 Table 构造,该构造独立于映射属性本身。Mypy插件不考虑这一点 Table ,而是支持使用完整的批注显式声明属性,该批注 must 使用 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 List, TYPE_CHECKING
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")

使用@DECLARATED_ATTR和声明性混合

这个 declared_attr 类允许在类级别函数中声明声明性映射属性,在使用 declarative mixins 。对于这些函数,函数的返回类型应使用 Mapped[] 构造或通过指示函数返回的确切对象类型。此外,没有以其他方式映射的“混合”类(即不是从 declarative_base() 类,它们也不是用如下方法映射的 registry.mapped() )应该用 declarative_mixin() 修饰符,它向Mypy插件提供提示,表明特定类打算用作声明性混合::

from sqlalchemy.orm import declared_attr
from sqlalchemy.orm import declarative_mixin

@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数据类集成示例 具有数据类和属性的声明性映射 提出了一个问题;Python数据类需要一个它将用来构建类的显式类型,并且每个赋值语句中给出的值都很重要。也就是说,如下所示的类必须如实声明,才能被数据类接受:

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映射类。