Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions Lib/test/test_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -3888,6 +3888,39 @@ def __init__(self):
self.assertIsInstance(B(), P)
self.assertIsInstance(C(), P)

def test_none_on_non_callable_doesnt_defeat_the_abc_cache(self):
# gh-156413: a None-valued non-callable member used to be rejected by
# _proto_hook even though __instancecheck__ accepts it, which kept the
# class out of ABCMeta's cache and made every isinstance() call walk
# all of the protocol members again.
@runtime_checkable
class PAttr(Protocol):
x = 1

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you check with a protocol defining a property as well? it's not really a callable strictly speaking.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Deleted comment that the bot wrote).

Yes - added in a new commit.


@runtime_checkable
class PProperty(Protocol):
@property
def x(self) -> int: ...

class B:
x = None

for P in (PAttr, PProperty):
with self.subTest(protocol=P.__name__):
self.assertIn("x", P.__non_callable_proto_members__)
self.assertIsInstance(B(), P)

# The first check must have cached B as a subclass of P, so
# the second one may not touch the members at all.
typing._lazy_load_getattr_static.cache_clear()
try:
with patch.object(
inspect, "getattr_static", side_effect=AssertionError
):
self.assertIsInstance(B(), P)
finally:
typing._lazy_load_getattr_static.cache_clear()

def test_none_on_callable_blocks_implementation(self):
@runtime_checkable
class P(Protocol):
Expand Down
6 changes: 5 additions & 1 deletion Lib/typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -2127,11 +2127,15 @@ def _proto_hook(cls, other):
if not cls.__dict__.get('_is_protocol', False):
return NotImplemented

# Setting a member to None only means "explicitly not implemented" for
# *callable* members; this mirrors _ProtocolMeta.__instancecheck__.
non_callable_members = cls.__dict__.get('__non_callable_proto_members__') or ()
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:
if (base.__dict__[attr] is None
and attr not in non_callable_members):
return NotImplemented
break

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Make :func:`isinstance` checks against a :func:`runtime-checkable
<typing.runtime_checkable>` :class:`typing.Protocol` take the cached fast path
when the object's class sets a non-callable protocol member to ``None``. Such a
class already passed the check, but was excluded from :class:`abc.ABCMeta`'s
cache and so re-examined every protocol member on every call.
Loading