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:
send signature to that of the connect signature - they can be updated independently, type-checking would pass, but the code would crash when runThe ideal approach would apply a signature defined once to both send and connect - probably through generics. I've tried a few approaches so far:
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.
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.
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]
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.
I don't think this is possible.
You can annotate **kwargs in 3 ways:
**kwargs: Foo -> kwargs has type dict[str, Foo]**kwargs: Unpack[TD] where TD is a TypedDict -> kwargs has type TD*args: P.args, **kwargs: P.kwargs where P is a ParamSpec -> kwargs is generic over PSo 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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With