Search code examples
pythonpycharmannotationstype-hinting

Define variable parameter type hints for Callables in Python


I have a class that may contain a function that will be called elsewhere. Depending on what's calling it, it could have a variable number of arguments. One owner might be calling it with the arguments (event, int, str) and another might be calling it with (event, str, bool, dict). There are validations that occur later to ensure that the signature matches what is needed by the owner. For type hinting purposes, I need to ensure that the signature being passed matches anything starting with our Event object, with anything after being fine and dandy. As a result, functions like def foo(event: Event, a: int, b: bool) and def (event: ClickEvent, c: dict, *args, **kwargs) -> typing.Coroutine[Any, Any, str] are both absolutely valid.

Given the following example:

from typing import *
from dataclasses import dataclass

PARAMS = ParamSpec("PARAMS")

@dataclass
class Event:
    field1: int
    field2: bool
    name: str

    def get_name(self) -> str:
        return self.name

class SomeCaller:
    ...

class Element:
    ...

class ClickEvent(Event):
    def __init__(self, field1: int, field2: int, target: str):
        super().__init__(field1=field1, field2=field2, name="click")
        self.__target = target

    @property
    def target(self) -> str:
        return self.__target

# The type hint in question
HANDLER = Callable[
    [
        Event,
        PARAMS
    ], Union[Any, Coroutine]
]

def control(event: Event, *args, **kwargs) -> bool:
    pass

def false_control(event: ClickEvent, *args, **kwargs) -> bool:
    pass

async def async_function(event: Event, arg1: int, arg2: int, *args, **kwargs):
    return 9

def function(event: ClickEvent, arg1: int, arg2: int, *args, **kwargs):
    return 7

def other_function(event: Event, caller: SomeCaller, element: Element):
    return 8


class EventHandlerWhatsit:
    def __init__(self, handler: HANDLER):
        self.__handler = handler
        

control_value = EventHandlerWhatsit(control)
false_control_value = EventHandlerWhatsit(false_control)
async_function_value = EventHandlerWhatsit(async_function)
function_value = EventHandlerWhatsit(function)
other_function_value = EventHandlerWhatsit(other_function)

def main():
    print("This works")

if __name__ == "__main__":
    main()

Typing hinting warnings appear on the declaration of false_control_value, async_function_value, function_value, and other_function_value, all with warnings like Expected type '(Event, ParamSpec("PARAMS")) -> Coroutine | Any' (matched generic type '(Event, ParamSpec("PARAMS")) -> Coroutine | Any'), got '(event: Event, arg1: int, arg2: int, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any' instead . The declaration of control_value presents no issue. The assignment of self.__handler = handler within the initializer in EventHandlerWhatsit also shows a warning of Expected type '(Event, ParamSpec("PARAMS")) -> Coroutine | Any', got '(Event, ParamSpec("PARAMS")) -> Coroutine | Any' instead , which I find odd.

All it needs to indicate is "Something that may be called as long as it starts with a parameter that is a subclass of Event". The names don't matter. I've played with the definition of HANDLER in all sorts of ways, such as defining *args and **kwargs as Tuple[Any, ...] and Dict[str, Any] (with and without Optional), with the extra parameters, and still end up with the same sorts of warnings.

I'm stuck in python 3.8 and I'm editing in PyCharm, which shows the warnings.

Any ideas?

EDIT:

@Daniil Fajnberg and @SUTerliakov provided perfect answers in the comments:

HANDLER = Callable[
    Concatenate[
        Event,
        PARAMS
    ], Union[Any, Coroutine]
]

Concatenate allows the annotation to match with slightly different values than what is in the input definition.

Take the following invalid code:

def test_inner(arg: Callable[[str, int, P], Any]):
    pass

def test_input(i: str, j: int, q: str = None, *args, **kwargs):
    pass

def test_outer():
    test_inner(test_input)

The linter will trigger a warning because the existence of the optional q parameter does not fit the expectation of the arg parameter in test_inner. Changing the definition of arg in test_inner to look like arg: Callable[Concatenate[str, int, P], Any], however, and the linter is just fine.

A word of warning: ParamSpec and Concatenate were introduced in Python 3.10. If you need to use an older version due to environment constraints, use the typing_extensions package to provide that functionality.


Solution

  • @Daniil Fajnberg and @SUTerliakov provided perfect answers in the comments:

    HANDLER = Callable[
        Concatenate[
            Event,
            PARAMS
        ], Union[Any, Coroutine]
    ]
    

    Concatenate allows the annotation to match with slightly different values than what is in the input definition.

    Take the following invalid code:

    def test_inner(arg: Callable[[str, int, P], Any]):
        pass
    
    def test_input(i: str, j: int, q: str = None, *args, **kwargs):
        pass
    
    def test_outer():
        test_inner(test_input)
    

    The linter will trigger a warning because the existence of the optional q parameter does not fit the expectation of the arg parameter in test_inner. Changing the definition of arg in test_inner to look like arg: Callable[Concatenate[str, int, P], Any], however, and the linter is just fine.

    A word of warning: ParamSpec and Concatenate were introduced in Python 3.10. If you need to use an older version due to environment constraints, use the typing_extensions package to provide that functionality.