Skip to content

Commit 1a970b3

Browse files
committed
pythongh-132493: Lazily determine protocol attributes
pythongh-132494 made typing.py eagerly import annotationlib again because typing contains several protocols. Avoid this by determining annotations lazily. This should also make protocol creation faster: Unpatched: $ ./python.exe -m timeit -s 'from typing import Protocol, runtime_checkable' '''@runtime_checkable class MyProtocol(Protocol): def meth(self): pass ''' 50000 loops, best of 5: 9.28 usec per loop $ ./python.exe -m timeit -s 'from typing import Protocol, runtime_checkable' '''class MyProtocol(Protocol): def meth(self): pass ''' 50000 loops, best of 5: 9.05 usec per loop Patched: $ ./python.exe -m timeit -s 'from typing import Protocol, runtime_checkable' '''@runtime_checkable class MyProtocol(Protocol): def meth(self): pass ''' 50000 loops, best of 5: 7.69 usec per loop $ ./python.exe -m timeit -s 'from typing import Protocol, runtime_checkable' '''class MyProtocol(Protocol): def meth(self): pass ''' 50000 loops, best of 5: 7.78 usec per loop This was on a debug build though and I haven't compared it with versions where Protocol just accessed `.__annotations__` directly, and it's not a huge difference, so I don't think it's worth calling out the optimization too much. A downside of this change is that any errors that happen during the determination of attributes now happen only the first time isinstance() is called. This seems OK since these errors happen only in fairly exotic circumstances. Another downside is that any attributes added after class initialization now get picked up as protocol members. This came up in the typing test suite due to `@final`, but may cause issues elsewhere too.
1 parent 10a7761 commit 1a970b3

File tree

2 files changed

+41
-25
lines changed

2 files changed

+41
-25
lines changed

Lib/test/test_typing.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4542,10 +4542,15 @@ class classproperty:
45424542
def __get__(self, instance, type):
45434543
raise CustomError
45444544

4545+
@runtime_checkable
4546+
class Commentable(Protocol):
4547+
evil = classproperty()
4548+
4549+
class Normal:
4550+
evil = None
4551+
45454552
with self.assertRaises(TypeError) as cm:
4546-
@runtime_checkable
4547-
class Commentable(Protocol):
4548-
evil = classproperty()
4553+
isinstance(Normal(), Commentable)
45494554

45504555
exc = cm.exception
45514556
self.assertEqual(

Lib/typing.py

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1777,6 +1777,8 @@ class _TypingEllipsis:
17771777
'__parameters__', '__orig_bases__', '__orig_class__',
17781778
'_is_protocol', '_is_runtime_protocol', '__protocol_attrs__',
17791779
'__non_callable_proto_members__', '__type_params__',
1780+
'__protocol_attrs_cache__', '__non_callable_proto_members_cache__',
1781+
'__final__',
17801782
})
17811783

17821784
_SPECIAL_NAMES = frozenset({
@@ -1941,11 +1943,6 @@ def __new__(mcls, name, bases, namespace, /, **kwargs):
19411943
)
19421944
return super().__new__(mcls, name, bases, namespace, **kwargs)
19431945

1944-
def __init__(cls, *args, **kwargs):
1945-
super().__init__(*args, **kwargs)
1946-
if getattr(cls, "_is_protocol", False):
1947-
cls.__protocol_attrs__ = _get_protocol_attrs(cls)
1948-
19491946
def __subclasscheck__(cls, other):
19501947
if cls is Protocol:
19511948
return type.__subclasscheck__(cls, other)
@@ -1997,14 +1994,44 @@ def __instancecheck__(cls, instance):
19971994
val = getattr_static(instance, attr)
19981995
except AttributeError:
19991996
break
2000-
# this attribute is set by @runtime_checkable:
20011997
if val is None and attr not in cls.__non_callable_proto_members__:
20021998
break
20031999
else:
20042000
return True
20052001

20062002
return False
20072003

2004+
@property
2005+
def __protocol_attrs__(cls):
2006+
try:
2007+
return cls.__protocol_attrs_cache__
2008+
except AttributeError:
2009+
protocol_attrs = _get_protocol_attrs(cls)
2010+
cls.__protocol_attrs_cache__ = protocol_attrs
2011+
return protocol_attrs
2012+
2013+
@property
2014+
def __non_callable_proto_members__(cls):
2015+
# PEP 544 prohibits using issubclass()
2016+
# with protocols that have non-method members.
2017+
try:
2018+
return cls.__non_callable_proto_members_cache__
2019+
except AttributeError:
2020+
non_callable_members = set()
2021+
for attr in cls.__protocol_attrs__:
2022+
try:
2023+
is_callable = callable(getattr(cls, attr, None))
2024+
except Exception as e:
2025+
raise TypeError(
2026+
f"Failed to determine whether protocol member {attr!r} "
2027+
"is a method member"
2028+
) from e
2029+
else:
2030+
if not is_callable:
2031+
non_callable_members.add(attr)
2032+
cls.__non_callable_proto_members_cache__ = non_callable_members
2033+
return non_callable_members
2034+
20082035

20092036
@classmethod
20102037
def _proto_hook(cls, other):
@@ -2220,22 +2247,6 @@ def close(self): ...
22202247
raise TypeError('@runtime_checkable can be only applied to protocol classes,'
22212248
' got %r' % cls)
22222249
cls._is_runtime_protocol = True
2223-
# PEP 544 prohibits using issubclass()
2224-
# with protocols that have non-method members.
2225-
# See gh-113320 for why we compute this attribute here,
2226-
# rather than in `_ProtocolMeta.__init__`
2227-
cls.__non_callable_proto_members__ = set()
2228-
for attr in cls.__protocol_attrs__:
2229-
try:
2230-
is_callable = callable(getattr(cls, attr, None))
2231-
except Exception as e:
2232-
raise TypeError(
2233-
f"Failed to determine whether protocol member {attr!r} "
2234-
"is a method member"
2235-
) from e
2236-
else:
2237-
if not is_callable:
2238-
cls.__non_callable_proto_members__.add(attr)
22392250
return cls
22402251

22412252

0 commit comments

Comments
 (0)