Skip to content

Commit

Permalink
gh-105974: Revert unintentional behaviour change for protocols with n…
Browse files Browse the repository at this point in the history
…on-callable members and custom `__subclasshook__` methods (#105976)
  • Loading branch information
AlexWaygood committed Jun 23, 2023
1 parent 968435d commit 9499b0f
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 @@ -3477,6 +3477,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 @@ -1818,14 +1818,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 @@ -1869,6 +1872,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 @@ -1914,37 +1941,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 9499b0f

Please sign in to comment.