diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index f35f864dce21e86..1543a8e71af4897 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -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 + + @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): diff --git a/Lib/typing.py b/Lib/typing.py index 65e1d1ea6be5844..1e6dbb90f63a503 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -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 diff --git a/Misc/NEWS.d/next/Library/2026-08-27-09-40-00.gh-issue-156413.Kq7Xm2.rst b/Misc/NEWS.d/next/Library/2026-08-27-09-40-00.gh-issue-156413.Kq7Xm2.rst new file mode 100644 index 000000000000000..45b607ea47ace34 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-27-09-40-00.gh-issue-156413.Kq7Xm2.rst @@ -0,0 +1,5 @@ +Make :func:`isinstance` checks against a :func:`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.