From e65b036661eb472a3682eca1ceb78eb57b21d200 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 23 Jun 2023 16:18:19 +0100 Subject: [PATCH] Backport CPython PR 105976 (#252) --- CHANGELOG.md | 10 ++++++++ src/test_typing_extensions.py | 44 +++++++++++++++++++++++++++++++++++ src/typing_extensions.py | 27 +++++++++------------ 3 files changed, 65 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fd5ef1f..54c4d1c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# Unreleased + +- Fix bug where a `typing_extensions.Protocol` class that had one or more + non-callable members would raise `TypeError` when `issubclass()` + was called against it, even if it defined a custom `__subclasshook__` + method. The correct behaviour -- which has now been restored -- is not to + raise `TypeError` in these situations if a custom `__subclasshook__` method + is defined. Patch by Alex Waygood (backporting + https://github.com/python/cpython/pull/105976). + # Release 4.7.0rc1 (June 21, 2023) - Add `typing_extensions.get_protocol_members` and diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index d7e9dc44..c2ab6d7f 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -2713,6 +2713,50 @@ def __subclasshook__(cls, other): self.assertIsSubclass(OKClass, C) self.assertNotIsSubclass(BadClass, C) + @skipIf( + sys.version_info[:4] == (3, 12, 0, 'beta') and sys.version_info[4] < 4, + "Early betas of Python 3.12 had a bug" + ) + def test_custom_subclasshook_2(self): + @runtime_checkable + class HasX(Protocol): + # The presence of a non-callable member + # would mean issubclass() checks would fail with TypeError + # if it weren't for the custom `__subclasshook__` method + x = 1 + + @classmethod + def __subclasshook__(cls, other): + return hasattr(other, 'x') + + class Empty: pass + + class ImplementsHasX: + x = 1 + + self.assertIsInstance(ImplementsHasX(), HasX) + self.assertNotIsInstance(Empty(), HasX) + self.assertIsSubclass(ImplementsHasX, HasX) + self.assertNotIsSubclass(Empty, HasX) + + # isinstance() and issubclass() checks against this still raise TypeError, + # despite the presence of the custom __subclasshook__ method, + # as it's not decorated with @runtime_checkable + class NotRuntimeCheckable(Protocol): + @classmethod + def __subclasshook__(cls, other): + return hasattr(other, 'x') + + must_be_runtime_checkable = ( + "Instance and class checks can only be used " + "with @runtime_checkable protocols" + ) + + with self.assertRaisesRegex(TypeError, must_be_runtime_checkable): + issubclass(object, NotRuntimeCheckable) + with self.assertRaisesRegex(TypeError, must_be_runtime_checkable): + isinstance(object(), NotRuntimeCheckable) + @skip_if_py312b1 def test_issubclass_fails_correctly(self): @runtime_checkable diff --git a/src/typing_extensions.py b/src/typing_extensions.py index c37926d2..b77c1fdb 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -644,14 +644,17 @@ def __init__(cls, *args, **kwargs): def __subclasscheck__(cls, other): if cls is Protocol: return type.__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 _allow_reckless_class_checks() ): - if not cls.__callable_proto_members_only__: + if not isinstance(other, type): + # Same error message as for issubclass(1, int). + raise TypeError('issubclass() arg 1 must be a class') + if ( + not cls.__callable_proto_members_only__ + and cls.__dict__.get("__subclasshook__") is _proto_hook + ): raise TypeError( "Protocols with non-method members don't support issubclass()" ) @@ -752,12 +755,8 @@ def __init_subclass__(cls, *args, **kwargs): if '__subclasshook__' not in cls.__dict__: cls.__subclasshook__ = _proto_hook - # We have nothing more to do for non-protocols... - if not cls._is_protocol: - return - - # ... otherwise prohibit instantiation. - if cls.__init__ is Protocol.__init__: + # Prohibit instantiation for protocol classes + if cls._is_protocol and cls.__init__ is Protocol.__init__: cls.__init__ = _no_init else: @@ -847,12 +846,8 @@ def __init_subclass__(cls, *args, **kwargs): if '__subclasshook__' not in cls.__dict__: cls.__subclasshook__ = _proto_hook - # We have nothing more to do for non-protocols. - if not cls._is_protocol: - return - - # Prohibit instantiation - if cls.__init__ is Protocol.__init__: + # Prohibit instantiation for protocol classes + if cls._is_protocol and cls.__init__ is Protocol.__init__: cls.__init__ = _no_init