Repository: python/cpython
Pull request: gh-104555: Fix isinstance() and issubclass() for runtime-checkable protocols that use PEP 695
Fixes #104555

<!-- gh-issue-number: gh-104555 -->
* Issue: gh-104555
<!-- /gh-issue-number -->


Problem statement:
Typing: runtime-checkable protocols are broken on `main`
PEP-695 protocols don't work as intended:

Here's the behaviour you get with protocols that use pre-PEP 695 syntax, which is correct:

```pycon
>>> from typing import Protocol, runtime_checkable, TypeVar
>>> T_co = TypeVar("T_co", covariant=True)
>>> @runtime_checkable
... class SupportsAbsOld(Protocol[T_co]):
...     def __abs__(self) -> T_co:
...         ...
...
>>> isinstance(0, SupportsAbsOld)
True
>>> issubclass(float, SupportsAbsOld)
True
```

And here's the behaviour you get on `main` with protocols that use PEP 695 syntax, which is incorrect:

```pycon
>>> @runtime_checkable
... class SupportsAbsNew[T_co](Protocol):
...     def __abs__(self) -> T_co:
...         ...
...
>>> isinstance(0, SupportsAbsNew)
False
>>> issubclass(float, SupportsAbsNew)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "C:\Users\alexw\coding\cpython\Lib\abc.py", line 123, in __subclasscheck__
    return _abc_subclasscheck(cls, subclass)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\alexw\coding\cpython\Lib\typing.py", line 1875, in _proto_hook
    raise TypeError("Protocols with non-method members"
TypeError: Protocols with non-method members don't support issubclass()
```

<!-- gh-linked-prs -->
### Linked PRs
* gh-104556
* gh-104559
<!-- /gh-linked-prs -->

Hints from issue discussion:
(I'm working on a fix)
Thanks for catching! I suppose this is about `__type_params__`?
> Thanks for catching! I suppose this is about `__type_params__`?

Yes.
I'm getting very confused here. The fix for the original issue is simple, and I've fixed it locally. But as part of the PR, I tried adding this test, and the last assertion fails (the test reports that `TypeError` _wasn't_ raised):

```py
    def test_pep695_generic_protocol_noncallable_members(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)
        with self.assertRaises(TypeError):
            issubclass(Eggs, Spam)
```

But I can't reproduce the failure in the REPL:

```pycon
>>> from typing import *
>>> @runtime_checkable
... class Spam[T](Protocol):
...     x: T
... 
...     
>>> class Eggs[T]:
...     def __init__(self, x: T) -> None:
...         self.x = x
... 
...         
>>> issubclass(Eggs, Spam)
Traceback (most recent call last):
  File "<pyshell#5>", line 1, in <module>
    issubclass(Eggs, Spam)
  File "C:\Users\alexw\coding\cpython\Lib\abc.py", line 123, in __subclasscheck__
    return _abc_subclasscheck(cls, subclass)
  File "C:\Users\alexw\coding\cpython\Lib\typing.py", line 1875, in _proto_hook
    raise TypeError("Protocols with non-method members"
TypeError: Protocols with non-method members don't support issubclass()
```
Anyway, I'll submit a PR without that test for now...

Commit message: Fix isinstance() and issubclass() for runtime-checkable protocols that use PEP 695

Patch to review:
diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py
index 71aff87dcaa3c9..9ac978533c0059 100644
--- a/Lib/test/test_typing.py
+++ b/Lib/test/test_typing.py
@@ -3134,6 +3134,24 @@ def bar(self, x: str) -> str:
 
         self.assertIsInstance(Test(), PSub)
 
+    def test_pep695_generic_protocol_callable_members(self):
+        @runtime_checkable
+        class Foo[T_co](Protocol):
+            def meth(self, x: T_co) -> None: ...
+
+        class Bar[T_co]:
+            def meth(self, x: T_co) -> None: ...
+
+        self.assertIsInstance(Bar(), Foo)
+        self.assertIsSubclass(Bar, Foo)
+
+        @runtime_checkable
+        class SupportsTrunc[T_co](Protocol):
+            def __trunc__(self) -> T_co: ...
+
+        self.assertIsInstance(0.0, SupportsTrunc)
+        self.assertIsSubclass(float, SupportsTrunc)
+
     def test_init_called(self):
         T = TypeVar('T')
 
diff --git a/Lib/typing.py b/Lib/typing.py
index 8d132e2cbf8771..50a8f515945804 100644
--- a/Lib/typing.py
+++ b/Lib/typing.py
@@ -1663,7 +1663,7 @@ class _TypingEllipsis:
 _TYPING_INTERNALS = frozenset({
     '__parameters__', '__orig_bases__',  '__orig_class__',
     '_is_protocol', '_is_runtime_protocol', '__protocol_attrs__',
-    '__callable_proto_members_only__',
+    '__callable_proto_members_only__', '__type_params__',
 })
 
 _SPECIAL_NAMES = frozenset({


Code review comments:
diff_hunk: @@ -3134,6 +3134,24 @@ def bar(self, x: str) -> str:
 
         self.assertIsInstance(Test(), PSub)
 
+    def test_pep695_generic_protocol_callable_members(self):
+        @runtime_checkable
+        class Foo[T_co](Protocol):
path: Lib/test/test_typing.py
text: @user1:
Pretty sure this is a T_contra :)

@author:
changed my mind, now I hate Hungarian notation