https://github.com/python/cpython/commit/ac14d4a23f58c1f3e1909753aa17c1c302ea771d
commit: ac14d4a23f58c1f3e1909753aa17c1c302ea771d
branch: main
author: Jelle Zijlstra <jelle.zijls...@gmail.com>
committer: JelleZijlstra <jelle.zijls...@gmail.com>
date: 2025-04-05T04:36:34Z
summary:

gh-129463, gh-128593: Simplify ForwardRef (#129465)

files:
A Misc/NEWS.d/next/Library/2025-01-29-21-27-45.gh-issue-128593.r3j4l-.rst
A Misc/NEWS.d/next/Library/2025-01-29-21-29-46.gh-issue-129463.qePexX.rst
M Doc/library/annotationlib.rst
M Doc/library/typing.rst
M Doc/whatsnew/3.14.rst
M Lib/annotationlib.py
M Lib/test/support/__init__.py
M Lib/test/test_annotationlib.py
M Lib/test/test_inspect/test_inspect.py
M Lib/test/test_types.py
M Lib/test/test_typing.py

diff --git a/Doc/library/annotationlib.rst b/Doc/library/annotationlib.rst
index ab9393e8cd8ab7..e07081e3c5dd7a 100644
--- a/Doc/library/annotationlib.rst
+++ b/Doc/library/annotationlib.rst
@@ -204,12 +204,6 @@ Classes
       means may not have any information about their scope, so passing
       arguments to this method may be necessary to evaluate them successfully.
 
-      .. important::
-
-         Once a :class:`~ForwardRef` instance has been evaluated, it caches
-         the evaluated value, and future calls to :meth:`evaluate` will return
-         the cached value, regardless of the parameters passed in.
-
    .. versionadded:: 3.14
 
 
diff --git a/Doc/library/typing.rst b/Doc/library/typing.rst
index d5870498fa35b9..84f77e8f206438 100644
--- a/Doc/library/typing.rst
+++ b/Doc/library/typing.rst
@@ -3449,7 +3449,9 @@ Introspection helpers
    .. versionadded:: 3.7.4
 
    .. versionchanged:: 3.14
-      This is now an alias for :class:`annotationlib.ForwardRef`.
+      This is now an alias for :class:`annotationlib.ForwardRef`. Several 
undocumented
+      behaviors of this class have been changed; for example, after a 
``ForwardRef`` has
+      been evaluated, the evaluated value is no longer cached.
 
 .. function:: evaluate_forward_ref(forward_ref, *, owner=None, globals=None, 
locals=None, type_params=None, format=annotationlib.Format.VALUE)
 
diff --git a/Doc/whatsnew/3.14.rst b/Doc/whatsnew/3.14.rst
index d97874fe7a88d4..81c7969e8af245 100644
--- a/Doc/whatsnew/3.14.rst
+++ b/Doc/whatsnew/3.14.rst
@@ -209,7 +209,7 @@ This example shows how these formats behave:
      ...
    NameError: name 'Undefined' is not defined
    >>> get_annotations(func, format=Format.FORWARDREF)
-   {'arg': ForwardRef('Undefined')}
+   {'arg': ForwardRef('Undefined', owner=<function func at 0x...>)}
    >>> get_annotations(func, format=Format.STRING)
    {'arg': 'Undefined'}
 
diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py
index c42dad3503bff7..d6243c8863610e 100644
--- a/Lib/annotationlib.py
+++ b/Lib/annotationlib.py
@@ -32,18 +32,16 @@ class Format(enum.IntEnum):
 # preserved for compatibility with the old typing.ForwardRef class. The 
remaining
 # names are private.
 _SLOTS = (
-    "__forward_evaluated__",
-    "__forward_value__",
     "__forward_is_argument__",
     "__forward_is_class__",
     "__forward_module__",
     "__weakref__",
     "__arg__",
-    "__ast_node__",
-    "__code__",
     "__globals__",
-    "__owner__",
+    "__code__",
+    "__ast_node__",
     "__cell__",
+    "__owner__",
     "__stringifier_dict__",
 )
 
@@ -76,14 +74,12 @@ def __init__(
             raise TypeError(f"Forward reference must be a string -- got 
{arg!r}")
 
         self.__arg__ = arg
-        self.__forward_evaluated__ = False
-        self.__forward_value__ = None
         self.__forward_is_argument__ = is_argument
         self.__forward_is_class__ = is_class
         self.__forward_module__ = module
+        self.__globals__ = None
         self.__code__ = None
         self.__ast_node__ = None
-        self.__globals__ = None
         self.__cell__ = None
         self.__owner__ = owner
 
@@ -95,17 +91,11 @@ def evaluate(self, *, globals=None, locals=None, 
type_params=None, owner=None):
 
         If the forward reference cannot be evaluated, raise an exception.
         """
-        if self.__forward_evaluated__:
-            return self.__forward_value__
         if self.__cell__ is not None:
             try:
-                value = self.__cell__.cell_contents
+                return self.__cell__.cell_contents
             except ValueError:
                 pass
-            else:
-                self.__forward_evaluated__ = True
-                self.__forward_value__ = value
-                return value
         if owner is None:
             owner = self.__owner__
 
@@ -171,8 +161,6 @@ def evaluate(self, *, globals=None, locals=None, 
type_params=None, owner=None):
         else:
             code = self.__forward_code__
             value = eval(code, globals=globals, locals=locals)
-        self.__forward_evaluated__ = True
-        self.__forward_value__ = value
         return value
 
     def _evaluate(self, globalns, localns, type_params=_sentinel, *, 
recursive_guard):
@@ -230,18 +218,30 @@ def __forward_code__(self):
     def __eq__(self, other):
         if not isinstance(other, ForwardRef):
             return NotImplemented
-        if self.__forward_evaluated__ and other.__forward_evaluated__:
-            return (
-                self.__forward_arg__ == other.__forward_arg__
-                and self.__forward_value__ == other.__forward_value__
-            )
         return (
             self.__forward_arg__ == other.__forward_arg__
             and self.__forward_module__ == other.__forward_module__
+            # Use "is" here because we use id() for this in __hash__
+            # because dictionaries are not hashable.
+            and self.__globals__ is other.__globals__
+            and self.__forward_is_class__ == other.__forward_is_class__
+            and self.__code__ == other.__code__
+            and self.__ast_node__ == other.__ast_node__
+            and self.__cell__ == other.__cell__
+            and self.__owner__ == other.__owner__
         )
 
     def __hash__(self):
-        return hash((self.__forward_arg__, self.__forward_module__))
+        return hash((
+            self.__forward_arg__,
+            self.__forward_module__,
+            id(self.__globals__),  # dictionaries are not hashable, so hash by 
identity
+            self.__forward_is_class__,
+            self.__code__,
+            self.__ast_node__,
+            self.__cell__,
+            self.__owner__,
+        ))
 
     def __or__(self, other):
         return types.UnionType[self, other]
@@ -250,11 +250,14 @@ def __ror__(self, other):
         return types.UnionType[other, self]
 
     def __repr__(self):
-        if self.__forward_module__ is None:
-            module_repr = ""
-        else:
-            module_repr = f", module={self.__forward_module__!r}"
-        return f"ForwardRef({self.__forward_arg__!r}{module_repr})"
+        extra = []
+        if self.__forward_module__ is not None:
+            extra.append(f", module={self.__forward_module__!r}")
+        if self.__forward_is_class__:
+            extra.append(", is_class=True")
+        if self.__owner__ is not None:
+            extra.append(f", owner={self.__owner__!r}")
+        return f"ForwardRef({self.__forward_arg__!r}{''.join(extra)})"
 
 
 class _Stringifier:
@@ -276,8 +279,6 @@ def __init__(
         # represent a single name).
         assert isinstance(node, (ast.AST, str))
         self.__arg__ = None
-        self.__forward_evaluated__ = False
-        self.__forward_value__ = None
         self.__forward_is_argument__ = False
         self.__forward_is_class__ = is_class
         self.__forward_module__ = None
diff --git a/Lib/test/support/__init__.py b/Lib/test/support/__init__.py
index 1ae4e0b756f007..6d670a575b0c4f 100644
--- a/Lib/test/support/__init__.py
+++ b/Lib/test/support/__init__.py
@@ -3,6 +3,7 @@
 if __name__ != 'test.support':
     raise ImportError('support must be imported from the test package')
 
+import annotationlib
 import contextlib
 import functools
 import inspect
@@ -3021,6 +3022,47 @@ def is_libssl_fips_mode():
     return get_fips_mode() != 0
 
 
+class EqualToForwardRef:
+    """Helper to ease use of annotationlib.ForwardRef in tests.
+
+    This checks only attributes that can be set using the constructor.
+
+    """
+
+    def __init__(
+        self,
+        arg,
+        *,
+        module=None,
+        owner=None,
+        is_class=False,
+    ):
+        self.__forward_arg__ = arg
+        self.__forward_is_class__ = is_class
+        self.__forward_module__ = module
+        self.__owner__ = owner
+
+    def __eq__(self, other):
+        if not isinstance(other, (EqualToForwardRef, 
annotationlib.ForwardRef)):
+            return NotImplemented
+        return (
+            self.__forward_arg__ == other.__forward_arg__
+            and self.__forward_module__ == other.__forward_module__
+            and self.__forward_is_class__ == other.__forward_is_class__
+            and self.__owner__ == other.__owner__
+        )
+
+    def __repr__(self):
+        extra = []
+        if self.__forward_module__ is not None:
+            extra.append(f", module={self.__forward_module__!r}")
+        if self.__forward_is_class__:
+            extra.append(", is_class=True")
+        if self.__owner__ is not None:
+            extra.append(f", owner={self.__owner__!r}")
+        return f"EqualToForwardRef({self.__forward_arg__!r}{''.join(extra)})"
+
+
 _linked_to_musl = None
 def linked_to_musl():
     """
diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py
index 495606b48ed2e8..f10282042c7430 100644
--- a/Lib/test/test_annotationlib.py
+++ b/Lib/test/test_annotationlib.py
@@ -97,27 +97,27 @@ def f(
         anno = annotationlib.get_annotations(f, format=Format.FORWARDREF)
         x_anno = anno["x"]
         self.assertIsInstance(x_anno, ForwardRef)
-        self.assertEqual(x_anno, ForwardRef("some.module"))
+        self.assertEqual(x_anno, support.EqualToForwardRef("some.module", 
owner=f))
 
         y_anno = anno["y"]
         self.assertIsInstance(y_anno, ForwardRef)
-        self.assertEqual(y_anno, ForwardRef("some[module]"))
+        self.assertEqual(y_anno, support.EqualToForwardRef("some[module]", 
owner=f))
 
         z_anno = anno["z"]
         self.assertIsInstance(z_anno, ForwardRef)
-        self.assertEqual(z_anno, ForwardRef("some(module)"))
+        self.assertEqual(z_anno, support.EqualToForwardRef("some(module)", 
owner=f))
 
         alpha_anno = anno["alpha"]
         self.assertIsInstance(alpha_anno, ForwardRef)
-        self.assertEqual(alpha_anno, ForwardRef("some | obj"))
+        self.assertEqual(alpha_anno, support.EqualToForwardRef("some | obj", 
owner=f))
 
         beta_anno = anno["beta"]
         self.assertIsInstance(beta_anno, ForwardRef)
-        self.assertEqual(beta_anno, ForwardRef("+some"))
+        self.assertEqual(beta_anno, support.EqualToForwardRef("+some", 
owner=f))
 
         gamma_anno = anno["gamma"]
         self.assertIsInstance(gamma_anno, ForwardRef)
-        self.assertEqual(gamma_anno, ForwardRef("some < obj"))
+        self.assertEqual(gamma_anno, support.EqualToForwardRef("some < obj", 
owner=f))
 
 
 class TestSourceFormat(unittest.TestCase):
@@ -362,12 +362,13 @@ def test_fwdref_to_builtin(self):
         obj = object()
         self.assertIs(ForwardRef("int").evaluate(globals={"int": obj}), obj)
 
-    def test_fwdref_value_is_cached(self):
+    def test_fwdref_value_is_not_cached(self):
         fr = ForwardRef("hello")
         with self.assertRaises(NameError):
             fr.evaluate()
         self.assertIs(fr.evaluate(globals={"hello": str}), str)
-        self.assertIs(fr.evaluate(), str)
+        with self.assertRaises(NameError):
+            fr.evaluate()
 
     def test_fwdref_with_owner(self):
         self.assertEqual(
@@ -457,7 +458,7 @@ def f2(a: undefined):
         )
         self.assertEqual(annotationlib.get_annotations(f1, format=1), {"a": 
int})
 
-        fwd = annotationlib.ForwardRef("undefined")
+        fwd = support.EqualToForwardRef("undefined", owner=f2)
         self.assertEqual(
             annotationlib.get_annotations(f2, format=Format.FORWARDREF),
             {"a": fwd},
@@ -1014,7 +1015,7 @@ def evaluate(format, exc=NotImplementedError):
             annotationlib.call_evaluate_function(evaluate, Format.VALUE)
         self.assertEqual(
             annotationlib.call_evaluate_function(evaluate, Format.FORWARDREF),
-            annotationlib.ForwardRef("undefined"),
+            support.EqualToForwardRef("undefined"),
         )
         self.assertEqual(
             annotationlib.call_evaluate_function(evaluate, Format.STRING),
diff --git a/Lib/test/test_inspect/test_inspect.py 
b/Lib/test/test_inspect/test_inspect.py
index 73cf5ac64eece6..daae990458d708 100644
--- a/Lib/test/test_inspect/test_inspect.py
+++ b/Lib/test/test_inspect/test_inspect.py
@@ -37,7 +37,7 @@
 
 from test.support import cpython_only, import_helper
 from test.support import MISSING_C_DOCSTRINGS, ALWAYS_EQ
-from test.support import run_no_yield_async_fn
+from test.support import run_no_yield_async_fn, EqualToForwardRef
 from test.support.import_helper import DirsOnSysPath, ready_to_import
 from test.support.os_helper import TESTFN, temp_cwd
 from test.support.script_helper import assert_python_ok, 
assert_python_failure, kill_python
@@ -4940,9 +4940,12 @@ def test_signature_annotation_format(self):
                     signature_func(ida.f, annotation_format=Format.STRING),
                     sig([par("x", PORK, annotation="undefined")])
                 )
+                s1 = signature_func(ida.f, annotation_format=Format.FORWARDREF)
+                s2 = sig([par("x", PORK, 
annotation=EqualToForwardRef("undefined", owner=ida.f))])
+                #breakpoint()
                 self.assertEqual(
                     signature_func(ida.f, annotation_format=Format.FORWARDREF),
-                    sig([par("x", PORK, annotation=ForwardRef("undefined"))])
+                    sig([par("x", PORK, 
annotation=EqualToForwardRef("undefined", owner=ida.f))])
                 )
                 with self.assertRaisesRegex(NameError, "undefined"):
                     signature_func(ida.f, annotation_format=Format.VALUE)
diff --git a/Lib/test/test_types.py b/Lib/test/test_types.py
index f014f7e9ee08c9..350567ec7e990d 100644
--- a/Lib/test/test_types.py
+++ b/Lib/test/test_types.py
@@ -2,7 +2,7 @@
 
 from test.support import (
     run_with_locale, cpython_only, no_rerun,
-    MISSING_C_DOCSTRINGS,
+    MISSING_C_DOCSTRINGS, EqualToForwardRef,
 )
 import collections.abc
 from collections import namedtuple, UserDict
@@ -1089,7 +1089,13 @@ def test_instantiation(self):
         self.assertIs(int, types.UnionType[int])
         self.assertIs(int, types.UnionType[int, int])
         self.assertEqual(int | str, types.UnionType[int, str])
-        self.assertEqual(int | typing.ForwardRef("str"), types.UnionType[int, 
"str"])
+
+        for obj in (
+            int | typing.ForwardRef("str"),
+            typing.Union[int, "str"],
+        ):
+            self.assertIsInstance(obj, types.UnionType)
+            self.assertEqual(obj.__args__, (int, EqualToForwardRef("str")))
 
 
 class MappingProxyTests(unittest.TestCase):
diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py
index ead589fef6cd11..edf3cf9d4a3658 100644
--- a/Lib/test/test_typing.py
+++ b/Lib/test/test_typing.py
@@ -49,7 +49,10 @@
 import warnings
 import types
 
-from test.support import captured_stderr, cpython_only, infinite_recursion, 
requires_docstrings, import_helper, run_code
+from test.support import (
+    captured_stderr, cpython_only, infinite_recursion, requires_docstrings, 
import_helper, run_code,
+    EqualToForwardRef,
+)
 from test.typinganndata import ann_module695, mod_generics_cache, 
_typed_dict_helper
 
 
@@ -468,8 +471,8 @@ def test_or(self):
         self.assertEqual(X | "x", Union[X, "x"])
         self.assertEqual("x" | X, Union["x", X])
         # make sure the order is correct
-        self.assertEqual(get_args(X | "x"), (X, ForwardRef("x")))
-        self.assertEqual(get_args("x" | X), (ForwardRef("x"), X))
+        self.assertEqual(get_args(X | "x"), (X, EqualToForwardRef("x")))
+        self.assertEqual(get_args("x" | X), (EqualToForwardRef("x"), X))
 
     def test_union_constrained(self):
         A = TypeVar('A', str, bytes)
@@ -4992,7 +4995,7 @@ class C3:
         def f(x: X): ...
         self.assertEqual(
             get_type_hints(f, globals(), locals()),
-            {'x': list[list[ForwardRef('X')]]}
+            {'x': list[list[EqualToForwardRef('X')]]}
         )
 
     def test_pep695_generic_class_with_future_annotations(self):
@@ -6186,7 +6189,7 @@ def fun(x: a):
             return a
 
         self.assertEqual(namespace1(), namespace1())
-        self.assertNotEqual(namespace1(), namespace2())
+        self.assertEqual(namespace1(), namespace2())
 
     def test_forward_repr(self):
         self.assertEqual(repr(List['int']), "typing.List[ForwardRef('int')]")
@@ -6244,14 +6247,10 @@ def fun(x: a): pass
             ret = get_type_hints(fun, globals(), locals())
             return a
 
-        def cmp(o1, o2):
-            return o1 == o2
-
-        with infinite_recursion(25):
-            r1 = namespace1()
-            r2 = namespace2()
-            self.assertIsNot(r1, r2)
-            self.assertRaises(RecursionError, cmp, r1, r2)
+        r1 = namespace1()
+        r2 = namespace2()
+        self.assertIsNot(r1, r2)
+        self.assertEqual(r1, r2)
 
     def test_union_forward_recursion(self):
         ValueList = List['Value']
@@ -7173,7 +7172,8 @@ def func(x: undefined) -> undefined: ...
         # FORWARDREF
         self.assertEqual(
             get_type_hints(func, format=annotationlib.Format.FORWARDREF),
-            {'x': ForwardRef('undefined'), 'return': ForwardRef('undefined')},
+            {'x': EqualToForwardRef('undefined', owner=func),
+             'return': EqualToForwardRef('undefined', owner=func)},
         )
 
         # STRING
@@ -8044,7 +8044,7 @@ class Y(NamedTuple):
         class Z(NamedTuple):
             a: None
             b: "str"
-        annos = {'a': type(None), 'b': ForwardRef("str")}
+        annos = {'a': type(None), 'b': EqualToForwardRef("str")}
         self.assertEqual(Z.__annotations__, annos)
         self.assertEqual(Z.__annotate__(annotationlib.Format.VALUE), annos)
         self.assertEqual(Z.__annotate__(annotationlib.Format.FORWARDREF), 
annos)
@@ -8060,7 +8060,7 @@ class X(NamedTuple):
         """
         ns = run_code(textwrap.dedent(code))
         X = ns['X']
-        self.assertEqual(X.__annotations__, {'a': ForwardRef("int"), 'b': 
ForwardRef("None")})
+        self.assertEqual(X.__annotations__, {'a': EqualToForwardRef("int"), 
'b': EqualToForwardRef("None")})
 
     def test_deferred_annotations(self):
         class X(NamedTuple):
@@ -9079,7 +9079,7 @@ class X(TypedDict):
         class Y(TypedDict):
             a: None
             b: "int"
-        fwdref = ForwardRef('int', module=__name__)
+        fwdref = EqualToForwardRef('int', module=__name__)
         self.assertEqual(Y.__annotations__, {'a': type(None), 'b': fwdref})
         self.assertEqual(Y.__annotate__(annotationlib.Format.FORWARDREF), 
{'a': type(None), 'b': fwdref})
 
diff --git 
a/Misc/NEWS.d/next/Library/2025-01-29-21-27-45.gh-issue-128593.r3j4l-.rst 
b/Misc/NEWS.d/next/Library/2025-01-29-21-27-45.gh-issue-128593.r3j4l-.rst
new file mode 100644
index 00000000000000..03fb5a5413a4a8
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2025-01-29-21-27-45.gh-issue-128593.r3j4l-.rst
@@ -0,0 +1,3 @@
+:class:`annotationlib.ForwardRef` objects no longer cache their value when
+they are successfully evaluated. Successive calls to
+:meth:`annotationlib.ForwardRef.evaluate` may return different values.
diff --git 
a/Misc/NEWS.d/next/Library/2025-01-29-21-29-46.gh-issue-129463.qePexX.rst 
b/Misc/NEWS.d/next/Library/2025-01-29-21-29-46.gh-issue-129463.qePexX.rst
new file mode 100644
index 00000000000000..2dea03d2384578
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2025-01-29-21-29-46.gh-issue-129463.qePexX.rst
@@ -0,0 +1,3 @@
+The implementations of equality and hashing for 
:class:`annotationlib.ForwardRef`
+now use all attributes on the object. Two :class:`!ForwardRef` objects
+are equal only if all attributes are equal.

_______________________________________________
Python-checkins mailing list -- python-checkins@python.org
To unsubscribe send an email to python-checkins-le...@python.org
https://mail.python.org/mailman3/lists/python-checkins.python.org/
Member address: arch...@mail-archive.com

Reply via email to