Skip to content

Commit 6c93956

Browse files
authored
Backport performance improvements to runtime-checkable protocols (#137)
1 parent 4dfc5c5 commit 6c93956

File tree

3 files changed

+50
-21
lines changed

3 files changed

+50
-21
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@
77
(originally by Yurii Karabas), ensuring that `isinstance()` calls on
88
protocols raise `TypeError` when the protocol is not decorated with
99
`@runtime_checkable`. Patch by Alex Waygood.
10+
- Backport several significant performance improvements to runtime-checkable
11+
protocols that have been made in Python 3.12 (see
12+
https://github.com/python/cpython/issues/74690 for details). Patch by Alex
13+
Waygood.
14+
15+
A side effect of one of the performance improvements is that the members of
16+
a runtime-checkable protocol are now considered “frozen” at runtime as soon
17+
as the class has been created. Monkey-patching attributes onto a
18+
runtime-checkable protocol will still work, but will have no impact on
19+
`isinstance()` checks comparing objects to the protocol. See
20+
["What's New in Python 3.12"](https://docs.python.org/3.12/whatsnew/3.12.html#typing)
21+
for more details.
1022

1123
# Release 4.5.0 (February 14, 2023)
1224

src/test_typing_extensions.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3452,9 +3452,11 @@ def test_typing_extensions_defers_when_possible(self):
34523452
'is_typeddict',
34533453
}
34543454
if sys.version_info < (3, 10):
3455-
exclude |= {'get_args', 'get_origin', 'Protocol', 'runtime_checkable'}
3455+
exclude |= {'get_args', 'get_origin'}
34563456
if sys.version_info < (3, 11):
34573457
exclude |= {'final', 'NamedTuple', 'Any'}
3458+
if sys.version_info < (3, 12):
3459+
exclude |= {'Protocol', 'runtime_checkable'}
34583460
for item in typing_extensions.__all__:
34593461
if item not in exclude and hasattr(typing, item):
34603462
self.assertIs(

src/typing_extensions.py

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,7 @@ def clear_overloads():
403403
"_is_runtime_protocol", "__dict__", "__slots__", "__parameters__",
404404
"__orig_bases__", "__module__", "_MutableMapping__marker", "__doc__",
405405
"__subclasshook__", "__orig_class__", "__init__", "__new__",
406+
"__protocol_attrs__", "__callable_proto_members_only__",
406407
}
407408

408409
if sys.version_info < (3, 8):
@@ -420,19 +421,15 @@ def clear_overloads():
420421
def _get_protocol_attrs(cls):
421422
attrs = set()
422423
for base in cls.__mro__[:-1]: # without object
423-
if base.__name__ in ('Protocol', 'Generic'):
424+
if base.__name__ in {'Protocol', 'Generic'}:
424425
continue
425426
annotations = getattr(base, '__annotations__', {})
426-
for attr in list(base.__dict__.keys()) + list(annotations.keys()):
427+
for attr in (*base.__dict__, *annotations):
427428
if (not attr.startswith('_abc_') and attr not in _EXCLUDED_ATTRS):
428429
attrs.add(attr)
429430
return attrs
430431

431432

432-
def _is_callable_members_only(cls):
433-
return all(callable(getattr(cls, attr, None)) for attr in _get_protocol_attrs(cls))
434-
435-
436433
def _maybe_adjust_parameters(cls):
437434
"""Helper function used in Protocol.__init_subclass__ and _TypedDictMeta.__new__.
438435
@@ -442,7 +439,7 @@ def _maybe_adjust_parameters(cls):
442439
"""
443440
tvars = []
444441
if '__orig_bases__' in cls.__dict__:
445-
tvars = typing._collect_type_vars(cls.__orig_bases__)
442+
tvars = _collect_type_vars(cls.__orig_bases__)
446443
# Look for Generic[T1, ..., Tn] or Protocol[T1, ..., Tn].
447444
# If found, tvars must be a subset of it.
448445
# If not found, tvars is it.
@@ -480,9 +477,9 @@ def _caller(depth=2):
480477
return None
481478

482479

483-
# A bug in runtime-checkable protocols was fixed in 3.10+,
484-
# but we backport it to all versions
485-
if sys.version_info >= (3, 10):
480+
# The performance of runtime-checkable protocols is significantly improved on Python 3.12,
481+
# so we backport the 3.12 version of Protocol to Python <=3.11
482+
if sys.version_info >= (3, 12):
486483
Protocol = typing.Protocol
487484
runtime_checkable = typing.runtime_checkable
488485
else:
@@ -500,6 +497,15 @@ def _no_init(self, *args, **kwargs):
500497
class _ProtocolMeta(abc.ABCMeta):
501498
# This metaclass is a bit unfortunate and exists only because of the lack
502499
# of __instancehook__.
500+
def __init__(cls, *args, **kwargs):
501+
super().__init__(*args, **kwargs)
502+
cls.__protocol_attrs__ = _get_protocol_attrs(cls)
503+
# PEP 544 prohibits using issubclass()
504+
# with protocols that have non-method members.
505+
cls.__callable_proto_members_only__ = all(
506+
callable(getattr(cls, attr, None)) for attr in cls.__protocol_attrs__
507+
)
508+
503509
def __instancecheck__(cls, instance):
504510
# We need this method for situations where attributes are
505511
# assigned in __init__.
@@ -511,17 +517,22 @@ def __instancecheck__(cls, instance):
511517
):
512518
raise TypeError("Instance and class checks can only be used with"
513519
" @runtime_checkable protocols")
514-
if ((not is_protocol_cls or
515-
_is_callable_members_only(cls)) and
516-
issubclass(instance.__class__, cls)):
520+
521+
if super().__instancecheck__(instance):
517522
return True
523+
518524
if is_protocol_cls:
519-
if all(hasattr(instance, attr) and
520-
(not callable(getattr(cls, attr, None)) or
521-
getattr(instance, attr) is not None)
522-
for attr in _get_protocol_attrs(cls)):
525+
for attr in cls.__protocol_attrs__:
526+
try:
527+
val = getattr(instance, attr)
528+
except AttributeError:
529+
break
530+
if val is None and callable(getattr(cls, attr, None)):
531+
break
532+
else:
523533
return True
524-
return super().__instancecheck__(instance)
534+
535+
return False
525536

526537
class Protocol(metaclass=_ProtocolMeta):
527538
# There is quite a lot of overlapping code with typing.Generic.
@@ -613,15 +624,15 @@ def _proto_hook(other):
613624
return NotImplemented
614625
raise TypeError("Instance and class checks can only be used with"
615626
" @runtime protocols")
616-
if not _is_callable_members_only(cls):
627+
if not cls.__callable_proto_members_only__:
617628
if _allow_reckless_class_checks():
618629
return NotImplemented
619630
raise TypeError("Protocols with non-method members"
620631
" don't support issubclass()")
621632
if not isinstance(other, type):
622633
# Same error as for issubclass(1, int)
623634
raise TypeError('issubclass() arg 1 must be a class')
624-
for attr in _get_protocol_attrs(cls):
635+
for attr in cls.__protocol_attrs__:
625636
for base in other.__mro__:
626637
if attr in base.__dict__:
627638
if base.__dict__[attr] is None:
@@ -1819,6 +1830,10 @@ class Movie(TypedDict):
18191830

18201831
if hasattr(typing, "Unpack"): # 3.11+
18211832
Unpack = typing.Unpack
1833+
1834+
def _is_unpack(obj):
1835+
return get_origin(obj) is Unpack
1836+
18221837
elif sys.version_info[:2] >= (3, 9):
18231838
class _UnpackSpecialForm(typing._SpecialForm, _root=True):
18241839
def __repr__(self):

0 commit comments

Comments
 (0)