I made a PR to numpy to improve shape typing. The primary purpose was to make the type variable for shape (a) bound to a tuple of ints and (b) covariant.
As an additional feature, someone requested an alias that used TypeVarTuple, as in the PEP 646 example. Although TypeVarTuple cannot be bound or made covariant, unpacking one inside a bound typevar seemed to work, and still does, for the most part. What is the intended intended behavior? As a related question, is there any activity to add bounds/variance to TypeVarTuple?
MWE (as debug.pyi, to avoid “___ is unbound” errors):
Define Array alias to ndarray (no numpy)
from typing import Generic, NewType, TypeVar
import sys
if sys.version_info < (3, 11):
from typing_extensions import assert_type, reveal_type, TypeVarTuple, Unpack
else:
from typing import assert_type, reveal_type, TypeVarTuple, Unpack
_ShapeType = TypeVar("_ShapeType", covariant=True, bound=tuple[int, ...])
_ShapeTypeTuple = TypeVarTuple("_ShapeTypeTuple")
class ndarray(Generic[_ShapeType]):
shape: _ShapeType
Array = ndarray[tuple[Unpack[_ShapeTypeTuple]]]
The definition of Array causes a type error in both pyright and mypy: Type argument "tuple[*_ShapeTypeTuple]" of "ndarray" must be a subtype of "tuple[int, ...]". The crux here is whether type checkers still safely handle functions that use Array, and whether that safety is guaranteed or an accident of implementation.
Test 1: Array parameters unpacked correctly, bounds apply
Length = NewType("Length", int)
Width = NewType("Width", int)
arr: Array[Length, Width]
assert_type(arr.shape, tuple[Length, Width])
bad: Array[str]
This passes pyright. OTOH, mypy allows the definition of arr, but correctly flags the definiton of bad with "tuple[str]" of "ndarray" must be a subtype of "tuple[int, ...]". I assume this means that pyright has decided that since the Array definition is flawed, it can’t provide any safety to Array objects. Mypy, however, seems to pass the bounds of _ShapeType through _ShapeTypeTuple.
Test 2: I can use Array in function signatures
Ts = TypeVarTuple("Ts")
def stack(a: Array[Unpack[Ts]], b: Array[Unpack[Ts]]) -> Array[int, Unpack[Ts]]: ...
doubled = stack(arr, arr)
assert_type(doubled.shape, tuple[int, Length, Width])
doublebad = stack(bad, bad)
Mypy raises an error about all the types in the stack signature ("tuple[*Ts]"/"tuple[int, *Ts]" must be a subtype of subtype of "tuple[int, ...]"), but pyright does not. I assume that’s because of when each resolves type aliases. Both pass the assert_type, but both unfortunately also accept doubelbad. Nevertheless, if mypy prevents us from ever creating an Array[str], it would seem like a safe to ignore the function signature errors, no?
Corollary problem: Cannot unpack tuple-bound TypeVar
T = TypeVar("T", covariant=True, bound=tuple[int, ...])
def stack2(a: ndarray[T], b: ndarray[T]) -> ndarray[tuple[int, Unpack[T]]]: ...
doubled2 = stack2(arr, arr)
assert_type(doubled2.shape, tuple[int, Length, Width])
doublebad2 = stack2(bad, bad)
Here, both mypy and pyright complain that they can only unpack a tuple or a TypeVarTuple, but not a tuple-bound TypeVar. As a result, the assert_type fails, as since the shape of doubled2 is either tuple[int, *tuple[Any, ...]] (mypy) or tuple[int, Unknown] (pyright). This is unfortunate, because this definition (correctly) causes type checkers to complain about stack2(bad, bad)