diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index c41df197eee7d5e..7054f7ebae1a74e 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -672,6 +672,17 @@ def __init__(self, namespace, *, globals=None, owner=None, is_class=False, forma self.next_id = 1 self.format = format + def __getitem__(self, key): + value = super().__getitem__(key) + if isinstance(value, types.LazyImportType): + try: + return value.resolve() + except Exception: + # Treat an unresolved lazy import like an undefined name, + # so the rest of the annotation can still be evaluated. + return self.__missing__(key) + return value + def __missing__(self, key): fwdref = _Stringifier( key, diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 56e5731849cbd7c..a8c03d691ef4274 100644 --- a/Lib/test/test_annotationlib.py +++ b/Lib/test/test_annotationlib.py @@ -2334,6 +2334,85 @@ class B: with self.assertRaisesRegex(ValueError, "always fails to import"): annos["b"].evaluate(format=Format.VALUE) + def test_get_annotations_subscripted_lazy_import(self): + module = types.ModuleType("annotationlib_lazy_import_test") + ns = vars(module) + exec(textwrap.dedent(""" + lazy from test.test_lazy_import.data.basic2 import x + lazy from test.test_lazy_import.data.broken_module import y + + a: list[y] + b: dict[str, list[y]] + c: list[x] + d: list[undefined] + + class C: + a: list[y] + b: dict[str, list[y]] + c: list[x] + d: list[undefined] + + def f(a: list[y], b: dict[str, list[y]], c: list[x], + d: list[undefined]): + pass + """), ns) + self.addCleanup(import_helper.unload, + "test.test_lazy_import.data.basic2") + self.addCleanup(import_helper.unload, + "test.test_lazy_import.data.broken_module") + for owner in (module, ns["C"], ns["f"]): + with self.subTest(owner=owner): + annos = get_annotations(owner, format=Format.FORWARDREF) + self.assertIsInstance(annos["a"], types.GenericAlias) + self.assertIs(annos["a"].__origin__, list) + ref, = annos["a"].__args__ + self.assertIsInstance(ref, ForwardRef) + self.assertEqual(ref.__forward_arg__, "y") + self.assertEqual(annos["b"], dict[str, list[ref]]) + self.assertEqual(annos["c"], list[42]) + unknown, = annos["d"].__args__ + self.assertIsInstance(unknown, ForwardRef) + self.assertEqual(unknown.__forward_arg__, "undefined") + self.assertEqual(get_annotations(owner, format=Format.STRING), { + "a": "list[y]", "b": "dict[str, list[y]]", + "c": "list[x]", "d": "list[undefined]", + }) + with self.assertRaisesRegex(ValueError, "always fails to import"): + get_annotations(owner, format=Format.VALUE) + + def test_evaluate_subscripted_lazy_import(self): + ns = {} + exec("lazy from test.test_lazy_import.data.broken_module import y", ns) + self.addCleanup(import_helper.unload, + "test.test_lazy_import.data.broken_module") + for scope in ("globals", "locals"): + with self.subTest(scope=scope): + ref = ForwardRef("list[y]") + value = ref.evaluate(**{scope: ns}, format=Format.FORWARDREF) + self.assertIsInstance(value, types.GenericAlias) + self.assertIs(value.__origin__, list) + inner, = value.__args__ + self.assertIsInstance(inner, ForwardRef) + self.assertEqual(inner.__forward_arg__, "y") + with self.assertRaisesRegex(ValueError, "always fails to import"): + ref.evaluate(**{scope: ns}, format=Format.VALUE) + + def test_unreferenced_lazy_import(self): + name = "test.test_lazy_import.data.basic2" + with import_helper.CleanImport(name): + ns = {} + exec(textwrap.dedent(""" + lazy from test.test_lazy_import.data.basic2 import x + class C: + a: undefined + b: int + """), ns) + self.assertNotIn(name, sys.modules) + annos = get_annotations(ns["C"], format=Format.FORWARDREF) + self.assertEqual(annos["b"], int) + self.assertEqual(annos["a"].__forward_arg__, "undefined") + self.assertNotIn(name, sys.modules) + def test_evaluate_notimplemented_format(self): class C: x: alias diff --git a/Lib/test/test_dataclasses/__init__.py b/Lib/test/test_dataclasses/__init__.py index 2b4defe35f8a33d..9aa48acc4c6dcc8 100644 --- a/Lib/test/test_dataclasses/__init__.py +++ b/Lib/test/test_dataclasses/__init__.py @@ -4530,6 +4530,34 @@ def test_lazy_import_annotations(self): self.assertEqual([f.name for f in fields(m.C)], ['x', 'y']) self.assertNotIsInstance(vars(m)['t'], types.LazyImportType) + def test_lazy_import_forwardref_annotations(self): + m = import_helper.import_fresh_module( + 'test.test_dataclasses.dataclass_lazy_initvar', + fresh=['test.test_dataclasses.dataclass_lazy_initvar_other'], + ) + self.assertIsNotNone(m) + value = m.A() + for cls in (m.DirectInitVar, m.QualifiedInitVar, m.AliasedInitVar): + with self.subTest(cls=cls): + instance = cls(value) + self.assertIs(instance.received, value) + self.assertEqual(fields(cls), ()) + self.assertNotIn('value', vars(instance)) + field_type = cls.__dataclass_fields__['value'].type + self.assertIsInstance(field_type, InitVar) + self.assertIsInstance(field_type.type, annotationlib.ForwardRef) + self.assertEqual(field_type.type.__forward_arg__, 'A') + + with self.subTest(cls=m.DeferredClassVar): + self.assertEqual(fields(m.DeferredClassVar), ()) + self.assertEqual(vars(m.DeferredClassVar()), {}) + + with self.subTest(cls=m.DeferredField): + self.assertEqual([f.name for f in fields(m.DeferredField)], ['value']) + self.assertIs(m.DeferredField(value).value, value) + self.assertIsInstance(fields(m.DeferredField)[0].type, + annotationlib.ForwardRef) + def test_text_annotations(self): from test.test_dataclasses import dataclass_textanno diff --git a/Lib/test/test_dataclasses/dataclass_lazy_initvar.py b/Lib/test/test_dataclasses/dataclass_lazy_initvar.py new file mode 100644 index 000000000000000..f72c659d36e813b --- /dev/null +++ b/Lib/test/test_dataclasses/dataclass_lazy_initvar.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + +from test.test_dataclasses.dataclass_lazy_initvar_other import ( + AliasedInitVar, DeferredClassVar, DeferredField, DirectInitVar, + QualifiedInitVar, +) + +# Resolve every circular import before A is defined, even with lazy_imports=all. +_ = (AliasedInitVar, DeferredClassVar, DeferredField, DirectInitVar, + QualifiedInitVar) + +__all__ = [ + 'A', 'AliasedInitVar', 'DeferredClassVar', 'DeferredField', + 'DirectInitVar', 'QualifiedInitVar', +] + + +@dataclass +class A: + pass diff --git a/Lib/test/test_dataclasses/dataclass_lazy_initvar_other.py b/Lib/test/test_dataclasses/dataclass_lazy_initvar_other.py new file mode 100644 index 000000000000000..97c7f6dac61bd5c --- /dev/null +++ b/Lib/test/test_dataclasses/dataclass_lazy_initvar_other.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass, InitVar +from dataclasses import InitVar as IV +from typing import ClassVar +import dataclasses + +lazy from test.test_dataclasses.dataclass_lazy_initvar import A + + +@dataclass +class DirectInitVar: + value: InitVar[A] + + def __post_init__(self, value): + self.received = value + + +@dataclass +class QualifiedInitVar: + value: dataclasses.InitVar[A] + + def __post_init__(self, value): + self.received = value + + +@dataclass +class AliasedInitVar: + value: IV[A] + + def __post_init__(self, value): + self.received = value + + +@dataclass +class DeferredClassVar: + value: ClassVar[A] = None + + +@dataclass +class DeferredField: + value: A diff --git a/Misc/NEWS.d/next/Library/2026-10-25-10-12-00.gh-issue-160665.3jLz8x.rst b/Misc/NEWS.d/next/Library/2026-10-25-10-12-00.gh-issue-160665.3jLz8x.rst new file mode 100644 index 000000000000000..bb113602bdad2ae --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-25-10-12-00.gh-issue-160665.3jLz8x.rst @@ -0,0 +1,4 @@ +Preserve partially resolved annotations when a lazy import cannot be resolved +in :mod:`annotationlib`'s :attr:`~annotationlib.Format.FORWARDREF` format. This +also fixes :class:`~dataclasses.InitVar` and :data:`~typing.ClassVar` handling +during circular imports.