Type parameter list cannot be empty for TypeVarTuple

284 views Asked by At

I have the following type parametrized signal:

from __future__ import annotations

from typing_extensions import Callable, TypeVarTuple, Generic, Unpack, List, TypeVar

VarArgs = TypeVarTuple('VarArgs')


class Signal(Generic[Unpack[VarArgs]]):

    def __init__(self):
        self.functions: List[Callable[..., None]] = []
        """
        Simple mechanism that allows abstracted invocation of callbacks. Multiple callbacks can be attached to a signal
        so that they are all called when the signal is emitted.
        """

    def connect(self, function: Callable[..., None]):
        """
        Add a callback to this Signal
        :param function: callback to call when emited
        """
        self.functions.append(function)

    def emit(self, *args: Unpack[VarArgs]):
        """
        Call all callbacks with the arguments passed
        :param args: arguments for the signal, must be the same type as type parameter
        """
        for function in self.functions:
            if args:
                function(*args)
            else:
                function()


def main():
    def signalEmptyCall():
        print("Hello!")

    signal: Signal[()] = Signal[()]()

    signal.connect(signalEmptyCall)

    signal.emit()


if __name__ == '__main__':
    main()

The problem is that I cannot have empty argument list in the Callable:

signal: Signal[()] = Signal[()]()

In Python 3.10 I get this error:

