Skip to content

annotationlib.ForwardRef.evaluate() ignores explicit globals/locals when the reference holds a closure cell #160948

Description

@arpittkhandelwal

Bug report

Bug description

When a ForwardRef obtained from annotationlib.get_annotations(..., format=Format.FORWARDREF) refers to a single name from an enclosing
function scope, it stores the value in a closure cell (__cell__).
ForwardRef.evaluate() returns that cell value immediately, before
looking at the globals and locals arguments — so explicitly passed
namespaces are silently ignored:

from annotationlib import Format, ForwardRef, get_annotations

def outer():
    def inner(a: x):
        pass
    fr = get_annotations(inner, format=Format.FORWARDREF)["a"]
    x = 99
    return fr

fr = outer()
print(fr.evaluate())                    # 99 (correct)
print(fr.evaluate(locals={"x": 42}))    # 99  ← expected 42
print(fr.evaluate(globals={"x": 43}))   # 99  ← expected 43

# A plain ForwardRef without a cell honours the explicit namespaces:
print(ForwardRef("x").evaluate(locals={"x": 42}))  # 42

This is inconsistent with every other resolution path in the same
method: for a plain ForwardRef, and for references holding multiple
cells (a dict of cells), explicitly passed locals win over the
captured scope. Only the single-cell path flips the precedence.

The documented contract points the other way as well:
ForwardRef.evaluate() documents that "The arguments to this method
can be used to provide bindings for names that would otherwise be
undefined" and that "globals and locals are passed to eval(),
representing the global and local namespaces in which the name is
evaluated" (Doc/library/annotationlib.rst).

Expected behaviour: explicitly passed globals/locals take
precedence over the closure cell, as they do on the other paths; the
cell value is used as a fallback when the name is not provided
explicitly (and when no namespaces are passed at all, behaviour is
unchanged).

I have a fix and regression tests ready and will open a PR.

Environment

  • CPython main (3.16.0a1+dev, 83fe630), built locally; annotationlib
    was added in 3.14, and the single-cell shortcut in
    ForwardRef.evaluate() is present there in the same form.

CPython versions tested on:

3.16

Operating systems tested on:

No response

Linked PRs

Activity

  1. added a commit that references this issue on Oct 30, 2026
  2. picnixz commented on Oct 30, 2026

    @picnixz
    Member

    I think I already saw that issue elsewhere. Can you look at past issues?

  3. DavidCEllis commented on Oct 30, 2026

    @DavidCEllis
    Contributor
    print(fr.evaluate(globals={"x": 43}))   # 99  ← expected 43

    I'd still expect 99 here.

    The lookup should check the closure before looking at globals, you can see this behaviour if you put your value inside a generic forward reference where the nonlocals are placed between the globals and locals.

    from annotationlib import get_annotations, Format
    
    def outer():
        def inner(a: unknown[x]):
            pass
        fr = get_annotations(inner, format=Format.FORWARDREF)["a"]
        unknown = list  # define the generic here
        x = 99
        return fr
    
    fr = outer()
    print("Replacing globals:", fr.evaluate(globals={'x': 42}))
    print("Replacing locals:", fr.evaluate(locals={'x': 43}))
    Replacing globals: list[99]
    Replacing locals: list[43]
    
  4. added
    pendingThe issue will be closed if no feedback is provided
    on Oct 31, 2026
  5. picnixz commented on Oct 31, 2026

    @picnixz
    Member

    The globals suggestion also looks incorrect to me. Otheriwse, the local change seems relevant.

  6. added a commit that references this issue on Oct 31, 2026
  7. arpittkhandelwal commented on Oct 31, 2026

    @arpittkhandelwal
    Author

    Correction to the expected behaviour in the report above, following the discussion here: the globals part was wrong. As @DavidCEllis showed, on the multi-name path the nonlocals already sit between the locals and the globals, so fr.evaluate(globals={"x": 43}) returning 99 is correct. The bug is only that an explicitly passed locals is ignored: fr.evaluate(locals={"x": 42}) should return 42. PR #160952 has been updated to change only the locals handling.

  8. removed
    pendingThe issue will be closed if no feedback is provided
    on Oct 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    stdlibStandard Library Python modules in the Lib/ directorytopic-typingtype-bugAn unexpected behavior, bug, or error

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions