sqlalchemy-stubs icon indicating copy to clipboard operation
sqlalchemy-stubs copied to clipboard

hybrid_property support

Open ckarnell opened this issue 4 years ago • 7 comments

Hi, I saw the thread a this topic here, but can't haven't seen any TODOs in this repo for it, so I'll go ahead and make one.

We need support to infer types for hybrid_property similar to property, returning different types based on whether it's being accessed on a class or an instance, and support for the @<property>.expression decorator.

ckarnell avatar Aug 20 '19 20:08 ckarnell

If anyone's reading this and is curious if there's a quick way to get an easy (but janky) fix here, you can do something like this to basically sub in mypy's type checking for property for your hybrid_propertys by using a third variable called typed_hybrid_property who's type changes depending on whether it's runtime.

if TYPE_CHECKING:
    # Use this to make hybrid_property's have the same typing as a normal property until stubs are improved.
    typed_hybrid_property = property
else:
    from sqlalchemy.ext.hybrid import hybrid_property as typed_hybrid_property

This also allows mypy to type expression functions, which is nice.

ckarnell avatar Nov 25 '19 19:11 ckarnell

I tried keeping the name and mypy doesn't recognise it as an alias for property:

if TYPE_CHECKING:
    hybrid_property = property  # type: ignore[misc,assignment]

Maybe it's because of the errors raised:

error: Cannot assign to a type  [misc]
error: Incompatible types in assignment (expression has type "Type[property]", variable has type "Type[hybrid_property]")  [assignment]

jace avatar Jan 19 '21 11:01 jace

@jace Make sure that you're not re-assigning hybrid_property (which is what the error message points out).

The following should work:

if TYPE_CHECKING:
  hybrid_property = property
else:
  from sqlalchemy.ext.hybrid import hybrid_property

# hybrid_property will be Type[hybrid_property] during type checking
# but refer to sqlalchemy.ext.hybrid.hybrid_property at runtime

The following won't:

from sqlalchemy.ext.hybrid import hybrid_property

if TYPE_CHECKING:
  hybrid_property = property # reassignment -> conflict

mrcljx avatar Jan 19 '21 14:01 mrcljx

Thank you. This does indeed work. The .expression and .comparator decorators also work without issue.

jace avatar Jan 20 '21 21:01 jace

im playing with descriptors today and shouldn't this be the general approach for a descriptor?

from typing import Any, Union, overload


class Descriptor:
    def some_method(self) -> Any:
        pass

    @overload
    def __get__(self, instance: None, other: Any) -> "Descriptor": ...

    @overload
    def __get__(self, instance: object, other: Any) -> "int": ...


    def __get__(self, instance: object, other: Any) -> "Any":
        if instance is None:
            return self
        else:
            return 5

class Foo:
    value = Descriptor()

# class level access, you get "Descriptor" 
# (for hybrid this would be ColumnElement)
Foo.value.some_method()

f1 = Foo()

# instance level, you get a value type
val : int = f1.value


that is, the hybrid_property separation of "expression" and "instance" is made apparent by the type of "instance" passed to the descriptor protocol.

can someone comment on this approach? considering this is what I would try to adapt to hybrid properties, which are just descriptors with pluggable class/instance level functions.

zzzeek avatar Feb 19 '21 03:02 zzzeek

Here's POC 1 for this approach with hybrids:


from typing import Any
from typing import Callable
from typing import Generic
from typing import Optional
from typing import overload
from typing import Type
from typing import TypeVar
from typing import Union

from sqlalchemy import column
from sqlalchemy import Integer
from sqlalchemy.sql import ColumnElement

_T = TypeVar("_T")


class hybrid_property(Generic[_T]):
    def __init__(
        self,
        fget: Callable[[Any], _T],
        expr: Callable[[Any], ColumnElement[_T]],
    ):
        self.fget = fget
        self.expr = expr

    @overload
    def __get__(
        self, instance: None, owner: Optional[Type[Any]]
    ) -> "ColumnElement[_T]":
        ...

    @overload
    def __get__(self, instance: object, owner: Optional[Type[Any]]) -> _T:
        ...

    def __get__(
        self, instance: Union[object, None], owner: Optional[Type[Any]] = None
    ) -> Any:
        if instance is None:
            return self.expr(owner)
        else:
            return self.fget(instance)

    def expression(
        self, expr: "Callable[[Any], ColumnElement[_T]]"
    ) -> "hybrid_property[_T]":
        return hybrid_property(self.fget, expr)


class MyClass:
    def my_thing_inst(self) -> int:
        return 5

    def my_thing_expr(cls) -> "ColumnElement[int]":
        return column("five", Integer)

    my_thing = hybrid_property(my_thing_inst, my_thing_expr)


mc = MyClass()

int_value: int = mc.my_thing
expr: ColumnElement[int] = MyClass.my_thing

zzzeek avatar Feb 19 '21 03:02 zzzeek

Here we go, this is just about the whole thing, how about this

from typing import Any
from typing import Callable
from typing import Generic
from typing import Optional
from typing import overload
from typing import Type
from typing import TypeVar
from typing import Union

from sqlalchemy import column
from sqlalchemy import Integer
from sqlalchemy.sql import ColumnElement

_T = TypeVar("_T")


class hybrid_property(Generic[_T]):
    def __init__(
        self,
        fget: Callable[[Any], Union[_T, ColumnElement[_T]]],
        expr: Optional[Callable[[Any], ColumnElement[_T]]] = None,
    ):
        self.fget = fget
        if expr is None:
            self.expr = fget
        else:
            self.expr = expr

    @overload
    def __get__(
        self, instance: None, owner: Optional[Type[Any]]
    ) -> "ColumnElement[_T]":
        ...

    @overload
    def __get__(self, instance: object, owner: Optional[Type[Any]]) -> _T:
        ...

    def __get__(
        self, instance: Union[object, None], owner: Optional[Type[Any]] = None
    ) -> Any:
        if instance is None:
            return self.expr(owner)
        else:
            return self.fget(instance)

    def expression(
        self, expr: "Callable[[Any], ColumnElement[_T]]"
    ) -> "hybrid_property[_T]":
        return hybrid_property(self.fget, expr)


class MyClass:

    # seems like "use the name twice" pattern isn't accepted by
    # mypy, so use two separate names?

    @hybrid_property
    def _my_thing_inst(self) -> int:
        return 5

    @_my_thing_inst.expression
    def my_thing(cls) -> "ColumnElement[int]":
        return column("five", Integer)


mc = MyClass()

int_value: int = mc.my_thing
expr: ColumnElement[int] = MyClass.my_thing

zzzeek avatar Feb 19 '21 03:02 zzzeek