Skip to content

Commit ff531f9

Browse files
gh-132604: Deprecate inherited runtime checkability of protocols (GH-143806)
Co-authored-by: Jelle Zijlstra <jelle.zijlstra@gmail.com>
1 parent a91b5c3 commit ff531f9

File tree

4 files changed

+125
-3
lines changed

4 files changed

+125
-3
lines changed

Doc/library/typing.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2527,6 +2527,12 @@ types.
25272527

25282528
.. versionadded:: 3.8
25292529

2530+
.. deprecated-removed:: 3.15 3.20
2531+
It is deprecated to call :func:`isinstance` and :func:`issubclass` checks on
2532+
protocol classes that were not explicitly decorated with :func:`!runtime_checkable`
2533+
but that inherit from a runtime-checkable protocol class. This will throw
2534+
a :exc:`TypeError` in Python 3.20.
2535+
25302536
.. decorator:: runtime_checkable
25312537

25322538
Mark a protocol class as a runtime protocol.
@@ -2548,6 +2554,18 @@ types.
25482554
import threading
25492555
assert isinstance(threading.Thread(name='Bob'), Named)
25502556

2557+
Runtime checkability of protocols is not inherited. A subclass of a runtime-checkable protocol
2558+
is only runtime-checkable if it is explicitly marked as such, regardless of class hierarchy::
2559+
2560+
@runtime_checkable
2561+
class Iterable(Protocol):
2562+
def __iter__(self): ...
2563+
2564+
# Without @runtime_checkable, Reversible would no longer be runtime-checkable.
2565+
@runtime_checkable
2566+
class Reversible(Iterable, Protocol):
2567+
def __reversed__(self): ...
2568+
25512569
This decorator raises :exc:`TypeError` when applied to a non-protocol class.
25522570

25532571
.. note::
@@ -2588,6 +2606,11 @@ types.
25882606
protocol. See :ref:`What's new in Python 3.12 <whatsnew-typing-py312>`
25892607
for more details.
25902608

2609+
.. deprecated-removed:: 3.15 3.20
2610+
It is deprecated to call :func:`isinstance` and :func:`issubclass` checks on
2611+
protocol classes that were not explicitly decorated with :func:`!runtime_checkable`
2612+
but that inherit from a runtime-checkable protocol class. This will throw
2613+
a :exc:`TypeError` in Python 3.20.
25912614

25922615
.. class:: TypedDict(dict)
25932616

Lib/test/test_typing.py

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151

