diff --git a/Doc/library/typing.rst b/Doc/library/typing.rst index 735d477db4371e..7220453d1cf907 100644 --- a/Doc/library/typing.rst +++ b/Doc/library/typing.rst @@ -1284,6 +1284,11 @@ These are not used in annotations. They are building blocks for creating generic .. versionadded:: 3.8 + .. versionchanged:: 3.11 + Protocols with data members annotated with :data:`ClassVar` now support + :func:`issubclass` checks. Subclasses must set these data members to pass. + + Other special directives """""""""""""""""""""""" diff --git a/Doc/whatsnew/3.11.rst b/Doc/whatsnew/3.11.rst index 10dc30939414c5..056d6a34fb6c3b 100644 --- a/Doc/whatsnew/3.11.rst +++ b/Doc/whatsnew/3.11.rst @@ -284,6 +284,13 @@ sqlite3 experience. (Contributed by Erlend E. Aasland in :issue:`45828`.) +typing +------ + +* Runtime protocols with data members now support :func:`issubclass` as long + as those members are annotated with :data:`typing.ClassVar`. + (Contributed by Ken Jin in :issue:`44975`). + sys --- diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index 90d6bea59f899c..703f5a28c4a2c0 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -1622,6 +1622,55 @@ def __init__(self): Foo() # Previously triggered RecursionError + def test_runtime_issubclass_with_classvar_data_members(self): + @runtime_checkable + class P(Protocol): + x: ClassVar[int] = 1 + + class C: pass + + class D: + x = 1 + + class E: + x = 2 + + class F: + x = 'foo' + + self.assertNotIsSubclass(C, P) + self.assertIsSubclass(D, P) + self.assertIsSubclass(E, P) + self.assertNotIsSubclass(F, P) + + # String annotations (forward references). + @runtime_checkable + class P(Protocol): + # Special case, bare ClassVar, our checks should + # just skip these. + w: "ClassVar" + x: "ClassVar[int]" = 1 + y: "typing.ClassVar[int]" = 2 + z: "t.ClassVar[int]" = 3 + + class D: + w = 0 + x = 1 + y = 2 + z = 3 + + self.assertNotIsSubclass(C, P) + self.assertIsSubclass(D, P) + + # Make sure mixed are forbidden. + @runtime_checkable + class P(Protocol): + x: "ClassVar[int]" = 1 + y = 2 + + self.assertRaises(TypeError, issubclass, C, P) + self.assertRaises(TypeError, issubclass, D, P) + class GenericTests(BaseTestCase): diff --git a/Lib/typing.py b/Lib/typing.py index bdd19cadb471a6..e6b0de32f37e5e 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -1404,10 +1404,43 @@ def _get_protocol_attrs(cls): attrs.add(attr) return attrs +_CLASSVAR_PREFIXES = ("typing.ClassVar", "t.ClassVar", "ClassVar") -def _is_callable_members_only(cls): +def _is_callable_or_classvar_members_only(cls, instance): + """Returns a 2-tuple signalling two things: + (Valid protocol?, If not valid protocol, was it due to ClassVar value mismatch?) + """ + attr_names = _get_protocol_attrs(cls) + annotations = getattr(cls, '__annotations__', {}) # PEP 544 prohibits using issubclass() with protocols that have non-method members. - return all(callable(getattr(cls, attr, None)) for attr in _get_protocol_attrs(cls)) + # bpo-44975: Relaxing that restriction to allow for runtime-checkable + # protocols with class variables since those should be available at class + # definition time. + for attr_name in attr_names: + attr = getattr(cls, attr_name, None) + # Method-like. + if callable(attr): + continue + annotation = annotations.get(attr_name) + # ClassVar member + if getattr(annotation, '__origin__', None) is ClassVar: + instance_attr = getattr(instance, attr_name, None) + # If we couldn't find anything, don't bother checking value types. + if (instance_attr is not None + and attr is not None + and type(instance_attr) != type(attr)): + return False, True + continue + # ClassVar string annotations (forward references). + if isinstance(annotation, str) and annotation.startswith(_CLASSVAR_PREFIXES): + instance_attr = getattr(instance, attr_name, None) + if (instance_attr is not None + and attr is not None + and type(instance_attr) != type(attr)): + return False, True + continue + return False, False + return True, False def _no_init_or_replace_init(self, *args, **kwargs): @@ -1477,10 +1510,9 @@ def __instancecheck__(cls, instance): ): raise TypeError("Instance and class checks can only be used with" " @runtime_checkable protocols") - if ((not getattr(cls, '_is_protocol', False) or - _is_callable_members_only(cls)) and - issubclass(instance.__class__, cls)): + _is_callable_or_classvar_members_only(cls, instance)[0]) and + issubclass(instance.__class__, cls)): return True if cls._is_protocol: if all(hasattr(instance, attr) and @@ -1544,11 +1576,13 @@ def _proto_hook(other): return NotImplemented raise TypeError("Instance and class checks can only be used with" " @runtime_checkable protocols") - if not _is_callable_members_only(cls): - if _allow_reckless_class_checks(): + ok_members, classvar_mismatch = _is_callable_or_classvar_members_only(cls, other) + if not ok_members: + if _allow_reckless_class_checks() or classvar_mismatch: return NotImplemented - raise TypeError("Protocols with non-method members" - " don't support issubclass()") + raise TypeError("Protocol members must be methods or data" + " attributes annotated with ClassVar to support" + " issubclass()") if not isinstance(other, type): # Same error message as for issubclass(1, int). raise TypeError('issubclass() arg 1 must be a class') diff --git a/Misc/NEWS.d/next/Library/2021-08-22-16-58-02.bpo-44975.ub3Vxk.rst b/Misc/NEWS.d/next/Library/2021-08-22-16-58-02.bpo-44975.ub3Vxk.rst new file mode 100644 index 00000000000000..a1b96539594528 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2021-08-22-16-58-02.bpo-44975.ub3Vxk.rst @@ -0,0 +1,2 @@ +Runtime protocols with data members now support :func:`issubclass` as long +as those members are annotated with :data:`typing.ClassVar`.