From 8e9cfaf18219e0b5173fa1171fade688619b0f19 Mon Sep 17 00:00:00 2001 From: Mohammed Anas Nathani Date: Thu, 30 Jul 2026 23:14:33 +0530 Subject: [PATCH 1/4] Fix generated __init__ forward-ref annotations (#1596) Stop snapshotting module globals into generated constructors so late forward references resolve like hand-written methods. On 3.14+, attach PEP 649 __annotate__ that re-reads live class annotations, and rewrite __annotate__ closure cells for slots classes. --- changelog.d/1596.change.md | 4 + src/attr/_make.py | 229 ++++++++++++++++++++++++++----- tests/test_annotations.py | 50 +++++++ tests/test_dunders.py | 20 +-- tests/test_forward_references.py | 98 ++++++++++++- 5 files changed, 353 insertions(+), 48 deletions(-) create mode 100644 changelog.d/1596.change.md diff --git a/changelog.d/1596.change.md b/changelog.d/1596.change.md new file mode 100644 index 000000000..c7f64ad0e --- /dev/null +++ b/changelog.d/1596.change.md @@ -0,0 +1,4 @@ +Generated ``__init__`` methods now resolve late forward references the same way as hand-written constructors. + +Previously, *attrs* snapshotted the defining module's globals when building ``__init__``, so names defined *after* the class body were missing from ``__init__.__globals__``. +On Python 3.14+, annotations are now attached via a PEP 649 ``__annotate__`` that re-reads live class annotations instead of a static dict of stale ``ForwardRef`` objects, and slot classes rewrite ``__annotate__`` closure cells to the final class. diff --git a/src/attr/_make.py b/src/attr/_make.py index 6794464e9..c66f01137 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -25,6 +25,7 @@ PY_3_10_PLUS, PY_3_11_PLUS, PY_3_13_PLUS, + PY_3_14_PLUS, _AnnotationExtractor, _get_annotations, get_generic_base, @@ -970,31 +971,44 @@ def _create_slots_class(self): # compiler will bake a reference to the class in the method itself # as `method.__closure__`. Since we replace the class with a # clone, we rewrite these references so it keeps working. + # + # Also rewrite cells on generated ``__annotate__`` callables (PEP 649), + # which close over the original class much like dataclasses do. for item in itertools.chain( cls.__dict__.values(), additional_closure_functions_to_update ): + funcs_to_rewrite = [] if isinstance(item, (classmethod, staticmethod)): # Class- and staticmethods hide their functions inside. # These might need to be rewritten as well. - closure_cells = getattr(item.__func__, "__closure__", None) + funcs_to_rewrite.append(item.__func__) elif isinstance(item, property): # Workaround for property `super()` shortcut (PY3-only). # There is no universal way for other descriptors. - closure_cells = getattr(item.fget, "__closure__", None) + funcs_to_rewrite.extend((item.fget, item.fset, item.fdel)) + elif isinstance(item, types.FunctionType): + funcs_to_rewrite.append(item) + annotate = getattr(item, "__annotate__", None) + if annotate is not None: + funcs_to_rewrite.append(annotate) else: - closure_cells = getattr(item, "__closure__", None) - - if not closure_cells: # Catch None or the empty list. continue - for cell in closure_cells: - try: - match = cell.cell_contents is self._cls - except ValueError: # noqa: PERF203 - # ValueError: Cell is empty - pass - else: - if match: - cell.cell_contents = cls + + for func in funcs_to_rewrite: + if func is None: + continue + closure_cells = getattr(func, "__closure__", None) + if not closure_cells: # Catch None or the empty list. + continue + for cell in closure_cells: + try: + match = cell.cell_contents is self._cls + except ValueError: # noqa: PERF203 + # ValueError: Cell is empty + pass + else: + if match: + cell.cell_contents = cls return cls def add_repr(self, ns): @@ -1079,7 +1093,7 @@ def attach_hash(cls_dict: dict, locs: dict) -> None: return self def add_init(self): - script, globs, annotations = _make_init_script( + script, helpers, annotations, field_arg_map = _make_init_script( self._cls, self._attrs, self._has_pre_init, @@ -1094,12 +1108,21 @@ def add_init(self): attrs_init=False, ) - def _attach_init(cls_dict, globs): - init = globs["__init__"] - init.__annotations__ = annotations + def _attach_init(cls_dict, _locs): + init = _compile_init_method( + self._cls, + script, + helpers, + annotations, + field_arg_map, + method_name="__init__", + ) cls_dict["__init__"] = self._add_method_dunders(init) - self._script_snippets.append((script, globs, _attach_init)) + # Compiled separately so ``__globals__`` is the live module dict (see + # ``_compile_init_method``). An empty script keeps the attach hook in + # the existing snippet pipeline without polluting shared globs. + self._script_snippets.append(("", {}, _attach_init)) return self @@ -1115,7 +1138,7 @@ def add_match_args(self): ) def add_attrs_init(self): - script, globs, annotations = _make_init_script( + script, helpers, annotations, field_arg_map = _make_init_script( self._cls, self._attrs, self._has_pre_init, @@ -1130,12 +1153,18 @@ def add_attrs_init(self): attrs_init=True, ) - def _attach_attrs_init(cls_dict, globs): - init = globs["__attrs_init__"] - init.__annotations__ = annotations + def _attach_attrs_init(cls_dict, _locs): + init = _compile_init_method( + self._cls, + script, + helpers, + annotations, + field_arg_map, + method_name="__attrs_init__", + ) cls_dict["__attrs_init__"] = self._add_method_dunders(init) - self._script_snippets.append((script, globs, _attach_attrs_init)) + self._script_snippets.append(("", {}, _attach_attrs_init)) return self @@ -2016,7 +2045,7 @@ def _make_init_script( is_exc, cls_on_setattr, attrs_init, -) -> tuple[str, dict, dict]: +) -> tuple[str, dict, dict, dict]: has_cls_on_setattr = ( cls_on_setattr is not None and cls_on_setattr is not setters.NO_OP ) @@ -2044,7 +2073,7 @@ def _make_init_script( elif has_cls_on_setattr and a.on_setattr is not setters.NO_OP: needs_cached_setattr = True - script, globs, annotations = _attrs_to_init_script( + script, helpers, annotations, field_arg_map = _attrs_to_init_script( filtered_attrs, frozen, slots, @@ -2058,18 +2087,136 @@ def _make_init_script( has_cls_on_setattr, "__attrs_init__" if attrs_init else "__init__", ) - if cls.__module__ in sys.modules: - # This makes typing.get_type_hints(CLS.__init__) resolve string types. - globs.update(sys.modules[cls.__module__].__dict__) - - globs.update({"NOTHING": NOTHING, "attr_dict": attr_dict}) + # Helpers are injected as closure cells by ``_compile_init_method`` so the + # generated ``__init__`` can use the *live* module ``__dict__`` as + # ``__globals__`` (same model as dataclasses / hand-written methods). + # Do **not** snapshot ``sys.modules[cls.__module__].__dict__`` into the + # function globals — that breaks late forward references under + # ``inspect.get_annotations(..., eval_str=True)`` / ``eval_str`` signatures. + helpers.update({"NOTHING": NOTHING, "attr_dict": attr_dict}) if needs_cached_setattr: # Save the lookup overhead in __init__ if we need to circumvent # setattr hooks. - globs["_cached_setattr_get"] = _OBJ_SETATTR.__get__ + helpers["_cached_setattr_get"] = _OBJ_SETATTR.__get__ + + return script, helpers, annotations, field_arg_map + + +def _compile_init_method( + cls: type, + script: str, + helpers: dict[str, Any], + annotations: dict[str, Any], + field_arg_map: dict[str, str], + method_name: str, +): + """ + Compile a generated ``__init__`` / ``__attrs_init__`` script. + + The body is nested inside a factory so *helpers* become free variables + (closures) while ``__globals__`` is the class module's live ``__dict__``. + That mirrors hand-written constructors and lets string annotations resolve + names defined *after* the class body (issue #1596). + + On Python 3.14+, annotations are attached via a PEP 649 ``__annotate__`` + function that re-reads class annotations (like dataclasses), instead of a + static ``__annotations__`` dict of possibly stale ``ForwardRef`` objects. + """ + if cls.__module__ in sys.modules: + globals_dict = sys.modules[cls.__module__].__dict__ + else: + # Custom/missing ``__module__``: still introspectable for helpers only. + globals_dict = {"__builtins__": __builtins__} + + helper_names = tuple(helpers) + params = ", ".join(helper_names) + indented = "\n".join( + (" " + line if line else line) for line in script.splitlines() + ) + factory_script = ( + f"def __attr_factory__({params}):\n" + f"{indented}\n" + f" return {method_name}\n" + ) + + ns: dict[str, Any] = {} + _linecache_and_compile( + factory_script, + _generate_unique_filename(cls, method_name), + globals_dict, + ns, + ) + factory = ns["__attr_factory__"] + init = factory(**helpers) if helpers else factory() + + if PY_3_14_PLUS: + # Setting ``__annotations__`` clears ``__annotate__``; prefer annotate. + static_annotations = { + key: value + for key, value in annotations.items() + if key not in field_arg_map + } + init.__annotate__ = _make_init_annotate( + cls, method_name, field_arg_map, static_annotations + ) + else: + init.__annotations__ = annotations + + return init + + +def _make_init_annotate( + cls: type, + method_name: str, + field_arg_map: dict[str, str], + static_annotations: dict[str, Any], +): + """ + Build a PEP 649 ``__annotate__`` for a generated init method. + + Field annotations are re-fetched from *cls* (and its MRO) on each call so + deferred / forward references resolve like a compiler-built annotate + function. Converter parameter types and ``return`` stay static. + """ + # Local import keeps annotationlib off the hot path on older Pythons; the + # outer caller only invokes this on 3.14+. + import annotationlib + + def __annotate__(format: annotationlib.Format, /) -> dict[str, Any]: + Format = annotationlib.Format + if format not in (Format.VALUE, Format.FORWARDREF, Format.STRING): + raise NotImplementedError(format) + + cls_annotations: dict[str, Any] = {} + for base in reversed(cls.__mro__): + # Unusual dynamic bases may lack usable annotations; skip them. + with contextlib.suppress(Exception): + cls_annotations.update( + annotationlib.get_annotations(base, format=format) + ) + + new_annotations: dict[str, Any] = {} + for arg_name, field_name in field_arg_map.items(): + # gh-style: annotation may be missing in unusual dynamic cases. + try: + new_annotations[arg_name] = cls_annotations[field_name] + except KeyError: + pass + + for arg_name, typ in static_annotations.items(): + if format is Format.STRING: + new_annotations[arg_name] = ( + annotationlib.type_repr(typ) if typ is not None else "None" + ) + else: + new_annotations[arg_name] = typ + + return new_annotations - return script, globs, annotations + __annotate__.__generated_by_attrs__ = True # type: ignore[attr-defined] + __annotate__.__qualname__ = f"{cls.__qualname__}.{method_name}.__annotate__" + return __annotate__ def _setattr(attr_name: str, value_var: str, has_on_setattr: bool) -> str: @@ -2173,12 +2320,13 @@ def _attrs_to_init_script( needs_cached_setattr: bool, has_cls_on_setattr: bool, method_name: str, -) -> tuple[str, dict, dict]: +) -> tuple[str, dict, dict, dict]: """ - Return a script of an initializer for *attrs*, a dict of globals, and - annotations for the initializer. + Return a script of an initializer for *attrs*, a dict of helpers, static + annotations for the initializer, and a map of init arg name → field name + for annotations that should be re-read from the class (PEP 649). - The globals are required by the generated script. + The helpers are required by the generated script and become closure cells. """ lines = ["self.__attrs_pre_init__()"] if call_pre_init else [] @@ -2201,9 +2349,12 @@ def _attrs_to_init_script( attrs_to_validate = [] # This is a dictionary of names to validator and converter callables. - # Injecting this into __init__ globals lets us avoid lookups. + # Injected as closure cells on the compiled ``__init__``. names_for_globals = {} annotations = {"return": None} + # arg_name -> field name on the class (for live / PEP 649 re-fetch). + # Converter-derived annotations are static and stay only in *annotations*. + field_arg_map: dict[str, str] = {} for a in attrs: if a.validator: @@ -2355,6 +2506,7 @@ def _attrs_to_init_script( if a.init is True: if a.type is not None and converter is None: annotations[arg_name] = a.type + field_arg_map[arg_name] = attr_name elif converter is not None and converter._first_param_type: # Use the type from the converter if present. annotations[arg_name] = converter._first_param_type @@ -2421,6 +2573,7 @@ def _attrs_to_init_script( """, names_for_globals, annotations, + field_arg_map, ) diff --git a/tests/test_annotations.py b/tests/test_annotations.py index e193d4ba1..769671358 100644 --- a/tests/test_annotations.py +++ b/tests/test_annotations.py @@ -698,3 +698,53 @@ def test_is_class_var(annot): ClassVars are detected, even if they're a string or quoted. """ assert _is_class_var(annot) + + +@pytest.mark.skipif( + sys.version_info[:2] < (3, 13), + reason="inspect.signature(..., eval_str=True) needs 3.13+", +) +@pytest.mark.parametrize("slots", [True, False]) +def test_init_forward_ref_with_future_annotations(slots): + """ + With ``from __future__ import annotations``, generated ``__init__`` string + annotations resolve late names via the live module globals — same as a + hand-written constructor (issue #1596). + """ + import inspect + + mod = types.ModuleType("attrs_fwdref_future_mod") + sys.modules[mod.__name__] = mod + try: + exec( + "from __future__ import annotations\n" + "import attrs\n" + "\n" + "class Works:\n" + " def __init__(self, foo: Foo) -> None:\n" + " self._foo = foo\n" + "\n" + f"@attrs.define(slots={slots!r})\n" + "class DoesNotWork:\n" + " _foo: Foo\n" + "\n" + "class Foo:\n" + " pass\n", + mod.__dict__, + ) + + Works = mod.Works + DoesNotWork = mod.DoesNotWork + Foo = mod.Foo + + assert inspect.signature(Works, eval_str=True) == inspect.signature( + DoesNotWork, eval_str=True + ) + assert ( + DoesNotWork.__init__.__globals__ is mod.__dict__ + ), "generated __init__ must use the live module dict as __globals__" + hints = typing.get_type_hints(DoesNotWork.__init__) + assert hints["foo"] is Foo + assert DoesNotWork(Foo())._foo is not None + finally: + del sys.modules[mod.__name__] diff --git a/tests/test_dunders.py b/tests/test_dunders.py index b98929c98..dd07188fd 100644 --- a/tests/test_dunders.py +++ b/tests/test_dunders.py @@ -19,7 +19,7 @@ NOTHING, Factory, _add_repr, - _compile_and_eval, + _compile_init_method, _make_init_script, fields, make_class, @@ -86,7 +86,7 @@ def _add_init(cls, frozen): """ has_pre_init = bool(getattr(cls, "__attrs_pre_init__", False)) - script, globs, annots = _make_init_script( + script, helpers, annots, field_arg_map = _make_init_script( cls, cls.__attrs_attrs__, has_pre_init, @@ -104,9 +104,9 @@ def _add_init(cls, frozen): cls_on_setattr=None, attrs_init=False, ) - _compile_and_eval(script, globs, filename="__init__") - cls.__init__ = globs["__init__"] - cls.__init__.__annotations__ = annots + cls.__init__ = _compile_init_method( + cls, script, helpers, annots, field_arg_map, method_name="__init__" + ) return cls @@ -1003,10 +1003,14 @@ class TestFilenames: def test_filenames(self): """ The created dunder methods have a "consistent" filename. + + ``__init__`` is compiled on its own (live module ``__globals__`` for + forward-ref evaluation) so it uses an ``__init__``-specific filename; + other generated methods still share the collective ``methods`` file. """ assert ( OriginalC.__init__.__code__.co_filename - == "" + == "" ) assert ( OriginalC.__eq__.__code__.co_filename @@ -1018,7 +1022,7 @@ def test_filenames(self): ) assert ( CopyC.__init__.__code__.co_filename - == "" + == "" ) assert ( CopyC.__eq__.__code__.co_filename @@ -1030,7 +1034,7 @@ def test_filenames(self): ) assert ( C.__init__.__code__.co_filename - == "" + == "" ) assert ( C.__eq__.__code__.co_filename diff --git a/tests/test_forward_references.py b/tests/test_forward_references.py index 90dfc2b0c..b0ed74c7d 100644 --- a/tests/test_forward_references.py +++ b/tests/test_forward_references.py @@ -1,8 +1,18 @@ +# SPDX-License-Identifier: MIT + """ -Tests for behavior specific to forward references via PEP 749. +Tests for behavior specific to forward references via PEP 649 / 749. + +Collected only on Python 3.14+ (see conftest.py). """ -from attrs import define, fields, resolve_types +import inspect +import sys + +import annotationlib +import pytest + +from attrs import define, field, fields, resolve_types def test_forward_class_reference(): @@ -20,3 +30,87 @@ class B: resolve_types(A) assert fields(A).b.type is B + + +def test_generated_init_matches_handwritten_forward_ref(slots): + """ + Generated ``__init__`` annotations resolve late forward references like a + hand-written constructor (issue #1596, no ``from __future__ import + annotations``). + """ + + class Works: + def __init__(self, foo: Foo) -> None: + self._foo = foo + + @define(slots=slots) + class DoesNotWork: + _foo: Foo + + class Foo: + pass + + assert inspect.signature(Works) == inspect.signature(DoesNotWork) + assert ( + annotationlib.get_annotations( + DoesNotWork.__init__, format=annotationlib.Format.VALUE + )["foo"] + is Foo + ) + # Live module globals (not a creation-time snapshot). + assert ( + DoesNotWork.__init__.__globals__ + is sys.modules[DoesNotWork.__module__].__dict__ + ) + assert DoesNotWork.__init__.__annotate__ is not None + # Runtime still constructs. + assert DoesNotWork(Foo())._foo.__class__ is Foo + + +def test_generated_init_annotate_uses_slotted_class(slots): + """ + ``__annotate__`` closes over the final class, including after slots rewrite. + """ + + @define(slots=slots) + class C: + _x: X + + class X: + pass + + annotate = C.__init__.__annotate__ + assert annotate is not None + anns = annotate(annotationlib.Format.VALUE) + assert anns["x"] is X + # Cell rewrite must point at the class users actually subclass / isinstance. + found_cls = False + for cell in annotate.__closure__ or (): + try: + contents = cell.cell_contents + except ValueError: + continue + if contents is C: + found_cls = True + break + assert found_cls + + +def test_converter_annotation_remains_static(): + """ + Converter first-parameter types stay on the generated init (not re-fetched + from the field annotation). + """ + + def to_int(value: str) -> int: + return int(value) + + @define + class C: + x: int = field(converter=to_int) + + anns = annotationlib.get_annotations( + C.__init__, format=annotationlib.Format.VALUE + ) + assert anns["x"] is str + assert C("5").x == 5 From 1c57965279e51e5de143dd2c9e988239b659ea60 Mon Sep 17 00:00:00 2001 From: Mohammed Anas Nathani Date: Thu, 30 Jul 2026 23:35:38 +0530 Subject: [PATCH 2/4] Fix 3.14 init annotate fallback and getsource indent CI on #1598 failed because __annotate__ only re-fetched class annotations and dropped attr.ib(type=...) / string types that never appear on the class. Prefer live class anns, fall back to the full static map, and leave bare strings under VALUE so get_type_hints can eval (and raise NameError for a missing fake module). Also rebind the compiled init to an unindented linecache entry under the stable filename so inspect.getsource matches docs/doctests without colliding with the factory script cache. --- src/attr/_make.py | 89 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 71 insertions(+), 18 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index c66f01137..cac09b234 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -2141,24 +2141,28 @@ def _compile_init_method( ) ns: dict[str, Any] = {} + # Factory uses a distinct filename so its indented source does not collide + # with the bare-method linecache entry used for ``inspect.getsource``. _linecache_and_compile( factory_script, - _generate_unique_filename(cls, method_name), + _generate_unique_filename(cls, f"{method_name}-factory"), globals_dict, ns, ) factory = ns["__attr_factory__"] init = factory(**helpers) if helpers else factory() + # Factory nesting indents the body; rebind so ``inspect.getsource`` returns + # the bare method text expected by docs and tests, under the stable + # ```` filename. + _rebind_init_source(init, script, cls, method_name) + if PY_3_14_PLUS: # Setting ``__annotations__`` clears ``__annotate__``; prefer annotate. - static_annotations = { - key: value - for key, value in annotations.items() - if key not in field_arg_map - } + # Pass the full static map as fallback for ``attr.ib(type=...)`` fields + # that never appear on the class ``__annotations__``. init.__annotate__ = _make_init_annotate( - cls, method_name, field_arg_map, static_annotations + cls, method_name, field_arg_map, annotations ) else: init.__annotations__ = annotations @@ -2166,6 +2170,42 @@ def _compile_init_method( return init +def _rebind_init_source( + init, script: str, cls: type, method_name: str +) -> None: + """ + Point *init*'s code object at an unindented linecache entry of *script*. + + Compilation nests the method inside a factory (live module globals + helper + freevars). Without this rebind, ``inspect.getsource`` would report the + indented factory fragment instead of the bare ``def __init__`` / + ``def __attrs_init__`` body. + + Identical scripts share one filename (same uniqueness rules as other + generated methods) so ``co_filename`` stays stable across equivalent classes. + """ + if not script.endswith("\n"): + script += "\n" + filename = _generate_unique_filename(cls, method_name) + count = 1 + base_filename = filename + while True: + linecache_tuple = ( + len(script), + None, + script.splitlines(True), + filename, + ) + old_val = linecache.cache.setdefault(filename, linecache_tuple) + if old_val == linecache_tuple: + break + filename = f"{base_filename[:-1]}-{count}>" + count += 1 + init.__code__ = init.__code__.replace( + co_filename=filename, co_firstlineno=1 + ) + + def _make_init_annotate( cls: type, method_name: str, @@ -2177,7 +2217,12 @@ def _make_init_annotate( Field annotations are re-fetched from *cls* (and its MRO) on each call so deferred / forward references resolve like a compiler-built annotate - function. Converter parameter types and ``return`` stay static. + function. ``attr.ib(type=...)`` values, converter parameter types, and + ``return`` fall back to the static map collected at class-creation time. + + String types are left as strings under VALUE/FORWARDREF so + ``typing.get_type_hints`` re-evaluates them against the init's + ``__globals__`` (and raises ``NameError`` for a missing fake module). """ # Local import keeps annotationlib off the hot path on older Pythons; the # outer caller only invokes this on 3.14+. @@ -2185,6 +2230,7 @@ def _make_init_annotate( def __annotate__(format: annotationlib.Format, /) -> dict[str, Any]: Format = annotationlib.Format + # Match dataclasses: VALUE_WITH_FAKE_GLOBALS is intentionally skipped. if format not in (Format.VALUE, Format.FORWARDREF, Format.STRING): raise NotImplementedError(format) @@ -2196,21 +2242,28 @@ def __annotate__(format: annotationlib.Format, /) -> dict[str, Any]: annotationlib.get_annotations(base, format=format) ) + def _static_value(typ: Any) -> Any: + if format == Format.STRING: + if isinstance(typ, str): + return typ + return annotationlib.type_repr(typ) if typ is not None else "None" + # VALUE / FORWARDREF: keep bare strings for get_type_hints eval. + return typ + new_annotations: dict[str, Any] = {} for arg_name, field_name in field_arg_map.items(): - # gh-style: annotation may be missing in unusual dynamic cases. - try: + if field_name in cls_annotations: new_annotations[arg_name] = cls_annotations[field_name] - except KeyError: - pass + elif arg_name in static_annotations: + # ``attr.ib(type=...)`` / string types never land on the class. + new_annotations[arg_name] = _static_value( + static_annotations[arg_name] + ) for arg_name, typ in static_annotations.items(): - if format is Format.STRING: - new_annotations[arg_name] = ( - annotationlib.type_repr(typ) if typ is not None else "None" - ) - else: - new_annotations[arg_name] = typ + if arg_name in new_annotations: + continue + new_annotations[arg_name] = _static_value(typ) return new_annotations From a71d25a511297d5a50debe938938878cd2866fe5 Mon Sep 17 00:00:00 2001 From: Mohammed Anas Nathani Date: Fri, 31 Jul 2026 01:32:27 +0530 Subject: [PATCH 3/4] Cover 3.14 init annotate STRING path and source rebind attrs requires 100% coverage; the CI combine job flagged the STRING format static-fallback branches and the no-trailing-newline rebind arm. --- tests/test_forward_references.py | 53 ++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/test_forward_references.py b/tests/test_forward_references.py index b0ed74c7d..6e6ea2ec9 100644 --- a/tests/test_forward_references.py +++ b/tests/test_forward_references.py @@ -12,6 +12,8 @@ import annotationlib import pytest +import attr + from attrs import define, field, fields, resolve_types @@ -114,3 +116,54 @@ class C: ) assert anns["x"] is str assert C("5").x == 5 + + +def test_init_annotate_string_format_and_type_fallback(): + """ + STRING format stringifies static fallbacks; ``attr.ib(type=...)`` values + that never appear on the class still surface on the generated init. + """ + + # ``attr.s`` (not ``define``/auto_attribs) so bare ``type=`` fields are kept + # alongside PEP 526 annotations — the case that needs static fallback. + @attr.s + class C: + x: int = attr.ib() + y = attr.ib(type=str) + z = attr.ib(type="list[int]") + + annotate = C.__init__.__annotate__ + assert annotate is not None + + string_anns = annotate(annotationlib.Format.STRING) + assert string_anns["x"] == "int" + assert string_anns["y"] == "str" + assert string_anns["z"] == "list[int]" + assert string_anns["return"] == "None" + + value_anns = annotate(annotationlib.Format.VALUE) + assert value_anns["x"] is int + assert value_anns["y"] is str + assert value_anns["z"] == "list[int]" + assert value_anns["return"] is None + + with pytest.raises(NotImplementedError): + annotate(annotationlib.Format.VALUE_WITH_FAKE_GLOBALS) + + +def test_rebind_init_source_without_trailing_newline(): + """ + ``_rebind_init_source`` normalizes scripts that lack a trailing newline so + ``inspect.getsource`` stays well-formed. + """ + from attr._make import _rebind_init_source + + @define + class C: + x: int + + script = "def __init__(self, x):\n self.x = x" + assert not script.endswith("\n") + _rebind_init_source(C.__init__, script, C, "__init__") + src = inspect.getsource(C.__init__) + assert src == "def __init__(self, x):\n self.x = x\n" From 0c6bf4f92c451339d314536b89d43d7a11a699c9 Mon Sep 17 00:00:00 2001 From: Mohammed Anas Nathani Date: Fri, 31 Jul 2026 01:38:31 +0530 Subject: [PATCH 4/4] Inline init annotate STRING static fallback for full coverage Nested _static_value's def line stayed uncovered under combined coverage despite body hits; fold the stringify path into the annotate body so fail-under=100 can pass. --- src/attr/_make.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/attr/_make.py b/src/attr/_make.py index cac09b234..ae6523cda 100644 --- a/src/attr/_make.py +++ b/src/attr/_make.py @@ -2242,28 +2242,28 @@ def __annotate__(format: annotationlib.Format, /) -> dict[str, Any]: annotationlib.get_annotations(base, format=format) ) - def _static_value(typ: Any) -> Any: - if format == Format.STRING: - if isinstance(typ, str): - return typ - return annotationlib.type_repr(typ) if typ is not None else "None" - # VALUE / FORWARDREF: keep bare strings for get_type_hints eval. - return typ - new_annotations: dict[str, Any] = {} for arg_name, field_name in field_arg_map.items(): if field_name in cls_annotations: new_annotations[arg_name] = cls_annotations[field_name] elif arg_name in static_annotations: # ``attr.ib(type=...)`` / string types never land on the class. - new_annotations[arg_name] = _static_value( - static_annotations[arg_name] - ) + new_annotations[arg_name] = static_annotations[arg_name] for arg_name, typ in static_annotations.items(): - if arg_name in new_annotations: - continue - new_annotations[arg_name] = _static_value(typ) + if arg_name not in new_annotations: + new_annotations[arg_name] = typ + + if format == Format.STRING: + # Class STRING annotations are already strings; static fallbacks + # (``type=`` objects, ``return``/None) still need stringifying. + # VALUE / FORWARDREF keep bare strings for get_type_hints eval. + for arg_name, typ in new_annotations.items(): + if isinstance(typ, str): + continue + new_annotations[arg_name] = ( + annotationlib.type_repr(typ) if typ is not None else "None" + ) return new_annotations