The freshly released Python 3.13 mentions some updates to the locals() behaviour. Reading those notes, confirms to me (as I have outlined here) that trying to create new variables in exec()/compile() will have no effect outside of the "block" executed in exec-compile itself (reassigning an "external" variable will not have effect either) the code will always run against an independent snapshot of the local variables in optimized scopes, and hence the changes will never be visible in subsequent calls to locals(), and also opens the door to some really interesting stuff: FrameType.f_locals now returns a write-through proxy to the frame’s local and locally referenced nonlocal variables in these scopes.
Let's go a bit deeper into the above statements. Each time we execute a function, a "local namespace" object is created for that function (it's a sort of dictionary), where local variables and parameters are stored (and also free vars if the function is a closure). I guess we can think of this local namespace object as JavaScript's Activation Object. Let's see:
def create_fn():
trapped = "aaa"
def fn(param1):
nonlocal trapped
trapped = "AAAA"
local1 = "bbb"
print(f"fn local namespace: {locals()}")
return fn
fn1 = create_fn()
fn1("ppppppp")
# fn local namespace: {'param1': 'ppppppp', 'local1': 'bbb', 'trapped': 'AAAA'}
As aforementioned, code executed by the exec()/compile() functions receives a snapshot of the local namespace of the invoking function, meaning that adding a variable or reassigning a variable in that snapshot will not have effect outside the exec() itself. I mean:
def declare_new_variable(param1):
# creating a new variable or setting an existing variable in exec will not crash,, but it in the local namespace snapshot that it receives
# but will not have effect in the original local namespace
print(f"- {declare_new_variable.__name__}")
# create new variable
exec(
"a = 'Bonjour'\n"
"print('a inside exec: ' + a)\n"
)
# a inside exec: Bonjour
p_v = "bbb"
# assign to existing variable
exec(
"p_v = 'cccc'\n"
"print('pv inside exec: ' + p_v)\n"
)
# pv inside exec: cccc
print(f"locals: {locals()}")
# locals: {'param1': '11111', 'p_v': 'bbb'}
# the new variable "a" has not been created in the local namespace, and p_v has not been updated
And now the second part of the first paragraph, the FrameType.f_locals. I've been playing with it to learn that from a Python function we can traverse its call stack, getting references to a write-through proxy of the local namespace of each stack frame. This means that from one function we have access (read and write) to any variable in any of its calling functions (any function down in the stack), and even "sort of" add new variables. I'm using inspect.stack() to get access to the stack-chain, then freely move through it, get the stack-frame I want, and use f_locals to get that "write-through proxy" to its local namespace.
def child2():
print("- enter child2")
c2_v1 = "child2 v1"
c2_v2 = 200
print("child2")
parent_locals = inspect.stack()[2].frame.f_locals
print(f"parent_locals viewed from child2: {parent_locals}")
print("modify existing parent variable, p_v1")
parent_locals["p_v1"] = parent_locals["p_v1"] + "_modified"
print("add variable p_v3 to parent")
parent_locals["p_v3"] = "extra var"
# remove variable this way fails:
#del parent_locals["p_v2"]
# TypeError: cannot remove variables from FrameLocalsProxy
print("- exit child2")
def child1():
print("- enter child1")
c1_v1 = "child1 v1"
c1_v2 = 20
child2()
print("- exit child1")
def parent():
p_v1 = "parent v1"
p_v2 = 2
print("before calling child")
print(f"parent: {locals()}")
child1()
print("after calling child")
# p_v1 has been updated and p_v3 has been added:
print(f"parent: {locals()}")
# I can see the updated value of this var
print(f"p_v1: {p_v1}")
#but trying to acces the new variable like this will fail:
try:
print(f"p_v3: {p_v3}")
except Exception as ex:
print(f"Exception: {ex}")
parent()
# before calling child
# parent: {'p_v1': 'parent v1', 'p_v2': 2}
# - enter child1
# - enter child2
# child2
# parent_locals viewed from child2: {'p_v1': 'parent v1', 'p_v2': 2}
# modify existing parent variable, p_v1
# add variable p_v3 to parent
# - exit child2
# - exit child1
# after calling child
# parent: {'p_v1': 'parent v1_modified', 'p_v2': 2, 'p_v3': 'extra var'}
# p_v1: parent v1_modified
# Exception: name 'p_v3' is not defined
As you can see at the end of the above code, adding new variables to a function through the f_locals has an odd behaviour. A new entry is created in the local namespace corresponding to that f_locals. We can see the variable with locals() (regardless of whether it was added by code deeper in the stack chain) but trying to access it directly by its name will fail. The new variable exists in the local namespace, but it seems as if the variable name does not exist, and yes it's just that, as explained by this post:
Functions are special, because they introduce a separate local scope. The variables inside that scope are fixed when the function is compiled (the number and names of the variables, not their values). You can see that by inspecting a function's .__code__.co_varnames attribute.
That fixed registry of variable names is what is used when names are looked up from inside the function. And that registry is not updated when you're calling exec.