Skip to content

Fix edge-case Protocol bug on Python 3.7 #242

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
74 changes: 74 additions & 0 deletions src/test_typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 3 additions & 17 deletions src/typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand All @@ -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, "
Expand Down Expand Up @@ -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:
Expand Down