Mypy / Pep-484 Unterstützung für ORM-Mappings

Unterstützung für PEP 484-Typannotationen sowie das MyPy-Typüberprüfungswerkzeug bei Verwendung von SQLAlchemy deklarativen Mappings, die direkt auf das Column-Objekt verweisen, anstatt auf die in SQLAlchemy 2.0 eingeführte Konstruktion mapped_column().

Seit Version 2.0 veraltet: Das SQLAlchemy Mypy Plugin ist VERALTET und wird in der SQLAlchemy 2.1-Version entfernt. Wir bitten die Benutzer dringend, es so schnell wie möglich zu migrieren. Das mypy-Plugin funktioniert auch nur bis mypy Version 1.10.1. Version 1.11.0 und höher funktionieren möglicherweise nicht ordnungsgemäß.

Dieses Plugin kann nicht über ständig wechselnde Versionen von mypy hinweg gewartet werden und seine zukünftige Stabilität KANN NICHT garantiert werden.

Modernes SQLAlchemy bietet jetzt vollständige PEP-484-konforme Mapping-Syntaxen; siehe den verlinkten Abschnitt für Migrationsdetails.

Installation

Nur für SQLAlchemy 2.0: Es sollten keine Stubs installiert werden und Pakete wie sqlalchemy-stubs und sqlalchemy2-stubs sollten vollständig deinstalliert werden.

Das Mypy-Paket selbst ist eine Abhängigkeit.

Mypy kann über die "mypy"-Extras-Hook mit pip installiert werden

pip install sqlalchemy[mypy]

Das Plugin selbst wird wie in Konfigurieren von mypy zur Verwendung von Plugins beschrieben konfiguriert, unter Verwendung des Modulnamens sqlalchemy.ext.mypy.plugin, beispielsweise in setup.cfg

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

Was das Plugin tut

Der Hauptzweck des Mypy-Plugins ist es, die statische Definition von SQLAlchemy deklarativen Mappings abzufangen und zu ändern, damit sie mit der Art und Weise übereinstimmen, wie sie nach der Instrumentierung durch ihre Mapper-Objekte strukturiert sind. Dies ermöglicht es sowohl der Klassenstruktur selbst als auch dem Code, der die Klasse verwendet, für das Mypy-Werkzeug verständlich zu sein, was ansonsten aufgrund der Funktionsweise von deklarativen Mappings nicht der Fall wäre. Das Plugin ist nicht unähnlich zu ähnlichen Plugins, die für Bibliotheken wie Dataclasses erforderlich sind, die Klassen zur Laufzeit dynamisch ändern.

Um die wichtigsten Bereiche abzudecken, in denen dies auftritt, betrachten Sie das folgende ORM-Mapping, das typische Beispiel der User-Klasse

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

Die Schritte, die die Mypy-Erweiterung oben ausführen kann, umfassen:

  • Interpretation der von declarative_base() generierten dynamischen Base-Klasse, sodass Klassen, die von ihr erben, als gemappt erkannt werden. Es kann auch den Klassen-Decorator-Ansatz unterstützen, der unter Deklaratives Mapping mittels eines Decorators (ohne deklarative Basis) beschrieben wird.

  • Typinferenz für ORM-gemappte Attribute, die im deklarativen "Inline"-Stil definiert sind, im obigen Beispiel die Attribute id und name der User-Klasse. Dies beinhaltet, dass eine Instanz von User int für id und str für name verwendet. Dies beinhaltet auch, dass, wenn auf die Klassenattribute User.id und User.name zugegriffen wird, wie oben in der select()-Anweisung, sie mit dem SQL-Ausdrucksverhalten kompatibel sind, das von der Attributdeskriptorklasse InstrumentedAttribute abgeleitet wird.

  • Anwendung einer __init__()-Methode auf gemappte Klassen, die noch keinen expliziten Konstruktor enthalten, der Schlüsselwörter mit spezifischen Typen für alle erkannten gemappten Attribute akzeptiert.

Wenn das Mypy-Plugin die obige Datei verarbeitet, ist die resultierende statische Klassendefinition und der Python-Code, der dem Mypy-Tool übergeben wird, äquivalent zu folgendem:

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

