Skip to content

Commit 7b9f8a1

Browse files
committed
pythongh-113320: Reduce the number of dangerous getattr() calls when constructing protocol classes
1 parent df1eec3 commit 7b9f8a1

3 files changed

Lines changed: 70 additions & 20 deletions

File tree

‎Lib/test/test_typing.py‎

Lines changed: 40 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"})
@@ -3472,6 +3472,12 @@ def meth(self): pass
34723472
vars(NonPR).keys(), vars(D).keys() | acceptable_extra_attrs
34733473
)
34743474

3475+
def test_nonruntime_protocols_not_unnecessarily_introspected(self) -> None:
3476+
class Foo(Protocol):
3477+
x = 1
3478+
3479+
self.assertFalse(hasattr(Foo, "__non_callable_proto_members__"))
3480+
34753481
def test_custom_subclasshook(self):
34763482
class P(Protocol):
34773483
x = 1
@@ -4105,6 +4111,7 @@ def method(self) -> None: ...
41054111
self.assertNotIsInstance(42, ProtocolWithMixedMembers)
41064112

41074113
def test_protocol_issubclass_error_message(self):
4114+
@runtime_checkable
41084115
class Vec2D(Protocol):
41094116
x: float
41104117
y: float
@@ -4120,6 +4127,37 @@ def square_norm(self) -> float:
41204127
with self.assertRaisesRegex(TypeError, re.escape(expected_error_message)):
41214128
issubclass(int, Vec2D)
41224129

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

41244162
class GenericTests(BaseTestCase):
41254163

‎Lib/typing.py‎

Lines changed: 26 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,22 @@ 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+
cls.__non_callable_proto_members__
18511852
and cls.__dict__.get("__subclasshook__") is _proto_hook
18521853
):
18531854
_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-
)
1855+
non_method_attrs = sorted(cls.__non_callable_proto_members__)
18581856
raise TypeError(
18591857
"Protocols with non-method members don't support issubclass()."
18601858
f" Non-method members: {str(non_method_attrs)[1:-1]}."
18611859
)
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-
)
18681860
return _abc_subclasscheck(cls, other)
18691861

18701862
def __instancecheck__(cls, instance):
@@ -1892,7 +1884,7 @@ def __instancecheck__(cls, instance):
18921884
val = getattr_static(instance, attr)
18931885
except AttributeError:
18941886
break
1895-
if val is None and callable(getattr(cls, attr, None)):
1887+
if val is None and attr not in cls.__non_callable_proto_members__:
18961888
break
18971889
else:
18981890
return True
@@ -2114,6 +2106,22 @@ def close(self): ...
21142106
raise TypeError('@runtime_checkable can be only applied to protocol classes,'
21152107
' got %r' % cls)
21162108
cls._is_runtime_protocol = True
2109+
# PEP 544 prohibits using issubclass()
2110+
# with protocols that have non-method members.
2111+
# See gh-113320 for why we compute this attribute here,
2112+
# rather than in `_ProtocolMeta.__init__`
2113+
cls.__non_callable_proto_members__ = set()
2114+
for attr in cls.__protocol_attrs__:
2115+
try:
2116+
is_callable = callable(getattr(cls, attr, None))
2117+
except Exception as e:
2118+
raise TypeError(
2119+
f"Failed to determine whether protocol member {attr!r} "
2120+
"is a method member"
2121+
) from e
2122+
else:
2123+
if not is_callable:
2124+
cls.__non_callable_proto_members__.add(attr)
21172125
return cls
21182126

21192127

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)