Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/attr/_make.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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*.
Expand Down
48 changes: 48 additions & 0 deletions tests/test_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
Tests for PEP-526 type annotations.
"""

import inspect
import sys
import types
import typing
Expand Down Expand Up @@ -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`.
Expand Down