Skip to content

Fix generated __init__ forward-ref annotations under get_annotations / 3.14 - #1598

Open
MohammedAnasNathani wants to merge 4 commits into
python-attrs:mainfrom
MohammedAnasNathani:fix/1596-init-forward-ref-annotations
Open

Fix generated __init__ forward-ref annotations under get_annotations / 3.14#1598
MohammedAnasNathani wants to merge 4 commits into
python-attrs:mainfrom
MohammedAnasNathani:fix/1596-init-forward-ref-annotations

Conversation

@MohammedAnasNathani

Copy link
Copy Markdown

Summary

Fixes #1596.

Generated __init__ / __attrs_init__ methods no longer diverge from hand-written constructors when:

  1. A field uses a late forward reference (the name is only bound after the class body), and
  2. Callers introspect with inspect.signature(..., eval_str=True) / annotationlib.get_annotations(..., format=VALUE).

Root causes

There were two independent problems:

1. Snapshot globals (all supported Pythons)
_make_init_script used to copy sys.modules[cls.__module__].__dict__ into the generated function's globals at class-creation time. Names defined later (e.g. Foo after @define class C: x: Foo) were therefore missing from __init__.__globals__, so eval_str=True raised NameError. Hand-written methods keep a live module __dict__ as globals, so they succeed.

2. Stale static annotations (Python 3.14+)
attrs set init.__annotations__ to a frozen map of ForwardRef objects captured at class create. Setting __annotations__ clears PEP 649 __annotate__. Hand-written methods (and dataclasses) keep an __annotate__ that re-reads live class annotations, so VALUE-format evaluation can see the real class once it exists.

Fix

  • Compile __init__ / __attrs_init__ via a small factory so helpers (NOTHING, converters, validators, …) are closure freevars, while __globals__ is the live module __dict__ (same model as dataclasses / hand-written code). No more module snapshot.
  • On 3.14+, attach __annotate__ only (do not set __annotations__). The annotate function re-fetches field types from the class via a field_arg_map and reuses static entries for return / converter types.
  • When building slots classes, also rewrite cells on generated __annotate__ callables (and property fget/fset/fdel) so they close over the final class, not the pre-slots original.

Tests

  • tests/test_forward_references.py (3.14+): signatures match hand-written classes, VALUE format resolves to the real type, live globals, slots annotate-cell rewrite, converters stay static.
  • tests/test_annotations.py: 3.13+ regression with a real ModuleType + from __future__ import annotations late ref.
  • tests/test_dunders.py: internal _add_init helper updated for the new 4-tuple + compile path; init co_filename is method-specific by design.

Verified locally on 3.13 (full suite green aside from pre-existing packaging version parse on dirty trees) and 3.14 (manual #1596 repro + targeted checks; system 3.14 lacks a working pytest install here).

Pull Request Checklist

  • I acknowledge this project's AI policy.
  • This pull request is not from my main branch.
  • There's tests for all new and changed code.
  • Changes or additions to public APIs are reflected in our type stubs (files ending in .pyi).
    • ...and used in the stub test file typing-examples/baseline.py or, if necessary, typing-examples/mypy.py.
    • If they've been added to attr/__init__.pyi, they've also been re-imported in attrs/__init__.pyi.
  • The documentation has been updated.
    • New functions/classes have to be added to docs/api.rst by hand.
    • Changes to the signatures of @attr.s() and @attrs.define() have to be added by hand too.
    • Changed/added classes/methods/functions have appropriate versionadded, versionchanged, or deprecated directives.
      The next version is the second number in the current release + 1.
      The first number represents the current year.
      So if the current version on PyPI is 26.2.0, the next version is gonna be 26.3.0.
      If the next version is the first in the new year, it'll be 27.1.0.
    • Documentation in .rst and .md files is written using semantic newlines.
  • Changes have news fragments in changelog.d.

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.
Copilot AI review requested due to automatic review settings July 30, 2026 17:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@codspeed-hq

codspeed-hq Bot commented Jul 30, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 13.4%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 2 regressed benchmarks
✅ 13 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
test_create_simple_class_make_class 3.5 s 4 s -13.45%
test_create_simple_class 3.7 s 4.3 s -13.35%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing MohammedAnasNathani:fix/1596-init-forward-ref-annotations (0c6bf4f) with main (92e12ea)

Open in CodSpeed

CI on python-attrs#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 <attrs generated __init__ ...> filename so inspect.getsource
matches docs/doctests without colliding with the factory script cache.
attrs requires 100% coverage; the CI combine job flagged the STRING
format static-fallback branches and the no-trailing-newline rebind arm.
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.
@MohammedAnasNathani

Copy link
Copy Markdown
Author

CodSpeed class-creation note (−13.4% on create benches)

CodSpeed’s bot flags test_create_simple_class / test_create_simple_class_make_class at about −13% vs main, with a different runtime environments warning on the comparison. The CodSpeed check itself is NEUTRAL / skipping (not a required failure); all required CI is green.

What the cost is: this fix stops snapshotting sys.modules[cls.__module__].__dict__ into generated __init__ / __attrs_init__ globals (the root of #1596). Helpers stay as closure freevars from a small factory; __globals__ is the live module dict so late names resolve under inspect.get_annotations(..., eval_str=True) / signature(eval_str=True) the same way hand-written constructors do. That is one extra compile + factory call at class-creation time versus main’s flat joint snippet compile with a frozen globals copy.

What it is not: instance construction is unchanged. This is class-definition microbench only.

What I tried and rejected (so we don’t ship a “faster” wrong fix):

  • Dict-subclass / proxy “live globals” that falls through to the module — matches snapshot speed, but on 3.13 inspect.get_annotations(..., eval_str=True) and inspect.signature(..., eval_str=True) still NameError (they need a real module __dict__, not a proxy). Also breaks the intentional __init__.__globals__ is module.__dict__ guarantee.
  • Temporarily stuffing helpers into the module dict — races / name collisions / pollution.
  • Folding the factory into the joint _eval_snippets compile (one compile for all dunders) — local measurement did not close the gap vs main (compile of the larger nested script still dominates).

Happy to take review direction if maintainers prefer a different tradeoff; I believe the live-globals model is the correct fix for #1596 and the class-create cost is the honest price of that correctness.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Incorrect annotation for forward reference in generated constructor

2 participants