[Python-checkins] [3.12] gh-105974: Revert unintentional behaviour change for protocols with non-callable members and custom `__subclasshook__` methods (GH-105976) (#106032)

AlexWaygood webhook-mailer at python.org
Fri Jun 23 11:26:41 EDT 2023


https://github.com/python/cpython/commit/7d6ee298e96559e13d343b47ac489fcb4f219cd4
commit: 7d6ee298e96559e13d343b47ac489fcb4f219cd4
branch: 3.12
author: Miss Islington (bot) <31488909+miss-islington at users.noreply.github.com>
committer: AlexWaygood <Alex.Waygood at Gmail.com>
date: 2023-06-23T15:26:37Z
summary:

[3.12] gh-105974: Revert unintentional behaviour change for protocols with non-callable members and custom `__subclasshook__` methods (GH-105976) (#106032)

gh-105974: Revert unintentional behaviour change for protocols with non-callable members and custom `__subclasshook__` methods (GH-105976)
(cherry picked from commit 9499b0f138cc53b9a2590350d0b545d2f69ee126)

Co-authored-by: Alex Waygood <Alex.Waygood at Gmail.com>

files:
A Misc/NEWS.d/next/Library/2023-06-21-19-04-27.gh-issue-105974.M47n3t.rst
M Lib/test/test_typing.py
M Lib/typing.py

diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py
index a1fa54a9f1cad..c88152de7725f 100644
--- a/Lib/test/test_typing.py
+++ b/Lib/test/test_typing.py
@@ -3465,6 +3465,46 @@ def __subclasshook__(cls, other):
         self.assertIsSubclass(OKClass, C)
         self.assertNotIsSubclass(BadClass, C)
 
+    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)
+
     def test_issubclass_fails_correctly(self):
         @runtime_checkable
         class P(Protocol):
diff --git a/Lib/typing.py b/Lib/typing.py
index 0b1d9689a6b7a..9e2adbe2214a8 100644
--- a/Lib/typing.py
+++ b/Lib/typing.py
@@ -1822,14 +1822,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()"
                 )
@@ -1873,6 +1876,30 @@ def __instancecheck__(cls, instance):
         return False
 
 
+ at classmethod
+def _proto_hook(cls, other):
+    if not cls.__dict__.get('_is_protocol', False):
+        return NotImplemented
+
+    for attr in cls.__protocol_attrs__:
+        for base in other.__mro__:
+            # Check if the members appears in the class dictionary...
+            if attr in base.__dict__:
+                if base.__dict__[attr] is None:
+                    return NotImplemented
+                break
+
+            # ...or in annotations, if it is a sub-protocol.
+            annotations = getattr(base, '__annotations__', {})
+            if (isinstance(annotations, collections.abc.Mapping) and
+                    attr in annotations and
+                    issubclass(other, Generic) and getattr(other, '_is_protocol', False)):
+                break
+        else:
+            return NotImplemented
+    return True
+
+
 class Protocol(Generic, metaclass=_ProtocolMeta):
     """Base class for protocol classes.
 
@@ -1918,37 +1945,11 @@ def __init_subclass__(cls, *args, **kwargs):
             cls._is_protocol = any(b is Protocol for b in cls.__bases__)
 
         # Set (or override) the protocol subclass hook.
-        def _proto_hook(other):
-            if not cls.__dict__.get('_is_protocol', False):
-                return NotImplemented
-
-            for attr in cls.__protocol_attrs__:
-                for base in other.__mro__:
-                    # Check if the members appears in the class dictionary...
-                    if attr in base.__dict__:
-                        if base.__dict__[attr] is None:
-                            return NotImplemented
-                        break
-
-                    # ...or in annotations, if it is a sub-protocol.
-                    annotations = getattr(base, '__annotations__', {})
-                    if (isinstance(annotations, collections.abc.Mapping) and
-                            attr in annotations and
-                            issubclass(other, Generic) and getattr(other, '_is_protocol', False)):
-                        break
-                else:
-                    return NotImplemented
-            return True
-
         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_or_replace_init
 
 
diff --git a/Misc/NEWS.d/next/Library/2023-06-21-19-04-27.gh-issue-105974.M47n3t.rst b/Misc/NEWS.d/next/Library/2023-06-21-19-04-27.gh-issue-105974.M47n3t.rst
new file mode 100644
index 0000000000000..982192e59e3a5
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2023-06-21-19-04-27.gh-issue-105974.M47n3t.rst
@@ -0,0 +1,6 @@
+Fix bug where a :class:`typing.Protocol` class that had one or more
+non-callable members would raise :exc:`TypeError` when :func:`issubclass`
+was called against it, even if it defined a custom ``__subclasshook__``
+method. The behaviour in Python 3.11 and lower -- which has now been
+restored -- was not to raise :exc:`TypeError` in these situations if a
+custom ``__subclasshook__`` method was defined. Patch by Alex Waygood.



More information about the Python-checkins mailing list