Fix locals()/vars() corrupting/leaking __conditional_annotations__ outside function scope (#8467)

CPython's _PyFrame_GetLocals only syncs fastlocals/cells into locals()
for function scope; module/class scope just returns the namespace dict
as-is. sync_visible_locals_to_mapping did this for every scope, so the
implicit __conditional_annotations__ cell (used for PEP 649/749
deferred annotations) broke two ways: at module scope its cell is
always empty, so syncing overwrote the dict's real value with None,
causing NameError on the next annotated statement; at class scope its
cell is the only real value, so syncing leaked it into locals()/dir(),
unlike CPython.

Skip cell/free slots outside function scope to match CPython, and add
a regression snippet.

Closes #8379

Assisted-by: Claude Code:claude-sonnet-5
This commit is contained in:
Sumi Jeong authored and GitHub committed 2026-08-08 22:32:13 +09:00
1 parent 25e2df1731
commit 7545bb1993
2 files changed
+54 -3

No files matched your search

+4 -3
View File
@@ -1794,9 +1794,10 @@ impl FrameObject {
}
}
// Free variables only included for optimized (function-like) scopes.
// Class/module scopes should not expose free vars in locals().
if kind == CO_FAST_FREE && !is_optimized {
// CPython only syncs fastlocals/cells into locals() for function
// scope; class/module scope just returns the namespace dict as-is
// (_PyFrame_GetLocals, Objects/frameobject.c).
if !is_optimized && kind & (CO_FAST_CELL | CO_FAST_FREE) != 0 {
continue;
}
@@ -0,0 +1,50 @@
"""Module/class-scope locals() must not corrupt or leak __conditional_annotations__.
CPython's _PyFrame_GetLocals never syncs cell variables into a module/class
scope's namespace dict, it just returns the dict directly (verified against
CPython 3.14.6). __conditional_annotations__ is a cell in both scopes, but
only module codegen also writes it into the dict (StoreName); class codegen
only ever uses the cell (StoreDeref). So it's visible via locals()/dir() at
module scope and absent at class scope.
RustPython's fast-locals-to-mapping sync used to read every cellvar's value
straight from the cell regardless of scope. At module scope the cell is
always empty, so this overwrote the dict's real value with None -- deleting
it, and the next annotated statement raised NameError. At class scope it
leaked __conditional_annotations__ into locals()/dir(), which CPython never
does.
"""
count: int = 1
_ = locals()
maybe: int = None # used to raise NameError before the fix
assert maybe is None
assert "__conditional_annotations__" in dir(), (
"module-level annotation should expose __conditional_annotations__, matching CPython"
)
exec("a: int = 1\nlocals()\nb: int = 2")
if True:
x: int = 1
vars()
if True:
y: int = 2
assert (x, y) == (1, 2)
class C:
if True:
cx: int = 1
locals()
if True:
cy: int = 2
assert "__conditional_annotations__" not in dir(), (
"class-level locals() should not leak __conditional_annotations__, matching CPython"
)
assert (C.cx, C.cy) == (1, 2)
print("ok")