Die wichtigsten Schritte, die oben durchgeführt wurden, umfassen:

  • Die Base-Klasse wird nun explizit im Sinne der DeclarativeMeta-Klasse definiert, anstatt eine dynamische Klasse zu sein.

  • Die Attribute id und name werden im Sinne der Mapped-Klasse definiert, die einen Python-Deskriptor darstellt, der unterschiedliche Verhaltensweisen auf Klassen- vs. Instanzebene aufweist. Die Mapped-Klasse ist nun die Basisklasse für die InstrumentedAttribute-Klasse, die für alle ORM-gemappten Attribute verwendet wird.

    Mapped ist als generische Klasse für beliebige Python-Typen definiert, was bedeutet, dass spezifische Vorkommen von Mapped mit einem spezifischen Python-Typ verbunden sind, wie z. B. Mapped[Optional[int]] und Mapped[Optional[str]] oben.

  • Die rechte Seite der Zuweisungen von deklarativ gemappten Attributen wird entfernt, da dies der Operation ähnelt, die die Mapper-Klasse normalerweise tun würde, nämlich dass sie diese Attribute durch spezifische Instanzen von InstrumentedAttribute ersetzen würde. Der ursprüngliche Ausdruck wird in einen Funktionsaufruf verschoben, der es ermöglicht, ihn weiterhin typgeprüft zu lassen, ohne mit der linken Seite des Ausdrucks zu kollidieren. Für Mypy-Zwecke ist die linke Typannotation ausreichend, damit das Verhalten des Attributs verstanden wird.

  • Ein Typ-Stub für die User.__init__()-Methode wird hinzugefügt, die die korrekten Schlüsselwörter und Datentypen enthält.

Verwendung

Die folgenden Unterabschnitte behandeln einzelne Anwendungsfälle, die bisher für die PEP-484-Konformität in Betracht gezogen wurden.

Introspektion von Spalten basierend auf TypeEngine

Für gemappte Spalten, die einen expliziten Datentyp enthalten, wird der gemappte Typ automatisch introspektiert, wenn sie als Inline-Attribute gemappt werden.

class MyClass(Base):
    # ...

    id = Column(Integer, primary_key=True)
    name = Column("employee_name", String(50), nullable=False)
    other_name = Column(String(50))

Oben werden die endgültigen Klassenebene-Datentypen von id, name und other_name als Mapped[Optional[int]], Mapped[Optional[str]] und Mapped[Optional[str]] introspektiert. Die Typen werden standardmäßig immer als Optional betrachtet, selbst für den Primärschlüssel und die nicht-nullbare Spalte. Der Grund dafür ist, dass die Datenbankspalten "id" und "name" zwar nicht NULL sein können, die Python-Attribute id und name jedoch sehr wohl None sein können, wenn kein expliziter Konstruktor vorhanden ist.

>>> m1 = MyClass()
>>> m1.id
None

Die Typen der obigen Spalten können explizit angegeben werden, was die zwei Vorteile einer klareren Selbstdokumentation bietet und auch die Kontrolle darüber ermöglicht, welche Typen optional sind.

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

Das Mypy-Plugin akzeptiert die obigen int, str und Optional[str] und konvertiert sie, um den Mapped[]-Typ um sie herum einzuschließen. Die Mapped[]-Konstruktion kann auch explizit verwendet werden.

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

Wenn der Typ nicht-optional ist, bedeutet dies einfach, dass das Attribut, wie es von einer Instanz von MyClass aus zugegriffen wird, als nicht-None betrachtet wird.

mc = MyClass(...)

# will pass mypy --strict
name: str = mc.name

Für optionale Attribute geht Mypy davon aus, dass der Typ None enthalten muss oder anderweitig Optional ist.

mc = MyClass(...)

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

Unabhängig davon, ob das gemappte Attribut als Optional typisiert ist, wird die Generierung der __init__()-Methode immer noch alle Schlüsselwörter als optional betrachten. Dies entspricht wiederum dem, was der SQLAlchemy ORM tatsächlich tut, wenn er den Konstruktor erstellt, und sollte nicht mit dem Verhalten eines validierenden Systems wie Python Dataclasses verwechselt werden, das einen Konstruktor generiert, der den Annotationen hinsichtlich optionaler vs. erforderlicher Attribute entspricht.

Spalten ohne explizite Typisierung

Spalten, die einen ForeignKey-Modifikator enthalten, müssen in einem SQLAlchemy-Deklarationsmapping keinen Datentyp angeben. Für diesen Attributtyp informiert das Mypy-Plugin den Benutzer, dass eine explizite Typisierung erforderlich ist.

# .. 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"))

Das Plugin liefert die folgende Meldung:

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

Zur Behebung weisen Sie der Address.user_id-Spalte eine explizite Typannotation zu.

class Address(Base):
    __tablename__ = "address"

    id = Column(Integer, primary_key=True)
    user_id: int = Column(ForeignKey("user.id"))

Mapping von Spalten mit imperativem Tabellenobjekt

Im imperativen Tabellenstil werden die Column-Definitionen innerhalb einer Table-Konstruktion angegeben, die von den gemappten Attributen selbst getrennt ist. Das Mypy-Plugin berücksichtigt diese Table nicht, sondern unterstützt, dass die Attribute explizit mit einer vollständigen Annotation angegeben werden können, die zwingend die Mapped-Klasse verwenden muss, um sie als gemappte Attribute zu identifizieren.

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

Die obigen Mapped-Annotationen werden als gemappte Spalten betrachtet und in den Standardkonstruktor aufgenommen, sowie das korrekte Typisierungsprofil für MyClass sowohl auf Klassen- als auch auf Instanzebene bereitstellen.

Mapping von Beziehungen

