Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Generic with ParamSpec not subscriptable with multiple arguments [non-generic-variant] #491

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
110 changes: 105 additions & 5 deletions src/test_typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3705,6 +3705,10 @@ def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T: ...
self.assertEqual(Y.__parameters__, ())
self.assertEqual(Y.__args__, ((int, str, str), bytes, memoryview))

# Regression test; fixing #126 might cause an error here
with self.assertRaisesRegex(TypeError, "not a generic class"):
Y[int]

def test_protocol_generic_over_typevartuple(self):
Ts = TypeVarTuple("Ts")
T = TypeVar("T")
Expand Down Expand Up @@ -5259,6 +5263,7 @@ class X(Generic[T, P]):
class Y(Protocol[T, P]):
pass

things = "arguments" if sys.version_info >= (3, 10) else "parameters"
for klass in X, Y:
with self.subTest(klass=klass.__name__):
G1 = klass[int, P_2]
Expand All @@ -5273,20 +5278,117 @@ class Y(Protocol[T, P]):
self.assertEqual(G3.__args__, (int, Concatenate[int, ...]))
self.assertEqual(G3.__parameters__, ())

with self.assertRaisesRegex(
TypeError,
f"Too few {things} for {klass}"
):
klass[int]

# The following are some valid uses cases in PEP 612 that don't work:
# These do not work in 3.9, _type_check blocks the list and ellipsis.
# G3 = X[int, [int, bool]]
# G4 = X[int, ...]
# G5 = Z[[int, str, bool]]
# Not working because this is special-cased in 3.10.
# G6 = Z[int, str, bool]

def test_single_argument_generic(self):
P = ParamSpec("P")
T = TypeVar("T")
P_2 = ParamSpec("P_2")

class Z(Generic[P]):
pass

class ProtoZ(Protocol[P]):
pass

for klass in Z, ProtoZ:
with self.subTest(klass=klass.__name__):
# Note: For 3.10+ __args__ are nested tuples here ((int, ),) instead of (int, )
G6 = klass[int, str, T]
G6args = G6.__args__[0] if sys.version_info >= (3, 10) else G6.__args__
self.assertEqual(G6args, (int, str, T))

# P = [int]
G7 = klass[int]
G7args = G7.__args__[0] if sys.version_info >= (3, 10) else G7.__args__
self.assertEqual(G7args, (int,))
self.assertEqual(G7.__parameters__, ())

G8 = klass[Concatenate[T, ...]]
self.assertEqual(G8.__args__, (Concatenate[T, ...], ))
self.assertEqual(G8.__parameters__, (T,))

G9 = klass[Concatenate[T, P_2]]
self.assertEqual(G9.__args__, (Concatenate[T, P_2], ))

# This is an invalid form but useful for testing correct subsitution
G10 = klass[int, Concatenate[str, P]]
G10args = G10.__args__[0] if sys.version_info >= (3, 10) else G10.__args__
self.assertEqual(G10args, (int, Concatenate[str, P], ))

def test_single_argument_generic_with_parameter_expressions(self):
P = ParamSpec("P")
T = TypeVar("T")
P_2 = ParamSpec("P_2")

class Z(Generic[P]):
pass

class ProtoZ(Protocol[P]):
pass

things = "arguments" if sys.version_info >= (3, 10) else "parameters"
for klass in Z, ProtoZ:
with self.subTest(klass=klass.__name__):
G6 = klass[int, str, T]
G8 = klass[Concatenate[T, ...]]
G9 = klass[Concatenate[T, P_2]]
G10 = klass[int, Concatenate[str, P]]

with self.subTest("Check generic substitution", klass=klass.__name__):
if sys.version_info < (3, 10):
self.skipTest("_ConcatenateGenericAlias not subscriptable")
with self.assertRaisesRegex(TypeError, "Expected a list of types, an ellipsis, ParamSpec, or Concatenate"):
G9[int, int]

