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

Typing: runtime-checkable protocols are broken on main #104555

Closed
AlexWaygood opened this issue May 16, 2023 · 23 comments · Fixed by #104556 or #104559
Closed

Typing: runtime-checkable protocols are broken on main #104555

AlexWaygood opened this issue May 16, 2023 · 23 comments · Fixed by #104556 or #104559
Assignees
Labels
topic-typing type-bug An unexpected behavior, bug, or error

Comments

@AlexWaygood
Copy link
Member

AlexWaygood commented May 16, 2023

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:

>>> 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:

>>> @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()

Linked PRs

@AlexWaygood
Copy link
Member Author

(I'm working on a fix)

@JelleZijlstra
Copy link
Member

Thanks for catching! I suppose this is about __type_params__?

@AlexWaygood
Copy link
Member Author

Thanks for catching! I suppose this is about __type_params__?

Yes.

@AlexWaygood
Copy link
Member Author

AlexWaygood commented May 16, 2023

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):

    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:

>>> 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()

@AlexWaygood
Copy link
Member Author

Anyway, I'll submit a PR without that test for now...

@JelleZijlstra
Copy link
Member

I'll look into this more.

@AlexWaygood
Copy link
Member Author

Okay, cool. I'll enable automerge on my PR for now, as it definitely gets us closer to correct behaviour.

@JelleZijlstra
Copy link
Member

The issue you saw also reproduces for me without PEP 695 syntax. It seems to be due to the _allow_reckless_class_checks check, which somehow thinks the caller is abc if we're in a test, but not if we're in the REPL.

@AlexWaygood
Copy link
Member Author

Aha, so we've discovered a bug, it just isn't a new bug.

@JelleZijlstra
Copy link
Member

It's possible this bug was introduced by the fact that Generic is now a C class, haven't tried on 3.11 yet.

@AlexWaygood
Copy link
Member Author

Yes, looks like the bug is new in 3.12. Running this test file passes on 3.11, but not on main:

import unittest
from typing import runtime_checkable, Generic, Protocol, TypeVar

T = TypeVar("T")

class TestProtocols(unittest.TestCase):
    def test_pep695_generic_protocol_noncallable_members(self):
        @runtime_checkable
        class Spam(Protocol[T]):
            x: T

        class Eggs(Generic[T]):
            def __init__(self, x: T) -> None:
                self.x = x

        self.assertIsInstance(Eggs(42), Spam)
        with self.assertRaises(TypeError):
            issubclass(Eggs, Spam)


if __name__ == "__main__":
    unittest.main()

@AlexWaygood
Copy link
Member Author

AlexWaygood commented May 16, 2023

It's easier to keep the discussion in one place, so I'm reopening so we can continue discussing the other bug

@AlexWaygood AlexWaygood reopened this May 16, 2023
@JelleZijlstra
Copy link
Member

A few things I found:

  • The reason why Alex saw different behavior in the REPL and a test is that it matters whether you call isinstance() first. The issue shows up only if call isinstance() before you call issubclass().
  • The issue reproduces on a commit on main before PEP 695 was merged, so it's not due to PEP 695.
  • I'm bisecting now to find the culprit.

@AlexWaygood AlexWaygood changed the title Runtime-checkable protocols using PEP 695 are broken Typing: runtime-checkable protocols are broken on main May 16, 2023
@JelleZijlstra
Copy link
Member

JelleZijlstra commented May 16, 2023

