How to have typing support for a static property (using a decorator)

Question:

Given a static property decorator:

class static_property:
    def __init__(self, getter):
        self.__getter = getter

    def __get__(self, obj, objtype):
        return self.__getter(objtype)

    @staticmethod
    def __call__(getter_fn):
        return static_property(getter_fn)

That is applied to a class as follows:

class Foo:
    @static_prop
    def bar(self) -> int:
        return 10

Add called as static:

>>> print(Foo.bar)
10

How would I add typing support to static_property so Foo.bar is inferred as type int instead of as Any?

Or is there another way to create the decorator to support type inference?

See Also: how-to-define-class-field-in-python-that-is-an-instance-of-a-class

Asked By: Amour Spirit

||

Answers:

TL;DR

My preferred solution: (tested with Python 3.93.11)

from __future__ import annotations
from collections.abc import Callable
from typing import Generic, TypeVar


R = TypeVar("R")


class static_property(Generic[R]):
    def __init__(self, getter: Callable[[], R]) -> None:
        self.__getter = getter

    def __get__(self, obj: object, objtype: type) -> R:
        return self.__getter()

    @staticmethod
    def __call__(getter_fn: Callable[[], R]) -> static_property[R]:
        return static_property(getter_fn)


class Foo:
    @static_property
    def bar() -> int:  # type: ignore[misc]
        return 10

Details in section 3).


0) The problem: Decorate what exactly?

The way your code is written currently suggests that you want to create a descriptor that you can decorate class methods with, as evidenced by the fact that you are passing the objtype to your getter function inside __get__.

On the other hand, the name of your descriptor class static_property and the fact that bar is effectively static (does nothing with an instance or class) suggests that you actually want to decorate static methods with it. That would require a very different approach.


1) Generic descriptor taking a class method

But first things first, the solution for the methods taking a class as their only argument.

This can be accomplished by making your static_property descriptor class generic in terms of the return type R of the method being decorated (in the following example int and str).

Complete working example

from collections.abc import Callable
from typing import Any, Generic, TypeVar


R = TypeVar("R")


class static_property(Generic[R]):
    def __init__(self, getter: Callable[[Any], R]) -> None:
        self.__getter = getter

    def __get__(self, obj: object, objtype: type) -> R:
        return self.__getter(objtype)

    @staticmethod
    def __call__(getter_fn: Callable[[Any], R]) -> "static_property[R]":
        return static_property(getter_fn)


class Foo:
    @static_property
    def bar(cls) -> int:
        return 10

    @static_property
    def baz(cls) -> str:
        return "a"


x = Foo().bar
y = Foo.baz
reveal_type(x)
reveal_type(y)

Running this through mypy --strict gives the following output:

note: Revealed type is "builtins.int"
note: Revealed type is "builtins.str"
Success: no issues found in 1 source file

Caveat

Technically we are already encountering an issue because the bar and baz methods are still viewed as regular instance methods by the type checker, which means it expects the first argument to be an instance of Foo, not the class itself. This why I went with Any as the parameter for Callable in the descriptor methods.

This is not really relevant in this case since (as I mentioned above) the methods are effectively static anyway, so the first argument is meaningless. More importantly, since the bar and baz methods are only ever used in their unbound form inside the descriptor’s __get__, the annotations are technically still correct.


2) Generic descriptor taking a static method

Assuming you actually want static_property to decorate static methods, meaning in this case methods that really do not take any arguments, this would require a different approach.

Since the built-in staticmethod class is a subtype of Callable (due to it obviously supporting the __call__ protocol), we can decorate the bar and baz methods with staticmethod and pass the resulting objects to our own static_property.__call__.

We can keep R because typeshed defines staticmethod as generic in terms of the return type of the method passed to it as well. (At least for Python 3.10+.) This means we can retain the information about the return type of our bar and baz methods, even if we decorate them with staticmethod.

Complete working example

from collections.abc import Callable
from typing import Generic, TypeVar


R = TypeVar("R")


class static_property(Generic[R]):
    def __init__(self, getter: Callable[[], R]) -> None:
        self.__getter = getter

    def __get__(self, obj: object, objtype: type) -> R:
        return self.__getter()

    @staticmethod
    def __call__(getter_fn: Callable[[], R]) -> "static_property[R]":
        return static_property(getter_fn)

class Foo:
    @static_property
    @staticmethod
    def bar() -> int:
        return 10

    @static_property
    @staticmethod
    def baz() -> str:
        return "a"


x = Foo().bar
y = Foo.baz
reveal_type(x)
reveal_type(y)

The mypy --strict output is still the same as in the one above, meaning the types are correctly inferred as int and str respectively.

As you can see, the getter function can now simply be annotated as Callable[[], R], i.e. a callable taking no arguments, but returning our descriptor’s type argument.


3) Getting rid of staticmethod

The final step is to make it unnecessary to use the staticmethod decorator altogether. Unfortunately, this can not be accomplished by subclassing it and explicitly defining our subclass as generic in terms of R again. This is mainly due to the fact that staticmethod.__get__ returns a callable (see typeshed again for reference), but we explicitly want it to return R.

What stands in our way then, is that mypy (and presumably other type checkers) expect every method that is not decorated with staticmethod to take at least one argument. So we will get complaints, if we define bar and baz as taking no arguments.

So far I found no way around this other than explicitly ignoring that miscellaneous error and proceeding as follows.

Working example

from __future__ import annotations
from collections.abc import Callable
from typing import Generic, TypeVar


R = TypeVar("R")


class static_property(Generic[R]):
    def __init__(self, getter: Callable[[], R]) -> None:
        self.__getter = getter

    def __get__(self, obj: object, objtype: type) -> R:
        return self.__getter()

    @staticmethod
    def __call__(getter_fn: Callable[[], R]) -> static_property[R]:
        return static_property(getter_fn)


class Foo:
    @static_property
    def bar() -> int:  # type: ignore[misc]
        return 10

    @static_property
    def baz() -> str:  # type: ignore[misc]
        return "a"


x = Foo().bar
y = Foo.baz
reveal_type(x)
reveal_type(y)

Obvious caveat

The types are obviously inferred correctly again, but mypy will complain without the type: ignore directives, saying error: Method must have at least one argument. The descriptor still works fine though and there is no actual type safety issue here. Just to be clear here, that type: ignore is because of the missing parameter in the signatures of bar and baz, which mypy generally considers unsafe.

The reason this is actually type safe here is that we are again only ever dealing with unbound methods bar and baz. The decorator "consumes" them, which means they will never be called bound to their class or an instance thereof. Thus, our methods do not need any arguments.

(By the way, I just used __future__.annotations here to avoid the quotes around static_property[R]. You can obviously do the same with solutions 1) and 2) above.)

Trade-offs everywhere

Ultimately it seems as though you’ll have to decide, which solution is most useful to you. Each has its own downsides. It appears that we cannot do this in a 100 % clean way right now, but maybe someone else will find a way, or maybe the typing system will change in way to that allows a better solution.

Answered By: Daniil Fajnberg