diff --git a/CHANGELOG.md b/CHANGELOG.md index ae47941d..4bd5cad5 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 24f51e65..f9c3389c 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -1698,7 +1698,7 @@ class NT(NamedTuple): 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 @@ def x(self): ... 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 @@ def __init__(self) -> None: # 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 @@ class Eggs: ... # 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 @@ def __getattr__(self, attr): # 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 @@ def __init__(self): 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 @@ def __subclasshook__(cls, other): 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 @@ def __call__(self, *args: Unpack[Ts]) -> T: ... 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 9aa84d7e..1b92c396 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -547,7 +547,7 @@ def _caller(depth=2): 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 __init__(cls, *args, **kwargs): ) 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 @@ def __instancecheck__(cls, instance): 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 @@ def _proto_hook(cls, other): 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 @@ def _proto_hook(cls, other): 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