Skip to content

gh-137191: Fix how type parameters are collected from Protocol and Generic bases with parameters #137281

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

Merged
merged 3 commits into from
Aug 3, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions Lib/test/test_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -3958,6 +3958,7 @@ class C: pass

def test_defining_generic_protocols(self):
T = TypeVar('T')
T2 = TypeVar('T2')
S = TypeVar('S')

@runtime_checkable
Expand All @@ -3967,17 +3968,26 @@ def meth(self): pass
class P(PR[int, T], Protocol[T]):
y = 1

self.assertEqual(P.__parameters__, (T,))

with self.assertRaises(TypeError):
PR[int]
with self.assertRaises(TypeError):
P[int, str]
with self.assertRaisesRegex(
TypeError,
re.escape('Some type variables (~S) are not listed in Protocol[~T, ~T2]'),
):
class ExtraTypeVars(P[S], Protocol[T, T2]): ...

class C(PR[int, T]): pass

self.assertEqual(C.__parameters__, (T,))
self.assertIsInstance(C[str](), C)

def test_defining_generic_protocols_old_style(self):
T = TypeVar('T')
T2 = TypeVar('T2')
S = TypeVar('S')

@runtime_checkable
Expand All @@ -3996,9 +4006,19 @@ class P(PR[int, str], Protocol):
class P1(Protocol, Generic[T]):
def bar(self, x: T) -> str: ...

self.assertEqual(P1.__parameters__, (T,))

class P2(Generic[T], Protocol):
def bar(self, x: T) -> str: ...

self.assertEqual(P2.__parameters__, (T,))

msg = re.escape('Some type variables (~S) are not listed in Protocol[~T, ~T2]')
with self.assertRaisesRegex(TypeError, msg):
class ExtraTypeVars(P1[S], Protocol[T, T2]): ...
with self.assertRaisesRegex(TypeError, msg):
class ExtraTypeVars(P2[S], Protocol[T, T2]): ...

@runtime_checkable
class PSub(P1[str], Protocol):
x = 1
Expand All @@ -4011,6 +4031,28 @@ def bar(self, x: str) -> str:

self.assertIsInstance(Test(), PSub)

def test_protocol_parameter_order(self):
# https://github.com/python/cpython/issues/137191
T1 = TypeVar("T1")
T2 = TypeVar("T2", default=object)

class A(Protocol[T1]): ...

class B0(A[T2], Generic[T1, T2]): ...
self.assertEqual(B0.__parameters__, (T1, T2))

class B1(A[T2], Protocol, Generic[T1, T2]): ...
self.assertEqual(B1.__parameters__, (T1, T2))

class B2(A[T2], Protocol[T1, T2]): ...
self.assertEqual(B2.__parameters__, (T1, T2))

class B3[T1, T2](A[T2], Protocol):
@staticmethod
def get_typeparams():
return (T1, T2)
self.assertEqual(B3.__parameters__, B3.get_typeparams())

def test_pep695_generic_protocol_callable_members(self):
@runtime_checkable
class Foo[T](Protocol):
Expand Down
32 changes: 28 additions & 4 deletions Lib/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,16 +256,27 @@ def _type_repr(obj):
return _lazy_annotationlib.type_repr(obj)


def _collect_type_parameters(args, *, enforce_default_ordering: bool = True):
def _collect_type_parameters(
args,
*,
enforce_default_ordering: bool = True,
validate_all: bool = False,
):
"""Collect all type parameters in args
in order of first appearance (lexicographic order).

Having an explicit `Generic` or `Protocol` base class determins
the exact parameter order.

For example::

>>> P = ParamSpec('P')
>>> T = TypeVar('T')
>>> _collect_type_parameters((T, Callable[P, T]))
(~T, ~P)
>>> _collect_type_parameters((list[T], Generic[P, T]))
(~P, ~T)

"""
# required type parameter cannot appear after parameter with default
default_encountered = False
Expand Down Expand Up @@ -297,6 +308,17 @@ def _collect_type_parameters(args, *, enforce_default_ordering: bool = True):
' follows type parameter with a default')

parameters.append(t)
elif (
not validate_all
and isinstance(t, _GenericAlias)
and t.__origin__ in (Generic, Protocol)
):
# If we see explicit `Generic[...]` or `Protocol[...]` base classes,
# we need to just copy them as-is.
# Unless `validate_all` is passed, in this case it means that
# we are doing a validation of `Generic` subclasses,
# then we collect all unique parameters to be able to inspect them.
parameters = t.__parameters__
else:
if _is_unpacked_typevartuple(t):
type_var_tuple_encountered = True
Expand Down Expand Up @@ -1156,28 +1178,30 @@ def _generic_init_subclass(cls, *args, **kwargs):
if error:
raise TypeError("Cannot inherit from plain Generic")
if '__orig_bases__' in cls.__dict__:
tvars = _collect_type_parameters(cls.__orig_bases__)
tvars = _collect_type_parameters(cls.__orig_bases__, validate_all=True)
# Look for Generic[T1, ..., Tn].
# If found, tvars must be a subset of it.
# If not found, tvars is it.
# Also check for and reject plain Generic,
# and reject multiple Generic[...].
gvars = None
basename = None
for base in cls.__orig_bases__:
if (isinstance(base, _GenericAlias) and
base.__origin__ is Generic):
base.__origin__ in (Generic, Protocol)):
if gvars is not None:
raise TypeError(
"Cannot inherit from Generic[...] multiple times.")
gvars = base.__parameters__
basename = base.__origin__.__name__
if gvars is not None:
tvarset = set(tvars)
gvarset = set(gvars)
if not tvarset <= gvarset:
s_vars = ', '.join(str(t) for t in tvars if t not in gvarset)
s_args = ', '.join(str(g) for g in gvars)
raise TypeError(f"Some type variables ({s_vars}) are"
f" not listed in Generic[{s_args}]")
f" not listed in {basename}[{s_args}]")
tvars = gvars
cls.__parameters__ = tuple(tvars)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Fix how type parameters are collected, when :class:`typing.Protocol` are
specified with explicit parameters. Now, :class:`typing.Generic` and
:class:`typing.Protocol` always dictate the parameter number
and parameter ordering of types. Previous behavior was a bug.
Loading