8000 gh-102433: Use `inspect.getattr_static` in `typing._ProtocolMeta.__in… · python/cpython@6d59c9e · GitHub
[go: up one dir, main page]

Skip to content

Commit 6d59c9e

Browse files
authored
gh-102433: Use inspect.getattr_static in typing._ProtocolMeta.__instancecheck__ (#103034)
1 parent d828b35 commit 6d59c9e

File tree

5 files changed

+142
-7
lines changed

5 files changed

+142
-7
lines changed

Doc/library/typing.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1598,6 +1598,15 @@ These are not used in annotations. They are building blocks for creating generic
15981598
import threading
15991599
assert isinstance(threading.Thread(name='Bob'), Named)
16001600

1601+
.. versionchanged:: 3.12
1602+
The internal implementation of :func:`isinstance` checks against
1603+
runtime-checkable protocols now uses :func:`inspect.getattr_static`
1604+
to look up attributes (previously, :func:`hasattr` was used).
1605+
As a result, some objects which used to be considered instances
1606+
of a runtime-checkable protocol may no longer be considered instances
1607+
of that protocol on Python 3.12+, and vice versa.
1608+
Most users are unlikely to be affected by this change.
1609+
16011610
.. note::
16021611

16031612
:func:`!runtime_checkable` will check only the presence of the required

Doc/whatsnew/3.12.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,17 @@ typing
391391
same name on a base class, as per :pep:`698`. (Contributed by Steven Troxler in
392392
:gh:`101564`.)
393393

394+
* :func:`isinstance` checks against
395+
:func:`runtime-checkable protocols <typing.runtime_checkable>` now use
396+
:func:`inspect.getattr_static` rather than :func:`hasattr` to lookup whether
397+
attributes exist. This means that descriptors and :meth:`~object.__getattr__`
398+
methods are no longer unexpectedly evaluated during ``isinstance()`` checks
399+
against runtime-checkable protocols. However, it may also mean that some
400+
objects which used to be considered instances of a runtime-checkable protocol
401+
may no longer be considered instances of that protocol on Python 3.12+, and
402+
vice versa. Most users are unlikely to be affected by this change.
403+
(Contributed by Alex Waygood in :gh:`102433`.)
404+
394405
sys
395406
---
396407

Lib/test/test_typing.py

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2637,7 +2637,15 @@ def attr(self): ...
26372637
class PG1(Protocol[T]):
26382638
attr: T
26392639

2640-
for protocol_class in P, P1, PG, PG1:
2640+
@runtime_checkable
2641+
class MethodP(Protocol):
2642+
def attr(self): ...
2643+
2644+
@runtime_checkable
2645+
class MethodPG(Protocol[T]):
2646+
def attr(self) -> T: ...
2647+
2648+
for protocol_class in P, P1, PG, PG1, MethodP, MethodPG:
26412649
for klass in C, D, E, F:
26422650
with self.subTest(
26432651
klass=klass.__name__,
@@ -2662,7 +2670,12 @@ def attr(self): ...
26622670
class BadPG1(Protocol[T]):
26632671
attr: T
26642672

2665-
for obj in PG[T], PG[C], PG1[T], PG1[C], BadP, BadP1, BadPG, BadPG1:
2673+
cases = (
2674+
PG[T], PG[C], PG1[T], PG1[C], MethodPG[T],
2675+
MethodPG[C], BadP, BadP1, BadPG, BadPG1
2676+
)
2677+
2678+
for obj in cases:
26662679
for klass in C, D, E, F, Empty:
26672680
with self.subTest(klass=klass.__name__, obj=obj):
26682681
with self.assertRaises(TypeError):
@@ -2685,6 +2698,82 @@ def __dir__(self):
26852698
self.assertIsInstance(CustomDirWithX(), HasX)
26862699
self.assertNotIsInstance(CustomDirWithoutX(), HasX)
26872700

2701+
def test_protocols_isinstance_attribute_access_with_side_effects(self):
2702+
class C:
2703+
@property
2704+
def attr(self):
2705+
raise AttributeError('no')
2706+
2707+
class CustomDescriptor:
2708+
def __get__(self, obj, objtype=None):
2709+
raise RuntimeError("NO")
2710+
2711+
class D:
2712+
attr = CustomDescriptor()
2713+
2714+
# Check that properties set on superclasses
2715+
# are still found by the isinstance() logic
2716+
class E(C): ...
2717+
class F(D): ...
2718+
2719+
class WhyWouldYouDoThis:
2720+
def __getattr__(self, name):
2721+
raise RuntimeError("wut")
2722+
2723+
T = TypeVar('T')
2724+
2725+
@runtime_checkable
2726+
class P(Protocol):
2727+
@property
2728+
def attr(self): ...
2729+
2730+
@runtime_checkable
2731+
class P1(Protocol):
2732+
attr: int
2733+
2734+
@runtime_checkable
2735+
class PG(Protocol[T]):
2736+
@property
2737+
def attr(self): ...
2738+
2739+
@runtime_checkable
2740+
class PG1(Protocol[T]):
2741+
attr: T
2742+
2743+
@runtime_checkable
2744+
class MethodP(Protocol):
2745+
def attr(self): ...
2746+
2747+
@runtime_checkable
2748+
class MethodPG(Protocol[T]):
2749+
def attr(self) -> T: ...
2750+
2751+
for protocol_class in P, P1, PG, PG1, MethodP, MethodPG:
2752+
for klass in C, D, E, F:
2753+
with self.subTest(
2754+
klass=klass.__name__,
2755+
protocol_class=protocol_class.__name__
2756+
):
2757+
self.assertIsInstance(klass(), protocol_class)
2758+
2759+
with self.subTest(
2760+
klass="WhyWouldYouDoThis",
2761+
protocol_class=protocol_class.__name__
2762+
):
2763+
self.assertNotIsInstance(WhyWouldYouDoThis(), protocol_class)
2764+
2765+
def test_protocols_isinstance___slots__(self):
2766+
# As per the consensus in https://github.com/python/typing/issues/1367,
2767+
# this is desirable behaviour
2768+
@runtime_checkable
2769+
class HasX(Protocol):
2770+
x: int
2771+
2772+
class HasNothingButSlots:
2773+
__slots__ = ("x",)
2774+
2775+
self.assertIsInstance(HasNothingButSlots(), HasX)
2776+
26882777
def test_protocols_isinstance_py36(self):
26892778
class APoint:
26902779
def __init__(self, x, y, label):

Lib/typing.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1998,6 +1998,17 @@ def _allow_reckless_class_checks(depth=3):
19981998
}
19991999

20002000

2001+
@functools.cache
2002+
def _lazy_load_getattr_static():
2003+
# Import getattr_static lazily so as not to slow down the import of typing.py
2004+
# Cache the result so we don't slow down _ProtocolMeta.__instancecheck__ unnecessarily
2005+
from inspect import getattr_static
2006+
return getattr_static
2007+
2008+
2009+
_cleanups.append(_lazy_load_getattr_static.cache_clear)
2010+
2011+
20012012
class _ProtocolMeta(ABCMeta):
20022013
# This metaclass is really unfortunate and exists only because of
20032014
# the lack of __instancehook__.
@@ -2025,12 +2036,17 @@ def __instancecheck__(cls, instance):
20252036
return True
20262037

20272038
if is_protocol_cls:
2028-
if all(hasattr(instance, attr) and
2029-
# All *methods* can be blocked by setting them to None.
2030-
(not callable(getattr(cls, attr, None)) or
2031-
getattr(instance, attr) is not None)
2032-
for attr in protocol_attrs):
2039+
getattr_static = _lazy_load_getattr_static()
2040+
for attr in protocol_attrs:
2041+
try:
2042+
val = getattr_static(instance, attr)
2043+
except AttributeError:
2044+
break
2045+
if callable(getattr(cls, attr, None)) and val is None:
2046+
break
2047+
else:
20332048
return True
2049+
20342050
return super().__instancecheck__(instance)
20352051

20362052

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
:func:`isinstance` checks against :func:`runtime-checkable protocols
2+
<typing.runtime_checkable>` now use :func:`inspect.getattr_static` rather
3+
than :func:`hasattr` to lookup whether attributes exist. This means that
4+
descriptors and :meth:`~object.__getattr__` methods are no longer
5+
unexpectedly evaluated during ``isinstance()`` checks against
6+
runtime-checkable protocols. However, it may also mean that some objects
7+
which used to be considered instances of a runtime-checkable protocol may no
8+
longer be considered instances of that protocol on Python 3.12+, and vice
9+
versa. Most users are unlikely to be affected by this change. Patch by Alex
10+
Waygood.

0 commit comments

Comments
 (0)
0