From 3ae4cd61a9e36d66a9d6a18e5f47bd40e33842f7 Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Sun, 25 Oct 2026 10:21:53 -0400 Subject: [PATCH 1/3] gh-160665: Recognize dataclass markers deferred by lazy imports --- Lib/dataclasses.py | 3 ++ Lib/test/test_dataclasses/__init__.py | 26 ++++++++++++ .../dataclass_lazy_initvar.py | 16 ++++++++ .../dataclass_lazy_initvar_other.py | 40 +++++++++++++++++++ ...-10-25-10-12-00.gh-issue-160665.3jLz8x.rst | 3 ++ 5 files changed, 88 insertions(+) create mode 100644 Lib/test/test_dataclasses/dataclass_lazy_initvar.py create mode 100644 Lib/test/test_dataclasses/dataclass_lazy_initvar_other.py create mode 100644 Misc/NEWS.d/next/Library/2026-10-25-10-12-00.gh-issue-160665.3jLz8x.rst diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 5ec91cddaa3e10..f474e600b7c15c 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -854,6 +854,9 @@ def _get_field(cls, a_name, a_type, default_kw_only): # For the complete discussion, see https://bugs.python.org/issue33453 if isinstance(a_type, str): a_type_annotation = _get_type_from_annotation(a_type, cls) + elif isinstance(a_type, annotationlib.ForwardRef): + # An unresolved lazy import can defer the whole annotation. + a_type_annotation = _get_type_from_annotation(a_type.__forward_arg__, cls) else: a_type_annotation = a_type diff --git a/Lib/test/test_dataclasses/__init__.py b/Lib/test/test_dataclasses/__init__.py index 2b4defe35f8a33..c8a5b87f5fdc59 100644 --- a/Lib/test/test_dataclasses/__init__.py +++ b/Lib/test/test_dataclasses/__init__.py @@ -4530,6 +4530,32 @@ 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)) + self.assertIsInstance(cls.__dataclass_fields__['value'].type, + annotationlib.ForwardRef) + + 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 00000000000000..759cb70d1bf12a --- /dev/null +++ b/Lib/test/test_dataclasses/dataclass_lazy_initvar.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + +from test.test_dataclasses.dataclass_lazy_initvar_other import ( + 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 00000000000000..97c7f6dac61bd5 --- /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 00000000000000..5e2c4d7b92efcf --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-25-10-12-00.gh-issue-160665.3jLz8x.rst @@ -0,0 +1,3 @@ +Fix :func:`dataclasses.dataclass` handling of :class:`~dataclasses.InitVar` and +:data:`~typing.ClassVar` annotations when a lazy import cannot yet be resolved +during a circular import. From eb98d5fe609ca4923a4f055089b0612af4f23cd9 Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Mon, 26 Oct 2026 17:52:40 -0400 Subject: [PATCH 2/3] gh-160665: Resolve dataclass fixture imports before defining A Resolve the complete circular from-import before defining A so lazy_imports=all exercises unresolved annotations as in normal mode. Keep the strict ForwardRef and classification assertions unchanged. --- Lib/test/test_dataclasses/dataclass_lazy_initvar.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Lib/test/test_dataclasses/dataclass_lazy_initvar.py b/Lib/test/test_dataclasses/dataclass_lazy_initvar.py index 759cb70d1bf12a..f72c659d36e813 100644 --- a/Lib/test/test_dataclasses/dataclass_lazy_initvar.py +++ b/Lib/test/test_dataclasses/dataclass_lazy_initvar.py @@ -5,6 +5,10 @@ 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', From 5ae90a2f81b54ca3e0efc0029b15ad78f3ee0fb4 Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Fri, 30 Oct 2026 10:12:46 -0400 Subject: [PATCH 3/3] gh-160665: Preserve annotation structure for unresolved lazy imports --- Lib/annotationlib.py | 11 +++ Lib/dataclasses.py | 3 - Lib/test/test_annotationlib.py | 79 +++++++++++++++++++ Lib/test/test_dataclasses/__init__.py | 6 +- ...-10-25-10-12-00.gh-issue-160665.3jLz8x.rst | 7 +- 5 files changed, 98 insertions(+), 8 deletions(-) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index c41df197eee7d5..7054f7ebae1a74 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/dataclasses.py b/Lib/dataclasses.py index f474e600b7c15c..5ec91cddaa3e10 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -854,9 +854,6 @@ def _get_field(cls, a_name, a_type, default_kw_only): # For the complete discussion, see https://bugs.python.org/issue33453 if isinstance(a_type, str): a_type_annotation = _get_type_from_annotation(a_type, cls) - elif isinstance(a_type, annotationlib.ForwardRef): - # An unresolved lazy import can defer the whole annotation. - a_type_annotation = _get_type_from_annotation(a_type.__forward_arg__, cls) else: a_type_annotation = a_type diff --git a/Lib/test/test_annotationlib.py b/Lib/test/test_annotationlib.py index 56e5731849cbd7..a8c03d691ef427 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 c8a5b87f5fdc59..9aa48acc4c6dcc 100644 --- a/Lib/test/test_dataclasses/__init__.py +++ b/Lib/test/test_dataclasses/__init__.py @@ -4543,8 +4543,10 @@ def test_lazy_import_forwardref_annotations(self): self.assertIs(instance.received, value) self.assertEqual(fields(cls), ()) self.assertNotIn('value', vars(instance)) - self.assertIsInstance(cls.__dataclass_fields__['value'].type, - annotationlib.ForwardRef) + 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), ()) 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 index 5e2c4d7b92efcf..bb113602bdad2a 100644 --- 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 @@ -1,3 +1,4 @@ -Fix :func:`dataclasses.dataclass` handling of :class:`~dataclasses.InitVar` and -:data:`~typing.ClassVar` annotations when a lazy import cannot yet be resolved -during a circular import. +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.