From 34c60398388ff64e0cc82668df4ed160bdf0f671 Mon Sep 17 00:00:00 2001 From: Felix Scherz Date: Mon, 14 Apr 2025 23:51:14 +0200 Subject: [PATCH 01/12] gh-132493: enable protocol isinstance checks against classes with deferred annotations --- Lib/test/test_typing.py | 12 ++++++++++++ Lib/typing.py | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index 9f9e3eb17b9fc9..f5932ed352669b 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -4554,6 +4554,18 @@ class Commentable(Protocol): ) self.assertIs(type(exc.__cause__), CustomError) + def test_isinstance_with_deffered_evaluation_of_annotations(self): + @runtime_checkable + class P(Protocol): + x: undefined + def meth(self): + ... + + class DeferredClass: + x: undefined + + self.assertFalse(isinstance(DeferredClass(), P)) + class GenericTests(BaseTestCase): diff --git a/Lib/typing.py b/Lib/typing.py index e5d14b03a4fc94..7208477afdab58 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -1801,7 +1801,7 @@ def _get_protocol_attrs(cls): for base in cls.__mro__[:-1]: # without object if base.__name__ in {'Protocol', 'Generic'}: continue - annotations = getattr(base, '__annotations__', {}) + annotations = _lazy_annotationlib.get_annotations(base, format=_lazy_annotationlib.Format.FORWARDREF) for attr in (*base.__dict__, *annotations): if not attr.startswith('_abc_') and attr not in EXCLUDED_ATTRIBUTES: attrs.add(attr) @@ -2018,7 +2018,7 @@ def _proto_hook(cls, other): break # ...or in annotations, if it is a sub-protocol. - annotations = getattr(base, '__annotations__', {}) + annotations = _lazy_annotationlib.get_annotations(base, format=_lazy_annotationlib.Format.FORWARDREF) if (isinstance(annotations, collections.abc.Mapping) and attr in annotations and issubclass(other, Generic) and getattr(other, '_is_protocol', False)): From 8ad1181115a0b8380dc977c282259c3401a5cc19 Mon Sep 17 00:00:00 2001 From: Felix Scherz Date: Tue, 15 Apr 2025 08:15:40 +0200 Subject: [PATCH 02/12] fix: typo in test name Co-authored-by: Jelle Zijlstra --- Lib/test/test_typing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index f5932ed352669b..35a3aff6d7d0bb 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -4554,7 +4554,7 @@ class Commentable(Protocol): ) self.assertIs(type(exc.__cause__), CustomError) - def test_isinstance_with_deffered_evaluation_of_annotations(self): + def test_isinstance_with_deferred_evaluation_of_annotations(self): @runtime_checkable class P(Protocol): x: undefined From 079cf59663d1fb5848e1f39b75e2b4b737d25e86 Mon Sep 17 00:00:00 2001 From: Felix Scherz Date: Tue, 15 Apr 2025 08:25:35 +0200 Subject: [PATCH 03/12] test: add case for class implementing protocol with deferred annotations --- Lib/test/test_typing.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index 35a3aff6d7d0bb..73de6f15764d31 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -4557,14 +4557,24 @@ class Commentable(Protocol): def test_isinstance_with_deferred_evaluation_of_annotations(self): @runtime_checkable class P(Protocol): - x: undefined + x: undefined | int def meth(self): ... class DeferredClass: x: undefined + class DeferredClassImplementingP: + x: undefined | int + + def __init__(self): + self.x = 0 + + def meth(self): + ... + self.assertFalse(isinstance(DeferredClass(), P)) + self.assertTrue(isinstance(DeferredClassImplementingP(), P)) class GenericTests(BaseTestCase): From 998e83cc6e80e40fd4a12029d6f9d1829da4872f Mon Sep 17 00:00:00 2001 From: Felix Scherz Date: Tue, 15 Apr 2025 08:39:26 +0200 Subject: [PATCH 04/12] docs: add a news entry --- .../Library/2025-04-15-08-39-14.gh-issue-132493.V0gLkU.rst | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2025-04-15-08-39-14.gh-issue-132493.V0gLkU.rst diff --git a/Misc/NEWS.d/next/Library/2025-04-15-08-39-14.gh-issue-132493.V0gLkU.rst b/Misc/NEWS.d/next/Library/2025-04-15-08-39-14.gh-issue-132493.V0gLkU.rst new file mode 100644 index 00000000000000..63f321a7fee533 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-04-15-08-39-14.gh-issue-132493.V0gLkU.rst @@ -0,0 +1,4 @@ +:class:`Protocol` now uses :func:`annotationlib.get_annotations` when +checking whether or not an instance implements the protocol with +``isinstance``. This enables support for ``isinstance`` checks against +classes with deferred annotations. From 92e5ab9885e609184519d36bbe0be98bc17daccf Mon Sep 17 00:00:00 2001 From: Felix Scherz Date: Tue, 15 Apr 2025 09:16:03 +0200 Subject: [PATCH 05/12] docs: reference Protocol and isinstance correctly Co-authored-by: sobolevn --- .../Library/2025-04-15-08-39-14.gh-issue-132493.V0gLkU.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Misc/NEWS.d/next/Library/2025-04-15-08-39-14.gh-issue-132493.V0gLkU.rst b/Misc/NEWS.d/next/Library/2025-04-15-08-39-14.gh-issue-132493.V0gLkU.rst index 63f321a7fee533..a7f762703642a7 100644 --- a/Misc/NEWS.d/next/Library/2025-04-15-08-39-14.gh-issue-132493.V0gLkU.rst +++ b/Misc/NEWS.d/next/Library/2025-04-15-08-39-14.gh-issue-132493.V0gLkU.rst @@ -1,4 +1,4 @@ -:class:`Protocol` now uses :func:`annotationlib.get_annotations` when +:class:`typing.Protocol` now uses :func:`annotationlib.get_annotations` when checking whether or not an instance implements the protocol with -``isinstance``. This enables support for ``isinstance`` checks against +:func:`isinstance`. This enables support for ``isinstance`` checks against classes with deferred annotations. From 3413f780f1e5b293f624678b0186812f565b7956 Mon Sep 17 00:00:00 2001 From: Felix Scherz Date: Tue, 15 Apr 2025 17:10:43 +0200 Subject: [PATCH 06/12] feat: no longer check whether annotations is a mapping since it is guaranteed Co-authored-by: Alex Waygood --- Lib/typing.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/typing.py b/Lib/typing.py index 7208477afdab58..420c43db93e389 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -2019,9 +2019,9 @@ def _proto_hook(cls, other): # ...or in annotations, if it is a sub-protocol. annotations = _lazy_annotationlib.get_annotations(base, format=_lazy_annotationlib.Format.FORWARDREF) - if (isinstance(annotations, collections.abc.Mapping) and - attr in annotations and - issubclass(other, Generic) and getattr(other, '_is_protocol', False)): + if (attr in annotations + and issubclass(other, Generic) + and getattr(other, '_is_protocol', False)): break else: return NotImplemented From 8c7a1e9ece8c0c2e0f851fc1a6c96f6c77861c79 Mon Sep 17 00:00:00 2001 From: Felix Scherz Date: Tue, 15 Apr 2025 20:50:14 +0200 Subject: [PATCH 07/12] feat: check for Generic subclass and Protocol first to improve performance for common case --- Lib/typing.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Lib/typing.py b/Lib/typing.py index d8242bf1022df2..b593758c660e52 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -2021,9 +2021,11 @@ def _proto_hook(cls, other): # ...or in annotations, if it is a sub-protocol. annotations = _lazy_annotationlib.get_annotations(base, format=_lazy_annotationlib.Format.FORWARDREF) - if (attr in annotations - and issubclass(other, Generic) - and getattr(other, '_is_protocol', False)): + if ( + issubclass(other, Generic) + and getattr(other, "_is_protocol", False) + and attr in annotations + ): break else: return NotImplemented From 63e5a5bcda95a009bd60c36f60b6855b91520871 Mon Sep 17 00:00:00 2001 From: Felix Scherz Date: Tue, 15 Apr 2025 23:26:48 +0200 Subject: [PATCH 08/12] test: add a test with a sub-protocol --- Lib/test/test_typing.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index 8cdc700dcdffe0..ea34d0cdc34ba6 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -4557,7 +4557,6 @@ class Commentable(Protocol): def test_isinstance_with_deferred_evaluation_of_annotations(self): @runtime_checkable class P(Protocol): - x: undefined | int def meth(self): ... @@ -4573,6 +4572,11 @@ def __init__(self): def meth(self): ... + class SubProtocol(P, Protocol): + meth: undefined + + + self.assertTrue(issubclass(SubProtocol, P)) self.assertFalse(isinstance(DeferredClass(), P)) self.assertTrue(isinstance(DeferredClassImplementingP(), P)) From a820f7fa826e80b67aed9187278a0566bfada899 Mon Sep 17 00:00:00 2001 From: Felix Scherz Date: Tue, 15 Apr 2025 23:38:45 +0200 Subject: [PATCH 09/12] test: clarify override of method --- Lib/test/test_typing.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index ea34d0cdc34ba6..0062e41504471e 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -4557,6 +4557,7 @@ class Commentable(Protocol): def test_isinstance_with_deferred_evaluation_of_annotations(self): @runtime_checkable class P(Protocol): + x: int def meth(self): ... @@ -4572,6 +4573,7 @@ def __init__(self): def meth(self): ... + # override meth with a non-method attribute to make it part of __annotations__ instead of __dict__ class SubProtocol(P, Protocol): meth: undefined From 19d925a8183e38051a638ac1d063326ec943d97e Mon Sep 17 00:00:00 2001 From: Felix Scherz Date: Tue, 15 Apr 2025 23:44:06 +0200 Subject: [PATCH 10/12] test: fix test by removing non-member attribute --- Lib/test/test_typing.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index 0062e41504471e..5f530f7896db23 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -4557,7 +4557,6 @@ class Commentable(Protocol): def test_isinstance_with_deferred_evaluation_of_annotations(self): @runtime_checkable class P(Protocol): - x: int def meth(self): ... From 316741e34d55410947d89a1b16957f7baf3f2972 Mon Sep 17 00:00:00 2001 From: Felix Scherz Date: Wed, 16 Apr 2025 16:33:16 +0200 Subject: [PATCH 11/12] feat: compute annotations only if necessary Co-authored-by: Jelle Zijlstra --- Lib/typing.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Lib/typing.py b/Lib/typing.py index b593758c660e52..6863f4d7e2001b 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -2020,11 +2020,12 @@ def _proto_hook(cls, other): break # ...or in annotations, if it is a sub-protocol. - annotations = _lazy_annotationlib.get_annotations(base, format=_lazy_annotationlib.Format.FORWARDREF) if ( issubclass(other, Generic) and getattr(other, "_is_protocol", False) - and attr in annotations + and attr in _lazy_annotationlib.get_annotations( + base, format=_lazy_annotationlib.Format.FORWARDREF + ) ): break else: From 3d13c568abcdf4a317049712d2b61caab1e92689 Mon Sep 17 00:00:00 2001 From: Felix Scherz Date: Wed, 16 Apr 2025 16:38:44 +0200 Subject: [PATCH 12/12] fix: replace assertTrue(...) with helper methods --- Lib/test/test_typing.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py index 5f530f7896db23..25e0b3686092d7 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -4577,9 +4577,9 @@ class SubProtocol(P, Protocol): meth: undefined - self.assertTrue(issubclass(SubProtocol, P)) - self.assertFalse(isinstance(DeferredClass(), P)) - self.assertTrue(isinstance(DeferredClassImplementingP(), P)) + self.assertIsSubclass(SubProtocol, P) + self.assertNotIsInstance(DeferredClass(), P) + self.assertIsInstance(DeferredClassImplementingP(), P) def test_deferred_evaluation_of_annotations(self): class DeferredProto(Protocol):