Backport CPython PR 105152 (#208)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae47941..4bd5cad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Unreleased
+- Fix a regression introduced in v4.6.0 in the implementation of
+ runtime-checkable protocols. The regression meant
+ that doing `class Foo(X, typing_extensions.Protocol)`, where `X` was a class that
+ had `abc.ABCMeta` as its metaclass, would then cause subsequent
+ `isinstance(1, X)` calls to erroneously raise `TypeError`. Patch by
+ Alex Waygood (backporting the CPython PR
+ https://github.com/python/cpython/pull/105152).
- Sync the repository's LICENSE file with that of CPython.
`typing_extensions` is distributed under the same license as
CPython itself.
diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py
index 24f51e6..f9c3389 100644
--- a/src/test_typing_extensions.py
+++ b/src/test_typing_extensions.py
@@ -1698,7 +1698,7 @@
skip_if_py312b1 = skipIf(
sys.version_info == (3, 12, 0, 'beta', 1),
- "CPython had a bug in 3.12.0b1"
+ "CPython had bugs in 3.12.0b1"
)
@@ -1902,40 +1902,75 @@
self.assertIsSubclass(C, P)
self.assertIsSubclass(C, PG)
self.assertIsSubclass(BadP, PG)
- with self.assertRaises(TypeError):
+
+ no_subscripted_generics = (
+ "Subscripted generics cannot be used with class and instance checks"
+ )
+
+ with self.assertRaisesRegex(TypeError, no_subscripted_generics):
issubclass(C, PG[T])
- with self.assertRaises(TypeError):
+ with self.assertRaisesRegex(TypeError, no_subscripted_generics):
issubclass(C, PG[C])
- with self.assertRaises(TypeError):
+
+ only_runtime_checkable_protocols = (
+ "Instance and class checks can only be used with "
+ "@runtime_checkable protocols"
+ )
+
+ with self.assertRaisesRegex(TypeError, only_runtime_checkable_protocols):
issubclass(C, BadP)
- with self.assertRaises(TypeError):
+ with self.assertRaisesRegex(TypeError, only_runtime_checkable_protocols):
issubclass(C, BadPG)
- with self.assertRaises(TypeError):
+
+ with self.assertRaisesRegex(TypeError, no_subscripted_generics):
issubclass(P, PG[T])
- with self.assertRaises(TypeError):
+ with self.assertRaisesRegex(TypeError, no_subscripted_generics):
issubclass(PG, PG[int])
+ only_classes_allowed = r"issubclass\(\) arg 1 must be a class"
+
+ with self.assertRaisesRegex(TypeError, only_classes_allowed):
+ issubclass(1, P)
+ with self.assertRaisesRegex(TypeError, only_classes_allowed):
+ issubclass(1, PG)
+ with self.assertRaisesRegex(TypeError, only_classes_allowed):
+ issubclass(1, BadP)
+ with self.assertRaisesRegex(TypeError, only_classes_allowed):
+ issubclass(1, BadPG)
+
def test_protocols_issubclass_non_callable(self):
class C:
x = 1
+
@runtime_checkable
class PNonCall(Protocol):
x = 1
- with self.assertRaises(TypeError):
+
+ non_callable_members_illegal = (
+ "Protocols with non-method members don't support issubclass()"
+ )
+
+ with self.assertRaisesRegex(TypeError, non_callable_members_illegal):
issubclass(C, PNonCall)
+
self.assertIsInstance(C(), PNonCall)
PNonCall.register(C)
- with self.assertRaises(TypeError):
+
+ with self.assertRaisesRegex(TypeError, non_callable_members_illegal):
issubclass(C, PNonCall)
+
self.assertIsInstance(C(), PNonCall)
+
# check that non-protocol subclasses are not affected
class D(PNonCall): ...
+
self.assertNotIsSubclass(C, D)
self.assertNotIsInstance(C(), D)
D.register(C)
self.assertIsSubclass(C, D)
self.assertIsInstance(C(), D)
- with self.assertRaises(TypeError):
+
+ with self.assertRaisesRegex(TypeError, non_callable_members_illegal):
issubclass(D, PNonCall)
def test_no_weird_caching_with_issubclass_after_isinstance(self):
@@ -1954,7 +1989,10 @@
# as the cached result of the isinstance() check immediately above
# would mean the issubclass() call would short-circuit
# before we got to the "raise TypeError" line
- with self.assertRaises(TypeError):
+ with self.assertRaisesRegex(
+ TypeError,
+ "Protocols with non-method members don't support issubclass()"
+ ):
issubclass(Eggs, Spam)
def test_no_weird_caching_with_issubclass_after_isinstance_2(self):
@@ -1971,7 +2009,10 @@
# as the cached result of the isinstance() check immediately above
# would mean the issubclass() call would short-circuit
# before we got to the "raise TypeError" line
- with self.assertRaises(TypeError):
+ with self.assertRaisesRegex(
+ TypeError,
+ "Protocols with non-method members don't support issubclass()"
+ ):
issubclass(Eggs, Spam)
def test_no_weird_caching_with_issubclass_after_isinstance_3(self):
@@ -1992,7 +2033,10 @@
# as the cached result of the isinstance() check immediately above
# would mean the issubclass() call would short-circuit
# before we got to the "raise TypeError" line
- with self.assertRaises(TypeError):
+ with self.assertRaisesRegex(
+ TypeError,
+ "Protocols with non-method members don't support issubclass()"
+ ):
issubclass(Eggs, Spam)
def test_protocols_isinstance(self):
@@ -2028,13 +2072,24 @@
for proto in P, PG, WeirdProto, WeirdProto2, WeirderProto:
with self.subTest(klass=klass.__name__, proto=proto.__name__):
self.assertIsInstance(klass(), proto)
- with self.assertRaises(TypeError):
+
+ no_subscripted_generics = (
+ "Subscripted generics cannot be used with class and instance checks"
+ )
+
+ with self.assertRaisesRegex(TypeError, no_subscripted_generics):
isinstance(C(), PG[T])
- with self.assertRaises(TypeError):
+ with self.assertRaisesRegex(TypeError, no_subscripted_generics):
isinstance(C(), PG[C])
- with self.assertRaises(TypeError):
+
+ only_runtime_checkable_msg = (
+ "Instance and class checks can only be used "
+ "with @runtime_checkable protocols"
+ )
+
+ with self.assertRaisesRegex(TypeError, only_runtime_checkable_msg):
isinstance(C(), BadP)
- with self.assertRaises(TypeError):
+ with self.assertRaisesRegex(TypeError, only_runtime_checkable_msg):
isinstance(C(), BadPG)
def test_protocols_isinstance_properties_and_descriptors(self):
@@ -2435,12 +2490,13 @@
self.assertIsSubclass(OKClass, C)
self.assertNotIsSubclass(BadClass, C)
+ @skip_if_py312b1
def test_issubclass_fails_correctly(self):
@runtime_checkable
class P(Protocol):
x = 1
class C: pass
- with self.assertRaises(TypeError):
+ with self.assertRaisesRegex(TypeError, r"issubclass\(\) arg 1 must be a class"):
issubclass(C(), P)
def test_defining_generic_protocols(self):
@@ -2768,6 +2824,30 @@
self.assertEqual(Y.__parameters__, ())
self.assertEqual(Y.__args__, (int, bytes, memoryview))
+ @skip_if_py312b1
+ def test_interaction_with_isinstance_checks_on_superclasses_with_ABCMeta(self):
+ # Ensure the cache is empty, or this test won't work correctly
+ collections.abc.Sized._abc_registry_clear()
+
+ class Foo(collections.abc.Sized, Protocol): pass
+
+ # CPython gh-105144: this previously raised TypeError
+ # if a Protocol subclass of Sized had been created
+ # before any isinstance() checks against Sized
+ self.assertNotIsInstance(1, collections.abc.Sized)
+
+ @skip_if_py312b1
+ def test_interaction_with_isinstance_checks_on_superclasses_with_ABCMeta_2(self):
+ # Ensure the cache is empty, or this test won't work correctly
+ collections.abc.Sized._abc_registry_clear()
+
+ class Foo(typing.Sized, Protocol): pass
+
+ # CPython gh-105144: this previously raised TypeError
+ # if a Protocol subclass of Sized had been created
+ # before any isinstance() checks against Sized
+ self.assertNotIsInstance(1, typing.Sized)
+
class Point2DGeneric(Generic[T], TypedDict):
a: T
diff --git a/src/typing_extensions.py b/src/typing_extensions.py
index 9aa84d7..1b92c39 100644
--- a/src/typing_extensions.py
+++ b/src/typing_extensions.py
@@ -547,7 +547,7 @@
Protocol = typing.Protocol
runtime_checkable = typing.runtime_checkable
else:
- def _allow_reckless_class_checks(depth=4):
+ def _allow_reckless_class_checks(depth=3):
"""Allow instance and class checks for special stdlib modules.
The abc and functools modules indiscriminately call isinstance() and
issubclass() on the whole MRO of a user class, which may contain protocols.
@@ -572,14 +572,22 @@
)
def __subclasscheck__(cls, other):
+ if not isinstance(other, type):
+ # Same error message as for issubclass(1, int).
+ raise TypeError('issubclass() arg 1 must be a class')
if (
getattr(cls, '_is_protocol', False)
- and not cls.__callable_proto_members_only__
- and not _allow_reckless_class_checks(depth=3)
+ and not _allow_reckless_class_checks()
):
- raise TypeError(
- "Protocols with non-method members don't support issubclass()"
- )
+ if not cls.__callable_proto_members_only__:
+ raise TypeError(
+ "Protocols with non-method members don't support issubclass()"
+ )
+ if not getattr(cls, '_is_runtime_protocol', False):
+ raise TypeError(
+ "Instance and class checks can only be used with "
+ "@runtime_checkable protocols"
+ )
return super().__subclasscheck__(other)
def __instancecheck__(cls, instance):
@@ -591,7 +599,7 @@
if (
not getattr(cls, '_is_runtime_protocol', False) and
- not _allow_reckless_class_checks(depth=2)
+ not _allow_reckless_class_checks()
):
raise TypeError("Instance and class checks can only be used with"
" @runtime_checkable protocols")
@@ -632,18 +640,6 @@
if not cls.__dict__.get('_is_protocol', False):
return NotImplemented
- # First, perform various sanity checks.
- if not getattr(cls, '_is_runtime_protocol', False):
- if _allow_reckless_class_checks():
- return NotImplemented
- raise TypeError("Instance and class checks can only be used with"
- " @runtime_checkable protocols")
-
- if not isinstance(other, type):
- # Same error message as for issubclass(1, int).
- raise TypeError('issubclass() arg 1 must be a class')
-
- # Second, perform the actual structural compatibility check.
for attr in cls.__protocol_attrs__:
for base in other.__mro__:
# Check if the members appears in the class dictionary...
@@ -658,8 +654,6 @@
isinstance(annotations, collections.abc.Mapping)
and attr in annotations
and issubclass(other, (typing.Generic, _ProtocolMeta))
- # All subclasses of Generic have an _is_proto attribute on 3.8+
- # But not on 3.7
and getattr(other, "_is_protocol", False)
):
break