Skip to content

Typing support for both "modes" of single-function hybrid_method/hybrid_property #13247

@Muzer

Description

@Muzer

Ensure stubs packages are not installed

  • No sqlalchemy stub packages is installed (both sqlalchemy-stubs and sqlalchemy2-stubs are not compatible with v2)

Verify if the api is typed

  • The api is not in a module listed in #6810 so it should pass type checking

Describe the typing issue

In the project I'm working on I have decided we should write some helper functions for hybrid methods/properties to allow us to, wherever possible, write them as single implementations. This is to avoid potential implementation drift between the Python and SQL Expression versions of these implementations.

However, the advertised way in sqlalchemy to implement one hybrid method that covers both cases is kinda hacky. As far as I can tell you are basically writing it as a method, but having the same function also run as a classmethod with the model type passed instead of a model instance when you want to run in Expression mode.

This obviously means that while it might work fine, when implementing a hybrid_method/hybrid_property as a single function, the expression variant remains completely un-type checked.

I was wondering if there is any way of supporting type checking of both variants somehow? Is this even possible? Maybe the answer is explicitly typing the first argument (ie what would normally be self/cls) and having overloads. Any suggestions from those who have done it?

I'm mostly filing this as an issue as I think if it's currently possible, the docs should make clear how to do it; and if it's not currently possible, but it is something that could potentially be implemented, it'd be nice to see it.

To Reproduce

from datetime import datetime
from typing import overload

from sqlalchemy import ColumnElement, SQLColumnExpression
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class SomeModel(DeclarativeBase):
    ID: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
    archived_at: Mapped[datetime | None] = mapped_column()

    @hybrid_property
    def archived(self) -> bool:
        # This call will ONLY be type-checked in the Python case, not the SQL Expression case!
        return is_not_none(self, self.archived_at)


# This overload will appear to be unused even though it's not!
@overload
def is_not_none[
    T
](magic: type[DeclarativeBase], field: SQLColumnExpression[T | None]) -> ColumnElement[
    bool
]:
    ...


@overload
def is_not_none[T](magic: DeclarativeBase, field: T | None) -> bool:
    ...


def is_not_none[
    T
](
    # We pass this magic value to provide a canonical way of determining if we're being
    # called from the instance method (Python) or the class method (SQL).
    magic: type[DeclarativeBase] | DeclarativeBase,
    # There is a weird mypy bug where if you add `SQLColumnExpression[T | None]` to the
    # below it will fail. Since T can be `SQLColumnExpression[U | None]` anyway leaving
    # it off is fine.
    field: T | None,
) -> (bool | ColumnElement[bool]):
    if isinstance(magic, DeclarativeBase):
        if isinstance(field, SQLColumnExpression):
            raise ValueError("field must not be of type SQLColumnExpression")
        return field is not None
    else:
        if not isinstance(field, SQLColumnExpression):
            raise ValueError("field must be of type SQLColumnExpression")
        return field.is_not(None)

Error

No error, the issue is just that one substantial code path is not being type checked.

Versions

  • OS: Linux
  • Python: 3.12.3
  • SQLAlchemy: 2.0.44
  • Type checker (eg: mypy 0.991, pyright 1.1.290, etc): mypy 1.19.1

Additional context

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    typingpep -484 typing issues. independent of "mypy"

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions