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
Answers:
TL;DR
My preferred solution: (tested with Python 3.9
–3.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.
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
TL;DR
My preferred solution: (tested with Python 3.9
–3.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.