Skip to content
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

Backport some recent Protocol fixes from 3.12 #161

Merged
merged 3 commits into from
May 19, 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
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)
Comment on lines +2400 to +2437
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these pass, but had no way of checking, since typing_extensions currently fails to import on 3.12:

Traceback (most recent call last):
  File "C:\Users\alexw\coding\typing_extensions\src\test_typing_extensions.py", line 29, in <module>
    import typing_extensions
  File "C:\Users\alexw\coding\typing_extensions\src\typing_extensions.py", line 1361, in <module>
    class TypeVar(typing.TypeVar, _DefaultMixin, _root=True):
TypeError: type 'typing.TypeVar' is not an acceptable base type

"""
))

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