[PEP 558] thinking through locals() semantics
First, I want to say: I'm very happy with PEP 558's changes to f_locals. It solves the weird threading bugs, and exposes the fundamental operations you need for debugging in a simple and clean way, while leaving a lot of implementation flexibility for future Python VMs. It's a huge improvement over what we had before. I'm not as sure about the locals() parts of the proposal. It might be fine, but there are some complex trade-offs here that I'm still trying to wrap my head around. The rest of this document is me thinking out loud to try to clarify these issues. ##### What are we trying to solve? There are two major questions, which are somewhat distinct: - What should the behavior of locals() be in CPython? - How much of that should be part of the language definition, vs CPython implementation details? The status quo is that for locals() inside function scope, the behavior is quite complex and subtle, and it's entirely implementation defined. In the current PEP draft, there are some small changes to the semantics, and also it promotes them becoming part of the official language semantics. I think the first question, about semantics, is the more important one. If we're promoting them to the language definition, the main effect is just to make it more important we get the semantics right. ##### What are the PEP's proposed semantics for locals()? They're kinda subtle. [Nick: please double-check this section, both for errors and because I think it includes some edge cases that the PEP currently doesn't mention.] For module/class scopes, locals() has always returned a mapping object which acts as a "source of truth" for the actual local environment – mutating the environment directly changes the mapping object, and vice-versa. That's not going to change. In function scopes, things are more complicated. The *local environment* is conceptually well-defined, and includes: - local variables (current source of truth: "fast locals" array) - closed-over variables (current source of truth: cell objects) - any arbitrary key/values written to frame.f_locals that don't correspond to local or closed-over variables, e.g. you can do frame.f_locals[object()] = 10, and then later read it out again. However, the mapping returned by locals() does not directly reflect this local environment. Instead, each function frame has a dict associated with it. locals() returns this dict. The dict always holds any non-local/non-closed-over variables, and also, in certain circumstances, we write a snapshot of local and closed-over variables back into the dict. Specifically, we write back: - Whenever locals() is called - Whenever exec() or eval() is called without passing an explicit locals argument - After every trace/profile event, if a Python-level tracing/profiling function is registered. (Note: in CPython, the use of Python-level tracing/profiling functions is extremely rare. It's more common in alternative implementations like PyPy. For example, the coverage package uses a C-level tracing function on CPython, which does not trigger locals updates, but on PyPy it uses a Python-level tracing function, which does trigger updates.) In addition, the PEP doesn't say, but I think that any writes to f_locals immediately update both the environment and the locals dict. These semantics have some surprising consequences. Most obviously, in function scope (unlike other scopes), mutating locals() does not affect the actual local environment: def f(): a = 1 locals()["a"] = 2 assert a == 1 The writeback rules can also produce surprising results: def f(): loc1 = locals() # Since it's a snapshot created at the time of the call # to locals(), it doesn't contain 'loc1': assert "loc1" not in loc1 loc2 = locals() # Now loc1 has changed: assert "loc1" in loc1 However, the results here are totally different if a Python-level tracing/profiling function is installed – in particular, the first assertion fails. The interaction between f_locals and and locals() is also subtle: def f(): a = 1 loc = locals() assert "loc" not in loc # Regular variable updates don't affect 'loc' a = 2 assert loc["a"] == 1 # But debugging updates do: sys._getframe().f_locals["a"] = 3 assert a == 3 assert loc["a"] == 3 # But it's not a full writeback assert "loc" not in loc # Mutating 'loc' doesn't affect f_locals: loc["a"] = 1 assert sys._getframe().f_locals["a"] == 1 # Except when it does: loc["b"] = 3 assert sys._getframe().f_locals["b"] == 3 Again, the results here are totally different if a Python-level tracing/profiling function is installed. And you can also hit these subtleties via 'exec' and 'eval': def f(): a = 1 loc = locals() assert "loc" not in loc # exec() triggers writeback, and then mutates the locals dict exec("a = 2; b = 3") # So now the current environment has been reflected into 'loc' assert "loc" in loc # Also loc["a"] has been changed to reflect the exec'ed assignments assert loc["a"] == 2 # But if we look at the actual environment, directly or via # f_locals, we can see that 'a' has not changed: assert a == 1 assert sys._getframe().f_locals["a"] == 1 # loc["b"] changed as well: assert loc["b"] == 3 # And this *does* show up in f_locals: assert sys._getframe().f_locals["b"] == 3 Of course, many of these edge cases are pretty obscure, so it's not clear how much they matter. But I think we can at least agree that this isn't the one obvious way to do it :-). ##### What's the landscape of possible semantics? I did some brainstorming, and came up with 4 sets of semantics that seem plausible enough to at least consider: - [PEP]: the semantics in the current PEP draft. - [PEP-minus-tracing]: same as [PEP], except dropping the writeback on Python-level trace/profile events. - [snapshot]: in function scope, each call to locals() returns a new, *static* snapshot of the local environment, removing all this writeback stuff. Something like: def locals(): frame = get_caller_frame() if is_function_scope(frame): # make a point-in-time copy of the "live" proxy object return dict(frame.f_locals) else: # in module/class scope, return the actual local environment return frame.f_locals - [proxy]: Simply return the .f_locals object, so in all contexts locals() returns a live mutable view of the actual environment: def locals(): return get_caller_frame().f_locals ##### How to evaluate our options? I can think of a lot of criteria that all-else-being-equal we would like Python to meet. (Of course, in practice they conflict.) Consistency across APIs: it's surprising if locals() and frame.f_locals do different things. This argues for [proxy]. Consistency across contexts: it's surprising if locals() has acts differently in module/class scope versus function scope. This argues for [proxy]. Consistent behavior when the environment shifts, or small maintenance changes are made: it's nice if code that works today keeps working tomorrow. On this criterion, I think [snapshot] > [proxy] > [PEP-minus-tracing] >>> [PEP]. [PEP] is particularly bad here because a very rare environmental change that almost no-one tests and mostly only happens when debugging (i.e., enabling tracing) causes a radical change in semantics. Simplicity of explaining to users: all else being equal, it's nice if our docs are short and clear and the language fits in your head. On this criterion, I think: [proxy] > [snapshot] > [PEP-minus-tracing] > [PEP]. As evidence that the current behavior is confusing, see: - Ned gets confused and writes a long blog post after he figures it out: https://nedbatchelder.com/blog/201211/tricky_locals.html - A linter that warns against mutating locals(): https://lgtm.com/rules/10030096/ Simplicity of implementation: "If the implementation is easy to explain, it may be a good idea." Since we need the proxy code anyway to implement f_locals, I think it's: [proxy] (free) > [snapshot] (one 'if' statement) > ([PEP] = [PEP-minus-tracing]). Impact on other interpreter implementations: all else being equal, we'd like to give new interpreters maximal freedom to do clever things. (And local variables are a place where language VMs often expend a lot of cleverness.) [proxy] and [snapshot] are both easily implemented in terms of f_locals, so they basically don't constrain alternative implementations at all. I'm not as sure about [PEP] and [PEP-minus-tracing]. I originally thought they must be horrible. On further thought, I'm not convinced they're *that* bad, since the need to support people doing silly stuff like frame.f_locals[object()] = 10 means that implementations will already need to sometimes attach something like a dict object to their function frames. But perhaps alternative implementations would like to disallow this, or are OK with making it really slow but care about locals() performance more. Anyway, it's definitely ([proxy] = [snapshot]) > ([PEP] = [PEP-minus-tracing]), but I'm not sure whether the '>' is large or small. Backwards compatibility: help(locals) says: NOTE: Whether or not updates to this dictionary will affect name lookups in the local scope and vice-versa is *implementation dependent* and not covered by any backwards compatibility guarantees. So that claims that there are ~no backwards compatibility issues here. I'm going to ignore that; no matter what the docs say, we still don't want to break everyone's code. And unfortunately, I can't think of any realistic way to do a gradual transition, with like deprecation warnings and all that (can anyone else?), so whatever we do will be a flag-day change. Of our four options, [PEP] is intuitively the closest to what CPython has traditionally done. But what exactly breaks under the different approaches? I'll split this off into its own section. ##### Backwards compatibility I'll split this into three parts: code that treats locals() as read-only, exec()/eval(), and code that mutates locals(). I believe (but haven't checked) that the majority of uses of locals() are in simple cases like: def f(): .... print("{a} {b}".format(**locals())) Luckily, this code remains totally fine under all four of our candidates. exec() and eval() are an interesting case. In Python 2, exec'ing some assignments actually *did* mutate the local environment, e.g. you could do: # Python 2 def f(): exec "a = 1" assert a == 1 In Python 3, this was changed, so now exec() inside a function cannot mutate the enclosing scope. We got some bug reports about this change, and there are a number of questions on stackoverflow about it, e.g.: - https://bugs.python.org/issue4831 - https://stackoverflow.com/questions/52217525/how-can-i-change-the-value-of-v... - https://stackoverflow.com/questions/50995581/eval-exec-with-assigning-variab... In all released versions of Python, eval() was syntactically unable to rebind variables, so eval()'s interaction with the local environment was undefined. However, in 3.8, eval() *will* be able to rebind variables using the ':=' operator, so this interaction will become user-visible. Presumably we'll want eval() to match exec(). OK, with that background out of the way, let's look at our candidates. If we adopt [proxy], then that will mean exec()-inside-functions will go back to the Python 2 behavior, where executing assignments in the enclosing scope actually changes the enclosing scope. This will likely break some code out there that's relying on the Python 3 behavior, though I don't know how common that is. (I'm guessing not too common? Using the same variable inside and outside an 'exec' and trusting that they *won't* be the same seems like an unusual thing to do. But I don't know.) With [PEP] and [PEP-minus-tracing], exec() is totally unchanged. With [snapshot], there's technically a small difference: if you call locals() and then exec(), the exec() no longer triggers an implicit writeback to the dict that locals() returned. I think we can ignore this, and say that for all three of these, exec() is unlikely to produce backwards compatibility issues. OK, finally, let's talk about code that calls locals() and then mutates the return value. The main difference between our candidates is how they handle mutation, so this seems like the most important case to focus on. Conveniently, Mark Shannon and friends have statically analyzed a large corpus of Python code and made a list of cases where people do this: https://lgtm.com/rules/10030096/alerts/ Thanks! I haven't gone through the whole list, but I read through the first few in the hopes of getting a better sense of what kind of code does this in the real world and how it would be impacted by our different options. https://lgtm.com/projects/g/pydata/xarray/snapshot/a2ac6af744584c8afed3d56d00c7d6ace85341d9/files/xarray/plot/plot.py?sort=name&dir=ASC&mode=heatmap#L701 Current: raises if a Python-level trace/profile function is set [PEP]: raises if a Python-level trace/profile function is set [PEP-minus-tracing]: ok [snapshot]: ok [proxy]: always raises Comment: uses locals() to capture a bunch of passed in kwargs so it can pass them as **kwargs to another function, and treats them like a snapshot. The authors were clearly aware of the dangers, because this pattern appears multiple times in this file, and all the other places make an explicit copy of locals() before using it, but this place apparently got missed. Fix is trivial: just do that here too. https://github.com/swagger-api/swagger-codegen/blob/master/modules/swagger-c... Current: ok [PEP]: ok [PEP-minus-tracing]: ok [snapshot]: ok [proxy]: ok Comment: this is inside a tool used to generate Python wrappers for REST APIs. The vast majority of entries in the lgtm database are from code generated by this tool. This was tricky to analyze, because it's complex templated code, and it does mutate locals(). But I'm pretty confident that the mutations end up just... not mattering, and all possible generated code works under all of our candidates. Which is lucky, because if we did have to fix this it wouldn't be trivial: fixing up the code generator itself wouldn't be too hard, but it'll take years for everyone to regenerate their old wrappers. https://lgtm.com/projects/g/saltstack/salt/snapshot/bb0950e5eafbb897c8e969e3f20fd297d8ba2006/files/salt/utils/thin.py?sort=name&dir=ASC&mode=heatmap#L258 Current: ok [PEP]: ok [PEP-minus-tracing]: ok [snapshot]: raises [proxy]: ok Comment: the use of locals() here is totally superfluous – it repeatedly reads and writes to locals()[mod], and in all cases this could be replaced by a simple variable, or any other dict. And it's careful not to assign to any name that matches an actual local variable, so it works fine with [proxy] too. But it does assume that multiple calls to locals() return the same dict, so [snapshot] breaks it. In this case the fix is trivial: just use a variable. https://lgtm.com/projects/g/materialsproject/pymatgen/snapshot/fd6900ed1040a4d35f2cf2b3506e6e3d7cdf77db/files/pymatgen/ext/jhu.py?sort=name&dir=ASC&mode=heatmap#L52 Current: buggy if a trace/profile function is set [PEP]: buggy if a trace/profile function is set [PEP-minus-tracing]: ok [snapshot]: ok [proxy]: raises Comment: Another example of collecting kwargs into a dict. Actually this code is always buggy, because they seem to think that dict.pop("a", "b") removes both "a" and "b" from the dict... but it would raise an exception on [proxy], because they use one of those local variables after popping it from locals(). Fix is trivial: take an explicit snapshot of locals() before modifying it. ##### Conclusion so far [PEP-minus-tracing] seems to strictly dominate [PEP]. It's equal or better on all criteria, and actually *more* compatible with all the legacy code I looked at, even though it's technically less consistent with what CPython used to do. Unless someone points out some new argument, I think we can reject the writeback-when-tracing part of the PEP draft. Choosing between the remaining three is more of a judgement call. I'm leaning towards saying that on net, [snapshot] beats [PEP-minus-tracing]: it's dramatically simpler, and the backwards incompatibilities that we've found so far seem pretty minor, on par with what we do in every point release. (In fact, in 3/4 of the cases I looked at, [snapshot] is actually what users seemed to trying to use in the first place.) For [proxy] versus [snapshot], a lot depends on what we think of changing the semantics of exec(). [proxy] is definitely more consistent and elegant, and if we could go back in time I think it's what we'd have done from the start. Its compatibility is maybe a bit worse than [snapshot] on non-exec() cases, but this seems pretty minor overall (it often doesn't matter, and if it does just write dict(locals()) instead of locals(), like you would in non-function scope). But the change in exec() semantics is an actual language change, even though it may not affect much real code, so that's what stands out for me. I'd very much like to hear about any considerations I've missed, and any opinions on the "judgement call" part. -- Nathaniel J. Smith -- https://vorpus.org
I re-ran your examples and found that some of them fail.
On Mon, May 27, 2019 at 8:17 AM Nathaniel Smith
First, I want to say: I'm very happy with PEP 558's changes to f_locals. It solves the weird threading bugs, and exposes the fundamental operations you need for debugging in a simple and clean way, while leaving a lot of implementation flexibility for future Python VMs. It's a huge improvement over what we had before.
I'm not as sure about the locals() parts of the proposal. It might be fine, but there are some complex trade-offs here that I'm still trying to wrap my head around. The rest of this document is me thinking out loud to try to clarify these issues.
##### What are we trying to solve?
There are two major questions, which are somewhat distinct: - What should the behavior of locals() be in CPython? - How much of that should be part of the language definition, vs CPython implementation details?
The status quo is that for locals() inside function scope, the behavior is quite complex and subtle, and it's entirely implementation defined. In the current PEP draft, there are some small changes to the semantics, and also it promotes them becoming part of the official language semantics.
I think the first question, about semantics, is the more important one. If we're promoting them to the language definition, the main effect is just to make it more important we get the semantics right.
##### What are the PEP's proposed semantics for locals()?
They're kinda subtle. [Nick: please double-check this section, both for errors and because I think it includes some edge cases that the PEP currently doesn't mention.]
For module/class scopes, locals() has always returned a mapping object which acts as a "source of truth" for the actual local environment – mutating the environment directly changes the mapping object, and vice-versa. That's not going to change.
In function scopes, things are more complicated. The *local environment* is conceptually well-defined, and includes: - local variables (current source of truth: "fast locals" array) - closed-over variables (current source of truth: cell objects) - any arbitrary key/values written to frame.f_locals that don't correspond to local or closed-over variables, e.g. you can do frame.f_locals[object()] = 10, and then later read it out again.
However, the mapping returned by locals() does not directly reflect this local environment. Instead, each function frame has a dict associated with it. locals() returns this dict. The dict always holds any non-local/non-closed-over variables, and also, in certain circumstances, we write a snapshot of local and closed-over variables back into the dict.
Specifically, we write back:
- Whenever locals() is called - Whenever exec() or eval() is called without passing an explicit locals argument - After every trace/profile event, if a Python-level tracing/profiling function is registered.
(Note: in CPython, the use of Python-level tracing/profiling functions is extremely rare. It's more common in alternative implementations like PyPy. For example, the coverage package uses a C-level tracing function on CPython, which does not trigger locals updates, but on PyPy it uses a Python-level tracing function, which does trigger updates.)
In addition, the PEP doesn't say, but I think that any writes to f_locals immediately update both the environment and the locals dict.
These semantics have some surprising consequences. Most obviously, in function scope (unlike other scopes), mutating locals() does not affect the actual local environment:
def f(): a = 1 locals()["a"] = 2 assert a == 1
The writeback rules can also produce surprising results:
def f(): loc1 = locals() # Since it's a snapshot created at the time of the call # to locals(), it doesn't contain 'loc1': assert "loc1" not in loc1 loc2 = locals() # Now loc1 has changed: assert "loc1" in loc1
However, the results here are totally different if a Python-level tracing/profiling function is installed – in particular, the first assertion fails.
The interaction between f_locals and and locals() is also subtle:
def f(): a = 1 loc = locals() assert "loc" not in loc # Regular variable updates don't affect 'loc' a = 2 assert loc["a"] == 1 # But debugging updates do: sys._getframe().f_locals["a"] = 3 assert a == 3
That assert fails; `a` is still 2 here for me.
assert loc["a"] == 3 # But it's not a full writeback assert "loc" not in loc # Mutating 'loc' doesn't affect f_locals: loc["a"] = 1 assert sys._getframe().f_locals["a"] == 1 # Except when it does: loc["b"] = 3 assert sys._getframe().f_locals["b"] == 3
All of this can be explained by realizing `loc is sys._getframe().f_locals`. IOW locals() always returns the dict in f_locals.
Again, the results here are totally different if a Python-level tracing/profiling function is installed.
And you can also hit these subtleties via 'exec' and 'eval':
def f(): a = 1 loc = locals() assert "loc" not in loc # exec() triggers writeback, and then mutates the locals dict exec("a = 2; b = 3") # So now the current environment has been reflected into 'loc' assert "loc" in loc # Also loc["a"] has been changed to reflect the exec'ed assignments assert loc["a"] == 2 # But if we look at the actual environment, directly or via # f_locals, we can see that 'a' has not changed: assert a == 1 assert sys._getframe().f_locals["a"] == 1 # loc["b"] changed as well: assert loc["b"] == 3 # And this *does* show up in f_locals: assert sys._getframe().f_locals["b"] == 3
This works indeed. My understanding is that the bytecode interpreter, when accessing the value of a local variable, ignores f_locals and always uses the "fast" array. But exec() and eval() don't use fast locals, their code is always compiled as if it appears in a module-level scope. While the interpreter is running and no debugger is active, in a function scope f_locals is not used at all, the interpreter only interacts with the fast array and the cells. It is initialized by the first locals() call for a function scope, and locals() copies the fast array and the cells into it. Subsequent calls in the same function scope keep the same value for f_locals and re-copy fast and cells into it. This also clears out deleted local variables and emptied cells, but leaves "strange" keys (like "b" in the examples) unchanged. The truly weird case happen when Python-level tracers are present, then the contents of f_locals is written back to the fast array and cells at certain points. This is intended for use by pdb (am I the only user of pdb left in the world?), so one can step through a function and mutate local variables. I find this essential in some cases.
Of course, many of these edge cases are pretty obscure, so it's not clear how much they matter. But I think we can at least agree that this isn't the one obvious way to do it :-).
##### What's the landscape of possible semantics?
I did some brainstorming, and came up with 4 sets of semantics that seem plausible enough to at least consider:
- [PEP]: the semantics in the current PEP draft.
To be absolutely clear this copies the fast array and cells to f_locals when locals() is called, but never copies back, except when Python-level tracing/profiling is on.
- [PEP-minus-tracing]: same as [PEP], except dropping the writeback on Python-level trace/profile events.
But this still copies the fast array and cells to f_locals when a Python trace function is called, right? It just doesn't write back.
- [snapshot]: in function scope, each call to locals() returns a new, *static* snapshot of the local environment, removing all this writeback stuff. Something like:
def locals(): frame = get_caller_frame() if is_function_scope(frame): # make a point-in-time copy of the "live" proxy object return dict(frame.f_locals) else: # in module/class scope, return the actual local environment return frame.f_locals
This is the most extreme variant, and in this case there is no point in having f_locals at all for a function scope (since nothing uses it). I'm not 100% sure that you understand this.
- [proxy]: Simply return the .f_locals object, so in all contexts locals() returns a live mutable view of the actual environment:
def locals(): return get_caller_frame().f_locals
So this is PEP without any writeback. But there is still copying from the fast array and cells to f_locals. Does that only happen when locals() is called? Or also when a Python-level trace/profiling function is called? My problem with all variants except what's in the PEP is that it would leave pdb *no* way (short of calling into the C API using ctypes) of writing back local variables.
##### How to evaluate our options?
I can think of a lot of criteria that all-else-being-equal we would like Python to meet. (Of course, in practice they conflict.)
Consistency across APIs: it's surprising if locals() and frame.f_locals do different things. This argues for [proxy].
Consistency across contexts: it's surprising if locals() has acts differently in module/class scope versus function scope. This argues for [proxy].
Consistent behavior when the environment shifts, or small maintenance changes are made: it's nice if code that works today keeps working tomorrow. On this criterion, I think [snapshot] > [proxy] > [PEP-minus-tracing] >>> [PEP]. [PEP] is particularly bad here because a very rare environmental change that almost no-one tests and mostly only happens when debugging (i.e., enabling tracing) causes a radical change in semantics.
Simplicity of explaining to users: all else being equal, it's nice if our docs are short and clear and the language fits in your head. On this criterion, I think: [proxy] > [snapshot] > [PEP-minus-tracing] > [PEP]. As evidence that the current behavior is confusing, see:
- Ned gets confused and writes a long blog post after he figures it out: https://nedbatchelder.com/blog/201211/tricky_locals.html - A linter that warns against mutating locals(): https://lgtm.com/rules/10030096/
Simplicity of implementation: "If the implementation is easy to explain, it may be a good idea." Since we need the proxy code anyway to implement f_locals, I think it's: [proxy] (free) > [snapshot] (one 'if' statement) > ([PEP] = [PEP-minus-tracing]).
Impact on other interpreter implementations: all else being equal, we'd like to give new interpreters maximal freedom to do clever things. (And local variables are a place where language VMs often expend a lot of cleverness.) [proxy] and [snapshot] are both easily implemented in terms of f_locals, so they basically don't constrain alternative implementations at all. I'm not as sure about [PEP] and [PEP-minus-tracing]. I originally thought they must be horrible. On further thought, I'm not convinced they're *that* bad, since the need to support people doing silly stuff like frame.f_locals[object()] = 10 means that implementations will already need to sometimes attach something like a dict object to their function frames. But perhaps alternative implementations would like to disallow this, or are OK with making it really slow but care about locals() performance more. Anyway, it's definitely ([proxy] = [snapshot]) > ([PEP] = [PEP-minus-tracing]), but I'm not sure whether the '>' is large or small.
Backwards compatibility: help(locals) says:
NOTE: Whether or not updates to this dictionary will affect name lookups in the local scope and vice-versa is *implementation dependent* and not covered by any backwards compatibility guarantees.
So that claims that there are ~no backwards compatibility issues here. I'm going to ignore that; no matter what the docs say, we still don't want to break everyone's code. And unfortunately, I can't think of any realistic way to do a gradual transition, with like deprecation warnings and all that (can anyone else?), so whatever we do will be a flag-day change.
Of our four options, [PEP] is intuitively the closest to what CPython has traditionally done. But what exactly breaks under the different approaches? I'll split this off into its own section.
##### Backwards compatibility
I'll split this into three parts: code that treats locals() as read-only, exec()/eval(), and code that mutates locals().
I believe (but haven't checked) that the majority of uses of locals() are in simple cases like:
def f(): .... print("{a} {b}".format(**locals()))
Luckily, this code remains totally fine under all four of our candidates.
exec() and eval() are an interesting case. In Python 2, exec'ing some assignments actually *did* mutate the local environment, e.g. you could do:
# Python 2 def f(): exec "a = 1" assert a == 1
In Python 3, this was changed, so now exec() inside a function cannot mutate the enclosing scope. We got some bug reports about this change, and there are a number of questions on stackoverflow about it, e.g.:
- https://bugs.python.org/issue4831 - https://stackoverflow.com/questions/52217525/how-can-i-change-the-value-of-v... - https://stackoverflow.com/questions/50995581/eval-exec-with-assigning-variab...
In all released versions of Python, eval() was syntactically unable to rebind variables, so eval()'s interaction with the local environment was undefined. However, in 3.8, eval() *will* be able to rebind variables using the ':=' operator, so this interaction will become user-visible. Presumably we'll want eval() to match exec().
OK, with that background out of the way, let's look at our candidates.
If we adopt [proxy], then that will mean exec()-inside-functions will go back to the Python 2 behavior, where executing assignments in the enclosing scope actually changes the enclosing scope. This will likely break some code out there that's relying on the Python 3 behavior, though I don't know how common that is. (I'm guessing not too common? Using the same variable inside and outside an 'exec' and trusting that they *won't* be the same seems like an unusual thing to do. But I don't know.)
With [PEP] and [PEP-minus-tracing], exec() is totally unchanged. With [snapshot], there's technically a small difference: if you call locals() and then exec(), the exec() no longer triggers an implicit writeback to the dict that locals() returned. I think we can ignore this, and say that for all three of these, exec() is unlikely to produce backwards compatibility issues.
OK, finally, let's talk about code that calls locals() and then mutates the return value. The main difference between our candidates is how they handle mutation, so this seems like the most important case to focus on.
Conveniently, Mark Shannon and friends have statically analyzed a large corpus of Python code and made a list of cases where people do this: https://lgtm.com/rules/10030096/alerts/ Thanks! I haven't gone through the whole list, but I read through the first few in the hopes of getting a better sense of what kind of code does this in the real world and how it would be impacted by our different options.
https://lgtm.com/projects/g/pydata/xarray/snapshot/a2ac6af744584c8afed3d56d00c7d6ace85341d9/files/xarray/plot/plot.py?sort=name&dir=ASC&mode=heatmap#L701 Current: raises if a Python-level trace/profile function is set [PEP]: raises if a Python-level trace/profile function is set [PEP-minus-tracing]: ok [snapshot]: ok [proxy]: always raises Comment: uses locals() to capture a bunch of passed in kwargs so it can pass them as **kwargs to another function, and treats them like a snapshot. The authors were clearly aware of the dangers, because this pattern appears multiple times in this file, and all the other places make an explicit copy of locals() before using it, but this place apparently got missed. Fix is trivial: just do that here too.
https://github.com/swagger-api/swagger-codegen/blob/master/modules/swagger-c... Current: ok [PEP]: ok [PEP-minus-tracing]: ok [snapshot]: ok [proxy]: ok Comment: this is inside a tool used to generate Python wrappers for REST APIs. The vast majority of entries in the lgtm database are from code generated by this tool. This was tricky to analyze, because it's complex templated code, and it does mutate locals(). But I'm pretty confident that the mutations end up just... not mattering, and all possible generated code works under all of our candidates. Which is lucky, because if we did have to fix this it wouldn't be trivial: fixing up the code generator itself wouldn't be too hard, but it'll take years for everyone to regenerate their old wrappers.
https://lgtm.com/projects/g/saltstack/salt/snapshot/bb0950e5eafbb897c8e969e3f20fd297d8ba2006/files/salt/utils/thin.py?sort=name&dir=ASC&mode=heatmap#L258 Current: ok [PEP]: ok [PEP-minus-tracing]: ok [snapshot]: raises [proxy]: ok Comment: the use of locals() here is totally superfluous – it repeatedly reads and writes to locals()[mod], and in all cases this could be replaced by a simple variable, or any other dict. And it's careful not to assign to any name that matches an actual local variable, so it works fine with [proxy] too. But it does assume that multiple calls to locals() return the same dict, so [snapshot] breaks it. In this case the fix is trivial: just use a variable.
https://lgtm.com/projects/g/materialsproject/pymatgen/snapshot/fd6900ed1040a4d35f2cf2b3506e6e3d7cdf77db/files/pymatgen/ext/jhu.py?sort=name&dir=ASC&mode=heatmap#L52 Current: buggy if a trace/profile function is set [PEP]: buggy if a trace/profile function is set [PEP-minus-tracing]: ok [snapshot]: ok [proxy]: raises Comment: Another example of collecting kwargs into a dict. Actually this code is always buggy, because they seem to think that dict.pop("a", "b") removes both "a" and "b" from the dict... but it would raise an exception on [proxy], because they use one of those local variables after popping it from locals(). Fix is trivial: take an explicit snapshot of locals() before modifying it.
##### Conclusion so far
[PEP-minus-tracing] seems to strictly dominate [PEP]. It's equal or better on all criteria, and actually *more* compatible with all the legacy code I looked at, even though it's technically less consistent with what CPython used to do. Unless someone points out some new argument, I think we can reject the writeback-when-tracing part of the PEP draft.
Choosing between the remaining three is more of a judgement call.
I'm leaning towards saying that on net, [snapshot] beats [PEP-minus-tracing]: it's dramatically simpler, and the backwards incompatibilities that we've found so far seem pretty minor, on par with what we do in every point release. (In fact, in 3/4 of the cases I looked at, [snapshot] is actually what users seemed to trying to use in the first place.)
For [proxy] versus [snapshot], a lot depends on what we think of changing the semantics of exec(). [proxy] is definitely more consistent and elegant, and if we could go back in time I think it's what we'd have done from the start. Its compatibility is maybe a bit worse than [snapshot] on non-exec() cases, but this seems pretty minor overall (it often doesn't matter, and if it does just write dict(locals()) instead of locals(), like you would in non-function scope). But the change in exec() semantics is an actual language change, even though it may not affect much real code, so that's what stands out for me.
I'd very much like to hear about any considerations I've missed, and any opinions on the "judgement call" part.
To me it looks as if you're working with the wrong mental model of how it currently works. Unfortunately there's no way to find out how it currently works without reading the source code, since from Python code you cannot access the fast array and cells directly, and by just observing f_locals you get a slightly wrong picture. Perhaps we should give the two operations that copy from/to the fast array and cells names, so we can talk about them more easily. Sorry I didn't snip stuff. -- --Guido van Rossum (python.org/~guido) *Pronouns: he/him/his **(why is my pronoun here?)* http://feministing.com/2015/02/03/how-using-they-as-a-singular-pronoun-can-c...
On Mon, May 27, 2019 at 9:16 AM Guido van Rossum
I re-ran your examples and found that some of them fail.
On Mon, May 27, 2019 at 8:17 AM Nathaniel Smith
wrote:
[...]
The interaction between f_locals and and locals() is also subtle:
def f(): a = 1 loc = locals() assert "loc" not in loc # Regular variable updates don't affect 'loc' a = 2 assert loc["a"] == 1 # But debugging updates do: sys._getframe().f_locals["a"] = 3 assert a == 3
That assert fails; `a` is still 2 here for me.
I think you're running on current Python, and I'm talking about the semantics in the current PEP 558 draft, which redefines f_locals so that the assert passes. Nick has a branch here if you want to try it: https://github.com/python/cpython/pull/3640 (Though I admit I was lazy, and haven't tried running my examples at all -- they're just based on the text.)
assert loc["a"] == 3 # But it's not a full writeback assert "loc" not in loc # Mutating 'loc' doesn't affect f_locals: loc["a"] = 1 assert sys._getframe().f_locals["a"] == 1 # Except when it does: loc["b"] = 3 assert sys._getframe().f_locals["b"] == 3
All of this can be explained by realizing `loc is sys._getframe().f_locals`. IOW locals() always returns the dict in f_locals.
That's not true in the PEP version of things. locals() and frame.f_locals become radically different. locals() is still a dict stored in the frame object, but f_locals is a magic proxy object that reads/writes to the fast locals array directly.
Again, the results here are totally different if a Python-level tracing/profiling function is installed.
And you can also hit these subtleties via 'exec' and 'eval':
def f(): a = 1 loc = locals() assert "loc" not in loc # exec() triggers writeback, and then mutates the locals dict exec("a = 2; b = 3") # So now the current environment has been reflected into 'loc' assert "loc" in loc # Also loc["a"] has been changed to reflect the exec'ed assignments assert loc["a"] == 2 # But if we look at the actual environment, directly or via # f_locals, we can see that 'a' has not changed: assert a == 1 assert sys._getframe().f_locals["a"] == 1 # loc["b"] changed as well: assert loc["b"] == 3 # And this *does* show up in f_locals: assert sys._getframe().f_locals["b"] == 3
This works indeed. My understanding is that the bytecode interpreter, when accessing the value of a local variable, ignores f_locals and always uses the "fast" array. But exec() and eval() don't use fast locals, their code is always compiled as if it appears in a module-level scope.
While the interpreter is running and no debugger is active, in a function scope f_locals is not used at all, the interpreter only interacts with the fast array and the cells. It is initialized by the first locals() call for a function scope, and locals() copies the fast array and the cells into it. Subsequent calls in the same function scope keep the same value for f_locals and re-copy fast and cells into it. This also clears out deleted local variables and emptied cells, but leaves "strange" keys (like "b" in the examples) unchanged.
The truly weird case happen when Python-level tracers are present, then the contents of f_locals is written back to the fast array and cells at certain points. This is intended for use by pdb (am I the only user of pdb left in the world?), so one can step through a function and mutate local variables. I find this essential in some cases.
Right, the original goal for the PEP was to remove the "truly weird case" but keep pdb working
Of course, many of these edge cases are pretty obscure, so it's not clear how much they matter. But I think we can at least agree that this isn't the one obvious way to do it :-).
##### What's the landscape of possible semantics?
I did some brainstorming, and came up with 4 sets of semantics that seem plausible enough to at least consider:
- [PEP]: the semantics in the current PEP draft.
To be absolutely clear this copies the fast array and cells to f_locals when locals() is called, but never copies back, except when Python-level tracing/profiling is on.
In the PEP draft, it never copies back at all, under any circumstance.
- [PEP-minus-tracing]: same as [PEP], except dropping the writeback on Python-level trace/profile events.
But this still copies the fast array and cells to f_locals when a Python trace function is called, right? It just doesn't write back.
No, when I say "writeback" in this email I always mean PyFrame_FastToLocals. The PEP removes PyFrame_LocalsToFast entirely.
- [snapshot]: in function scope, each call to locals() returns a new, *static* snapshot of the local environment, removing all this writeback stuff. Something like:
def locals(): frame = get_caller_frame() if is_function_scope(frame): # make a point-in-time copy of the "live" proxy object return dict(frame.f_locals) else: # in module/class scope, return the actual local environment return frame.f_locals
This is the most extreme variant, and in this case there is no point in having f_locals at all for a function scope (since nothing uses it). I'm not 100% sure that you understand this.
Yes, this does suggest an optimization: you should be able to skip allocating a dict for every frame in most cases. I'm not sure how much of a difference it makes. In principle we could implement that optimization right now by delaying the dict allocation until the first time f_locals or locals() is used, but we currently don't bother. And even if we adopt this, we'll still need to keep a slot in the frame struct to allocate the dict if we need to, because people can still be obnoxious and do frame.f_locals["unique never before seen name"] = blah and expect to be able to read it back later, which means we need somewhere to store that. (In fact Trio does do this right now, as part of its control-C handling stuff, because there's literally no other place where you can store information that a signal handler can see when it's walking the stack.) We could deprecate writing new names to f_locals like this, but that's a longer-term thing.
- [proxy]: Simply return the .f_locals object, so in all contexts locals() returns a live mutable view of the actual environment:
def locals(): return get_caller_frame().f_locals
So this is PEP without any writeback. But there is still copying from the fast array and cells to f_locals. Does that only happen when locals() is called? Or also when a Python-level trace/profiling function is called? My problem with all variants except what's in the PEP is that it would leave pdb *no* way (short of calling into the C API using ctypes) of writing back local variables.
No, this option is called [proxy] because this is the version where locals() and f_locals *both* give you magic proxy objects where __getitem__ and __setitem__ access the fast locals array directly, as compared to the PEP where only f_locals gives you that magic object. -n -- Nathaniel J. Smith -- https://vorpus.org
OK, I apologize for not catching on to the changed semantics of f_locals
(which in the proposal is always a proxy for the fast locals and the
cells). I don't know if I just skimmed that part of the PEP or that it
needs calling out more.
I'm assuming that there are backwards compatibility concerns, and the PEP
is worried that more code will break if one of the simpler options is
chosen. In particular I guess that the choice of returning the same object
is meant to make locals() in a function frame more similar to locals() in a
module frame. And the choice of returning a plain dict rather than a proxy
is meant to make life easier for code that assumes it's getting a plain
dict.
Other than that I agree that returning a proxy (i.e. just a reference to
f_locals) seems to be the most attractive option...
On Mon, May 27, 2019 at 9:41 AM Nathaniel Smith
On Mon, May 27, 2019 at 9:16 AM Guido van Rossum
wrote: I re-ran your examples and found that some of them fail.
On Mon, May 27, 2019 at 8:17 AM Nathaniel Smith
wrote: [...]
The interaction between f_locals and and locals() is also subtle:
def f(): a = 1 loc = locals() assert "loc" not in loc # Regular variable updates don't affect 'loc' a = 2 assert loc["a"] == 1 # But debugging updates do: sys._getframe().f_locals["a"] = 3 assert a == 3
That assert fails; `a` is still 2 here for me.
I think you're running on current Python, and I'm talking about the semantics in the current PEP 558 draft, which redefines f_locals so that the assert passes. Nick has a branch here if you want to try it: https://github.com/python/cpython/pull/3640
(Though I admit I was lazy, and haven't tried running my examples at all -- they're just based on the text.)
assert loc["a"] == 3 # But it's not a full writeback assert "loc" not in loc # Mutating 'loc' doesn't affect f_locals: loc["a"] = 1 assert sys._getframe().f_locals["a"] == 1 # Except when it does: loc["b"] = 3 assert sys._getframe().f_locals["b"] == 3
All of this can be explained by realizing `loc is sys._getframe().f_locals`. IOW locals() always returns the dict in f_locals.
That's not true in the PEP version of things. locals() and frame.f_locals become radically different. locals() is still a dict stored in the frame object, but f_locals is a magic proxy object that reads/writes to the fast locals array directly.
Again, the results here are totally different if a Python-level tracing/profiling function is installed.
And you can also hit these subtleties via 'exec' and 'eval':
def f(): a = 1 loc = locals() assert "loc" not in loc # exec() triggers writeback, and then mutates the locals dict exec("a = 2; b = 3") # So now the current environment has been reflected into 'loc' assert "loc" in loc # Also loc["a"] has been changed to reflect the exec'ed
assignments
assert loc["a"] == 2 # But if we look at the actual environment, directly or via # f_locals, we can see that 'a' has not changed: assert a == 1 assert sys._getframe().f_locals["a"] == 1 # loc["b"] changed as well: assert loc["b"] == 3 # And this *does* show up in f_locals: assert sys._getframe().f_locals["b"] == 3
This works indeed. My understanding is that the bytecode interpreter, when accessing the value of a local variable, ignores f_locals and always uses the "fast" array. But exec() and eval() don't use fast locals, their code is always compiled as if it appears in a module-level scope.
While the interpreter is running and no debugger is active, in a function scope f_locals is not used at all, the interpreter only interacts with the fast array and the cells. It is initialized by the first locals() call for a function scope, and locals() copies the fast array and the cells into it. Subsequent calls in the same function scope keep the same value for f_locals and re-copy fast and cells into it. This also clears out deleted local variables and emptied cells, but leaves "strange" keys (like "b" in the examples) unchanged.
The truly weird case happen when Python-level tracers are present, then the contents of f_locals is written back to the fast array and cells at certain points. This is intended for use by pdb (am I the only user of pdb left in the world?), so one can step through a function and mutate local variables. I find this essential in some cases.
Right, the original goal for the PEP was to remove the "truly weird case" but keep pdb working
Of course, many of these edge cases are pretty obscure, so it's not clear how much they matter. But I think we can at least agree that this isn't the one obvious way to do it :-).
##### What's the landscape of possible semantics?
I did some brainstorming, and came up with 4 sets of semantics that seem plausible enough to at least consider:
- [PEP]: the semantics in the current PEP draft.
To be absolutely clear this copies the fast array and cells to f_locals when locals() is called, but never copies back, except when Python-level tracing/profiling is on.
In the PEP draft, it never copies back at all, under any circumstance.
- [PEP-minus-tracing]: same as [PEP], except dropping the writeback on Python-level trace/profile events.
But this still copies the fast array and cells to f_locals when a Python trace function is called, right? It just doesn't write back.
No, when I say "writeback" in this email I always mean PyFrame_FastToLocals. The PEP removes PyFrame_LocalsToFast entirely.
- [snapshot]: in function scope, each call to locals() returns a new, *static* snapshot of the local environment, removing all this writeback stuff. Something like:
def locals(): frame = get_caller_frame() if is_function_scope(frame): # make a point-in-time copy of the "live" proxy object return dict(frame.f_locals) else: # in module/class scope, return the actual local environment return frame.f_locals
This is the most extreme variant, and in this case there is no point in having f_locals at all for a function scope (since nothing uses it). I'm not 100% sure that you understand this.
Yes, this does suggest an optimization: you should be able to skip allocating a dict for every frame in most cases. I'm not sure how much of a difference it makes. In principle we could implement that optimization right now by delaying the dict allocation until the first time f_locals or locals() is used, but we currently don't bother. And even if we adopt this, we'll still need to keep a slot in the frame struct to allocate the dict if we need to, because people can still be obnoxious and do frame.f_locals["unique never before seen name"] = blah and expect to be able to read it back later, which means we need somewhere to store that. (In fact Trio does do this right now, as part of its control-C handling stuff, because there's literally no other place where you can store information that a signal handler can see when it's walking the stack.) We could deprecate writing new names to f_locals like this, but that's a longer-term thing.
- [proxy]: Simply return the .f_locals object, so in all contexts locals() returns a live mutable view of the actual environment:
def locals(): return get_caller_frame().f_locals
So this is PEP without any writeback. But there is still copying from the fast array and cells to f_locals. Does that only happen when locals() is called? Or also when a Python-level trace/profiling function is called? My problem with all variants except what's in the PEP is that it would leave pdb *no* way (short of calling into the C API using ctypes) of writing back local variables.
No, this option is called [proxy] because this is the version where locals() and f_locals *both* give you magic proxy objects where __getitem__ and __setitem__ access the fast locals array directly, as compared to the PEP where only f_locals gives you that magic object.
-n
-- Nathaniel J. Smith -- https://vorpus.org
-- --Guido van Rossum (python.org/~guido) *Pronouns: he/him/his **(why is my pronoun here?)* http://feministing.com/2015/02/03/how-using-they-as-a-singular-pronoun-can-c...
On Mon, May 27, 2019 at 08:15:01AM -0700, Nathaniel Smith wrote: [...]
I'm not as sure about the locals() parts of the proposal. It might be fine, but there are some complex trade-offs here that I'm still trying to wrap my head around. The rest of this document is me thinking out loud to try to clarify these issues.
Wow. Thanks for the detail on this, I think the PEP should link to this thread, you've done some great work here. [...]
In function scopes, things are more complicated. The *local environment* is conceptually well-defined, and includes: - local variables (current source of truth: "fast locals" array) - closed-over variables (current source of truth: cell objects)
I don't think closed-over variables are *local* variables. They're "nonlocal", and you need a special keyword to write to them.
- any arbitrary key/values written to frame.f_locals that don't correspond to local or closed-over variables, e.g. you can do frame.f_locals[object()] = 10, and then later read it out again.
Today I learned something new.
However, the mapping returned by locals() does not directly reflect this local environment. Instead, each function frame has a dict associated with it. locals() returns this dict. The dict always holds any non-local/non-closed-over variables, and also, in certain circumstances, we write a snapshot of local and closed-over variables back into the dict.
I'm going to try to make a case for your "snapshot" scenario. The locals dict inside a function is rather weird: - unlike in the global or class scope, writing to the dict does not update the variables - writing to it is discouraged, but its not a read-only proxy - it seems to be a snapshot of the state of variables when you called locals(): # inside a function x = 1 d = locals() x = 2 assert d['x'] == 1 - but it's not a proper, static, snapshot, because sometimes it will mutate without you touching it. That last point is Action At A Distance, and while it is explicable ("there's only one locals dict, and calling locals() updates it") its also rather unintuitive and surprising and violates the Principle Of Least Surprise. [Usual disclaimers about "surprising to whom?" applies.] Unless I missed something, it doesn't seem that any of the code you (Nathan) analysed is making use of this AAAD behaviour, at least not deliberately. At least one of the examples took steps to avoid it by making an explicit copy after calling locals(), but missed one leaving that function possibly buggy. Given how weirdly the locals dict behaves, and how tricky it is to explain all the corner cases, I'm going to +1 your "snapshot" idea: - we keep the current behaviour for locals() in the global and class scopes; - we keep the PEP's behaviour for writebacks when locals() or exec() (and eval with walrus operator) are called, for the frame dict; - but we change locals() to return a copy of that dict, rather than the dict itself. (I think I've got the details right... please correct me if I've misunderstood anything.) Being a backwards-incompatible change, that means that folks who were relying on that automagical refresh of the snapshot will need to change their code to explicitly refresh: # update in place: d.update(locals()) # or get a new snapshot d = locals() Or they explicitly grab a reference to the frame dict instead of calling locals(). Either way is likely to be less surprising than the status quo and less likely to lead to accidental, unexpected updates of the local dictionary without your knowledge. [...]
Of course, many of these edge cases are pretty obscure, so it's not clear how much they matter. But I think we can at least agree that this isn't the one obvious way to do it :-).
Indeed. I thought I was doing well to know that writing to locals() inside a function didn't necessarily update the variable, but I had no idea of the levels of complexity actually involved!
I can think of a lot of criteria that all-else-being-equal we would like Python to meet. (Of course, in practice they conflict.)
Consistency across APIs: it's surprising if locals() and frame.f_locals do different things. This argues for [proxy].
I don't think it is that surprising, since frame.f_locals is kinda obscure (the average Python coder wouldn't know a frame if one fell on them) and locals() has been documented as weird for decades. In any case, at least "its a copy of ..." is simple and understandable.
Consistency across contexts: it's surprising if locals() has acts differently in module/class scope versus function scope. This argues for [proxy].
True as far as it goes, but its also true that for the longest time, in most implementations, locals() has acted differently. So no change there. On the other hand, locals() currently returns a dict everywhere. It might be surprising for it to start returning a proxy object inside functions instead of a dict. -- Steven
On 5/27/2019 7:28 PM, Steven D'Aprano wrote:
On the other hand, locals() currently returns a dict everywhere. It might be surprising for it to start returning a proxy object inside functions instead of a dict. I thought the proxy object sounded more useful... how different is it in use from a dict? "proxy" sounds like it should quack like a dict, as a general term, but maybe a more specific "proxy" is meant here, that doesn't quite quack like a dict?
Note that the weird, Action At A Distance behavior is also visible for
locals() called at module scope (since there, locals() is globals(), which
returns the actual dict that's the module's __dict__, i.e. the Source Of
Truth. So I think it's unavoidable in general, and we would do wise not to
try and "fix" it just for function locals. (And I certainly don't want to
mess with globals().)
This is another case for the proposed [proxy] semantics, assuming we can
get over our worry about backwards incompatibility.
On Mon, May 27, 2019 at 7:31 PM Steven D'Aprano
On Mon, May 27, 2019 at 08:15:01AM -0700, Nathaniel Smith wrote: [...]
I'm not as sure about the locals() parts of the proposal. It might be fine, but there are some complex trade-offs here that I'm still trying to wrap my head around. The rest of this document is me thinking out loud to try to clarify these issues.
Wow. Thanks for the detail on this, I think the PEP should link to this thread, you've done some great work here.
[...]
In function scopes, things are more complicated. The *local environment* is conceptually well-defined, and includes: - local variables (current source of truth: "fast locals" array) - closed-over variables (current source of truth: cell objects)
I don't think closed-over variables are *local* variables. They're "nonlocal", and you need a special keyword to write to them.
- any arbitrary key/values written to frame.f_locals that don't correspond to local or closed-over variables, e.g. you can do frame.f_locals[object()] = 10, and then later read it out again.
Today I learned something new.
However, the mapping returned by locals() does not directly reflect this local environment. Instead, each function frame has a dict associated with it. locals() returns this dict. The dict always holds any non-local/non-closed-over variables, and also, in certain circumstances, we write a snapshot of local and closed-over variables back into the dict.
I'm going to try to make a case for your "snapshot" scenario.
The locals dict inside a function is rather weird:
- unlike in the global or class scope, writing to the dict does not update the variables
- writing to it is discouraged, but its not a read-only proxy
- it seems to be a snapshot of the state of variables when you called locals():
# inside a function x = 1 d = locals() x = 2 assert d['x'] == 1
- but it's not a proper, static, snapshot, because sometimes it will mutate without you touching it.
That last point is Action At A Distance, and while it is explicable ("there's only one locals dict, and calling locals() updates it") its also rather unintuitive and surprising and violates the Principle Of Least Surprise.
[Usual disclaimers about "surprising to whom?" applies.]
Unless I missed something, it doesn't seem that any of the code you (Nathan) analysed is making use of this AAAD behaviour, at least not deliberately. At least one of the examples took steps to avoid it by making an explicit copy after calling locals(), but missed one leaving that function possibly buggy.
Given how weirdly the locals dict behaves, and how tricky it is to explain all the corner cases, I'm going to +1 your "snapshot" idea:
- we keep the current behaviour for locals() in the global and class scopes;
- we keep the PEP's behaviour for writebacks when locals() or exec() (and eval with walrus operator) are called, for the frame dict;
- but we change locals() to return a copy of that dict, rather than the dict itself.
(I think I've got the details right... please correct me if I've misunderstood anything.)
Being a backwards-incompatible change, that means that folks who were relying on that automagical refresh of the snapshot will need to change their code to explicitly refresh:
# update in place: d.update(locals())
# or get a new snapshot d = locals()
Or they explicitly grab a reference to the frame dict instead of calling locals(). Either way is likely to be less surprising than the status quo and less likely to lead to accidental, unexpected updates of the local dictionary without your knowledge.
[...]
Of course, many of these edge cases are pretty obscure, so it's not clear how much they matter. But I think we can at least agree that this isn't the one obvious way to do it :-).
Indeed. I thought I was doing well to know that writing to locals() inside a function didn't necessarily update the variable, but I had no idea of the levels of complexity actually involved!
I can think of a lot of criteria that all-else-being-equal we would like Python to meet. (Of course, in practice they conflict.)
Consistency across APIs: it's surprising if locals() and frame.f_locals do different things. This argues for [proxy].
I don't think it is that surprising, since frame.f_locals is kinda obscure (the average Python coder wouldn't know a frame if one fell on them) and locals() has been documented as weird for decades.
In any case, at least "its a copy of ..." is simple and understandable.
Consistency across contexts: it's surprising if locals() has acts differently in module/class scope versus function scope. This argues for [proxy].
True as far as it goes, but its also true that for the longest time, in most implementations, locals() has acted differently. So no change there.
On the other hand, locals() currently returns a dict everywhere. It might be surprising for it to start returning a proxy object inside functions instead of a dict.
-- Steven _______________________________________________ Python-Dev mailing list Python-Dev@python.org https://mail.python.org/mailman/listinfo/python-dev Unsubscribe: https://mail.python.org/mailman/options/python-dev/guido%40python.org
-- --Guido van Rossum (python.org/~guido) *Pronouns: he/him/his **(why is my pronoun here?)* http://feministing.com/2015/02/03/how-using-they-as-a-singular-pronoun-can-c...
On Mon, May 27, 2019 at 9:18 PM Guido van Rossum
Note that the weird, Action At A Distance behavior is also visible for locals() called at module scope (since there, locals() is globals(), which returns the actual dict that's the module's __dict__, i.e. the Source Of Truth. So I think it's unavoidable in general, and we would do wise not to try and "fix" it just for function locals. (And I certainly don't want to mess with globals().)
I think it's worth distinguishing between two different types of weird Action At A Distance here: - There's the "justified" action-at-a-distance that currently happens at module scope, where locals().__setitem__ affects variable lookup, and variable mutation affects locals().__getitem__. This can produce surprising results if you pass locals() into something that's expecting a regular dict, but it's also arguably the point of an environment introspection API, and like you say, it's unavoidable and expected at module scope and when using globals(). - And then there's the "spooky" action-at-a-distance that currently happens at function scope, where calling locals() has the side-effect of mutating the return value from previous calls to locals(), and the objects returned from locals may or may not spontaneously mutate themselves depending on whether some other code registered a trace function. This is traditional, but extremely surprising if you aren't deeply familiar with internals of CPython's implementation. Of the four designs: [PEP] and [PEP-minus-tracing] both have "spooky" action-at-a-distance (worse in [PEP]), but they don't have "justified" action-at-a-distance. [proxy] adds "justified" action-at-a-distance, and removes "spooky" action at a distance. [snapshot] gets rid of both kinds of action-at-a-distance (at least in function scope). -n -- Nathaniel J. Smith -- https://vorpus.org
On Tue, 28 May 2019 at 06:00, Nathaniel Smith
- There's the "justified" action-at-a-distance that currently happens at module scope, where locals().__setitem__ affects variable lookup, and variable mutation affects locals().__getitem__. This can produce surprising results if you pass locals() into something that's expecting a regular dict, but it's also arguably the point of an environment introspection API, and like you say, it's unavoidable and expected at module scope and when using globals().
If I understand you, this "justified" action at a distance is exactly similar to how os.environ works, making it very reasonable that locals and globals work the same (indeed, explaining that locals *doesn't* do this at the moment is why (IMO) people consider locals() as "a bit weird").
- And then there's the "spooky" action-at-a-distance that currently happens at function scope, where calling locals() has the side-effect of mutating the return value from previous calls to locals(), and the objects returned from locals may or may not spontaneously mutate themselves depending on whether some other code registered a trace function. This is traditional, but extremely surprising if you aren't deeply familiar with internals of CPython's implementation.
Yep, this is the one that most non-experts commenting on this thread (including me) seem to have been surprised by.
Of the four designs:
[PEP] and [PEP-minus-tracing] both have "spooky" action-at-a-distance (worse in [PEP]), but they don't have "justified" action-at-a-distance.
[proxy] adds "justified" action-at-a-distance, and removes "spooky" action at a distance.
[snapshot] gets rid of both kinds of action-at-a-distance (at least in function scope).
This summary leaves me very strongly feeling that I prefer [proxy] first, then [snapshot], with the two [PEP] variants a distant third and fourth. Of course, all of this is only if we have decided to formalise the semantics and change CPython to conform. I've never personally been affected by any of the edge cases with locals(), so on a purely personal basis, I'm equally happy with "do nothing" :-) Paul
On Tue, May 28, 2019 at 08:37:17AM +0100, Paul Moore wrote:
Of course, all of this is only if we have decided to formalise the semantics and change CPython to conform. I've never personally been affected by any of the edge cases with locals(), so on a purely personal basis, I'm equally happy with "do nothing" :-)
I don't think "Do Nothing" is a good option, because (as I understand it) the status quo has some weird bugs when you have tracing functions written in Python (but not in C?). So something has to change, one way or another. Hence Nick's PEP. -- Steven
(I'll likely write a more detailed reply once I'm back on an actual computer, but wanted to send an initial response while folks in the US are still awake, as the detailed reply may not be needed) Thanks for this write-up Nathaniel - I think you've done a good job of capturing the available design alternatives. The one implicit function locals() update case that you missed is in the accessor for "frame.f_locals", so I think dropping CPython's implicit update for all Python trace functions would be a clear win (I actually almost changed the PEP to do that once I realized it was already pretty redundant given the frame accessor behaviour). I'm OK with either [PEP-minus-tracing] or [snapshot], with a slight preference for [snapshot] (since it's easier to explain). The only design option I wouldn't be OK with is [proxy], as I think that poses a significant potential backwards compatibility problem, and I trust Armin Rigo's perspective that it would be hellish for JIT-compiled Python implementations to handle without taking the same kind of performance hit they do when they need to emulate the frame API. By contrast, true snapshot semantics will hopefully make life *easier* for JIT compilers, and folks that actually want the update() behaviour can either rely on frame.f_locals, or do an explicit update. This would also create a possible opportunity to simplify the fast locals proxy semantics: if it doesn't need to emulate the current behaviour of allowing arbitrary keys to be added and preserved between calls to locals(), then it could dispense with its internal dict cache entirely, and instead reject any operations that try to add new keys that aren't defined on the underlying code object (removing keys and then adding them back would still be permitted, and handled like a code level del statement). Cheers, Nick.
So why is it “hellish” for JITs if locals() returns a proxy, while
frame.f_locals being a proxy is okay?
On Tue, May 28, 2019 at 9:12 PM Nick Coghlan
(I'll likely write a more detailed reply once I'm back on an actual computer, but wanted to send an initial response while folks in the US are still awake, as the detailed reply may not be needed)
Thanks for this write-up Nathaniel - I think you've done a good job of capturing the available design alternatives.
The one implicit function locals() update case that you missed is in the accessor for "frame.f_locals", so I think dropping CPython's implicit update for all Python trace functions would be a clear win (I actually almost changed the PEP to do that once I realized it was already pretty redundant given the frame accessor behaviour).
I'm OK with either [PEP-minus-tracing] or [snapshot], with a slight preference for [snapshot] (since it's easier to explain).
The only design option I wouldn't be OK with is [proxy], as I think that poses a significant potential backwards compatibility problem, and I trust Armin Rigo's perspective that it would be hellish for JIT-compiled Python implementations to handle without taking the same kind of performance hit they do when they need to emulate the frame API.
By contrast, true snapshot semantics will hopefully make life *easier* for JIT compilers, and folks that actually want the update() behaviour can either rely on frame.f_locals, or do an explicit update.
This would also create a possible opportunity to simplify the fast locals proxy semantics: if it doesn't need to emulate the current behaviour of allowing arbitrary keys to be added and preserved between calls to locals(), then it could dispense with its internal dict cache entirely, and instead reject any operations that try to add new keys that aren't defined on the underlying code object (removing keys and then adding them back would still be permitted, and handled like a code level del statement).
Cheers, Nick.
-- --Guido (mobile)
On Wed., 29 May 2019, 2:29 pm Guido van Rossum,
So why is it “hellish” for JITs if locals() returns a proxy, while frame.f_locals being a proxy is okay?
As I understand it, they already drop out of compiled mode if they detect that the code is tinkering with frame objects. Having a single locals() call de-optimize an entire function would be far from ideal. Cheers, Nick. )
Indeed.
On Tue, May 28, 2019 at 11:07 PM Greg Ewing
Nick Coghlan wrote:
Having a single locals() call de-optimize an entire function would be far from ideal.
I don't see what would be so bad about that. The vast majority of functions have no need for locals().
-- Greg _______________________________________________ Python-Dev mailing list Python-Dev@python.org https://mail.python.org/mailman/listinfo/python-dev Unsubscribe: https://mail.python.org/mailman/options/python-dev/guido%40python.org
-- --Guido (mobile)
On Wed, 29 May 2019 at 16:08, Greg Ewing
Nick Coghlan wrote:
Having a single locals() call de-optimize an entire function would be far from ideal.
I don't see what would be so bad about that. The vast majority of functions have no need for locals().
If there was a compelling use case for letting "a = 1; exec(src); print(a)" print something other than "1" at function scope, then I'd be more amenable to the idea of the associated compatibility break and potential performance regression in other implementations. However, there isn't any such use case - if there were, we wouldn't have deliberately changed the semantics from the old Python 2 ones to the current Python 3 ones in PEP 3100 [1]. It's also worth noting that the "no backwards compatibility guarantees" wording is only in the help() text, and not in https://docs.python.org/3/library/functions.html#locals - the latter just notes that writing back to it may not work, not that the semantics may arbitrarily change between CPython versions. I think the [snapshot] approach is a solid improvement over my initial proposal, though, since removing the "locals() must always return the same mapping object" requirement also makes it possible to remove some oddities in the fastlocalsproxy implementation, and Nathaniel makes a compelling case that in the areas where the status quo and the snapshot proposal differ, those differences mostly either don't matter, or else they will serve make code otherwise subject to subtle bugs in tracing mode more correct. Cheers, Nick. [1] "exec as a statement is not worth it -- make it a function" in https://www.python.org/dev/peps/pep-3100/#core-language -- Nick Coghlan | ncoghlan@gmail.com | Brisbane, Australia
Nick Coghlan wrote:
If there was a compelling use case for letting "a = 1; exec(src); print(a)" print something other than "1" at function scope, then I'd be more amenable to the idea of the associated compatibility break and potential performance regression in other implementations.
However, there isn't any such use case - if there were, we wouldn't have deliberately changed the semantics from the old Python 2 ones to the current Python 3 ones in PEP 3100 [1].
I get the impression that was done because everyone involved thought it wasn't worth the ugliness of maintaining all the fast/locals swapping stuff, not because of any principle that the current behaviour is right or better in any way. Given a locals proxy object, it would be much easier to support the old behaviour (which seems obvious and correct to me) without eval or exec having to be anything special. -- Greg
On Thu, 30 May 2019 at 09:12, Greg Ewing
Nick Coghlan wrote:
If there was a compelling use case for letting "a = 1; exec(src); print(a)" print something other than "1" at function scope, then I'd be more amenable to the idea of the associated compatibility break and potential performance regression in other implementations.
However, there isn't any such use case - if there were, we wouldn't have deliberately changed the semantics from the old Python 2 ones to the current Python 3 ones in PEP 3100 [1].
I get the impression that was done because everyone involved thought it wasn't worth the ugliness of maintaining all the fast/locals swapping stuff, not because of any principle that the current behaviour is right or better in any way.
You may have felt that way, but I certainly don't - routinely hiding function local rebinding from the compiler (and type checkers, and human readers) is awful, and a privilege that should be reserved to debuggers and other tools operating at a similar level of "able to interfere with the normal runtime execution of a program". (Module and class namespaces are different - they're inherently mutable shared namespaces as far as the Python runtime is occurred, so locals() providing one more way of getting a reference to them isn't that big of a deal - if it's a problem, you can just push the code where you need to avoid those semantics matters down into a helper function) So for me, getting rid of write backs via exec and "import *" was a matter of "Yay, we finally closed those unfortunate loopholes" rather than being any kind of regrettable necessity. It's likely also a factor that Python 2.2.2 was the first version of Python that I ever used extensively, so the existing snapshot-like behaviour of locals() is the behaviour that feels normal to me, and I'm somewhat mystified by the notion that anyone might actually *want* it to behave differently (except to close the remaining loopholes that still allowed mysterious implicit mutation of previously created snapshots). The only rationale for considering that possibility seems to be "It would make function namespaces behave more like class and module namespaces", to which my response is "Why would you even want that, and what practical benefit would it bring to justify the otherwise gratuitous compatibility break?" Cheers, Nick. -- Nick Coghlan | ncoghlan@gmail.com | Brisbane, Australia
Nick Coghlan wrote:
So for me, getting rid of write backs via exec and "import *" was a matter of "Yay, we finally closed those unfortunate loopholes" rather than being any kind of regrettable necessity.
If that were the reasoning, the principled thing to do would be to raise an exception if an eval or exec tries to write to a local, rather than mostly ignore it. In any case, I don't really agree with that philosophy. Python is at its essence a dynamic language. Things like JIT and static type analysis are only possible to the extent that you refrain from using some of its dynamic features. Removing features entirely just because they *can* interfere with these things goes against the spirit of the language, IMO. -- Greg
On Thu, May 30, 2019 at 4:28 PM Greg Ewing
Nick Coghlan wrote:
So for me, getting rid of write backs via exec and "import *" was a matter of "Yay, we finally closed those unfortunate loopholes" rather than being any kind of regrettable necessity.
If that were the reasoning, the principled thing to do would be to raise an exception if an eval or exec tries to write to a local, rather than mostly ignore it.
In any case, I don't really agree with that philosophy. Python is at its essence a dynamic language. Things like JIT and static type analysis are only possible to the extent that you refrain from using some of its dynamic features. Removing features entirely just because they *can* interfere with these things goes against the spirit of the language, IMO.
Right. And static analysis should also be able to detect most uses of locals() in a frame. I believe I've heard of some alternate Python implementations that detect usage of some functions to disable some optimizations (IIRC IronPython did this to sys._getframe()). -- --Guido van Rossum (python.org/~guido) *Pronouns: he/him/his **(why is my pronoun here?)* http://feministing.com/2015/02/03/how-using-they-as-a-singular-pronoun-can-c...
Hi,
On Wed, 29 May 2019 at 08:07, Greg Ewing
Nick Coghlan wrote:
Having a single locals() call de-optimize an entire function would be far from ideal.
I don't see what would be so bad about that. The vast majority of functions have no need for locals().
You have the occasional big function that benefits a lot from being JIT-compiled but which contains ``.format(**locals())``. That occurs in practice, and that's why PyPy is happy that there is a difference between ``locals()`` and ``sys._getframe().f_locals``. PyPy could be made to support the full mutable view, but that's extra work that isn't done so far and is a bit unlikely to occur at this point. It also raises the significantly the efforts for other JIT implementations of Python if they have to support a full-featured ``locals()``; supporting ``_getframe().f_locals`` is to some extent optional, but supporting ``locals()`` is not. A bientôt, Armin.
On Sun, Jun 02, 2019 at 11:52:02PM +1200, Greg Ewing wrote:
Armin Rigo wrote:
You have the occasional big function that benefits a lot from being JIT-compiled but which contains ``.format(**locals())``.
There should be a lot less need for that now that we have f-strings.
I think you're forgetting that a lot of code (especially libraries) either have to support older versions of Python, and so cannot use f-strings at all, or was written using **locals before f-strings came along, and hasn't been touched since. Another case where f-strings don't help is when the template is dynamically generated. It may be that there will be less new code written using **locals() but I don't think that the **locals() trick will disappear any time before Python 5000. -- Steven
On 2019-06-02 13:51, Steven D'Aprano wrote:
On Sun, Jun 02, 2019 at 11:52:02PM +1200, Greg Ewing wrote:
Armin Rigo wrote:
You have the occasional big function that benefits a lot from being JIT-compiled but which contains ``.format(**locals())``.
There should be a lot less need for that now that we have f-strings.
I think you're forgetting that a lot of code (especially libraries) either have to support older versions of Python, and so cannot use f-strings at all, or was written using **locals before f-strings came along, and hasn't been touched since.
Another case where f-strings don't help is when the template is dynamically generated.
It may be that there will be less new code written using **locals() but I don't think that the **locals() trick will disappear any time before Python 5000.
We've had .format_map since Python 3.2, so why use ``.format(**locals())`` instead of ``.format_map(locals())``?
On Wed, May 29, 2019, at 01:25, Nick Coghlan wrote:
Having a single locals() call de-optimize an entire function would be far from ideal.
What if there were a way to explicitly de-optimize a function, rather than guessing the user's intent based on looking for locals and exec calls (both of which are builtins which could be shadowed or assigned to other variables)? Also, regardless of anything else, maybe in an optimized function locals should return a read-only mapping?
Nathaniel Smith wrote:
- [proxy]: Simply return the .f_locals object, so in all contexts locals() returns a live mutable view of the actual environment:
def locals(): return get_caller_frame().f_locals
Not sure I quite follow this -- as far as I can see, f_locals currently has the same snapshot behaviour as locals(). I'm assuming you mean to change things so that locals() returns a mutable view tracking the environment in both directions. That sounds like a much better idea all round to me. No weird shared-snapshot behaviour, and no need for anything to behave differently when tracing. If the change to the behaviour of exec() and eval() is a concern, then perhaps there should be a new localsview() function that returns a mutable view, with locals() redefined as dict(localsview()). -- Greg
On Tue, May 28, 2019 at 6:48 PM Greg Ewing
Nathaniel Smith wrote:
- [proxy]: Simply return the .f_locals object, so in all contexts locals() returns a live mutable view of the actual environment:
def locals(): return get_caller_frame().f_locals
Not sure I quite follow this -- as far as I can see, f_locals currently has the same snapshot behaviour as locals().
I'm assuming you mean to change things so that locals() returns a mutable view tracking the environment in both directions. That sounds like a much better idea all round to me. No weird shared-snapshot behaviour, and no need for anything to behave differently when tracing.
Yeah, I made the classic mistake and forgot that my audience isn't as immersed in this as I am :-). Throughout the email I'm assuming we're going to adopt PEP 558's proposal about replacing f_locals with a new kind of mutable view object, and then given that, asking what we should do about locals(). -n -- Nathaniel J. Smith -- https://vorpus.org
participants (10)
-
Armin Rigo
-
Glenn Linderman
-
Greg Ewing
-
Guido van Rossum
-
MRAB
-
Nathaniel Smith
-
Nick Coghlan
-
Paul Moore
-
Random832
-
Steven D'Aprano