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