From 132c74ba5a50fca5f1b02aa0b0bae3a859fa9cb9 Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Mon, 28 Jul 2025 16:16:58 +0200 Subject: [PATCH 1/2] gh-89687: fix get_type_hints with dataclasses __init__ generation --- Lib/dataclasses.py | 22 +++- Lib/test/test_dataclasses/__init__.py | 124 ++++++++++++++++++ .../test_dataclasses/dataclass_textanno.py | 6 + .../test_dataclasses/dataclass_textanno2.py | 30 +++++ ...5-07-28-14-24-07.gh-issue-89687.7uW1c8.rst | 1 + 5 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 Lib/test/test_dataclasses/dataclass_textanno2.py create mode 100644 Misc/NEWS.d/next/Library/2025-07-28-14-24-07.gh-issue-89687.7uW1c8.rst diff --git a/Lib/dataclasses.py b/Lib/dataclasses.py index 83ea623dce6281..72a8b1940793e0 100644 --- a/Lib/dataclasses.py +++ b/Lib/dataclasses.py @@ -536,10 +536,25 @@ def _field_assign(frozen, name, value, self_name): return f' {self_name}.{name}={value}' -def _field_init(f, frozen, globals, self_name, slots): +def _field_init(f, frozen, globals, self_name, slots, module): # Return the text of the line in the body of __init__ that will # initialize this field. + if f.init and isinstance(f.type, str): + from typing import ForwardRef # `typing` is a heavy import + # We need to resolve this string type into a real `ForwardRef` object, + # because otherwise we might end up with unsolvable annotations. + # For example: + # def __init__(self, d: collections.OrderedDict) -> None: + # We won't be able to resolve `collections.OrderedDict` + # with wrong `module` param, when placed in a different module. #45524 + try: + f.type = ForwardRef(f.type, module=module, is_class=True) + except SyntaxError: + # We don't want to fail class creation + # when `ForwardRef` cannot be constructed. + pass + default_name = f'__dataclass_dflt_{f.name}__' if f.default_factory is not MISSING: if f.init: @@ -616,7 +631,7 @@ def _init_param(f): def _init_fn(fields, std_fields, kw_only_fields, frozen, has_post_init, - self_name, func_builder, slots): + self_name, func_builder, slots, module): # fields contains both real fields and InitVar pseudo-fields. # Make sure we don't have fields without defaults following fields @@ -643,7 +658,7 @@ def _init_fn(fields, std_fields, kw_only_fields, frozen, has_post_init, body_lines = [] for f in fields: - line = _field_init(f, frozen, locals, self_name, slots) + line = _field_init(f, frozen, locals, self_name, slots, module) # line is None means that this field doesn't require # initialization (it's a pseudo-field). Just skip it. if line: @@ -1093,6 +1108,7 @@ def _process_class(cls, init, repr, eq, order, unsafe_hash, frozen, else 'self', func_builder, slots, + cls.__module__, ) _set_new_attribute(cls, '__replace__', _replace) diff --git a/Lib/test/test_dataclasses/__init__.py b/Lib/test/test_dataclasses/__init__.py index e98a8f284cec9f..019802dcb84900 100644 --- a/Lib/test/test_dataclasses/__init__.py +++ b/Lib/test/test_dataclasses/__init__.py @@ -4140,6 +4140,130 @@ def test_text_annotations(self): {'foo': dataclass_textanno.Foo, 'return': type(None)}) + def test_dataclass_from_another_module(self): + # see bpo-45524 + from test.test_dataclasses import dataclass_textanno + from dataclasses import dataclass + + @dataclass + class Default(dataclass_textanno.Bar): + pass + + @dataclass(init=False) + class WithInitFalse(dataclass_textanno.Bar): + pass + + @dataclass(init=False) + class CustomInit(dataclass_textanno.Bar): + def __init__(self, foo: dataclass_textanno.Foo) -> None: + pass + + @dataclass + class FutureInitChild(dataclass_textanno.WithFutureInit): + pass + + classes = [ + Default, + WithInitFalse, + CustomInit, + dataclass_textanno.WithFutureInit, + FutureInitChild, + ] + for klass in classes: + with self.subTest(klass=klass): + self.assertEqual( + get_type_hints(klass), + {'foo': dataclass_textanno.Foo}, + ) + self.assertEqual(get_type_hints(klass.__new__), {}) + self.assertEqual( + get_type_hints(klass.__init__), + {'foo': dataclass_textanno.Foo, 'return': type(None)}, + ) + + def test_dataclass_from_proxy_module(self): + # see bpo-45524 + from test.test_dataclasses import dataclass_textanno + from test.test_dataclasses import dataclass_textanno2 + from dataclasses import dataclass + + @dataclass + class Default(dataclass_textanno2.Child): + pass + + @dataclass(init=False) + class WithInitFalse(dataclass_textanno2.Child): + pass + + @dataclass(init=False) + class CustomInit(dataclass_textanno2.Child): + def __init__( + self, + foo: dataclass_textanno.Foo, + custom: dataclass_textanno2.Custom, + ) -> None: + pass + + @dataclass + class FutureInitChild(dataclass_textanno2.WithFutureInit): + pass + + classes = [ + Default, + WithInitFalse, + CustomInit, + dataclass_textanno2.WithFutureInit, + FutureInitChild, + ] + for klass in classes: + with self.subTest(klass=klass): + self.assertEqual( + get_type_hints(klass), + { + 'foo': dataclass_textanno.Foo, + 'custom': dataclass_textanno2.Custom, + }, + ) + self.assertEqual(get_type_hints(klass.__new__), {}) + self.assertEqual( + get_type_hints(klass.__init__), + { + 'foo': dataclass_textanno.Foo, + 'custom': dataclass_textanno2.Custom, + 'return': type(None), + }, + ) + + def test_dataclass_proxy_modules_matching_name_override(self): + # see bpo-45524 + from test.test_dataclasses import dataclass_textanno2 + from dataclasses import dataclass + + @dataclass + class Default(dataclass_textanno2.WithMatchingNameOverride): + pass + + classes = [ + Default, + dataclass_textanno2.WithMatchingNameOverride + ] + for klass in classes: + with self.subTest(klass=klass): + self.assertEqual( + get_type_hints(klass), + { + 'foo': dataclass_textanno2.Foo, + }, + ) + self.assertEqual(get_type_hints(klass.__new__), {}) + self.assertEqual( + get_type_hints(klass.__init__), + { + 'foo': dataclass_textanno2.Foo, + 'return': type(None), + }, + ) + ByMakeDataClass = make_dataclass('ByMakeDataClass', [('x', int)]) ManualModuleMakeDataClass = make_dataclass('ManualModuleMakeDataClass', diff --git a/Lib/test/test_dataclasses/dataclass_textanno.py b/Lib/test/test_dataclasses/dataclass_textanno.py index 3eb6c943d4c434..3924fcf11dc99a 100644 --- a/Lib/test/test_dataclasses/dataclass_textanno.py +++ b/Lib/test/test_dataclasses/dataclass_textanno.py @@ -10,3 +10,9 @@ class Foo: @dataclasses.dataclass class Bar: foo: Foo + + +@dataclasses.dataclass(init=False) +class WithFutureInit(Bar): + def __init__(self, foo: Foo) -> None: + pass diff --git a/Lib/test/test_dataclasses/dataclass_textanno2.py b/Lib/test/test_dataclasses/dataclass_textanno2.py new file mode 100644 index 00000000000000..81fd2a7782d79d --- /dev/null +++ b/Lib/test/test_dataclasses/dataclass_textanno2.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import dataclasses + +# We need to be sure that `Foo` is not in scope +from test.test_dataclasses import dataclass_textanno + + +class Custom: + pass + + +@dataclasses.dataclass +class Child(dataclass_textanno.Bar): + custom: Custom + + +class Foo: # matching name with `dataclass_testanno.Foo` + pass + + +@dataclasses.dataclass +class WithMatchingNameOverride(dataclass_textanno.Bar): + foo: Foo # Existing `foo` annotation should be overridden + + +@dataclasses.dataclass(init=False) +class WithFutureInit(Child): + def __init__(self, foo: dataclass_textanno.Foo, custom: Custom) -> None: + pass diff --git a/Misc/NEWS.d/next/Library/2025-07-28-14-24-07.gh-issue-89687.7uW1c8.rst b/Misc/NEWS.d/next/Library/2025-07-28-14-24-07.gh-issue-89687.7uW1c8.rst new file mode 100644 index 00000000000000..71b280f1958a63 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-07-28-14-24-07.gh-issue-89687.7uW1c8.rst @@ -0,0 +1 @@ +Fix ``typing.get_type_hints()`` failure on ``@dataclass`` hierarchies in different modules. From 3ff28d2fb1f57b1420d68323ae92e9d3ff1a8021 Mon Sep 17 00:00:00 2001 From: Andrey Tikhonov <17@itishka.org> Date: Mon, 28 Jul 2025 17:58:25 +0200 Subject: [PATCH 2/2] Fix using forward module in ForwardRef --- Lib/annotationlib.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Lib/annotationlib.py b/Lib/annotationlib.py index c83a1573ccd3d1..2bcdbe13bf93b5 100644 --- a/Lib/annotationlib.py +++ b/Lib/annotationlib.py @@ -178,6 +178,11 @@ def evaluate( arg = self.__forward_arg__ if arg.isidentifier() and not keyword.iskeyword(arg): + if self.__forward_module__ is not None: + return getattr( + sys.modules.get(self.__forward_module__, None), + arg, + ) if arg in locals: return locals[arg] elif arg in globals: