diff --git a/src/test_typing_extensions.py b/src/test_typing_extensions.py index 469c31b6..d12c5de6 100644 --- a/src/test_typing_extensions.py +++ b/src/test_typing_extensions.py @@ -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 @@ -2235,10 +2292,10 @@ 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) @@ -2246,6 +2303,20 @@ def meth(self): pass 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 @@ -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 diff --git a/src/typing_extensions.py b/src/typing_extensions.py index dd12cfb8..b74bf135 100644 --- a/src/typing_extensions.py +++ b/src/typing_extensions.py @@ -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) @@ -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) ): @@ -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 @@ -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')