Skip to content

Commit

Permalink
pythongh-105974: Revert unintentional behaviour change for protocols …
Browse files Browse the repository at this point in the history
…with non-callable members and custom `__subclasshook__` methods (pythonGH-105976)

(cherry picked from commit 9499b0f)

Co-authored-by: Alex Waygood <[email protected]>
  • Loading branch information
AlexWaygood authored and miss-islington committed Jun 23, 2023
1 parent 1ffcd49 commit ab4be16
Show file tree
Hide file tree
Showing 3 changed files with 79 additions and 32 deletions.
40 changes: 40 additions & 0 deletions Lib/test/test_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
65 changes: 33 additions & 32 deletions Lib/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()"
)
Expand Down Expand Up @@ -1873,6 +1876,30 @@ def __instancecheck__(cls, instance):
return False


@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.
Expand Down Expand Up @@ -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


Expand Down
Original file line number Diff line number Diff line change
@@ -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.

0 comments on commit ab4be16

Please sign in to comment.