简体   繁体   中英

How to typecheck a property with a class decorator

I'm struggling to get this code to properly type check with mypy :

from typing import Any

class Decorator:
    def __init__(self, func) -> None:
        self.func = func

    def __call__(self, *args: Any, **kwargs: Any) -> Any:
        return self.func(*args, **kwargs)

class Foo:
    @property
    @Decorator
    def foo(self) -> int:
        return 42

f = Foo()
# reveal_type(f.foo)
a: int = f.foo
print(a)

This gives me this error, even though running it correctly prints 42 (note I've uncommented the reveal_type when running mypy ):

$ mypy test.py
test.py:17: note: Revealed type is "mypy_test.test.Decorator"
test.py:18: error: Incompatible types in assignment (expression has type "Decorator", variable has type "int")  [assignment]
Found 1 error in 1 file (checked 1 source file)

This code properly type checks with mypy :

from typing import Any

class Decorator:
    def __init__(self, func) -> None:
        self.func = func

    def __call__(self, *args: Any, **kwargs: Any) -> Any:
        return self.func(*args, **kwargs)

class Foo:
    @property
    @Decorator
    def foo(self) -> int:
        return 42

f = Foo()
# reveal_type(f.foo)
a: int = f.foo()
print(a)

But this obviously won't run correctly:

$ python3 test.py
Traceback (most recent call last):
  File "/home/gsgx/code/tmp/mypy_test/test.py", line 18, in <module>
    a: int = f.foo()
TypeError: 'int' object is not callable

Note that if instead of using a class decorator I use a function decorator, everything works fine (runs correctly and no typecheck errors, reveal_type now shows f.foo is Any ):

def Decorator(f):
    return f

How can I get this to work using a class decorator? I'm using Python 3.10.6 and mypy 0.991.

Decorator.__call__ hides the type hints that you provided for foo , and replaces them with uselessly general hints.

You need to use typing.ParamSpec and typing.TypeVar to capture the hints of the decorated function to reuse for the new one. I'm not entirely sure how to do that for your class, but here's an example using ordinary functions:

from typing import ParamSpec, TypeVar


P = ParamSpec('P')
RV = TypeVar('R')


def Decorator(f: Callable[P, RV]) -> Callable[P, RV]:
    def _(*args: P.args, **kwargs: P.kwargs) -> RV:
        return f(*args, **kwargs)
    return _

typing.ParamSpec is available starting in Python 3.11, though you can get it from the 3rd-party typing-extensions package as well.


The problem with the class is that you seem to need to make Decorator generic in order to associate the return value of the argument to __init__ with the return value of __call__ :

from typing import ParamSpec, TypeVar, Generic


P = ParamSpec('P')
RV = TypeVar('R')


class Decorator(Generic[RV]):
    def __init__(self, func: Callable[P, RV]) -> None:
        self.func = func

    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> RV:
        return self.func(*args, **kwargs)

This "works", but requires you to use typing.cast with

a: int = cast(int, f.foo)

to avoid

tmp.py:27: error: Incompatible types in assignment (expression has type "Decorator[int]", variable has type "int")  [assignment]

Perhaps there's a simpler solution for the class itself that I'm overlooking. It seems to me, though, that mypy simply isn't recognizing that f.foo and Foo.foo should have different types.


On the other hand, a seemingly trivial change

class Foo:
    @Decorator
    def foo2(self) -> int:
        return 42

    foo = property(foo2)

changes the revealed type of f.foo to Any , so this again may be a problem with how mypy handles property . property itself may not be correctly typed to handle the more precise typing in Decorator , and only when property is used "statically" as a decorator itself do the hints on foo carry through.

You can implement a very easy hack by doing this:

import typing as t
import collections.abc as cx

if t.TYPE_CHECKING:
    F = t.TypeVar("F")
    Decorator: cx.Callable[[F], F]
else:
    class Decorator:
        """Your runtime implementation here"""

If you want to do this properly, it's a bit more verbose.

property is generally special-cased across all the type checker implementations. For mypy , you need to write anything that interacts with property as if @property was not even there. This means re-implementing property 's descriptor protocol , if only for static type-checking purposes:

import collections.abc as cx
import typing as t


Self = t.TypeVar("Self")
P = t.ParamSpec("P")
R = t.TypeVar("R", covariant=True)


class Decorator(t.Generic[P, R]):

    func: cx.Callable[P, R]

    def __init__(self, func: cx.Callable[P, R]) -> None:
        self.func = func

    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
        return self.func(*args, **kwargs)

    if t.TYPE_CHECKING:
        # Type-check-only descriptor protocol access. No runtime implementation.
        @t.overload  # type: ignore[no-overload-impl]
        def __get__(self, instance: None, type_: type) -> property:
            """Descriptor access from the class (instance is `None`)"""

        @t.overload
        def __get__(self, instance: t.Any, type_: type) -> R:
            """Descriptor access from the instance"""


class Foo:
    @property
    @Decorator
    def foo(self) -> int:
        return 42


f = Foo()
# reveal_type(f.foo)  # mypy: Revealed type is "builtins.int"
# reveal_type(Foo.foo)  # mypy: Revealed type is "builtins.property"
a: int = f.foo
print(a)

However, if I were you, I would write class Decorator to be completely independent of needing property . This means providing a runtime implementation to the descriptor protocol's __get__ :

from __future__ import annotations

import collections.abc as cx
import typing as t


ObjT = t.TypeVar("ObjT")
P = t.ParamSpec("P")
R = t.TypeVar("R", covariant=True)


class Decorator(t.Generic[P, R]):

    func: cx.Callable[P, R]

    def __init__(self, func: cx.Callable[P, R]) -> None:
        self.func = func

    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
        return self.func(*args, **kwargs)

    @t.overload
    def __get__(self, instance: None, type_: type[ObjT]) -> Decorator[[ObjT], R]:
        ...

    @t.overload
    def __get__(self, instance: ObjT, type_: type[ObjT]) -> R:
        ...

    def __get__(
        self: Decorator[[ObjT], R], instance: ObjT | None, type_: type[ObjT]
    ) -> Decorator[[ObjT], R] | R:
        if instance is None:
            return self
        return self.func(instance)


class Foo:
    @Decorator
    def foo(self) -> int:
        return 42


f = Foo()
# reveal_type(f.foo)  # mypy: Revealed type is "builtins.int"
# reveal_type(Foo.foo)  # mypy: Revealed type is "Decorator[[call.Foo], builtins.int]"
a: int = f.foo
print(a)

Note that any descriptor-like access through the instance can only pass the instance object to Decorator.func ; you can't have arbitrary arguments passed through Foo().foo , as Python's syntax doesn't allow you to do that.

The Generic[P, R] and __call__ on Decorator[...] still allows you to use @Decorator over free functions ( not methods in class bodies ) which don't need property -like implementations, whatever your use case for that may be.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM