From a1a1e455ecae762532c915960609644f5e1d345e Mon Sep 17 00:00:00 2001 From: AlexWaygood Date: Wed, 12 Apr 2023 19:34:44 +0100 Subject: [PATCH] Use `inspect.getattr_static` in `_ProtocolMeta.__instancecheck__` --- CHANGELOG.md | 10 ++++ src/test_typing_extensions.py | 93 ++++++++++++++++++++++++++++++++++- src/typing_extensions.py | 2 +- 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82ec6605..792f25e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,16 @@ `isinstance()` checks comparing objects to the protocol. See ["What's New in Python 3.12"](https://docs.python.org/3.12/whatsnew/3.12.html#typing) for more details. +- `isinstance()` checks against runtime-checkable protocols now use + `inspect.getattr_static()` rather than `hasattr()` to lookup whether + attributes exist (backporting https://github.com/python/cpython/pull/103034). + This means that descriptors and `__getattr__` methods are no longer + unexpectedly evaluated during `isinstance()` checks against runtime-checkable + protocols. However, it may also mean that some objects which used to be + considered instances of a runtime-checkable protocol on older versions of + `typing_extensions` may no longer be considered instances of that protocol + using the new release, and vice versa. Most users are unlikely to be affected + by this change. Patch by Alex Waygood. # Release 4.5.0 (February 14, 2023) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 3a483b50..db4cf899 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -1658,7 +1658,15 @@ def attr(self): ... class PG1(Protocol[T]): attr: T - for protocol_class in P, P1, PG, PG1: + @runtime_checkable + class MethodP(Protocol): + def attr(self): ... + + @runtime_checkable + class MethodPG(Protocol[T]): + def attr(self) -> T: ... + + for protocol_class in P, P1, PG, PG1, MethodP, MethodPG: for klass in C, D, E, F: with self.subTest( klass=klass.__name__, @@ -1683,7 +1691,12 @@ def attr(self): ... class BadPG1(Protocol[T]): attr: T - for obj in PG[T], PG[C], PG1[T], PG1[C], BadP, BadP1, BadPG, BadPG1: + cases = ( + PG[T], PG[C], PG1[T], PG1[C], MethodPG[T], + MethodPG[C], BadP, BadP1, BadPG, BadPG1 + ) + + for obj in cases: for klass in C, D, E, F, Empty: with self.subTest(klass=klass.__name__, obj=obj): with self.assertRaises(TypeError): @@ -1706,6 +1719,82 @@ def __dir__(self): self.assertIsInstance(CustomDirWithX(), HasX) self.assertNotIsInstance(CustomDirWithoutX(), HasX) + def test_protocols_isinstance_attribute_access_with_side_effects(self): + class C: + @property + def attr(self): + raise AttributeError('no') + + class CustomDescriptor: + def __get__(self, obj, objtype=None): + raise RuntimeError("NO") + + class D: + attr = CustomDescriptor() + + # Check that properties set on superclasses + # are still found by the isinstance() logic + class E(C): ... + class F(D): ... + + class WhyWouldYouDoThis: + def __getattr__(self, name): + raise RuntimeError("wut") + + T = TypeVar('T') + + @runtime_checkable + class P(Protocol): + @property + def attr(self): ... + + @runtime_checkable + class P1(Protocol): + attr: int + + @runtime_checkable + class PG(Protocol[T]): + @property + def attr(self): ... + + @runtime_checkable + class PG1(Protocol[T]): + attr: T + + @runtime_checkable + class MethodP(Protocol): + def attr(self): ... + + @runtime_checkable + class MethodPG(Protocol[T]): + def attr(self) -> T: ... + + for protocol_class in P, P1, PG, PG1, MethodP, MethodPG: + for klass in C, D, E, F: + with self.subTest( + klass=klass.__name__, + protocol_class=protocol_class.__name__ + ): + self.assertIsInstance(klass(), protocol_class) + + with self.subTest( + klass="WhyWouldYouDoThis", + protocol_class=protocol_class.__name__ + ): + self.assertNotIsInstance(WhyWouldYouDoThis(), protocol_class) + + def test_protocols_isinstance___slots__(self): + # As per the consensus in https://github.com/python/typing/issues/1367, + # this is desirable behaviour + @runtime_checkable + class HasX(Protocol): + x: int + + class HasNothingButSlots: + __slots__ = ("x",) + + self.assertIsInstance(HasNothingButSlots(), HasX) + def test_protocols_isinstance_py36(self): class APoint: def __init__(self, x, y, label): diff --git a/src/typing_extensions.py b/src/typing_extensions.py index c28680c3..fc023921 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -524,7 +524,7 @@ def __instancecheck__(cls, instance): if is_protocol_cls: for attr in cls.__protocol_attrs__: try: - val = getattr(instance, attr) + val = inspect.getattr_static(instance, attr) except AttributeError: break if val is None and callable(getattr(cls, attr, None)):