5252
from test.support import (
5353
captured_stderr, cpython_only, requires_docstrings, import_helper, run_code,
54-
EqualToForwardRef,
54+
subTests, EqualToForwardRef,
5555
)
5656
from test.typinganndata import (
5757
ann_module695, mod_generics_cache, _typed_dict_helper,
@@ -3885,8 +3885,8 @@ def meth(self): pass
38853885
self.assertIsNot(get_protocol_members(PR), P.__protocol_attrs__)
38863886

38873887
acceptable_extra_attrs = {
3888-
'_is_protocol', '_is_runtime_protocol', '__parameters__',
3889-
'__init__', '__annotations__', '__subclasshook__', '__annotate__',
3888+
'_is_protocol', '_is_runtime_protocol', '__typing_is_deprecated_inherited_runtime_protocol__',
3889+
'__parameters__', '__init__', '__annotations__', '__subclasshook__', '__annotate__',
38903890
'__annotations_cache__', '__annotate_func__',
38913891
}
38923892
self.assertLessEqual(vars(NonP).keys(), vars(C).keys() | acceptable_extra_attrs)
@@ -4458,6 +4458,70 @@ class P(Protocol):
44584458
with self.assertRaisesRegex(TypeError, "@runtime_checkable"):
44594459
isinstance(1, P)
44604460

4461+
@subTests(['check_obj', 'check_func'], ([42, isinstance], [frozenset, issubclass]))
4462+
def test_inherited_runtime_protocol_deprecated(self, check_obj, check_func):
4463+
"""See GH-132604."""
4464+
4465+
class BareProto(Protocol):
4466+
"""I am not runtime-checkable."""
4467+
4468+
@runtime_checkable
4469+
class RCProto1(Protocol):
4470+
"""I am runtime-checkable."""
4471+
4472+
class InheritedRCProto1(RCProto1, Protocol):
4473+
"""I am accidentally runtime-checkable (by inheritance)."""
4474+
4475+
@runtime_checkable
4476+
class RCProto2(InheritedRCProto1, Protocol):
4477+
"""Explicit RC -> inherited RC -> explicit RC."""
4478+
def spam(self): ...
4479+
4480+
@runtime_checkable
4481+
class RCProto3(BareProto, Protocol):
4482+
"""Not RC -> explicit RC."""
4483+
4484+
class InheritedRCProto2(RCProto3, Protocol):
4485+
"""Not RC -> explicit RC -> inherited RC."""
4486+
def eggs(self): ...
4487+
4488+
class InheritedRCProto3(RCProto2, Protocol):
4489+
"""Explicit RC -> inherited RC -> explicit RC -> inherited RC."""
4490+
4491+
class Concrete1(BareProto):
4492+
pass
4493+
4494+
class Concrete2(InheritedRCProto2):
4495+
pass
4496+
4497+
class Concrete3(InheritedRCProto3):
4498+
pass
4499+
4500+
depr_message_re = (
4501+
r"<class .+\.InheritedRCProto\d'> isn't explicitly decorated "
4502+
r"with @runtime_checkable but it is used in issubclass\(\) or "
4503+
r"isinstance\(\). Instance and class checks can only be used with "
4504+
r"@runtime_checkable protocols. This will raise a TypeError in Python 3.20."
4505+
)
4506+
4507+
for inherited_runtime_proto in InheritedRCProto1, InheritedRCProto2, InheritedRCProto3:
4508+
with self.assertWarnsRegex(DeprecationWarning, depr_message_re):
4509+
check_func(check_obj, inherited_runtime_proto)
4510+
4511+
# Don't warn for explicitly checkable protocols and concrete implementations.
4512+
with warnings.catch_warnings():
4513+
warnings.simplefilter("error", DeprecationWarning)
4514+
4515+
for checkable in RCProto1, RCProto2, RCProto3, Concrete1, Concrete2, Concrete3:
4516+
check_func(check_obj, checkable)
4517+
4518+
# Don't warn for uncheckable protocols.
4519+
with warnings.catch_warnings():
4520+
warnings.simplefilter("error", DeprecationWarning)
4521+
4522+
with self.assertRaises(TypeError): # Self-test. Protocol below can't be runtime-checkable.
4523+
check_func(check_obj, BareProto)
4524+
44614525
def test_super_call_init(self):
44624526
class P(Protocol):
44634527
x: int

Lib/typing.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1826,6 +1826,7 @@ class _TypingEllipsis:
18261826
_TYPING_INTERNALS = frozenset({
18271827
'__parameters__', '__orig_bases__', '__orig_class__',
18281828
'_is_protocol', '_is_runtime_protocol', '__protocol_attrs__',
1829+
'__typing_is_deprecated_inherited_runtime_protocol__',
18291830
'__non_callable_proto_members__', '__type_params__',
18301831
})
18311832

@@ -2015,6 +2016,16 @@ def __subclasscheck__(cls, other):
20152016
"Instance and class checks can only be used with "
20162017
"@runtime_checkable protocols"
20172018
)
2019+
if getattr(cls, '__typing_is_deprecated_inherited_runtime_protocol__', False):
2020+
# See GH-132604.
2021+
import warnings
2022+
depr_message = (
2023+
f"{cls!r} isn't explicitly decorated with @runtime_checkable but "
2024+
"it is used in issubclass() or isinstance(). Instance and class "
2025+
"checks can only be used with @runtime_checkable protocols. "
2026+
"This will raise a TypeError in Python 3.20."
2027+
)
2028+
warnings.warn(depr_message, category=DeprecationWarning, stacklevel=2)
20182029
if (
20192030
# this attribute is set by @runtime_checkable:
20202031
cls.__non_callable_proto_members__
@@ -2044,6 +2055,18 @@ def __instancecheck__(cls, instance):
20442055
raise TypeError("Instance and class checks can only be used with"
20452056
" @runtime_checkable protocols")
20462057

2058+
if getattr(cls, '__typing_is_deprecated_inherited_runtime_protocol__', False):
2059+
# See GH-132604.
2060+
import warnings
2061+
2062+
depr_message = (
2063+
f"{cls!r} isn't explicitly decorated with @runtime_checkable but "
2064+
"it is used in issubclass() or isinstance(). Instance and class "
2065+
"checks can only be used with @runtime_checkable protocols. "
2066+
"This will raise a TypeError in Python 3.20."
2067+
)
2068+
warnings.warn(depr_message, category=DeprecationWarning, stacklevel=2)
2069+
20472070
if _abc_instancecheck(cls, instance):
20482071
return True
20492072

@@ -2136,6 +2159,11 @@ def __init_subclass__(cls, *args, **kwargs):
21362159
if not cls.__dict__.get('_is_protocol', False):
21372160
cls._is_protocol = any(b is Protocol for b in cls.__bases__)
21382161

2162+
# Mark inherited runtime checkability (deprecated). See GH-132604.
2163+
if cls._is_protocol and getattr(cls, '_is_runtime_protocol', False):
2164+
# This flag is set to False by @runtime_checkable.
2165+
cls.__typing_is_deprecated_inherited_runtime_protocol__ = True
2166+
21392167
# Set (or override) the protocol subclass hook.
21402168
if '__subclasshook__' not in cls.__dict__:
21412169
cls.__subclasshook__ = _proto_hook
@@ -2282,6 +2310,9 @@ def close(self): ...
22822310
raise TypeError('@runtime_checkable can be only applied to protocol classes,'
22832311
' got %r' % cls)
22842312
cls._is_runtime_protocol = True
2313+
# See GH-132604.
2314+
if hasattr(cls, '__typing_is_deprecated_inherited_runtime_protocol__'):
2315+
cls.__typing_is_deprecated_inherited_runtime_protocol__ = False
22852316
# PEP 544 prohibits using issubclass()
22862317
# with protocols that have non-method members.
22872318
# See gh-113320 for why we compute this attribute here,
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Previously, :class:`~typing.Protocol` classes that were not decorated with :deco:`~typing.runtime_checkable`,
2+
but that inherited from another ``Protocol`` class that did have this decorator, could be used in :func:`isinstance`
3+
and :func:`issubclass` checks. This behavior is now deprecated and such checks will throw a :exc:`TypeError`
4+
in Python 3.20. Patch by Bartosz Sławecki.

0 commit comments

Comments
 (0)