https://github.com/python/cpython/commit/95c257e2e691456140e79bd98d1674cbd289eb38
commit: 95c257e2e691456140e79bd98d1674cbd289eb38
branch: main
author: David Ellis <[email protected]>
committer: JelleZijlstra <[email protected]>
date: 2025-10-21T15:57:43Z
summary:

gh-138764: annotationlib: Make `call_annotate_function` fallback to using 
`VALUE` annotations if both the requested format and `VALUE_WITH_FAKE_GLOBALS` 
are not implemented (#138803)

files:
A Misc/NEWS.d/next/Library/2025-09-12-09-34-37.gh-issue-138764.mokHoY.rst
M Doc/library/annotationlib.rst
M Lib/annotationlib.py
M Lib/test/test_annotationlib.py

diff --git a/Doc/library/annotationlib.rst b/Doc/library/annotationlib.rst
index d6f5055955e8cf..40f2a6dc30460b 100644
--- a/Doc/library/annotationlib.rst
+++ b/Doc/library/annotationlib.rst
@@ -340,14 +340,29 @@ Functions
 
    * 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.
+
+     * When calling :attr:`!object.__annotate__` it is first called with 
:attr:`~Format.FORWARDREF`.
+       If this is not implemented, it will then check if 
:attr:`~Format.VALUE_WITH_FAKE_GLOBALS`
+       is supported and use that in the fake globals environment.
+       If neither of these formats are supported, it will fall back to using 
:attr:`~Format.VALUE`.
+       If :attr:`~Format.VALUE` fails, the error from this call will be raised.
+
    * STRING: If :attr:`!object.__annotate__` exists, it is called first;
      otherwise, :attr:`!object.__annotations__` is used and stringified
      using :func:`annotations_to_string`.
 
+     * When calling :attr:`!object.__annotate__` it is first called with 
:attr:`~Format.STRING`.
+       If this is not implemented, it will then check if 
:attr:`~Format.VALUE_WITH_FAKE_GLOBALS`
+       is supported and use that in the fake globals environment.
+       If neither of these formats are supported, it will fall back to using 
:attr:`~Format.VALUE`
+       with the result converted using :func:`annotations_to_string`.
+       If :attr:`~Format.VALUE` fails, the error from this call will be raised.
+
    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
    different but equivalent dicts.
diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py
index 544e069626d0d9..81886a0467d001 100644
--- a/Lib/annotationlib.py
+++ b/Lib/annotationlib.py
@@ -695,6 +695,18 @@ def call_annotate_function(annotate, format, *, 
owner=None, _is_evaluate=False):
         # possibly constants if the annotate function uses them directly). We 
then
         # convert each of those into a string to get an approximation of the
         # original source.
+
+        # Attempt to call with VALUE_WITH_FAKE_GLOBALS to check if it is 
implemented
+        # See: https://github.com/python/cpython/issues/138764
+        # Only fail on NotImplementedError
+        try:
+            annotate(Format.VALUE_WITH_FAKE_GLOBALS)
+        except NotImplementedError:
+            # Both STRING and VALUE_WITH_FAKE_GLOBALS are not implemented: 
fallback to VALUE
+            return annotations_to_string(annotate(Format.VALUE))
+        except Exception:
+            pass
+
         globals = _StringifierDict({}, format=format)
         is_class = isinstance(owner, type)
         closure = _build_closure(
@@ -753,6 +765,9 @@ def call_annotate_function(annotate, format, *, owner=None, 
_is_evaluate=False):
         )
         try:
             result = func(Format.VALUE_WITH_FAKE_GLOBALS)
+        except NotImplementedError:
+            # FORWARDREF and VALUE_WITH_FAKE_GLOBALS not supported, fall back 
to VALUE
+            return annotate(Format.VALUE)
         except Exception:
             pass
         else:
diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py
index 2c5bf2b3417344..8da4ff096e7593 100644
--- a/Lib/test/test_annotationlib.py
+++ b/Lib/test/test_annotationlib.py
@@ -1194,6 +1194,25 @@ class RaisesAttributeError:
             },
         )
 
+    def test_raises_error_from_value(self):
+        # test that if VALUE is the only supported format, but raises an error
+        # that error is propagated from get_annotations
+        class DemoException(Exception): ...
+
+        def annotate(format, /):
+            if format == Format.VALUE:
+                raise DemoException()
+            else:
+                raise NotImplementedError(format)
+
+        def f(): ...
+
+        f.__annotate__ = annotate
+
+        for fmt in [Format.VALUE, Format.FORWARDREF, Format.STRING]:
+            with self.assertRaises(DemoException):
+                get_annotations(f, format=fmt)
+
 
 class TestCallEvaluateFunction(unittest.TestCase):
     def test_evaluation(self):
@@ -1214,6 +1233,163 @@ def evaluate(format, exc=NotImplementedError):
         )
 
 
