Skip to content

Commit af89916

Browse files
authored
Fix edge-case Protocol bug on Python 3.7 (#242)
1 parent bc9ce4f commit af89916

File tree

3 files changed

+84
-17
lines changed

3 files changed

+84
-17
lines changed

CHANGELOG.md

+7
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@
3939
or `NT = NamedTuple("NT", None)` is now deprecated.
4040
- Creating a `TypedDict` with zero fields using the syntax `TD = TypedDict("TD")`
4141
or `TD = TypedDict("TD", None)` is now deprecated.
42+
- Fix bug on Python 3.7 where a protocol `X` that had a member `a` would not be
43+
considered an implicit subclass of an unrelated protocol `Y` that only has a
44+
member `a`. Where the members of `X` are a superset of the members of `Y`,
45+
`X` should always be considered a subclass of `Y` iff `Y` is a
46+
runtime-checkable protocol that only has callable members. Patch by Alex
47+
Waygood (backporting CPython PR
48+
https://github.com/python/cpython/pull/105835).
4249

4350
# Release 4.6.3 (June 1, 2023)
4451

src/test_typing_extensions.py

+74
Original file line numberDiff line numberDiff line change
@@ -1987,6 +1987,80 @@ def x(self): ...
19871987
with self.assertRaisesRegex(TypeError, only_classes_allowed):
19881988
issubclass(1, BadPG)
19891989

1990+
def test_implicit_issubclass_between_two_protocols(self):
1991+
@runtime_checkable
1992+
class CallableMembersProto(Protocol):
1993+
def meth(self): ...
1994+
1995+
# All the below protocols should be considered "subclasses"
1996+
# of CallableMembersProto at runtime,
1997+
# even though none of them explicitly subclass CallableMembersProto
1998+
1999+
class IdenticalProto(Protocol):
2000+
def meth(self): ...
2001+
2002+
class SupersetProto(Protocol):
2003+
def meth(self): ...
2004+
def meth2(self): ...
2005+
2006+
class NonCallableMembersProto(Protocol):
2007+
meth: Callable[[], None]
2008+
2009+
class NonCallableMembersSupersetProto(Protocol):
2010+
meth: Callable[[], None]
2011+
meth2: Callable[[str, int], bool]
2012+
2013+
class MixedMembersProto1(Protocol):
2014+
meth: Callable[[], None]
2015+
def meth2(self): ...
2016+
2017+
class MixedMembersProto2(Protocol):
2018+
def meth(self): ...
2019+
meth2: Callable[[str, int], bool]
2020+
2021+
for proto in (
2022+
IdenticalProto, SupersetProto, NonCallableMembersProto,
2023+
NonCallableMembersSupersetProto, MixedMembersProto1, MixedMembersProto2
2024+
):
2025+
with self.subTest(proto=proto.__name__):
2026+
self.assertIsSubclass(proto, CallableMembersProto)
2027+
2028+
# These two shouldn't be considered subclasses of CallableMembersProto, however,
2029+
# since they don't have the `meth` protocol member
2030+
2031+
class EmptyProtocol(Protocol): ...
2032+
class UnrelatedProtocol(Protocol):
2033+
def wut(self): ...
2034+
2035+
self.assertNotIsSubclass(EmptyProtocol, CallableMembersProto)
2036+
self.assertNotIsSubclass(UnrelatedProtocol, CallableMembersProto)
2037+
2038+
# These aren't protocols at all (despite having annotations),
2039+
# so they should only be considered subclasses of CallableMembersProto
2040+
# if they *actually have an attribute* matching the `meth` member
2041+
# (just having an annotation is insufficient)
2042+
2043+
class AnnotatedButNotAProtocol:
2044+
meth: Callable[[], None]
2045+
2046+
class NotAProtocolButAnImplicitSubclass:
2047+
def meth(self): pass
2048+
2049+
class NotAProtocolButAnImplicitSubclass2:
2050+
meth: Callable[[], None]
2051+
def meth(self): pass
2052+
2053+
class NotAProtocolButAnImplicitSubclass3:
2054+
meth: Callable[[], None]
2055+
meth2: Callable[[int, str], bool]
2056+
def meth(self): pass
2057+
def meth(self, x, y): return True
2058+
2059+
self.assertNotIsSubclass(AnnotatedButNotAProtocol, CallableMembersProto)
2060+
self.assertIsSubclass(NotAProtocolButAnImplicitSubclass, CallableMembersProto)
2061+
self.assertIsSubclass(NotAProtocolButAnImplicitSubclass2, CallableMembersProto)
2062+
self.assertIsSubclass(NotAProtocolButAnImplicitSubclass3, CallableMembersProto)
2063+
19902064
@skip_if_py312b1
19912065
def test_issubclass_and_isinstance_on_Protocol_itself(self):
19922066
class C:

src/typing_extensions.py

+3-17
Original file line numberDiff line numberDiff line change
@@ -604,23 +604,10 @@ def _no_init(self, *args, **kwargs):
604604
# to mix without getting TypeErrors about "metaclass conflict"
605605
_typing_Protocol = typing.Protocol
606606
_ProtocolMetaBase = type(_typing_Protocol)
607-
608-
def _is_protocol(cls):
609-
return (
610-
isinstance(cls, type)
611-
and issubclass(cls, typing.Generic)
612-
and getattr(cls, "_is_protocol", False)
613-
)
614607
else:
615608
_typing_Protocol = _marker
616609
_ProtocolMetaBase = abc.ABCMeta
617610

618-
def _is_protocol(cls):
619-
return (
620-
isinstance(cls, _ProtocolMeta)
621-
and getattr(cls, "_is_protocol", False)
622-
)
623-
624611
class _ProtocolMeta(_ProtocolMetaBase):
625612
# This metaclass is somewhat unfortunate,
626613
# but is necessary for several reasons...
@@ -634,9 +621,9 @@ def __new__(mcls, name, bases, namespace, **kwargs):
634621
elif {Protocol, _typing_Protocol} & set(bases):
635622
for base in bases:
636623
if not (
637-
base in {object, typing.Generic}
624+
base in {object, typing.Generic, Protocol, _typing_Protocol}
638625
or base.__name__ in _PROTO_ALLOWLIST.get(base.__module__, [])
639-
or _is_protocol(base)
626+
or is_protocol(base)
640627
):
641628
raise TypeError(
642629
f"Protocols can only inherit from other protocols, "
@@ -740,8 +727,7 @@ def _proto_hook(cls, other):
740727
if (
741728
isinstance(annotations, collections.abc.Mapping)
742729
and attr in annotations
743-
and issubclass(other, (typing.Generic, _ProtocolMeta))
744-
and getattr(other, "_is_protocol", False)
730+
and is_protocol(other)
745731
):
746732
break
747733
else:

0 commit comments

Comments
 (0)