From 2d12bdfa3e0556018a4b2f8055e276f2481a5655 Mon Sep 17 00:00:00 2001 From: Jelle Zijlstra Date: Tue, 29 Jul 2025 21:19:17 -0700 Subject: [PATCH] gh-137226: Fix behavior of ForwardRef.evaluate with type_params The previous behavior was copied from earlier typing code. It works around the way typing.get_type_hints passes its namespaces, but I don't think the behavior is logical or correct. --- Lib/annotationlib.py | 16 ++++---------- Lib/test/test_annotationlib.py | 20 +++++++++++++++++ Lib/typing.py | 22 +++++++++++++++++-- ...-07-29-21-18-31.gh-issue-137226.B_4lpu.rst | 3 +++ 4 files changed, 47 insertions(+), 14 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-07-29-21-18-31.gh-issue-137226.B_4lpu.rst diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index c83a1573ccd3d1..bee019cd51591e 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -158,21 +158,13 @@ def evaluate( # as a way of emulating annotation scopes when calling `eval()` type_params = getattr(owner, "__type_params__", None) - # type parameters require some special handling, - # as they exist in their own scope - # but `eval()` does not have a dedicated parameter for that scope. - # For classes, names in type parameter scopes should override - # names in the global scope (which here are called `localns`!), - # but should in turn be overridden by names in the class scope - # (which here are called `globalns`!) + # Type parameters exist in their own scope, which is logically + # between the locals and the globals. We simulate this by adding + # them to the globals. if type_params is not None: globals = dict(globals) - locals = dict(locals) for param in type_params: - param_name = param.__name__ - if not self.__forward_is_class__ or param_name not in globals: - globals[param_name] = param - locals.pop(param_name, None) + globals[param.__name__] = param if self.__extra_names__: locals = {**locals, **self.__extra_names__} diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index ae0e73f08c5bd0..88e0d611647f28 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -1365,6 +1365,11 @@ def test_annotations_to_string(self): class A: pass +TypeParamsAlias1 = int + +class TypeParamsSample[TypeParamsAlias1, TypeParamsAlias2]: + TypeParamsAlias2 = str + class TestForwardRefClass(unittest.TestCase): def test_forwardref_instance_type_error(self): @@ -1597,6 +1602,21 @@ class Gen[T]: ForwardRef("alias").evaluate(owner=Gen, locals={"alias": str}), str ) + def test_evaluate_with_type_params_and_scope_conflict(self): + for is_class in (False, True): + with self.subTest(is_class=is_class): + fwdref1 = ForwardRef("TypeParamsAlias1", owner=TypeParamsSample, is_class=is_class) + fwdref2 = ForwardRef("TypeParamsAlias2", owner=TypeParamsSample, is_class=is_class) + + self.assertIs( + fwdref1.evaluate(), + TypeParamsSample.__type_params__[0], + ) + self.assertIs( + fwdref2.evaluate(), + TypeParamsSample.TypeParamsAlias2, + ) + def test_fwdref_with_module(self): self.assertIs(ForwardRef("Format", module="annotationlib").evaluate(), Format) self.assertIs( diff --git a/Lib/typing.py b/Lib/typing.py index f1455c273d31ca..b3b16d730ad995 100644 --- a/Lib/typing.py +++ b/Lib/typing.py @@ -2342,10 +2342,13 @@ def get_type_hints(obj, globalns=None, localns=None, include_extras=False, # *base_globals* first rather than *base_locals*. # This only affects ForwardRefs. base_globals, base_locals = base_locals, base_globals + type_params = base.__type_params__ + base_globals, base_locals = _add_type_params_to_scope( + type_params, base_globals, base_locals, True) for name, value in ann.items(): if isinstance(value, str): value = _make_forward_ref(value, is_argument=False, is_class=True) - value = _eval_type(value, base_globals, base_locals, base.__type_params__, + value = _eval_type(value, base_globals, base_locals, (), format=format, owner=obj) if value is None: value = type(None) @@ -2381,6 +2384,7 @@ def get_type_hints(obj, globalns=None, localns=None, include_extras=False, elif localns is None: localns = globalns type_params = getattr(obj, "__type_params__", ()) + globalns, localns = _add_type_params_to_scope(type_params, globalns, localns, False) for name, value in hints.items(): if isinstance(value, str): # class-level forward refs were handled above, this must be either @@ -2390,13 +2394,27 @@ def get_type_hints(obj, globalns=None, localns=None, include_extras=False, is_argument=not isinstance(obj, types.ModuleType), is_class=False, ) - value = _eval_type(value, globalns, localns, type_params, format=format, owner=obj) + value = _eval_type(value, globalns, localns, (), format=format, owner=obj) if value is None: value = type(None) hints[name] = value return hints if include_extras else {k: _strip_annotations(t) for k, t in hints.items()} +# Add type parameters to the globals and locals scope. This is needed for +# compatibility. +def _add_type_params_to_scope(type_params, globalns, localns, is_class): + if not type_params: + return globalns, localns + globalns = dict(globalns) + localns = dict(localns) + for param in type_params: + if not is_class or param.__name__ not in globalns: + globalns[param.__name__] = param + localns.pop(param.__name__, None) + return globalns, localns + + def _strip_annotations(t): """Strip the annotations from a given type.""" if isinstance(t, _AnnotatedAlias): diff --git a/Misc/NEWS.d/next/Library/2025-07-29-21-18-31.gh-issue-137226.B_4lpu.rst b/Misc/NEWS.d/next/Library/2025-07-29-21-18-31.gh-issue-137226.B_4lpu.rst new file mode 100644 index 00000000000000..522943cdd376dc --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-07-29-21-18-31.gh-issue-137226.B_4lpu.rst @@ -0,0 +1,3 @@ +Fix behavior of :meth:`annotationlib.ForwardRef.evaluate` when the +*type_params* parameter is passed and the name of a type param is also +present in an enclosing scope.