+class TestCallAnnotateFunction(unittest.TestCase):
+    # Tests for user defined annotate functions.
+
+    # Format and NotImplementedError are provided as arguments so they exist in
+    # the fake globals namespace.
+    # This avoids non-matching conditions passing by being converted to 
stringifiers.
+    # See: https://github.com/python/cpython/issues/138764
+
+    def test_user_annotate_value(self):
+        def annotate(format, /):
+            if format == Format.VALUE:
+                return {"x": str}
+            else:
+                raise NotImplementedError(format)
+
+        annotations = annotationlib.call_annotate_function(
+            annotate,
+            Format.VALUE,
+        )
+
+        self.assertEqual(annotations, {"x": str})
+
+    def test_user_annotate_forwardref_supported(self):
+        # If Format.FORWARDREF is supported prefer it over Format.VALUE
+        def annotate(format, /, __Format=Format, 
__NotImplementedError=NotImplementedError):
+            if format == __Format.VALUE:
+                return {'x': str}
+            elif format == __Format.VALUE_WITH_FAKE_GLOBALS:
+                return {'x': int}
+            elif format == __Format.FORWARDREF:
+                return {'x': float}
+            else:
+                raise __NotImplementedError(format)
+
+        annotations = annotationlib.call_annotate_function(
+            annotate,
+            Format.FORWARDREF
+        )
+
+        self.assertEqual(annotations, {"x": float})
+
+    def test_user_annotate_forwardref_fakeglobals(self):
+        # If Format.FORWARDREF is not supported, use 
Format.VALUE_WITH_FAKE_GLOBALS
+        # before falling back to Format.VALUE
+        def annotate(format, /, __Format=Format, 
__NotImplementedError=NotImplementedError):
+            if format == __Format.VALUE:
+                return {'x': str}
+            elif format == __Format.VALUE_WITH_FAKE_GLOBALS:
+                return {'x': int}
+            else:
+                raise __NotImplementedError(format)
+
+        annotations = annotationlib.call_annotate_function(
+            annotate,
+            Format.FORWARDREF
+        )
+
+        self.assertEqual(annotations, {"x": int})
+
+    def test_user_annotate_forwardref_value_fallback(self):
+        # If Format.FORWARDREF and Format.VALUE_WITH_FAKE_GLOBALS are not 
supported
+        # use Format.VALUE
+        def annotate(format, /, __Format=Format, 
__NotImplementedError=NotImplementedError):
+            if format == __Format.VALUE:
+                return {"x": str}
+            else:
+                raise __NotImplementedError(format)
+
+        annotations = annotationlib.call_annotate_function(
+            annotate,
+            Format.FORWARDREF,
+        )
+
+        self.assertEqual(annotations, {"x": str})
+
+    def test_user_annotate_string_supported(self):
+        # If Format.STRING is supported prefer it over Format.VALUE
+        def annotate(format, /, __Format=Format, 
__NotImplementedError=NotImplementedError):
+            if format == __Format.VALUE:
+                return {'x': str}
+            elif format == __Format.VALUE_WITH_FAKE_GLOBALS:
+                return {'x': int}
+            elif format == __Format.STRING:
+                return {'x': "float"}
+            else:
+                raise __NotImplementedError(format)
+
+        annotations = annotationlib.call_annotate_function(
+            annotate,
+            Format.STRING,
+        )
+
+        self.assertEqual(annotations, {"x": "float"})
+
+    def test_user_annotate_string_fakeglobals(self):
+        # If Format.STRING is not supported but Format.VALUE_WITH_FAKE_GLOBALS 
is
+        # prefer that over Format.VALUE
+        def annotate(format, /, __Format=Format, 
__NotImplementedError=NotImplementedError):
+            if format == __Format.VALUE:
+                return {'x': str}
+            elif format == __Format.VALUE_WITH_FAKE_GLOBALS:
+                return {'x': int}
+            else:
+                raise __NotImplementedError(format)
+
+        annotations = annotationlib.call_annotate_function(
+            annotate,
+            Format.STRING,
+        )
+
+        self.assertEqual(annotations, {"x": "int"})
+
+    def test_user_annotate_string_value_fallback(self):
+        # If Format.STRING and Format.VALUE_WITH_FAKE_GLOBALS are not
+        # supported fall back to Format.VALUE and convert to strings
+        def annotate(format, /, __Format=Format, 
__NotImplementedError=NotImplementedError):
+            if format == __Format.VALUE:
+                return {"x": str}
+            else:
+                raise __NotImplementedError(format)
+
+        annotations = annotationlib.call_annotate_function(
+            annotate,
+            Format.STRING,
+        )
+
+        self.assertEqual(annotations, {"x": "str"})
+
+    def test_condition_not_stringified(self):
+        # Make sure the first condition isn't evaluated as True by being 
converted
+        # to a _Stringifier
+        def annotate(format, /):
+            if format == Format.FORWARDREF:
+                return {"x": str}
+            else:
+                raise NotImplementedError(format)
+
+        with self.assertRaises(NotImplementedError):
+            annotationlib.call_annotate_function(annotate, Format.STRING)
+
+    def test_error_from_value_raised(self):
+        # Test that the error from format.VALUE is raised
+        # if all formats fail
+
+        class DemoException(Exception): ...
+
+        def annotate(format, /):
+            if format == Format.VALUE:
+                raise DemoException()
+            else:
+                raise NotImplementedError(format)
+
+        for fmt in [Format.VALUE, Format.FORWARDREF, Format.STRING]:
+            with self.assertRaises(DemoException):
+                annotationlib.call_annotate_function(annotate, format=fmt)
+
+
 class MetaclassTests(unittest.TestCase):
     def test_annotated_meta(self):
         class Meta(type):
diff --git 
a/Misc/NEWS.d/next/Library/2025-09-12-09-34-37.gh-issue-138764.mokHoY.rst 
b/Misc/NEWS.d/next/Library/2025-09-12-09-34-37.gh-issue-138764.mokHoY.rst
new file mode 100644
index 00000000000000..85ebef8ff11d5c
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2025-09-12-09-34-37.gh-issue-138764.mokHoY.rst
@@ -0,0 +1,3 @@
+Prevent :func:`annotationlib.call_annotate_function` from calling 
``__annotate__`` functions that don't support ``VALUE_WITH_FAKE_GLOBALS`` in a 
fake globals namespace with empty globals.
+
+Make ``FORWARDREF`` and ``STRING`` annotations fall back to using ``VALUE`` 
annotations in the case that neither their own format, nor 
``VALUE_WITH_FAKE_GLOBALS`` are supported.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to