From a636abe97f506faa360a7894b648380ade984cf6 Mon Sep 17 00:00:00 2001 From: NIKHIL Date: Fri, 4 Sep 2026 22:14:44 +0530 Subject: [PATCH 1/2] Resolve forward references in generated init annotations --- src/attr/_make.py | 53 +++++++++++++++++++++++++++++++++++++++ tests/test_annotations.py | 29 +++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/attr/_make.py b/src/attr/_make.py index afbca4635..5985fa046 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -22,6 +22,7 @@ from ._compat import ( PY_3_11_PLUS, PY_3_13_PLUS, + PY_3_14_PLUS, _AnnotationExtractor, _get_annotations, _lazy_is_generator, @@ -1101,6 +1102,8 @@ def add_init(self): def _attach_init(cls_dict, globs): init = globs["__init__"] init.__annotations__ = annotations + if PY_3_14_PLUS: + init.__annotate__ = _make_init_annotate(annotations, self._cls) cls_dict["__init__"] = self._add_method_dunders(init) self._script_snippets.append((script, globs, _attach_init)) @@ -1141,6 +1144,8 @@ def add_attrs_init(self): def _attach_attrs_init(cls_dict, globs): init = globs["__attrs_init__"] init.__annotations__ = annotations + if PY_3_14_PLUS: + init.__annotate__ = _make_init_annotate(annotations, self._cls) cls_dict["__attrs_init__"] = self._add_method_dunders(init) self._script_snippets.append((script, globs, _attach_attrs_init)) @@ -2096,6 +2101,54 @@ def _make_init_script( return script, globs, annotations +def _make_init_annotate(annotations, cls): + """Create a lazy annotation provider for generated initializers. + + On Python 3.14, annotations can be evaluated after a class decorator has + run. Generated functions use a private globals dictionary, so evaluating + a forward reference there would miss names defined later in the module. + Resolve string annotations against the defining module when annotations + are requested instead. + """ + module = sys.modules.get(cls.__module__) + module_globals = module.__dict__ if module is not None else {} + module_name = cls.__module__ + + def annotate(format): + from annotationlib import Format, ForwardRef + + if format == Format.VALUE: + result = {} + for name, annotation in annotations.items(): + if isinstance(annotation, str): + try: + annotation = eval(annotation, module_globals) + except NameError: + annotation = ForwardRef(annotation, module=module_name) + result[name] = annotation + return result + + if format == Format.FORWARDREF: + return { + name: ( + ForwardRef(annotation, module=module_name) + if isinstance(annotation, str) + else annotation + ) + for name, annotation in annotations.items() + } + + if format == Format.STRING: + return { + name: annotation if isinstance(annotation, str) else repr(annotation) + for name, annotation in annotations.items() + } + + raise NotImplementedError + + return annotate + + def _setattr(attr_name: str, value_var: str, has_on_setattr: bool) -> str: """ Use the cached object.setattr to set *attr_name* to *value_var*. diff --git a/tests/test_annotations.py b/tests/test_annotations.py index de3d10589..946dfa314 100644 --- a/tests/test_annotations.py +++ b/tests/test_annotations.py @@ -4,6 +4,7 @@ Tests for PEP-526 type annotations. """ +import inspect import sys import types import typing @@ -451,6 +452,34 @@ class C: assert "cls_var" not in attr.fields_dict(C) assert 1 == C().value + @pytest.mark.skipif( + sys.version_info[:2] < (3, 14), + reason="Python 3.14 added lazy annotation evaluation for functions.", + ) + def test_forward_reference_in_generated_init(self): + module = types.ModuleType("attrs_test_forward_reference") + module.__dict__["attrs"] = attrs + sys.modules[module.__name__] = module + try: + exec( + "from __future__ import annotations\n" + "@attrs.define\n" + "class DoesNotWork:\n" + " _foo: Foo\n" + "class Foo:\n" + " pass\n", + module.__dict__, + ) + + cls = module.__dict__["DoesNotWork"] + foo = module.__dict__["Foo"] + + signature = inspect.signature(cls, eval_str=True) + assert signature.parameters["foo"].annotation is foo + assert typing.get_type_hints(cls.__init__)["foo"] is foo + finally: + del sys.modules[module.__name__] + def test_keyword_only_auto_attribs(self): """ `kw_only` propagates to attributes defined via `auto_attribs`. From bea95f8b86c7dbbfc5f1f0d289f985255280859c Mon Sep 17 00:00:00 2001 From: NIKHIL Date: Sat, 5 Sep 2026 13:01:29 +0530 Subject: [PATCH 2/2] Fix attrs annotation provider lint and coverage --- src/attr/_make.py | 15 ++++++++++----- tests/test_annotations.py | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index 5985fa046..4d9c22bc7 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -2119,12 +2119,15 @@ def annotate(format): if format == Format.VALUE: result = {} - for name, annotation in annotations.items(): - if isinstance(annotation, str): + for name, raw_annotation in annotations.items(): + annotation = raw_annotation + if isinstance(raw_annotation, str): try: - annotation = eval(annotation, module_globals) + annotation = eval(raw_annotation, module_globals) except NameError: - annotation = ForwardRef(annotation, module=module_name) + annotation = ForwardRef( + raw_annotation, module=module_name + ) result[name] = annotation return result @@ -2140,7 +2143,9 @@ def annotate(format): if format == Format.STRING: return { - name: annotation if isinstance(annotation, str) else repr(annotation) + name: annotation + if isinstance(annotation, str) + else repr(annotation) for name, annotation in annotations.items() } diff --git a/tests/test_annotations.py b/tests/test_annotations.py index 946dfa314..63750310c 100644 --- a/tests/test_annotations.py +++ b/tests/test_annotations.py @@ -457,6 +457,7 @@ class C: reason="Python 3.14 added lazy annotation evaluation for functions.", ) def test_forward_reference_in_generated_init(self): + """Resolve forward references in generated initializer annotations.""" module = types.ModuleType("attrs_test_forward_reference") module.__dict__["attrs"] = attrs sys.modules[module.__name__] = module @@ -477,6 +478,24 @@ def test_forward_reference_in_generated_init(self): signature = inspect.signature(cls, eval_str=True) assert signature.parameters["foo"].annotation is foo assert typing.get_type_hints(cls.__init__)["foo"] is foo + + import annotationlib + + forwardref_annotations = cls.__init__.__annotate__( + annotationlib.Format.FORWARDREF + ) + assert isinstance( + forwardref_annotations["foo"], annotationlib.ForwardRef + ) + assert forwardref_annotations["foo"].__forward_arg__ == "Foo" + + string_annotations = cls.__init__.__annotate__( + annotationlib.Format.STRING + ) + assert string_annotations["foo"] == "Foo" + + with pytest.raises(NotImplementedError): + cls.__init__.__annotate__(object()) finally: del sys.modules[module.__name__]