diff --git a/src/attr/_make.py b/src/attr/_make.py index afbca4635..4d9c22bc7 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,59 @@ 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, raw_annotation in annotations.items(): + annotation = raw_annotation + if isinstance(raw_annotation, str): + try: + annotation = eval(raw_annotation, module_globals) + except NameError: + annotation = ForwardRef( + raw_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..63750310c 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,53 @@ 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): + """Resolve forward references in generated initializer annotations.""" + 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 + + 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__] + def test_keyword_only_auto_attribs(self): """ `kw_only` propagates to attributes defined via `auto_attribs`.