dropbox / sqlalchemy-stubs

Mypy plugin and stubs for SQLAlchemy
Apache License 2.0
573 stars 101 forks source link

Declared attributes aren't supported #97

Open bochecha opened 5 years ago

bochecha commented 5 years ago

I'm using SQLAlchemy's declared_attr decorator to add columns and relationships.

However, the decorated columns and relationships are completely ignored.

Here is a very simplified reproducer:

from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.ext.declarative.api import declared_attr
from sqlalchemy.orm import relationship
from sqlalchemy.schema import Column, ForeignKey
from sqlalchemy.types import Integer

Base = declarative_base()

class Person(Base):
    __tablename__ = 'people'

    id = Column(Integer, primary_key=True)

class EmailAddress(Base):
    __tablename__ = 'emails'

    @declared_attr
    def owner_id(self) -> Column:
        return Column(Integer, ForeignKey('people.id'), nullable=False)

    @declared_attr
    def owner(self) -> relationship:
        return relationship(Person)

me = Person()
email_address = EmailAddress(owner=me)

This is what mypy says:

$ pipenv run mypy decl_attr.py 
decl_attr.py:30: error: Unexpected column "owner" for model "EmailAddress"
ckarnell commented 5 years ago

@bochecha @ilevkivskyi I'd like this feature too, and would like to help implement if I get some extra cycles soon! I think this is achievable through use of generics or a plugin, but not through simple stubs. Do we have a preference for how to implement this?

ilevkivskyi commented 5 years ago

@ckarnell Yes, this would require some updates to the plugin. You can try submitting a PR.

Autopilot9369 commented 3 years ago

Any updates on this feature request?

suconakh commented 3 years ago

Is there at least a workaround to suppress these errors other than # type: ignore for every single line?

aydumoulin commented 3 years ago

You can specify exception in .mypy configuration for this module or a file wide type ignore rather than line based

I also tried the following manual typing with runtime overload successfully

from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.ext.declarative.api import declared_attr
from sqlalchemy.schema import Column, ForeignKey

Base = declarative_base()

class TypedBaseModel:
    """typed base for mypy purposed"""
    external_id: Column[str]

class BaseModel(TypedBaseModel):
    """
    base Model
    In order to defined common foreign key we need to declare it in a mixin class
    """

    @declared_attr
    def external_id(self):
        return Column(String(512), nullable=False)

class Account(Base, BaseModel)
    __tablename__ = "accounts"

   name = Column(String(512), nullable=False)

You can built heritage with another layer of base class:

I did three levels like this

wbobeirne commented 2 years ago

@aydumoulin not sure how your example was supposed to work, if you try to set the @declared_attr property you get an error.

class Account(Base, BaseModel):
    __tablename__ = "accounts"

    name = Column(String(512), nullable=False)

    def __init__(self):
        self.external_id = "test"
             ~~~~~~~~~~~
             Cannot assign member "external_id" for type "Account"
               Property "external_id" has no defined setter - Pylance(reportGeneralTypeIssues)

EDIT: My apologies, this is using pyright with no plugin, not mypy with the plugin. That's probably the difference!

aydumoulin commented 2 years ago

@wbobeirne thanks for trying it out and your feedback

As a sidenote In editor Pylance type checking sometimes produces errors due to lack of mypy plugin support. Is mypy producing the same errors when ran on this file?

This is an expunged version of the solution I use in our codebase. The following code runs in production and passes mypy and pylance.

from typing import TYPE_CHECKING, cast
from uuid import UUID

from sqlalchemy import Column

if TYPE_CHECKING:
    CStr = Column[str]
    CUUID = Column[UUID]
else:
    CStr = str
    CUUID = UUID

class _TypedBase:
    """typed base for mypy purposed"""

    external_id: CStr
    revision_id: CUUID

class _BaseModel(_TypedBase):
    """
    In order to defined common foreign key we need to declare it in a mixin class
    -> Columns with foreign keys to other columns must be declared as @declared_attr callables on declarative mixin classes.
    """

    @declared_attr
    def external_id(self):
        return Column(String(512), nullable=False)

    @declared_attr
    def revision_id(self):
        return cast(CUUID, Column(UUID, ForeignKey("revisions.id", ondelete="CASCADE"))

class User(Model, _BaseModel):
    """user"""

    __tablename__ = "users"

    email = Column(String, nullable=False)
    first_name = Column(String(254), nullable=False)
    last_name = Column(String(254), nullable=False)
    deleted_at = Column(DateTime, nullable=True)

    def __init__(self):
        self.external_id = "test"

See the hover tooltip

Capture d’écran 2022-01-26 à 14 47 16