From af89916a21b0b720b413e5618dd119f55c53f7b7 Mon Sep 17 00:00:00 2001 From: Alex Waygood Date: Fri, 16 Jun 2023 17:05:57 +0100 Subject: [PATCH] Fix edge-case `Protocol` bug on Python 3.7 (#242) --- CHANGELOG.md | 7 ++++ src/test_typing_extensions.py | 74 +++++++++++++++++++++++++++++++++++ src/typing_extensions.py | 20 ++-------- 3 files changed, 84 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33d0c509..83e81f0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,13 @@ or `NT = NamedTuple("NT", None)` is now deprecated. - Creating a `TypedDict` with zero fields using the syntax `TD = TypedDict("TD")` or `TD = TypedDict("TD", None)` is now deprecated. +- Fix bug on Python 3.7 where a protocol `X` that had a member `a` would not be + considered an implicit subclass of an unrelated protocol `Y` that only has a + member `a`. Where the members of `X` are a superset of the members of `Y`, + `X` should always be considered a subclass of `Y` iff `Y` is a + runtime-checkable protocol that only has callable members. Patch by Alex + Waygood (backporting CPython PR + https://github.com/python/cpython/pull/105835). # Release 4.6.3 (June 1, 2023) diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 5814e00e..85e02b23 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -1987,6 +1987,80 @@ def x(self): ... with self.assertRaisesRegex(TypeError, only_classes_allowed): issubclass(1, BadPG) + def test_implicit_issubclass_between_two_protocols(self): + @runtime_checkable + class CallableMembersProto(Protocol): + def meth(self): ... + + # All the below protocols should be considered "subclasses" + # of CallableMembersProto at runtime, + # even though none of them explicitly subclass CallableMembersProto + + class IdenticalProto(Protocol): + def meth(self): ... + + class SupersetProto(Protocol): + def meth(self): ... + def meth2(self): ... + + class NonCallableMembersProto(Protocol): + meth: Callable[[], None] + + class NonCallableMembersSupersetProto(Protocol): + meth: Callable[[], None] + meth2: Callable[[str, int], bool] + + class MixedMembersProto1(Protocol): + meth: Callable[[], None] + def meth2(self): ... + + class MixedMembersProto2(Protocol): + def meth(self): ... + meth2: Callable[[str, int], bool] + + for proto in ( + IdenticalProto, SupersetProto, NonCallableMembersProto, + NonCallableMembersSupersetProto, MixedMembersProto1, MixedMembersProto2 + ): + with self.subTest(proto=proto.__name__): + self.assertIsSubclass(proto, CallableMembersProto) + + # These two shouldn't be considered subclasses of CallableMembersProto, however, + # since they don't have the `meth` protocol member + + class EmptyProtocol(Protocol): ... + class UnrelatedProtocol(Protocol): + def wut(self): ... + + self.assertNotIsSubclass(EmptyProtocol, CallableMembersProto) + self.assertNotIsSubclass(UnrelatedProtocol, CallableMembersProto) + + # These aren't protocols at all (despite having annotations), + # so they should only be considered subclasses of CallableMembersProto + # if they *actually have an attribute* matching the `meth` member + # (just having an annotation is insufficient) + + class AnnotatedButNotAProtocol: + meth: Callable[[], None] + + class NotAProtocolButAnImplicitSubclass: + def meth(self): pass + + class NotAProtocolButAnImplicitSubclass2: + meth: Callable[[], None] + def meth(self): pass + + class NotAProtocolButAnImplicitSubclass3: + meth: Callable[[], None] + meth2: Callable[[int, str], bool] + def meth(self): pass + def meth(self, x, y): return True + + self.assertNotIsSubclass(AnnotatedButNotAProtocol, CallableMembersProto) + self.assertIsSubclass(NotAProtocolButAnImplicitSubclass, CallableMembersProto) + self.assertIsSubclass(NotAProtocolButAnImplicitSubclass2, CallableMembersProto) + self.assertIsSubclass(NotAProtocolButAnImplicitSubclass3, CallableMembersProto) + @skip_if_py312b1 def test_issubclass_and_isinstance_on_Protocol_itself(self): class C: diff --git a/src/typing_extensions.py b/src/typing_extensions.py index 0aba3604..c37926d2 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -604,23 +604,10 @@ def _no_init(self, *args, **kwargs): # to mix without getting TypeErrors about "metaclass conflict" _typing_Protocol = typing.Protocol _ProtocolMetaBase = type(_typing_Protocol) - - def _is_protocol(cls): - return ( - isinstance(cls, type) - and issubclass(cls, typing.Generic) - and getattr(cls, "_is_protocol", False) - ) else: _typing_Protocol = _marker _ProtocolMetaBase = abc.ABCMeta - def _is_protocol(cls): - return ( - isinstance(cls, _ProtocolMeta) - and getattr(cls, "_is_protocol", False) - ) - class _ProtocolMeta(_ProtocolMetaBase): # This metaclass is somewhat unfortunate, # but is necessary for several reasons... @@ -634,9 +621,9 @@ def __new__(mcls, name, bases, namespace, **kwargs): elif {Protocol, _typing_Protocol} & set(bases): for base in bases: if not ( - base in {object, typing.Generic} + base in {object, typing.Generic, Protocol, _typing_Protocol} or base.__name__ in _PROTO_ALLOWLIST.get(base.__module__, []) - or _is_protocol(base) + or is_protocol(base) ): raise TypeError( f"Protocols can only inherit from other protocols, " @@ -740,8 +727,7 @@ def _proto_hook(cls, other): if ( isinstance(annotations, collections.abc.Mapping) and attr in annotations - and issubclass(other, (typing.Generic, _ProtocolMeta)) - and getattr(other, "_is_protocol", False) + and is_protocol(other) ): break else: