Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Generic type-hinting for kwargs

I'm trying to wrap the signal class of blinker with one that enforces typing so that the arguments to send and connect get type-checked for each specific signal.

eg if I have a signal user_update which expects sender to be an instance of User and have exactly two kwargs: time: int, audit: str, I can sub-class Signal to enforce that like so:

class UserUpdateSignal(Signal):
  class Receiver(Protocol):
    def __call__(sender: User, /, time: int, audit: str):
      ...

  def send(sender: User, /, time: int, audit: str):
    # super call

  def connect(receiver: Receiver):
    # super call

which results in the desired behavior when type-checking:

user_update.send(user, time=34, audit="user_initiated") # OK

@user_update.connect # OK
def receiver(sender: User, /, time: int, audit: str):
    ...

user_update.send("sender") # typing error - signature mismatch

@user_update.connect # typing error - signature mismatch
def receiver(sender: str):
    ...

The issues with this approach are:

  • it's very verbose, for a few dozen signals I'd have hundreds of lines of code
  • it doesn't actually tie the type of the send signature to that of the connect signature - they can be updated independently, type-checking would pass, but the code would crash when run

The ideal approach would apply a signature defined once to both send and connect - probably through generics. I've tried a few approaches so far:

Positional Args Only with ParamSpec

I can achieve the desired behavior using only

class TypedSignal(Generic[P], Signal):
    def send(self, *args: P.args, **kwargs: P.kwargs):
        super().send(*args, **kwargs)

    def connect(self, receiver: Callable[P, None]):
        return super().connect(receiver=receiver)


user_update = TypedSignal[[User, str]]()

This type-checks positional args correctly but has no support for kwargs due to the limitations of Callable. I need kwargs support since blinker uses kwargs for every arg past sender.

Other Attempts

Using TypeVar and TypeVarTuple

I can achieve type-hinting for the sender arg pretty simply using generics:

T = TypeVar("T")

class TypedSignal(Generic[T], Signal):
    def send(self, sender: Type[T], **kwargs):
        super(TypedSignal, self).send(sender)

    def connect(self, receiver: Callable[[Type[T], ...], None]) -> Callable:
        return super(TypedSignal, self).connect(receiver)

# used as
my_signal = TypedSignal[MyClass]()

what gets tricky is when I want to add type-checking for the kwargs. The approach I've been attempting to get working is using a variadic generic and Unpack like so:

T = TypeVar("T")
KW = TypeVarTuple("KW")

class TypedSignal(Generic[T, Unpack[KW]], Signal):
    def send(self, sender: Type[T], **kwargs: Unpack[Type[KW]]):
        super(TypedSignal, self).send(sender)

    def connect(self, receiver: Callable[[Type[T], Unpack[Type[KW]]], None]) -> Callable:
        return super(TypedSignal, self).connect(receiver)

but mypy complains: error: Unpack item in ** argument must be a TypedDict which seems odd because this error gets thrown even with no usage of the generic, let alone when a TypedDict is passed.

Using ParamSpec and Protocol

P = ParamSpec("P")

class TypedSignal(Generic[P], Signal):
    def send(self, *args: P.args, **kwargs: P.kwargs) -> None:
        super().send(*args, **kwargs)

    def connect(self, receiver: Callable[P, None]):
        return super().connect(receiver=receiver)


class Receiver(Protocol):
    def __call__(self, sender: MyClass) -> None:
        pass

update = TypedSignal[Receiver]()


@update.connect
def my_func(sender: MyClass) -> None:
    pass

update.send(MyClass())

but mypy seems to wrap the protocol, so it expects a function that takes the protocol, giving the following errors:

 error: Argument 1 to "connect" of "TypedSignal" has incompatible type "Callable[[MyClass], None]"; expected "Callable[[Receiver], None]"  [arg-type]
 error: Argument 1 to "send" of "TypedSignal" has incompatible type "MyClass"; expected "Receiver"  [arg-type]

Summary

Is there a simpler way to do this? Is this possible with current python typing?

mypy version is 1.9.0 - tried with earlier versions and it crashed completely.

like image 820
Michoel Avatar asked Oct 24 '25 15:10

Michoel


1 Answers

I don't think this is possible.

You can annotate **kwargs in 3 ways:

  1. **kwargs: Foo -> kwargs has type dict[str, Foo]
  2. **kwargs: Unpack[TD] where TD is a TypedDict -> kwargs has type TD
  3. *args: P.args, **kwargs: P.kwargs where P is a ParamSpec -> kwargs is generic over P

So the only way to have generic kwargs is to use 1. with a regular TypeVar, which means all keyword arguments need to have compatible types, or to use 3. but currently that requires you to also have *args, which you don't have.

like image 119
Jasmijn Avatar answered Oct 26 '25 05:10

Jasmijn



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!