Skip to content

Commit

Permalink
Backport some recent Protocol fixes from 3.12 (#161)
Browse files Browse the repository at this point in the history
  • Loading branch information
AlexWaygood authored May 19, 2023
1 parent 7e6a4c0 commit dfe4889
Show file tree
Hide file tree
Showing 2 changed files with 152 additions and 28 deletions.
119 changes: 116 additions & 3 deletions src/test_typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1907,6 +1907,63 @@ class D(PNonCall): ...
with self.assertRaises(TypeError):
issubclass(D, PNonCall)

def test_no_weird_caching_with_issubclass_after_isinstance(self):
@runtime_checkable
class Spam(Protocol):
x: int

class Eggs:
def __init__(self) -> None:
self.x = 42

self.assertIsInstance(Eggs(), Spam)

# gh-104555: If we didn't override ABCMeta.__subclasscheck__ in _ProtocolMeta,
# TypeError wouldn't be raised here,
# as the cached result of the isinstance() check immediately above
# would mean the issubclass() call would short-circuit
# before we got to the "raise TypeError" line
with self.assertRaises(TypeError):
issubclass(Eggs, Spam)

def test_no_weird_caching_with_issubclass_after_isinstance_2(self):
@runtime_checkable
class Spam(Protocol):
x: int

class Eggs: ...

self.assertNotIsInstance(Eggs(), Spam)

# gh-104555: If we didn't override ABCMeta.__subclasscheck__ in _ProtocolMeta,
# TypeError wouldn't be raised here,
# as the cached result of the isinstance() check immediately above
# would mean the issubclass() call would short-circuit
# before we got to the "raise TypeError" line
with self.assertRaises(TypeError):
issubclass(Eggs, Spam)

def test_no_weird_caching_with_issubclass_after_isinstance_3(self):
@runtime_checkable
class Spam(Protocol):
x: int

class Eggs:
def __getattr__(self, attr):
if attr == "x":
return 42
raise AttributeError(attr)

self.assertNotIsInstance(Eggs(), Spam)

# gh-104555: If we didn't override ABCMeta.__subclasscheck__ in _ProtocolMeta,
# TypeError wouldn't be raised here,
# as the cached result of the isinstance() check immediately above
# would mean the issubclass() call would short-circuit
# before we got to the "raise TypeError" line
with self.assertRaises(TypeError):
issubclass(Eggs, Spam)

def test_protocols_isinstance(self):
T = TypeVar('T')
@runtime_checkable
Expand Down Expand Up @@ -2235,17 +2292,31 @@ def meth(self): pass
class NonP(P):
x = 1
class NonPR(PR): pass
class C:
class C(metaclass=abc.ABCMeta):
x = 1
class D:
def meth(self): pass
class D(metaclass=abc.ABCMeta): # noqa: B024
def meth(self): pass # noqa: B027
self.assertNotIsInstance(C(), NonP)
self.assertNotIsInstance(D(), NonPR)
self.assertNotIsSubclass(C, NonP)
self.assertNotIsSubclass(D, NonPR)
self.assertIsInstance(NonPR(), PR)
self.assertIsSubclass(NonPR, PR)

self.assertNotIn("__protocol_attrs__", vars(NonP))
self.assertNotIn("__protocol_attrs__", vars(NonPR))
self.assertNotIn("__callable_proto_members_only__", vars(NonP))
self.assertNotIn("__callable_proto_members_only__", vars(NonPR))

acceptable_extra_attrs = {
'_is_protocol', '_is_runtime_protocol', '__parameters__',
'__init__', '__annotations__', '__subclasshook__',
}
self.assertLessEqual(vars(NonP).keys(), vars(C).keys() | acceptable_extra_attrs)
self.assertLessEqual(
vars(NonPR).keys(), vars(D).keys() | acceptable_extra_attrs
)

def test_custom_subclasshook(self):
class P(Protocol):
x = 1
Expand Down Expand Up @@ -2325,6 +2396,48 @@ def bar(self, x: str) -> str:
with self.assertRaises(TypeError):
PR[int, ClassVar]

if sys.version_info >= (3, 12):
exec(textwrap.dedent(
"""
def test_pep695_generic_protocol_callable_members(self):
@runtime_checkable
class Foo[T](Protocol):
def meth(self, x: T) -> None: ...
class Bar[T]:
def meth(self, x: T) -> None: ...
self.assertIsInstance(Bar(), Foo)
self.assertIsSubclass(Bar, Foo)
@runtime_checkable
class SupportsTrunc[T](Protocol):
def __trunc__(self) -> T: ...
self.assertIsInstance(0.0, SupportsTrunc)
self.assertIsSubclass(float, SupportsTrunc)
def test_no_weird_caching_with_issubclass_after_isinstance_pep695(self):
@runtime_checkable
class Spam[T](Protocol):
x: T
class Eggs[T]:
def __init__(self, x: T) -> None:
self.x = x
self.assertIsInstance(Eggs(42), Spam)
# gh-104555: If we didn't override ABCMeta.__subclasscheck__ in _ProtocolMeta,
# TypeError wouldn't be raised here,
# as the cached result of the isinstance() check immediately above
# would mean the issubclass() call would short-circuit
# before we got to the "raise TypeError" line
with self.assertRaises(TypeError):
issubclass(Eggs, Spam)
"""
))

def test_init_called(self):
T = TypeVar('T')
class P(Protocol[T]): pass
Expand Down
61 changes: 36 additions & 25 deletions src/typing_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,9 @@ def clear_overloads():
if sys.version_info >= (3, 9):
_EXCLUDED_ATTRS.add("__class_getitem__")

if sys.version_info >= (3, 12):
_EXCLUDED_ATTRS.add("__type_params__")

_EXCLUDED_ATTRS = frozenset(_EXCLUDED_ATTRS)


Expand Down Expand Up @@ -550,23 +553,37 @@ def _no_init(self, *args, **kwargs):
raise TypeError('Protocols cannot be instantiated')

class _ProtocolMeta(abc.ABCMeta):
# This metaclass is a bit unfortunate and exists only because of the lack
# of __instancehook__.
# This metaclass is somewhat unfortunate,
# but is necessary for several reasons...
def __init__(cls, *args, **kwargs):
super().__init__(*args, **kwargs)
cls.__protocol_attrs__ = _get_protocol_attrs(cls)
# PEP 544 prohibits using issubclass()
# with protocols that have non-method members.
cls.__callable_proto_members_only__ = all(
callable(getattr(cls, attr, None)) for attr in cls.__protocol_attrs__
)
if getattr(cls, "_is_protocol", False):
cls.__protocol_attrs__ = _get_protocol_attrs(cls)
# PEP 544 prohibits using issubclass()
# with protocols that have non-method members.
cls.__callable_proto_members_only__ = all(
callable(getattr(cls, attr, None)) for attr in cls.__protocol_attrs__
)

def __subclasscheck__(cls, other):
if (
getattr(cls, '_is_protocol', False)
and not cls.__callable_proto_members_only__
and not _allow_reckless_class_checks(depth=3)
):
raise TypeError(
"Protocols with non-method members don't support issubclass()"
)
return super().__subclasscheck__(other)

def __instancecheck__(cls, instance):
# We need this method for situations where attributes are
# assigned in __init__.
is_protocol_cls = getattr(cls, "_is_protocol", False)
if not getattr(cls, "_is_protocol", False):
# i.e., it's a concrete subclass of a protocol
return super().__instancecheck__(instance)

if (
is_protocol_cls and
not getattr(cls, '_is_runtime_protocol', False) and
not _allow_reckless_class_checks(depth=2)
):
Expand All @@ -576,16 +593,15 @@ def __instancecheck__(cls, instance):
if super().__instancecheck__(instance):
return True

if is_protocol_cls:
for attr in cls.__protocol_attrs__:
try:
val = inspect.getattr_static(instance, attr)
except AttributeError:
break
if val is None and callable(getattr(cls, attr, None)):
break
else:
return True
for attr in cls.__protocol_attrs__:
try:
val = inspect.getattr_static(instance, attr)
except AttributeError:
break
if val is None and callable(getattr(cls, attr, None)):
break
else:
return True

return False

Expand Down Expand Up @@ -679,11 +695,6 @@ def _proto_hook(other):
return NotImplemented
raise TypeError("Instance and class checks can only be used with"
" @runtime protocols")
if not cls.__callable_proto_members_only__:
if _allow_reckless_class_checks():
return NotImplemented
raise TypeError("Protocols with non-method members"
" don't support issubclass()")
if not isinstance(other, type):
# Same error as for issubclass(1, int)
raise TypeError('issubclass() arg 1 must be a class')
Expand Down

0 comments on commit dfe4889

Please sign in to comment.