]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-132064: Make annotationlib use __annotate__ if only it is present (#132195)
authorJelle Zijlstra <jelle.zijlstra@gmail.com>
Sun, 13 Apr 2025 23:32:44 +0000 (16:32 -0700)
committerGitHub <noreply@github.com>
Sun, 13 Apr 2025 23:32:44 +0000 (16:32 -0700)
Doc/library/annotationlib.rst
Lib/annotationlib.py
Lib/test/test_annotationlib.py
Lib/test/test_functools.py
Misc/NEWS.d/next/Library/2025-04-06-21-17-14.gh-issue-132064.ktPwDM.rst [new file with mode: 0644]

index 140e1aa12e2938db5f9e41cb936d3bc8eaf79c1e..7a6d44069ed005924596af5e5754b7f740613055 100644 (file)
@@ -317,11 +317,22 @@ Functions
    Compute the annotations dict for an object.
 
    *obj* may be a callable, class, module, or other object with
-   :attr:`~object.__annotate__` and :attr:`~object.__annotations__` attributes.
-   Passing in an object of any other type raises :exc:`TypeError`.
+   :attr:`~object.__annotate__` or :attr:`~object.__annotations__` attributes.
+   Passing any other object raises :exc:`TypeError`.
 
    The *format* parameter controls the format in which annotations are returned,
    and must be a member of the :class:`Format` enum or its integer equivalent.
+   The different formats work as follows:
+
+   * VALUE: :attr:`!object.__annotations__` is tried first; if that does not exist,
+     the :attr:`!object.__annotate__` function is called if it exists.
+   * FORWARDREF: If :attr:`!object.__annotations__` exists and can be evaluated successfully,
+     it is used; otherwise, the :attr:`!object.__annotate__` function is called. If it
+     does not exist either, :attr:`!object.__annotations__` is tried again and any error
+     from accessing it is re-raised.
+   * STRING: If :attr:`!object.__annotate__` exists, it is called first;
+     otherwise, :attr:`!object.__annotations__` is used and stringified
+     using :func:`annotations_to_string`.
 
    Returns a dict. :func:`!get_annotations` returns a new dict every time
    it's called; calling it twice on the same object will return two
index 237b3470b831fdf9a5fcb61a551df82c95db4d65..e1c96298426283d404cfc071fa60a37dc6ce5f28 100644 (file)
@@ -640,12 +640,18 @@ def get_annotations(
 ):
     """Compute the annotations dict for an object.
 
-    obj may be a callable, class, or module.
-    Passing in an object of any other type raises TypeError.
-
-    Returns a dict.  get_annotations() returns a new dict every time
-    it's called; calling it twice on the same object will return two
-    different but equivalent dicts.
+    obj may be a callable, class, module, or other object with
+    __annotate__ or __annotations__ attributes.
+    Passing any other object raises TypeError.
+
+    The *format* parameter controls the format in which annotations are returned,
+    and must be a member of the Format enum or its integer equivalent.
+    For the VALUE format, the __annotations__ is tried first; if it
+    does not exist, the __annotate__ function is called. The
+    FORWARDREF format uses __annotations__ if it exists and can be
+    evaluated, and otherwise falls back to calling the __annotate__ function.
+    The SOURCE format tries __annotate__ first, and falls back to
+    using __annotations__, stringified using annotations_to_string().
 
     This function handles several details for you:
 
@@ -687,24 +693,29 @@ def get_annotations(
 
     match format:
         case Format.VALUE:
-            # For VALUE, we only look at __annotations__
+            # For VALUE, we first look at __annotations__
             ann = _get_dunder_annotations(obj)
+
+            # If it's not there, try __annotate__ instead
+            if ann is None:
+                ann = _get_and_call_annotate(obj, format)
         case Format.FORWARDREF:
             # For FORWARDREF, we use __annotations__ if it exists
             try:
-                return dict(_get_dunder_annotations(obj))
+                ann = _get_dunder_annotations(obj)
             except NameError:
                 pass
+            else:
+                if ann is not None:
+                    return dict(ann)
 
             # But if __annotations__ threw a NameError, we try calling __annotate__
             ann = _get_and_call_annotate(obj, format)
-            if ann is not None:
-                return ann
-
-            # If that didn't work either, we have a very weird object: evaluating
-            # __annotations__ threw NameError and there is no __annotate__. In that case,
-            # we fall back to trying __annotations__ again.
-            return dict(_get_dunder_annotations(obj))
+            if ann is None:
+                # If that didn't work either, we have a very weird object: evaluating
+                # __annotations__ threw NameError and there is no __annotate__. In that case,
+                # we fall back to trying __annotations__ again.
+                ann = _get_dunder_annotations(obj)
         case Format.STRING:
             # For STRING, we try to call __annotate__
             ann = _get_and_call_annotate(obj, format)
@@ -712,12 +723,18 @@ def get_annotations(
                 return ann
             # But if we didn't get it, we use __annotations__ instead.
             ann = _get_dunder_annotations(obj)
-            return annotations_to_string(ann)
+            if ann is not None:
+                 ann = annotations_to_string(ann)
         case Format.VALUE_WITH_FAKE_GLOBALS:
             raise ValueError("The VALUE_WITH_FAKE_GLOBALS format is for internal use only")
         case _:
             raise ValueError(f"Unsupported format {format!r}")
 
+    if ann is None:
+        if isinstance(obj, type) or callable(obj):
+            return {}
+        raise TypeError(f"{obj!r} does not have annotations")
+
     if not ann:
         return {}
 
@@ -746,10 +763,8 @@ def get_annotations(
         obj_globals = getattr(obj, "__globals__", None)
         obj_locals = None
         unwrap = obj
-    elif ann is not None:
-        obj_globals = obj_locals = unwrap = None
     else:
-        raise TypeError(f"{obj!r} is not a module, class, or callable.")
+        obj_globals = obj_locals = unwrap = None
 
     if unwrap is not None:
         while True:
@@ -827,11 +842,11 @@ def _get_dunder_annotations(obj):
             ann = obj.__annotations__
         except AttributeError:
             # For static types, the descriptor raises AttributeError.
-            return {}
+            return None
     else:
         ann = getattr(obj, "__annotations__", None)
         if ann is None:
-            return {}
+            return None
 
     if not isinstance(ann, dict):
         raise ValueError(f"{obj!r}.__annotations__ is neither a dict nor None")
index f10282042c74300aad968315beee7e2023449ae0..9b3619afea2d45282ce16b1d9db8e3f2354deb9d 100644 (file)
@@ -885,6 +885,50 @@ class TestGetAnnotations(unittest.TestCase):
             annotationlib.get_annotations(hb, format=Format.STRING), {"x": str}
         )
 
+    def test_only_annotate(self):
+        def f(x: int):
+            pass
+
+        class OnlyAnnotate:
+            @property
+            def __annotate__(self):
+                return f.__annotate__
+
+        oa = OnlyAnnotate()
+        self.assertEqual(
+            annotationlib.get_annotations(oa, format=Format.VALUE), {"x": int}
+        )
+        self.assertEqual(
+            annotationlib.get_annotations(oa, format=Format.FORWARDREF), {"x": int}
+        )
+        self.assertEqual(
+            annotationlib.get_annotations(oa, format=Format.STRING),
+            {"x": "int"},
+        )
+
+    def test_no_annotations(self):
+        class CustomClass:
+            pass
+
+        class MyCallable:
+            def __call__(self):
+                pass
+
+        for format in Format:
+            if format == Format.VALUE_WITH_FAKE_GLOBALS:
+                continue
+            for obj in (None, 1, object(), CustomClass()):
+                with self.subTest(format=format, obj=obj):
+                    with self.assertRaises(TypeError):
+                        annotationlib.get_annotations(obj, format=format)
+
+            # Callables and types with no annotations return an empty dict
+            for obj in (int, len, MyCallable()):
+                with self.subTest(format=format, obj=obj):
+                    self.assertEqual(
+                        annotationlib.get_annotations(obj, format=format), {}
+                    )
+
     def test_pep695_generic_class_with_future_annotations(self):
         ann_module695 = inspect_stringized_annotations_pep695
         A_annotations = annotationlib.get_annotations(ann_module695.A, eval_str=True)
index 2b49615178f13660a7b9a45fae1fbf15b27eaee1..4794a7465f0b6629feb80a969693cbbbf41a793c 100644 (file)
@@ -1,4 +1,5 @@
 import abc
+from annotationlib import Format, get_annotations
 import builtins
 import collections
 import collections.abc
@@ -22,6 +23,7 @@ from inspect import Signature
 
 from test.support import import_helper
 from test.support import threading_helper
+from test.support import EqualToForwardRef
 
 import functools
 
@@ -2075,6 +2077,34 @@ class TestLRU:
         self.assertEqual(str(Signature.from_callable(lru.cache_info)), '()')
         self.assertEqual(str(Signature.from_callable(lru.cache_clear)), '()')
 
+    def test_get_annotations(self):
+        def orig(a: int) -> str: ...
+        lru = self.module.lru_cache(1)(orig)
+
+        self.assertEqual(
+            get_annotations(orig), {"a": int, "return": str},
+        )
+        self.assertEqual(
+            get_annotations(lru), {"a": int, "return": str},
+        )
+
+    def test_get_annotations_with_forwardref(self):
+        def orig(a: int) -> nonexistent: ...
+        lru = self.module.lru_cache(1)(orig)
+
+        self.assertEqual(
+            get_annotations(orig, format=Format.FORWARDREF),
+            {"a": int, "return": EqualToForwardRef('nonexistent', owner=orig)},
+        )
+        self.assertEqual(
+            get_annotations(lru, format=Format.FORWARDREF),
+            {"a": int, "return": EqualToForwardRef('nonexistent', owner=lru)},
+        )
+        with self.assertRaises(NameError):
+            get_annotations(orig, format=Format.VALUE)
+        with self.assertRaises(NameError):
+            get_annotations(lru, format=Format.VALUE)
+
     @support.skip_on_s390x
     @unittest.skipIf(support.is_wasi, "WASI has limited C stack")
     @support.skip_if_sanitizer("requires deep stack", ub=True, thread=True)
diff --git a/Misc/NEWS.d/next/Library/2025-04-06-21-17-14.gh-issue-132064.ktPwDM.rst b/Misc/NEWS.d/next/Library/2025-04-06-21-17-14.gh-issue-132064.ktPwDM.rst
new file mode 100644 (file)
index 0000000..2559b71
--- /dev/null
@@ -0,0 +1,4 @@
+:func:`annotationlib.get_annotations` now uses the ``__annotate__``
+attribute if it is present, even if ``__annotations__`` is not present.
+Additionally, the function now raises a :py:exc:`TypeError` if it is passed
+an object that does not have any annotatins.