with self.subTest("Check list as parameter expression", klass=klass.__name__):
if sys.version_info < (3, 10):
self.skipTest("Cannot pass non-types")
G5 = klass[[int, str, T]]
self.assertEqual(G5.__args__, ((int, str, T),))
H9 = G9[int, [T]]

self.assertEqual(G9.__parameters__, (T, P_2))
with self.subTest("Check parametrization", klass=klass.__name__):
if sys.version_info[:2] == (3, 10):
self.skipTest("Parameter detection fails in 3.10")
with self.assertRaisesRegex(TypeError, f"Too few {things}"):
G9[int] # for python 3.10 this has no parameters
self.assertEqual(G6.__parameters__, (T,))
if sys.version_info >= (3, 10): # skipped above
self.assertEqual(G5.__parameters__, (T,))
self.assertEqual(H9.__parameters__, (T,))

with self.subTest("Check further substitution", klass=klass.__name__):
if sys.version_info < (3, 10):
self.skipTest("_ConcatenateGenericAlias not subscriptable")
if sys.version_info[:2] == (3, 10):
self.skipTest("Parameter detection fails in 3.10")
if (3, 11, 0) <= sys.version_info[:3] < (3, 11, 3):
self.skipTest("Wrong recursive substitution")
H1 = G8[int]
self.assertEqual(H1.__parameters__, ())
with self.assertRaisesRegex(TypeError, "not a generic class"):
H1[str] # for python 3.11.0-3 this still has a parameter

H2 = G8[T][int]
self.assertEqual(H2.__parameters__, ())
with self.assertRaisesRegex(TypeError, "not a generic class"):
H2[str] # for python 3.11.0-3 this still has a parameter

H3 = G10[int]
self.assertEqual(H3.__args__, ((int, (str, int)),))

def test_pickle(self):
global P, P_co, P_contra, P_default
P = ParamSpec('P')
Expand Down Expand Up @@ -7465,11 +7567,9 @@ def test_callable_with_concatenate(self):
self.assertEqual(callable_concat.__parameters__, (P2,))
concat_usage = callable_concat[str]
with self.subTest("get_args of Concatenate in TypeAliasType"):
if not TYPING_3_9_0:
if not TYPING_3_10_0:
# args are: ([<class 'int'>, ~P2],)
self.skipTest("Nested ParamSpec is not substituted")
if sys.version_info < (3, 10, 2):
self.skipTest("GenericAlias keeps Concatenate in __args__ prior to 3.10.2")
self.assertEqual(get_args(concat_usage), ((int, str),))
with self.subTest("Equality of parameter_expression without []"):
if not TYPING_3_10_0:
Expand Down
15 changes: 15 additions & 0 deletions src/typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3006,6 +3006,10 @@ def wrapper(*args, **kwargs):
f"a class or callable, not {arg!r}"
)

def _is_param_expr(arg):
return arg is ... or isinstance(
arg, (tuple, list, ParamSpec, _ConcatenateGenericAlias)
)

# We have to do some monkey patching to deal with the dual nature of
# Unpack/TypeVarTuple:
Expand All @@ -3020,6 +3024,17 @@ def _check_generic(cls, parameters, elen=_marker):

This gives a nice error message in case of count mismatch.
"""
# If substituting a single ParamSpec with multiple arguments
# we do not check the count
if (inspect.isclass(cls) and issubclass(cls, typing.Generic)
and len(cls.__parameters__) == 1
and isinstance(cls.__parameters__[0], ParamSpec)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to check both typing.ParamSpec and typing_extensions.ParamSpec?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not think so. For 3.10 the _TypeVarLikeMeta __instancecheck__ of typing_extensions.ParamSpec will check for the typing variant.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it might be useful to have a test on 3.10+ that uses typing.Concatenate and typing.ParamSpec, which are different from the typing_extensions version.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests added & like noted in #489 for Concatenate a patch was necessary.

and parameters
and not _is_param_expr(parameters[0])
):
# Generic modifies parameters variable, but here we cannot do this
return

if not elen:
raise TypeError(f"{cls} is not a generic class")
if elen is _marker:
Expand Down
Loading