Das Plugin bietet nur begrenzte Unterstützung für die Verwendung von Typinferenz zur Erkennung von Beziehungstypen. In allen Fällen, in denen der Typ nicht erkannt werden kann, wird eine informative Fehlermeldung ausgegeben. In allen Fällen kann der entsprechende Typ explizit angegeben werden, entweder mit der Mapped-Klasse oder optional durch Weglassen für eine Inline-Deklaration. Das Plugin muss auch ermitteln, ob die Beziehung auf eine Sammlung oder einen Skalar verweist. Dazu verlässt es sich auf den expliziten Wert der Parameter relationship.uselist und/oder relationship.collection_class. Ein expliziter Typ ist erforderlich, wenn keiner dieser Parameter vorhanden ist, sowie wenn der Zieltyp der relationship() eine Zeichenkette oder ein aufrufbares Objekt und keine Klasse ist.

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)

Das obige Mapping führt zu folgender Fehlermeldung:

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)

Der Fehler kann entweder durch Verwendung von relationship(User, uselist=False) oder durch Angabe des Typs, in diesem Fall des skalaren User-Objekts, behoben werden.

class Address(Base):
    __tablename__ = "address"

    id = Column(Integer, primary_key=True)
    user_id: int = Column(ForeignKey("user.id"))

    user: User = relationship(User)

Für Sammlungen gilt ein ähnliches Muster, bei dem in Abwesenheit von uselist=True oder einer relationship.collection_class eine Sammlungsannotation wie List verwendet werden kann. Es ist auch vollkommen angebracht, den Zeichenkettennamen der Klasse in der Annotation zu verwenden, wie er von PEP 484 unterstützt wird, wobei sichergestellt wird, dass die Klasse im TYPE_CHECKING Block entsprechend importiert wird.

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

Wie bei Spalten kann auch die Mapped-Klasse explizit verwendet werden.

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

Verwendung von @declared_attr und Declarative Mixins

Die declared_attr-Klasse ermöglicht die Deklaration von deklarativ gemappten Attributen in Klassenfunktions-Ebene und ist besonders nützlich bei der Verwendung von deklarativen Mixins. Für diese Funktionen sollte der Rückgabetyp der Funktion entweder mit der Mapped[]-Konstruktion annotiert werden oder die genaue Art des von der Funktion zurückgegebenen Objekts angeben. Darüber hinaus sollten "Mixin"-Klassen, die nicht anderweitig gemappt sind (d. h. nicht von einer declarative_base()-Klasse erben und auch nicht mit einer Methode wie registry.mapped() gemappt sind), mit dem declarative_mixin()-Decorator dekoriert werden, der dem Mypy-Plugin einen Hinweis gibt, dass eine bestimmte Klasse als deklaratives Mixin dienen soll.

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)

Beachten Sie die Diskrepanz zwischen dem tatsächlichen Rückgabetyp einer Methode wie HasCompany.company und dem, was annotiert ist. Das Mypy-Plugin konvertiert alle @declared_attr-Funktionen in einfache annotierte Attribute, um diese Komplexität zu vermeiden.

# what Mypy sees
class HasCompany:
    company_id: Mapped[int]
    company: Mapped["Company"]

Kombination mit Dataclasses oder anderen typsensiblen Attributsystemen

Die Beispiele für die Integration von Python-Dataclasses unter Anwendung von ORM-Mappings auf eine bestehende Dataclass (Legacy-Dataclass-Verwendung) stellen ein Problem dar; Python-Dataclasses erwarten einen expliziten Typ, den sie zur Erstellung der Klasse verwenden, und der Wert in jeder Zuweisungsanweisung ist wichtig. Das heißt, eine Klasse wie folgt muss genau so angegeben werden, um von Dataclasses akzeptiert zu werden:

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

Wir können unsere Mapped[]-Typen nicht auf die Attribute id, name usw. anwenden, da diese vom @dataclass-Decorator abgelehnt werden. Außerdem gibt es für Dataclasses ein weiteres Mypy-Plugin, das ebenfalls mit dem, was wir tun, in Konflikt geraten kann.

Die obige Klasse wird tatsächlich ohne Probleme die Typüberprüfung von Mypy bestehen; das Einzige, was uns fehlt, ist die Möglichkeit, Attribute von User in SQL-Ausdrücken zu verwenden, wie z. B.:

stmt = select(User.name).where(User.id.in_([1, 2, 3]))

Um eine Umgehungslösung dafür zu bieten, verfügt das Mypy-Plugin über eine zusätzliche Funktion, mit der wir ein zusätzliches Attribut _mypy_mapped_attrs angeben können, das eine Liste ist, die die Klassenobjekte oder ihre Zeichenkettennamen enthält. Dieses Attribut kann innerhalb der TYPE_CHECKING-Variable bedingt sein.

@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")}
    }

Mit dem obigen Rezept werden die in _mypy_mapped_attrs aufgeführten Attribute mit den Mapped-Typinformationen versehen, sodass die User-Klasse sich wie eine SQLAlchemy-gemappte Klasse verhält, wenn sie in einem klassenbasierten Kontext verwendet wird.