From 307fe8f8166b6092d709123a8d14c3169e100e2b Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Thu, 27 Aug 2026 10:45:19 +0100 Subject: [PATCH 1/2] 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) Claude-Session: https://claude.ai/code/session_016TM2nkuPFj6FFmZyUJZYUQ --- Lib/test/test_typing.py | 23 +++++++++++++++++++ Lib/typing.py | 6 ++++- ...-08-27-09-40-00.gh-issue-156413.Kq7Xm2.rst | 5 ++++ 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-27-09-40-00.gh-issue-156413.Kq7Xm2.rst diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index f35f864dce21e8..f0554d7b871901 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -3888,6 +3888,29 @@ 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 P(Protocol): + x = 1 + + class B: + x = None + + 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 65e1d1ea6be584..1e6dbb90f63a50 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 00000000000000..45b607ea47ace3 --- /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. From 7f7bc3fd95b33aeb00da13ab71e58ac2ea66363b Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Thu, 27 Aug 2026 11:12:17 +0100 Subject: [PATCH 2/2] Cover a property member in the regression test A property is not callable when looked up on the class, so it lands in __non_callable_proto_members__ alongside a plain class attribute. Both subtests fail without the change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016TM2nkuPFj6FFmZyUJZYUQ --- Lib/test/test_typing.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index f0554d7b871901..1543a8e71af489 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -3894,22 +3894,32 @@ def test_none_on_non_callable_doesnt_defeat_the_abc_cache(self): # class out of ABCMeta's cache and made every isinstance() call walk # all of the protocol members again. @runtime_checkable - class P(Protocol): + class PAttr(Protocol): x = 1 + @runtime_checkable + class PProperty(Protocol): + @property + def x(self) -> int: ... + class B: x = None - 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): + for P in (PAttr, PProperty): + with self.subTest(protocol=P.__name__): + self.assertIn("x", P.__non_callable_proto_members__) self.assertIsInstance(B(), P) - finally: - typing._lazy_load_getattr_static.cache_clear() + + # 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