From 1563a8df834245ddeba6c37250bac610538ff179 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 9 May 2025 18:29:28 -0700 Subject: [PATCH 1/4] gh-133684: Fix get_annotations() where PEP 563 is involved --- Lib/annotationlib.py | 16 ++++- Lib/test/support/__init__.py | 4 +- Lib/test/test_annotationlib.py | 63 ++++++++++++++++++- ...-05-09-18-29-25.gh-issue-133684.Y1DFSt.rst | 3 + 4 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-05-09-18-29-25.gh-issue-133684.Y1DFSt.rst diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 32b8553458930c..6d13e3735886fa 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -1042,14 +1042,24 @@ def _get_and_call_annotate(obj, format): return None +_BASE_GET_ANNOTATIONS = type.__dict__["__annotations__"].__get__ + + def _get_dunder_annotations(obj): """Return the annotations for an object, checking that it is a dictionary. Does not return a fresh dictionary. """ - ann = getattr(obj, "__annotations__", None) - if ann is None: - return None + if isinstance(obj, type): + try: + ann = _BASE_GET_ANNOTATIONS(obj) + except AttributeError: + # For static types, the descriptor raises AttributeError. + return None + else: + ann = getattr(obj, "__annotations__", None) + if ann is None: + return None if not isinstance(ann, dict): raise ValueError(f"{obj!r}.__annotations__ is neither a dict nor None") diff --git a/Lib/test/support/__init__.py b/Lib/test/support/__init__.py index c74c3a3190947b..9b6e80fdad9747 100644 --- a/Lib/test/support/__init__.py +++ b/Lib/test/support/__init__.py @@ -696,9 +696,11 @@ def sortdict(dict): return "{%s}" % withcommas -def run_code(code: str) -> dict[str, object]: +def run_code(code: str, extra_names: dict[str, object] | None = None) -> dict[str, object]: """Run a piece of code after dedenting it, and return its global namespace.""" ns = {} + if extra_names: + ns.update(extra_names) exec(textwrap.dedent(code), ns) return ns diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 73a821d15e3481..5c30ad0620dc08 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -7,7 +7,7 @@ import functools import itertools import pickle -from string.templatelib import Interpolation, Template +from string.templatelib import Template import typing import unittest from annotationlib import ( @@ -815,6 +815,67 @@ def test_stringized_annotations_on_class(self): {"x": int}, ) + def test_stringized_annotation_permutations(self): + def define_class(name, has_future, has_annos, base_text, extra_names=None): + lines = [] + if has_future: + lines.append("from __future__ import annotations") + lines.append(f"class {name}({base_text}):") + if has_annos: + lines.append(f" {name}_attr: int") + else: + lines.append(" pass") + code = "\n".join(lines) + ns = support.run_code(code, extra_names=extra_names) + return ns[name] + + def check_annotations(cls, name, has_future, has_annos): + if has_annos: + if has_future: + anno = "int" + else: + anno = int + self.assertEqual(get_annotations(cls), {f"{name}_attr": anno}) + else: + self.assertEqual(get_annotations(cls), {}) + + for meta_future, base_future, child_future, meta_has_annos, base_has_annos, child_has_annos in itertools.product( + (False, True), + (False, True), + (False, True), + (False, True), + (False, True), + (False, True), + ): + with self.subTest( + meta_future=meta_future, + base_future=base_future, + child_future=child_future, + ): + meta = define_class( + "Meta", + has_future=meta_future, + has_annos=meta_has_annos, + base_text="type", + ) + base = define_class( + "Base", + has_future=base_future, + has_annos=base_has_annos, + base_text="metaclass=Meta", + extra_names={"Meta": meta}, + ) + child = define_class( + "Child", + has_future=child_future, + has_annos=child_has_annos, + base_text="Base", + extra_names={"Base": base}, + ) + check_annotations(meta, "Meta", meta_future, meta_has_annos) + check_annotations(base, "Base", base_future, base_has_annos) + check_annotations(child, "Child", child_future, child_has_annos) + def test_modify_annotations(self): def f(x: int): pass diff --git a/Misc/NEWS.d/next/Library/2025-05-09-18-29-25.gh-issue-133684.Y1DFSt.rst b/Misc/NEWS.d/next/Library/2025-05-09-18-29-25.gh-issue-133684.Y1DFSt.rst new file mode 100644 index 00000000000000..0cb1bc237a12c9 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-05-09-18-29-25.gh-issue-133684.Y1DFSt.rst @@ -0,0 +1,3 @@ +Fix bug where :func:`annotationlib.get_annotations` would return the wrong +result for certain classes that are part of a class hierarchy where ``from +__future__ import annotations`` is used. From 9fb748833d4520245e1f6a4723fba0b85ea4a8aa Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Fri, 9 May 2025 18:31:14 -0700 Subject: [PATCH 2/4] simplify --- Lib/test/test_annotationlib.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 5c30ad0620dc08..769a5f27533dcf 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -829,13 +829,13 @@ def define_class(name, has_future, has_annos, base_text, extra_names=None): ns = support.run_code(code, extra_names=extra_names) return ns[name] - def check_annotations(cls, name, has_future, has_annos): + def check_annotations(cls, has_future, has_annos): if has_annos: if has_future: anno = "int" else: anno = int - self.assertEqual(get_annotations(cls), {f"{name}_attr": anno}) + self.assertEqual(get_annotations(cls), {f"{cls.__name__}_attr": anno}) else: self.assertEqual(get_annotations(cls), {}) @@ -872,9 +872,9 @@ def check_annotations(cls, name, has_future, has_annos): base_text="Base", extra_names={"Base": base}, ) - check_annotations(meta, "Meta", meta_future, meta_has_annos) - check_annotations(base, "Base", base_future, base_has_annos) - check_annotations(child, "Child", child_future, child_has_annos) + check_annotations(meta, meta_future, meta_has_annos) + check_annotations(base, base_future, base_has_annos) + check_annotations(child, child_future, child_has_annos) def test_modify_annotations(self): def f(x: int): From 54fd9f835d74d9deb9b3f8fab34613cab9a7a1ab Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 14 May 2025 06:28:01 -0700 Subject: [PATCH 3/4] add comment --- Lib/annotationlib.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index 6d13e3735886fa..a7dfb91515a1c4 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -1050,6 +1050,9 @@ def _get_dunder_annotations(obj): Does not return a fresh dictionary. """ + # This special case is needed to support types defined under + # from __future__ import annotations, where accessing the __annotations__ + # attribute directly might return annotations for the wrong class. if isinstance(obj, type): try: ann = _BASE_GET_ANNOTATIONS(obj) From 2afb851ff4c30cb1e585ad775d77607701a70472 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Wed, 14 May 2025 06:29:10 -0700 Subject: [PATCH 4/4] more subtest --- Lib/test/test_annotationlib.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 769a5f27533dcf..fe091e52a86dc4 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -851,6 +851,9 @@ def check_annotations(cls, has_future, has_annos): meta_future=meta_future, base_future=base_future, child_future=child_future, + meta_has_annos=meta_has_annos, + base_has_annos=base_has_annos, + child_has_annos=child_has_annos, ): meta = define_class( "Meta",