Why does functools.wraps not preserve wrapped function signature when used on __call__?

I raised this as a bug on the pyright bug tracker, but they said this is working as designed and closed the ticked… but I don’t understand why.

If I write a class that has a method that is a decorator, and I use functools.wraps, then because of the generic type signature for functools.wraps, the decorated functions signature is “passed through”.

In other words, this works:

import functools
import typing as t

class WorkingStatefulDecorator:
    def __init__(self, some_state):
        self.some_state = some_state

    def decorate(self, fn):
        @functools.wraps(fn)
        async def wrapper(*args, **kwargs):
            return await fn(*args, **kwargs)

        return wrapper


working_decorator = WorkingStatefulDecorator("some state")

# This interface technically not needed to show the problem, but it makes 
# type checking fail in the broken example in the next code block
class MyInterface(t.Protocol):
    async def foo(self, x: str) -> str:
        ...


class MyWorkingImpl(MyInterface):
    # This is the bit I'm confused by: 
    # *this* produces a `foo` method with the correct signature
    @working_decorator.decorate
    async def foo(self, x: str) -> str:
        return "bar"

print('MyWorkingImpl.foo')
t.reveal_type(MyWorkingImpl.foo)

Type checking with pyright passes, and it prints out that the type signature for the final foo method is:

$ uv run pyright hmm.py
<snip>
Type of "MyWorkingImpl.foo" is "(self: MyWorkingImpl, x: str) -> Coroutine[Any, Any, str]"

My question then. If I change this code in one single way - I use __call__ as the decorator method instead of decorate… then the type signature is lost, and type checking fails:

class BrokenStatefulDecorator:
    def __init__(self, some_state):
        self.some_state = some_state

    def __call__(self, fn):
        @functools.wraps(fn)
        async def wrapper(*args, **kwargs):
            return await fn(*args, **kwargs)

        return wrapper


broken_decorator = BrokenStatefulDecorator("some state")

class MyBrokenImpl(MyInterface):
    # This is the bit I'm confused by: 
    # *this* produces a `foo` method that doesn't have the expected signature
    @broken_decorator
    async def foo(self, x: str) -> str:
        return "bar"


t.reveal_type(MyBrokenImpl.foo)

Now type checking fails, and the reveal_type call shows __call__ hasn’t had the wrapped type signature propagated the way decorate did:

hmm.py:49:15 - error: "foo" overrides method of same name in class "MyInterface" with incompatible type "_Wrapped[..., Unknown, ..., Coroutine[Any, Any, Unknown]]" (reportIncompatibleMethodOverride)
hmm.py:54:15 - information: Type of "MyBrokenImpl.foo" is "_Wrapped[..., Unknown, ..., Coroutine[Any, Any, Unknown]]"

The pyright devs said this is because I need to set correct generic type signatures on the __call__ method… so I’m posting to ask why that is? Why does the type signature for functools.wraps work for “normal” methods but not for the call magic method?

[…] so I’m posting to ask why that is?

This question is rather hard to answer. Type inference is an implementation-defined part of type checkers (at least currently). Whether one type checker is able to deduce your intentions depends (almost?) entirely on its internal workings. Thus, Pyright’s maintainers are the only ones who can give you an authoritative answer, which they had.

To quote:

You have failed to provide type annotations for your decorator. In particular, the input parameters have the type Any, so pyright is unable to infer the return type. When you apply this decorator to your method, the type information for the method is lost.

The workaround is there in the very first sentence: Add more annotations to clarify your intention. Personally, I would also suggest that you have strict mode enabled.

1 Like

The one thing to add, mypy treats unannotated as Any which is allowed/valid in type system spec. That your first example works at all is pyright going beyond what type spec requires. If you don’t include annotations, return type inference is not required at all.

Mypy likely won’t give you an error (Any are allowed anywhere), but it’s not going to infer right type either.

1 Like