I’d like to capture the local namespace at the end of the execution of a function-derived code object, but it seems that updates to fast locals can’t be propagated to the locals mapping supplied to exec:
And does it make sense that we submit a feature request for exec to translate fast locals back to a dict at the end of the execution so the supplied locals mapping can be updated?
Right, the said behavior needs to be optional or it would break existing code.
An optional flag such as capture_fast_locals to the exec function may indeed be the way to go.
The code object in my case is supplied by the end user, with no requirement for the function to return anything. The goal is to capture the ending state of the execution and let the user use the resulting namespace in a particular way.
One interesting quirk is that prior to 3.13, your own example actually worked:
Python 3.12.11 (main, Jun 12 2025, 00:00:00) [GCC 15.1.1 20250521 (Red Hat 15.1.1-2)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> def f():
... a = 1
... print(locals()["a"])
...
>>> f()
1
>>> ns = {}
>>> exec(f.__code__, globals(), ns)
1
>>> ns
{'a': 1}
It didn’t intrinsically work, it just worked for this specific example because locals() used to implicitly call PyFrame_FastToLocals(), so calling it inside the function updated the passed in namespace as a side effect.
That side effect went away in 3.13 due to the introduction of the fast locals proxy and the switch to making locals() return independent snapshots. PyFrame_FastToLocals() itself also no longer does anything: cpython/Objects/frameobject.c at 3.13 · python/cpython · GitHub
It seems like a reasonable feature request to me, and since the behaviour has been inconsistent over time, I’m not sure it would need to be an explicit flag (the builtin exec/eval could just enable it automatically when given an optimised code block).
That said, it would be a genuinely intrusive change at the implementation level (potentially affecting the signature of public C APIs), so it’s entirely plausible that the outcome might just be that the limitations of passing optimised code blocks to exec/eval get explicitly documented (which would still be a useful docs improvement).
Edit: on a closer look at the code, handling this may not affect any public APIs, assuming the change itself is deemed acceptable. Specifically, regular optimised function calls run with a NULLf_locals value, allowing eval/exec invocations to be detected by virtue of their non-NULLf_locals values (module and class body executions don’t have the local variable optimisation flag set on their code objects, since they intentionally don’t use fast locals). It may therefore be at least theoretically possible to write fast locals changes back to a non-NULL locals namespace when popping an internal interpreter frame from the stack without slowing down regular function call evaluation (beyond the single additional NULL pointer check). There’s a non-trivial risk of unintended side effects, though, so “not supported” would still be the safer conclusion.
Did you do it in a profiler/tracer function? I’ve thought about this workaround too but decided that it may pose too much risk of interfering with or being interfered by the users’ profilers/debuggers to be used as a general solution.
Great insights. Thanks!
Yeah if it’s deemed OK that this is going to be the new behavior, so that there’s no need to pass down a flag from the exec function to the C APIs, then it does appear that this is going to be a non-intrusive change.
As I can’t imagine any meaningful real-world use for the current behavior of no change to the locals dict passed in to exec, I’m hopeful that the said new behavior would have so little negative real-world impact that it would be deemed OK by the core devs.
def f():
a = 1
print(locals()['a']) # got 1
exec(f.__code__, globals(), {})
f = inspect.getcurrentframe()
print(f.f_back.f_back.f_locals['a']) # One f_back would be `exec` (would work too)
Apart from that, you could also make f return either a or even all locals. That would require you to “own” f though, so that you can be sure that it always returns what you need.
Iirc there also is a workaround with eval that I’ve used before, but I can’t think of that right now.
Sorry but there’s no way this can work. The frame in which f.__code__ is executed is already gone by the time exec returns, so no amount of f_back from inspect.currentframe() can retrieve what is already lost.
Yeah I would do that if I owned f, which I don’t. I’m trying to make the mechanism work for any given code object.
Since the current exec simply ignores the locals namespace when given an optimized code object, we can implement the default backwards-compatible state of exec’s new flag (tentatively named sync_fast_locals) by setting locals = globals, which is what currently happens when locals is not supplied.
When sync_fast_locals is enabled, exec shall proceed to synchronize the supplied locals to fast locals before executing the code, and synchronize fast locals back to the supplied locals after the execution finishes.
I’ve submitted a draft PR, which passes all current unit tests plus the following new test:
def test_eval_exec_sync_fast_locals(self):
def func_assign():
a = 1
def func_read():
b = a + 1
a = 3
for executor in eval, exec:
with self.subTest(executor=executor.__name__):
ns = {}
executor(func_assign.__code__, {}, ns, sync_fast_locals=True)
self.assertEqual(ns, {'a': 1})
ns = {'a': 1}
executor(func_read.__code__, {}, ns, sync_fast_locals=True)
self.assertEqual(ns, {'a': 3, 'b': 2})
Question though:
I implemented the sync both ways just for completeness, but while sync’ing from fast locals back to the supplied locals mapping after execution makes a lot of sense as seen in the func_assign function, I’m not sure if there’s any good use case for sync’ing from a supplied locals mapping to fast locals before execution, since as you see from the above func_read function, it would normally fail with UnboundLocalError if called directly because there’s no way to assign a value to the local variable a without it being an argument, while exec offers to no way to specify an argument for a code object.
One other possible way to test the pre-execution sync is to modify co_argcount of the code object of a function that does take an argument, though it feels equally contrived:
def func_read(a):
b = a + 1
ns = {'a': 1}
exec(func_read.__code__.replace(co_argcount=0), {}, ns, sync_fast_locals=True)
assert ns == {'a': 1, 'b': 2} # OK
Which test of a pre-execution sync do you think make more sense, if a pre-execution sync makes sense at all?