47753ecde21b79b5c5f11d883946fda2a340e427 is the first bad commit
commit 47753ecde21b79b5c5f11d883946fda2a340e427
Author: Alex Waygood <Alex.Waygood@Gmail.com>
Date:   Wed Apr 5 10:07:30 2023 +0100

    gh-74690: typing: Simplify and optimise `_ProtocolMeta.__instancecheck__` (#103159)

 Lib/typing.py | 14 +++-----------
 1 file changed, 3 insertions(+), 11 deletions(-)

47753ec

@AlexWaygood
Copy link
Member Author

47753ecde21b79b5c5f11d883946fda2a340e427 is the first bad commit
commit 47753ecde21b79b5c5f11d883946fda2a340e427
Author: Alex Waygood <Alex.Waygood@Gmail.com>
Date:   Wed Apr 5 10:07:30 2023 +0100

    gh-74690: typing: Simplify and optimise `_ProtocolMeta.__instancecheck__` (#103159)

 Lib/typing.py | 14 +++-----------
 1 file changed, 3 insertions(+), 11 deletions(-)

(I am, again, working on a fix :)

AlexWaygood added a commit to AlexWaygood/cpython that referenced this issue May 16, 2023
@AlexWaygood
Copy link
Member Author

Incredible stuff:

Python 3.12.0a7+ (heads/main:1163782868, May 16 2023, 18:09:28) [MSC v.1932 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> from typing import *
>>> @runtime_checkable
... class Foo(Protocol):
...     x: int
...
>>> class Bar:
...     x = 42
...
>>> issubclass(Bar, Foo)
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()
>>> isinstance(Bar(), Foo)
True
>>> issubclass(Bar, Foo)
False

@sunmy2019

This comment was marked as outdated.

@AlexWaygood
Copy link
Member Author

@sunmy2019 I was just about to file 2601138 as a PR, which fixes things, but do you think we could fix it by just adjusting the depth value we pass to _allow_reckless_class_checks()?

@sunmy2019
Copy link
Member

Oh sorry, ignore my previous comment.

@sunmy2019
Copy link
Member

Confirmed that the code path that does not raise TypeError goes through L606.

cpython/Modules/_abc.c

Lines 589 to 608 in 03e3c34

/* 2. Check negative cache; may have to invalidate. */
if (impl->_abc_negative_cache_version < abc_invalidation_counter) {
/* Invalidate the negative cache. */
if (impl->_abc_negative_cache != NULL &&
PySet_Clear(impl->_abc_negative_cache) < 0)
{
goto end;
}
impl->_abc_negative_cache_version = abc_invalidation_counter;
}
else {
incache = _in_weak_set(impl->_abc_negative_cache, subclass);
if (incache < 0) {
goto end;
}
if (incache > 0) {
result = Py_False;
goto end;
}
}

@AlexWaygood You are right!

@AlexWaygood
Copy link
Member Author

@sunmy2019 thanks for checking!

@sunmy2019
Copy link
Member

Oh, now I see the whole picture!

  1. abc.ABCMeta cache __subclasscheck__ results if no exception occurs.
  2. Protocol assigns a __subclasshook__, which raises or does not raise exceptions depending on the caller.
  3. _ProtocolMeta.__instancecheck__ calls the __subclasshook__ in (2) (not raising exceptions), and the result is cached by (1)
  4. User call (1), and accidentally get the cached result (not the exception).

@sunmy2019
Copy link
Member

sunmy2019 commented May 16, 2023

More details for (2) (3)

The __subclasshook__ in Protocol utilizes _allow_reckless_class_checks(). In normal cases, it is

_caller(0)=typing  _allow_reckless_class_checks
_caller(1)=typing  Protocol. _proto_hook
_caller(2)=abc     ABCMeta.__subclasscheck__
_caller(3)=__main__         <------- _allow_reckless_class_checks()=False

It then raises an exception.

In the problematic scenarios (3):

_caller(0)=typing   _allow_reckless_class_checks
_caller(1)=typing   Protocol. _proto_hook
_caller(2)=abc      ABCMeta.__subclasscheck__
_caller(3)=abc      ABCMeta.__instancecheck__              <------- _allow_reckless_class_checks()=True
_caller(4)=typing   _ProtocolMeta.__instancecheck__

It does raise an exception.

carljm added a commit to carljm/cpython that referenced this issue May 17, 2023
* main: (26 commits)
  pythonGH-101520: Move tracemalloc functionality into core, leaving interface in Modules. (python#104508)
  typing: Add more tests for TypeVar (python#104571)
  pythongh-104572: Improve error messages for invalid constructs in PEP 695 contexts (python#104573)
  typing: Use PEP 695 syntax in typing.py (python#104553)
  pythongh-102153: Start stripping C0 control and space chars in `urlsplit` (python#102508)
  pythongh-104469: Update README.txt for _testcapi (pythongh-104529)
  pythonGH-103092: isolate `_elementtree` (python#104561)
  pythongh-104050: Add typing to Argument Clinic converters (python#104547)
  pythonGH-103906: Remove immortal refcounting in the interpreter (pythonGH-103909)
  pythongh-87474: Fix file descriptor leaks in subprocess.Popen (python#96351)
  pythonGH-103092: isolate `pyexpat`  (python#104506)
  pythongh-75367: Fix data descriptor detection in inspect.getattr_static (python#104517)
  pythongh-104050: Add more annotations to `Tools/clinic.py` (python#104544)
  pythongh-104555: Fix isinstance() and issubclass() for runtime-checkable protocols that use PEP 695 (python#104556)
  pythongh-103865: add monitoring support to LOAD_SUPER_ATTR (python#103866)
  CODEOWNERS: Assign new PEP 695 files to myself (python#104551)
  pythonGH-104510: Fix refleaks in `_io` base types (python#104516)
  pythongh-104539: Fix indentation error in logging.config.rst (python#104545)
  pythongh-104050: Don't star-import 'types' in Argument Clinic (python#104543)
  pythongh-104050: Add basic typing to CConverter in clinic.py (python#104538)
  ...
AlexWaygood added a commit that referenced this issue May 17, 2023
…isinstance()` influence whether `issubclass()` raises an exception (#104559)

Co-authored-by: Carl Meyer <carl@oddbird.net>
JelleZijlstra pushed a commit to JelleZijlstra/cpython that referenced this issue May 18, 2023
…s to `isinstance()` influence whether `issubclass()` raises an exception (python#104559)

Co-authored-by: Carl Meyer <carl@oddbird.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment