Search code examples
pythonpropertiespython-typing

Is there a way to type hint a class property created without the property decorator?


Consider writing an interface class in Python. The interface hides getter and setter methods behind properties. There are several properties that have a very similar structure and only differ by their name. To reduce code duplication, the interface uses a factory method for its properties:

from __future__ import annotations


class Interface:

    def property_factory(name: str) -> property:
        """Create a property depending on the name."""

        @property
        def _complex_property(self: Interface) -> str:
            # Do something complex with the provided name
            return name

        @_complex_property.setter
        def _complex_property(self: Interface, _: str):
            pass

        return _complex_property

    foo = property_factory("foo")  # Works just like an actual property
    bar = property_factory("bar")


def main():
    interface = Interface()
    interface.foo  # Is of type '(variable) foo: Any' instead of '(property) foo: str'


if __name__ == "__main__":
    main()

One issue with this implementation is that Interface.foo and Interface.bar will be marked as (variable) foo/bar: Any, even though they should be (property) foo/bar: str. Is there a way to type hint foo and bar properly?

Using an inline type hint like

foo: str = property_factory("foo")

feels misleading because foo is not really a string.

I'm not dead set on this property pattern. If there is a better way to create the properties I'd also be happy to change that. However, keep in mind that the interface requires properties as the real code does more complex stuff in the getter/setter methods. Also, I want to keep the properties instead of having a general method like get_property(self, name: str).

I can also share the actual code I'm working on, the above example is just a minimal example of what I have in mind.


Solution

  • Wild speculation: property_factory is type hinted to return property; the type of the decorated method/function is lost, which normally wouldn't happen since a type checker would also check that if it were used inside the body of a class.

    property is not generic (yet). However, the linked issue's OP themself provided a generic child class of property as a possible solution. That was nearly 4 years ago when the type system is not anywhere as good as it currently is, so let's rewrite the class:

    from typing import Any, Generic, TypeVar, overload, cast
    
    T = TypeVar('T')  # The return type
    I = TypeVar('I')  # The outer instance's type
    
    class Property(property, Generic[I, T]):
        
        def __init__(
            self,
            fget: Callable[[I], T] | None = None,
            fset: Callable[[I, T], None] | None = None,
            fdel: Callable[[I], None] | None = None,
            doc: str | None = None
        ) -> None:
            super().__init__(fget, fset, fdel, doc)
        
        @overload
        def __get__(self, instance: None, owner: type[I] | None = None) -> Callable[[I], T]:
            ...
        
        @overload
        def __get__(self, instance: I, owner: type[I] | None = None) -> T:
            ...
        
        def __get__(self, instance: I | None, owner: type[I] | None = None) -> Callable[[I], T] | T:
            return cast(Callable[[I], T] | T, super().__get__(instance, owner))
        
        def __set__(self, instance: I, value: T) -> None:
            super().__set__(instance, value)
        
        def __delete__(self, instance: I) -> None:
            super().__delete__(instance)
    

    The rest of the methods can also be re-type-hinted this way, but that's an exercise for the reader. Now that we have a generic property class, the original design can be rewritten as:

    (The original works too, just use -> Property[Interface, str]/return Property(_getter, _setter).)

    from collections.abc import Callable
    
    Getter = Callable[['Interface'], str]
    Setter = Callable[['Interface', str], None]
    
    def complex_property(name: str) -> tuple[Getter, Setter]:
        def _getter(self: Interface) -> str:
            ...
        
        def _setter(self: Interface, value: str) -> None:
            ...
        
        return _getter, _setter
    

    ...which can then be used as:

    class Interface:
        
        foo = Property(*complex_property("foo"))
    

    Assuming bar is a normal @property of the same class, here are some tests (mypy playground, pyright playground):

    reveal_type(Interface.foo)  # mypy    => (Interface) -> str
                                # pyright => (Interface) -> str
    
    reveal_type(Interface.bar)  # mypy    => (Interface) -> str
                                # pyright => property
    
    reveal_type(instance.foo)   # mypy + pyright => str
    reveal_type(instance.bar)   # mypy + pyright => str
    
    instance.foo = 42           # mypy    => error: Incompatible types in assignment
                                # pyright => error: "Literal[42]" is incompatible with "str" ('foo' is underlined)
    
    instance.bar = 42           # mypy    => error: Incompatible types in assignment
                                # pyright => error: "Literal[42]" is incompatible with "str" ('42' is underlined)
    
    instance.foo = 'lorem'      # mypy + pyright => fine
    instance.bar = 'ipsum'      # mypy + pyright => fine