Skip to content

Commit 1d0f713

Browse files
AlexWaygoodaisk
authored andcommitted
pythongh-113320: Reduce the number of dangerous getattr() calls when constructing protocol classes (python#113401)
- Only attempt to figure out whether protocol members are "method members" or not if the class is marked as a runtime protocol. This information is irrelevant for non-runtime protocols; we can safely skip the risky introspection for them. - Only do the risky getattr() calls in one place (the runtime_checkable class decorator), rather than in three places (_ProtocolMeta.__init__, _ProtocolMeta.__instancecheck__ and _ProtocolMeta.__subclasscheck__). This reduces the number of locations in typing.py where the risky introspection could go wrong. - For runtime protocols, if determining whether a protocol member is callable or not fails, give a better error message. I think it's reasonable for us to reject runtime protocols that have members which raise strange exceptions when you try to access them. PEP-544 clearly states that all protocol member must be callable for issubclass() calls against the protocol to be valid -- and if a member raises when we try to access it, there's no way for us to figure out whether it's a callable member or not!
1 parent 3fedf61 commit 1d0f713

File tree

3 files changed

+68
-20
lines changed

3 files changed

+68
-20
lines changed

Lib/test/test_typing.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3448,8 +3448,8 @@ def meth(self): pass
34483448

34493449
self.assertNotIn("__protocol_attrs__", vars(NonP))
34503450
self.assertNotIn("__protocol_attrs__", vars(NonPR))
3451-
self.assertNotIn("__callable_proto_members_only__", vars(NonP))
3452-
self.assertNotIn("__callable_proto_members_only__", vars(NonPR))
3451+
self.assertNotIn("__non_callable_proto_members__", vars(NonP))
3452+
self.assertNotIn("__non_callable_proto_members__", vars(NonPR))
34533453

34543454
self.assertEqual(get_protocol_members(P), {"x"})
34553455
self.assertEqual(get_protocol_members(PR), {"meth"})
@@ -4105,6 +4105,7 @@ def method(self) -> None: ...
41054105
self.assertNotIsInstance(42, ProtocolWithMixedMembers)
41064106

41074107
def test_protocol_issubclass_error_message(self):
4108+
@runtime_checkable
41084109
class Vec2D(Protocol):
41094110
x: float
41104111
y: float
@@ -4120,6 +4121,39 @@ def square_norm(self) -> float:
41204121
with self.assertRaisesRegex(TypeError, re.escape(expected_error_message)):
41214122
issubclass(int, Vec2D)
41224123

4124+
def test_nonruntime_protocol_interaction_with_evil_classproperty(self):
4125+
class classproperty:
4126+
def __get__(self, instance, type):
4127+
raise RuntimeError("NO")
4128+
4129+
class Commentable(Protocol):
4130+
evil = classproperty()
4131+
4132+
# recognised as a protocol attr,
4133+
# but not actually accessed by the protocol metaclass
4134+
# (which would raise RuntimeError) for non-runtime protocols.
4135+
# See gh-113320
4136+
self.assertEqual(get_protocol_members(Commentable), {"evil"})
4137+
4138+
def test_runtime_protocol_interaction_with_evil_classproperty(self):
4139+
class CustomError(Exception): pass
4140+
4141+
class classproperty:
4142+
def __get__(self, instance, type):
4143+
raise CustomError
4144+
4145+
with self.assertRaises(TypeError) as cm:
4146+
@runtime_checkable
4147+
class Commentable(Protocol):
4148+
evil = classproperty()
4149+
4150+
exc = cm.exception
4151+
self.assertEqual(
4152+
exc.args[0],
4153+
"Failed to determine whether protocol member 'evil' is a method member"
4154+
)
4155+
self.assertIs(type(exc.__cause__), CustomError)
4156+
41234157

41244158
class GenericTests(BaseTestCase):
41254159

Lib/typing.py

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1670,7 +1670,7 @@ class _TypingEllipsis:
16701670
_TYPING_INTERNALS = frozenset({
16711671
'__parameters__', '__orig_bases__', '__orig_class__',
16721672
'_is_protocol', '_is_runtime_protocol', '__protocol_attrs__',
1673-
'__callable_proto_members_only__', '__type_params__',
1673+
'__non_callable_proto_members__', '__type_params__',
16741674
})
16751675

16761676
_SPECIAL_NAMES = frozenset({
@@ -1833,11 +1833,6 @@ def __init__(cls, *args, **kwargs):
18331833
super().__init__(*args, **kwargs)
18341834
if getattr(cls, "_is_protocol", False):
18351835
cls.__protocol_attrs__ = _get_protocol_attrs(cls)
1836-
# PEP 544 prohibits using issubclass()
1837-
# with protocols that have non-method members.
1838-
cls.__callable_proto_members_only__ = all(
1839-
callable(getattr(cls, attr, None)) for attr in cls.__protocol_attrs__
1840-
)
18411836

18421837
def __subclasscheck__(cls, other):
18431838
if cls is Protocol:
@@ -1846,25 +1841,23 @@ def __subclasscheck__(cls, other):
18461841
getattr(cls, '_is_protocol', False)
18471842
and not _allow_reckless_class_checks()
18481843
):
1844+
if not getattr(cls, '_is_runtime_protocol', False):
1845+
_type_check_issubclass_arg_1(other)
1846+
raise TypeError(
1847+
"Instance and class checks can only be used with "
1848+
"@runtime_checkable protocols"
1849+
)
18491850
if (
1850-
not cls.__callable_proto_members_only__
1851+
# this attribute is set by @runtime_checkable:
1852+
cls.__non_callable_proto_members__
18511853
and cls.__dict__.get("__subclasshook__") is _proto_hook
18521854
):
18531855
_type_check_issubclass_arg_1(other)
1854-
non_method_attrs = sorted(
1855-
attr for attr in cls.__protocol_attrs__
1856-
if not callable(getattr(cls, attr, None))
1857-
)
1856+
non_method_attrs = sorted(cls.__non_callable_proto_members__)
18581857
raise TypeError(
18591858
"Protocols with non-method members don't support issubclass()."
18601859
f" Non-method members: {str(non_method_attrs)[1:-1]}."
18611860
)
1862-
if not getattr(cls, '_is_runtime_protocol', False):
1863-
_type_check_issubclass_arg_1(other)
1864-
raise TypeError(
1865-
"Instance and class checks can only be used with "
1866-
"@runtime_checkable protocols"
1867-
)
18681861
return _abc_subclasscheck(cls, other)
18691862

18701863
def __instancecheck__(cls, instance):
@@ -1892,7 +1885,8 @@ def __instancecheck__(cls, instance):
18921885
val = getattr_static(instance, attr)
18931886
except AttributeError:
18941887
break
1895-
if val is None and callable(getattr(cls, attr, None)):
1888+
# this attribute is set by @runtime_checkable:
1889+
if val is None and attr not in cls.__non_callable_proto_members__:
18961890
break
18971891
else:
18981892
return True
@@ -2114,6 +2108,22 @@ def close(self): ...
21142108
raise TypeError('@runtime_checkable can be only applied to protocol classes,'
21152109
' got %r' % cls)
21162110
cls._is_runtime_protocol = True
2111+
# PEP 544 prohibits using issubclass()
2112+
# with protocols that have non-method members.
2113+
# See gh-113320 for why we compute this attribute here,
2114+
# rather than in `_ProtocolMeta.__init__`
2115+
cls.__non_callable_proto_members__ = set()
2116+
for attr in cls.__protocol_attrs__:
2117+
try:
2118+
is_callable = callable(getattr(cls, attr, None))
2119+
except Exception as e:
2120+
raise TypeError(
2121+
f"Failed to determine whether protocol member {attr!r} "
2122+
"is a method member"
2123+
) from e
2124+
else:
2125+
if not is_callable:
2126+
cls.__non_callable_proto_members__.add(attr)
21172127
return cls
21182128

21192129

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Fix regression in Python 3.12 where :class:`~typing.Protocol` classes that
2+
were not marked as :func:`runtime-checkable <typing.runtime_checkable>`
3+
would be unnecessarily introspected, potentially causing exceptions to be
4+
raised if the protocol had problematic members. Patch by Alex Waygood.

0 commit comments

Comments
 (0)