Skip to content

Commit

Permalink
Fix edge-case Protocol bug on Python 3.7 (#242)
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexWaygood committed Jun 16, 2023
1 parent bc9ce4f commit af89916
Show file tree
Hide file tree
Showing 3 changed files with 84 additions and 17 deletions.
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

0 comments on commit af89916

Please sign in to comment.