Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Remove Self from Callable Type signature to match Instance method

The motivation for this is to type check event handlers, to ensure matching between the types that the registered events expect as arguments and those the handler is primed to give.

I am trying to track function signatures in type annotations for a class based function decorator. This is just a mypy stubs project: the actual implementation will get the same result in a different way.

So, we have a basic decorator skeleton like so

from typing import Any, Callable, Generic, TypeVar

FuncT = TypeVar("FuncT", bound=Callable)

class decorator(Generic[FuncT]):
    def __init__(self, method: FuncT) -> None:
        ... # Allows mypy to infer the parameter type

    __call__: FuncT

    execute: FuncT

With the following stub example

class Widget:
    def bar(self: Any, a: int) -> int:
        ...

    @decorator
    def foo(self: Any, a: int) -> int:
        ...

w = Widget()

reveal_type(Widget.bar)
reveal_type(w.bar)

reveal_type(Widget.foo.__call__)
reveal_type(w.foo.__call__)

The revealed types are as follows:

Widget.bar (undecorated class method):         'def (self: demo.Widget, a: builtins.int) -> builtins.int'
w.bar (undecorated instance method):           'def (a: builtins.int) -> builtins.int'
Widget.foo.__call__ (decorated class method):  'def (self: demo.Widget, a: builtins.int) -> builtins.int'
w.foo.__call__ (decorated instance method):    'def (self: demo.Widget, a: builtins.int) -> builtins.int'

The implication of this is that if I call w.bar(2) it passes the type checker, but if I call w.foo(2) or w.foo.execute(2) then mypy complains that there aren't enough parameters. Meanwhile all of Widget.bar(w, 2) Widget.foo(w, 2), and Widget.foo.execute(w, 2) pass fine.

What I'm after is a way to annotate this to persuade w.foo.__call__ and w.foo.execute to give the same signature as w.bar.

like image 767
Josiah Avatar asked Dec 10 '25 22:12

Josiah


1 Answers

This is now possible using ParamSpec from PEP 612. It also requires an intermediate class overloading __get__ to distinguish class from instance access.

FuncT = TypeVar("FuncT", bound=Callable)
FuncT2 = TypeVar("FuncT2", bound=Callable)
SelfT = TypeVar("SelfT")
ParamTs = ParamSpec("ParamTs")
R = TypeVar("R")


class DecoratorCallable(Generic[FuncT]):
    __call__ : FuncT

# FuncT and FuncT2 refer to the method signature with and without self
class DecoratorBase(Generic[FuncT, FuncT2]):
    @overload
    def __get__(self, instance: None, owner: object) -> DecoratorCallable[FuncT]:
        # when a method is accessed directly, instance will be None
        ...
    @overload
    def __get__(self, instance: object, owner: object) -> DecoratorCallable[FuncT2]:
        # when a method is accessed through an instance, instance will be that object
        ...

    def __get__(self, instance: Optional[object], owner: object) -> DecoratorCallable:
        ...


def decorator(f: Callable[Concatenate[SelfT, ParamTs], R]) -> DecoratorBase[Callable[Concatenate[SelfT, ParamTs], R], Callable[ParamTs, R]] :
    ...
    
    
    
    
class Widget:
    def bar(self: Any, a: int) -> int:
        ...

    @decorator
    def foo(self: Any, a: int) -> int:
        ...

With the same class as before, the revealed types are now

Widget.bar (undecorated class method):         'def (self: Any, a: builtins.int) -> builtins.int'
w.bar (undecorated instance method):           'def (a: builtins.int) -> builtins.int'
Widget.foo.__call__ (decorated class method):  'def (Any, a: builtins.int) -> builtins.int'
w.foo.__call__ (decorated instance method):    'def (a: builtins.int) -> builtins.int'

This means that MyPy will correctly allow Widget.foo(w, 2) and w.foo(2), and will correctly disallow Widget.foo(w, "A"), w.foo("A"), w.foo(2, 5), and x: dict = w.foo(2). It also allows for keyword arguments; w.foo(a=2) passes.

One corner case that it fails on is that it forgets about the name of self and so Widget.foo(self=w, a = 2) fails with Unexpected keyword argument "self".

like image 187
Josiah Avatar answered Dec 13 '25 10:12

Josiah