Traceback (most recent call last):
  File ".../main.py", line 48, in <module>
    main()
  File ".../main.py", line 40, in main
    signal: Signal[()] = Signal[()]()
  File "/usr/lib/python3.10/typing.py", line 312, in inner
    return func(*args, **kwds)
  File "/usr/lib/python3.10/typing.py", line 1328, in __class_getitem__
    raise TypeError(
TypeError: Parameter list to Signal[...] cannot be empty

Reading the code in typing.py I find that Tuple is a special case (if not params and cls is not Tuple: where it doesn't crash for Tuple[()]), but TypeVarTuple has no special case, but is semantically a very similar thing.

Doing signal: Signal = Signal() works, but it seems like I am breaking something because PyCharm warns that Expected type '(Any) -> None', got '() -> None' instead.

How can I fix this?

Thanks for your time.

4

There are 4 answers

1
EmmanuelMess On BEST ANSWER

As @MikeWilliamson pointed out this seems to be a problem in python3.10 because of an edge case of how types are implemented. And as @InSync pointed out ParamSpec should be used for Callback parameters.

The simplest solution here seems to be to have two Signals, one generic, and the other one to deal with this special case.

General case:

from __future__ import annotations

from typing_extensions import Callable, Generic, List, ParamSpec

VarArgs = ParamSpec('VarArgs')


class Signal(Generic[VarArgs]):
    """
    Simple mechanism that allows abstracted invocation of callbacks. Multiple callbacks can be attached to a signal
    so that they are all called when the signal is emitted.
    """

    def __init__(self) -> None:
        self.functions: List[Callable[VarArgs, None]] = []
        """
        Simple mechanism that allows abstracted invocation of callbacks. Multiple callbacks can be attached to a signal
        so that they are all called when the signal is emitted.
        """

    def connect(self, function: Callable[VarArgs, None]) -> None:
        """
        Add a callback to this Signal
        :param function: callback to call when emited
        """
        self.functions.append(function)

    def emit(self, *args: VarArgs.args, **kwargs: VarArgs.kwargs) -> None:
        """
        Call all callbacks with the arguments passed
        :param args: arguments for the signal, must be the same type as type parameter
        """
        for function in self.functions:
            function(*args, **kwargs)

Special case:

from __future__ import annotations

from typing_extensions import List, Callable


class SimpleSignal:
    """
    HACK for signals with no parameters. See Signal for a generic version.
    """

    def __init__(self) -> None:
        self.functions: List[Callable[[], None]] = []
        """
        Simple mechanism that allows abstracted invocation of callbacks. Multiple callbacks can be attached to a signal
        so that they are all called when the signal is emitted.
        """

    def connect(self, function: Callable[[], None]) -> None:
        """
        Add a callback to this SimpleSignal
        :param function: callback to call when emited
        """
        self.functions.append(function)

    def emit(self) -> None:
        """
        Call all callbacks
        """
        for function in self.functions:
            function()
5
حمزة نبيل On

From PEP 646 – Variadic Generics:

If no arguments are passed, the type variable tuple behaves like an empty tuple, Tuple[()]

So you can try this:

appInitialization: Signal[()] = Signal()
3
Mike Williamson On

I was able to take your code exactly as is, and it all worked fine... if you use Python version 3.11. I had the same problem as you when working with version 3.10, as you had.

So, my guess is that maybe you have some sort of conflict with versioning, so that how things should work isn't matching the documentation that you're reading on how they do work.

First, my result:

>>> main()
Hello!
>>> signal: Signal[()] = Signal[()]()
>>> signal
<__main__.Signal object at 0x7f68c19439d0>
>>>

Next, my Python version:

> python --version
Python 3.11.7

AFAICT, you didn't do anything wrong, except there is maybe some underlying conflict.

4
InSync On

Simplest solution

Add from __future__ import annotations to the top of your module. This will prevent Python from evaluating your type hints and therefore the code in typing that would otherwise raise the exception will never run.

Note that only it will only prevent type hints as variable annotations from being evaluated.

This will be fine:

signal: Signal[()] = Signal()

...but this will not:

signal = Signal[()]()

You can also remove all your type hints, but you probably don't want that.

Continue reading for better ways.

Use ParamSpec instead

You don't actually want TypeVarTuple. You want ParamSpec. TypeVarTuple is used to pass an unknown number of type parameters to a generic. It is not usually used to specify the type of function parameters.

Also, AFAIK, a type checker won't be able to infer a type variable that is underterministic at the construction time depending on a later calls that might or might not happen. Not to mention, a Signal with no listeners is quite useless.

That said, if you can make the constructor accepting an initial listener, things will be much easier:

(playground links: mypy, Pyright)

class Signal(Generic[P]):

  listeners: list[Callable[P, None]]

  def __init__(self, initial_listener: Callable[P, None]) -> None:
    self.listeners = [initial_listener]

  def connect(self, listener: Callable[P, None]) -> None:
    self.listeners.append(listener)

  def emit(self, *args: P.args, **kwargs: P.kwargs) -> None:
    for listener in self.listeners:
      listener(*args, **kwargs)

For the following setup code:

def say_hi() -> None:
  print('Hello!')

def say_hi_to(target: str) -> None:
  print(f'Hello {target}!')

signal = Signal(say_hi)

...we get these results:

signal.connect(say_hi_to)
# mypy    => error: Argument 1 to "connect" of "Signal" has incompatible type "Callable[[str], None]"; expected "Callable[[], None]"
# Pyright => error: Argument of type "(target: str) -> None" cannot be assigned to parameter "listener" of type "() -> None" in function "connect" 
# PyCharm => fine (incorrect)

signal.emit()
# All: fine

signal.emit('world')
# mypy    => error: Too many arguments for "emit" of "Signal"
# Pyright => Expected 0 positional arguments
# PyCharm => Unexpected argument (from ParamSpec 'P')

You can also use ParamSpec without specifying an initial listener, but the results won't be as good.

(playground links: mypy, Pyright)

...
class Signal(Generic[P]):
  ...
  def __init__(self) -> None:
    self.listeners = []
  ...
signal: Signal[[]] = Signal()  # Fine both at type checking time and at runtime

Now PyCharm just passes everything:

signal.connect(say_hi)
# All: fine

signal.connect(say_hi_to)
# mypy & Pyright => same error
# PyCharm        => fine (incorrect)

signal.emit()
# All: fine

signal.emit('world')
# mypy & Pyright => same error
# PyCharm        => fine (incorrect)

Still want to use TypeVarTuple?

The first ParamSpec solution works best for PyCharm, since it has somewhat decent support for ParamSpec. For TypeVarTuple, not so much.

If you insists on using the latter, you need to specify the type of a listener to be Callable[[ Unpack[Ts] ], None]. It doesn't matter to PyCharm either way, but people using other type checkers would prefer you do that.

(playground links: mypy, Pyright)

Ts = TypeVarTuple('Ts')

class Signal(Generic[Unpack[Ts]]):

  listeners: list[Callable[[Unpack[Ts]], None]]

  def __init__(self, initial_listener: Callable[[Unpack[Ts]], None]) -> None:
    self.listeners = [initial_listener]

  def connect(self, listener: Callable[[Unpack[Ts]], None]) -> None:
    self.listeners.append(listener)

  def emit(self, *args: Unpack[Ts]) -> None:
    for listener in self.listeners:
      listener(*args)

The errors are the same for mypy and Pyright, but for PyCharm, it fails miserably. You can make it quiet with # noqa or # type: ignore if you want to.

signal = Signal(say_hi)    # warning: Expected type '(Any) -> None', got '() -> None' instead (incorrect)
signal.connect(say_hi_to)  # fine (incorrect)
signal.emit()              # fine (correct)
signal.emit('world')       # fine (incorrect)