Skip to content

Commit 307fe8f

Browse files
adamtheturtleclaude
andcommitted
gh-156413: Let a None-valued non-callable member keep the Protocol fast path
`_ProtocolMeta.__instancecheck__` treats a member set to `None` as "explicitly not implemented" only for callable members, but `_proto_hook` treated any `None` in a class `__dict__` that way. A class that set a non-callable protocol member to `None` therefore passed `isinstance()` but was rejected by the subclass hook, so it never entered `ABCMeta`'s cache and re-walked every protocol member on every call. Give `_proto_hook` the same rule, so such a class is cached like any other. `None`-valued *method* members are still rejected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016TM2nkuPFj6FFmZyUJZYUQ
1 parent fe3a26f commit 307fe8f

3 files changed

Lines changed: 33 additions & 1 deletion

File tree

Lib/test/test_typing.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3888,6 +3888,29 @@ def __init__(self):
38883888
self.assertIsInstance(B(), P)
38893889
self.assertIsInstance(C(), P)
38903890

3891+
def test_none_on_non_callable_doesnt_defeat_the_abc_cache(self):
3892+
# gh-156413: a None-valued non-callable member used to be rejected by
3893+
# _proto_hook even though __instancecheck__ accepts it, which kept the
3894+
# class out of ABCMeta's cache and made every isinstance() call walk
3895+
# all of the protocol members again.
3896+
@runtime_checkable
3897+
class P(Protocol):
3898+
x = 1
3899+
3900+
class B:
3901+
x = None
3902+
3903+
self.assertIsInstance(B(), P)
3904+
3905+
# The first check must have cached B as a subclass of P, so the second
3906+
# one may not touch the members at all.
3907+
typing._lazy_load_getattr_static.cache_clear()
3908+
try:
3909+
with patch.object(inspect, "getattr_static", side_effect=AssertionError):
3910+
self.assertIsInstance(B(), P)
3911+
finally:
3912+
typing._lazy_load_getattr_static.cache_clear()
3913+
38913914
def test_none_on_callable_blocks_implementation(self):
38923915
@runtime_checkable
38933916
class P(Protocol):

Lib/typing.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2127,11 +2127,15 @@ def _proto_hook(cls, other):
21272127
if not cls.__dict__.get('_is_protocol', False):
21282128
return NotImplemented
21292129

2130+
# Setting a member to None only means "explicitly not implemented" for
2131+
# *callable* members; this mirrors _ProtocolMeta.__instancecheck__.
2132+
non_callable_members = cls.__dict__.get('__non_callable_proto_members__') or ()
21302133
for attr in cls.__protocol_attrs__:
21312134
for base in other.__mro__:
21322135
# Check if the members appears in the class dictionary...
21332136
if attr in base.__dict__:
2134-
if base.__dict__[attr] is None:
2137+
if (base.__dict__[attr] is None
2138+
and attr not in non_callable_members):
21352139
return NotImplemented
21362140
break
21372141

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

0 commit comments

Comments
 (0)