I'm starting to think that the "callback protocol" approach is not usable for this usecase, because an instance of a class with a
__call__method is not functionally equivalent to a function when assigned as a class attribute.
That's correct. When Python looks up a method from an instance, it calls __get__ to get a method object, which it then __call__()s. If there is no __get__, the object is called as is, without passing self.
Consider this, for example:
>> f = Foo() >>> f.method > >>> Foo.method >>> Foo.method.__get__(f) >">>>> class Foo:
... def method(self):
... print("hi")
...
>>> f = Foo()
>>> f.method
>
>>> Foo.method
>>> Foo.method.__get__(f)
>
For function objects, the __get__ method returns a method object, which ensures that self is passed: f.method, aka Foo.method.__get__(f), behaves a lot like functools.partial(Foo.method, f). But the return value of __get__ can be anything, and __get__() can do whatever it wants instead of even calling your method. This is how @property and @staticmethod work, for example.
To tell mypy that your object isn't just a callable, but a method, you need to explain to mypy that __get__ behaves just like shown above. You can do this by adding a __get__ method to your protocol. I believe it works, but I haven't tried it. Let me know if you have trouble getting this to work, and I'll try to write some example code :)
Is it possible to define a protocol for a method?
How to provide type hint for a function that returns an Protocol subclass in Python? - Stack Overflow
python - How to combine a custom protocol with the Callable protocol? - Stack Overflow
Functional Protocols - Ideas - Discussions on Python.org
Videos
One can parameterise a Protocol by a Callable:
from typing import Callable, TypeVar, Protocol
C = TypeVar('C', bound=Callable) # placeholder for any Callable
class CallableObj(Protocol[C]): # Protocol is parameterised by Callable C ...
attr1: str
attr2: str
__call__: C # ... which defines the signature of the protocol
This creates an intersection of the Protocol itself with an arbitrary Callable.
A function that takes any callable C can thus return CallableObj[C], a callable of the same signature with the desired attributes:
def decorator(func: C) -> CallableObj[C]: ...
MyPy properly recognizes both the signature and attributes:
def dummy(arg: str) -> int: ...
reveal_type(decorator(dummy)) # CallableObj[def (arg: builtins.str) -> builtins.int]'
reveal_type(decorator(dummy)('Hello')) # int
reveal_type(decorator(dummy).attr1) # str
decorator(dummy)(b'Fail') # error: Argument 1 to "dummy" has incompatible type "bytes"; expected "str"
decorator(dummy).attr3 # error: "CallableObj[Callable[[str], int]]" has no attribute "attr3"; maybe "attr2"?
Since typing.Callable corresponds to collections.abc.Callable, you can just define a Protocol that implements __call__:
class CallableWithAttrs(Protocol):
attr1: str
attr2: str
def __call__(self, *args, **kwargs): pass