Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PEP 667: Clarify specification ambiguities and the expected impact #3845

Open
wants to merge 35 commits into
base: main
Choose a base branch
from

Conversation

gaogaotiantian
Copy link
Member

@gaogaotiantian gaogaotiantian commented Jun 19, 2024

  • Change is:
    • To an Accepted or Final PEP, with Steering Council approval
    • To fix editorial issues (mostly missing context for changes originally discussed under PEP 558)
  • PR title prefixed with PEP number (e.g. PEP 123: Summary of changes)

📚 Documentation preview 📚: https://pep-previews--3845.org.readthedocs.build/

Copy link
Member

@carljm carljm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks really good!

I think it might also make sense to add a "Rejected Alternatives" section, with a sub-section "Maintaining existing behavior of exec", discussing some of what we talked about in Discourse about the problems with trying to do this.

peps/pep-0667.rst Outdated Show resolved Hide resolved
peps/pep-0667.rst Outdated Show resolved Hide resolved
peps/pep-0667.rst Outdated Show resolved Hide resolved
peps/pep-0667.rst Outdated Show resolved Hide resolved
peps/pep-0667.rst Outdated Show resolved Hide resolved
@ncoghlan
Copy link
Contributor

The new section looks good to me. FWIW, What's New in 3.13 does discuss this aspect of the change (https://docs.python.org/dev/whatsnew/3.13.html#defined-mutation-semantics-for-locals), but having more details in the PEP definitely won't hurt.

@ncoghlan
Copy link
Contributor

It may also be worth referencing back to the PEP 558 discussions of the impact of changing the way locals() works:

And while PEP 667's text didn't explicitly mention the impact on exec() and eval(), PEP 558 did: https://peps.python.org/pep-0558/#what-happens-with-the-default-args-for-eval-and-exec

This is a case where the use of competing PEPs may have let us down a bit - PEP 667 (quite reasonably) didn't bother repeating the rationale for the aspects where it agreed with the design decisions in PEP 558, so reading it in isolation from PEP 558 doesn't quite give the full picture of the change.

Copy link
Member

@markshannon markshannon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good.
I've nothing to add beyond what @carljm and @ncoghlan say.

@gaogaotiantian
Copy link
Member Author

So should I post this on disclosure first?

Impact on ``exec()``
====================

Even though this PEP does not modify ``exec()`` directly, the semantic change
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the first pass at trying to document this behavior. I would suggest reframing this a bit to:

  • Call out the specific semantic change to locals() to give the reader context
  • Explain concisely how this PEP impacts or explicitly documents why a behavior change may be seen when using exec() with performance optimized locals().
  • Make clear the expected behavior before explaining the examples.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the semantic changes to locals(), it's mentioned in this PEP a bit earlier. We should assume the reader already reads that part right? How should I either refer back or summarize that?

The reason of the behavior change is basically

The core difference is that the current implementation returns the same dict
when locals() is called multiple times in function scope, so the following
code magically works

About the expected behavior, do you mean something like "the local variable update will not be propagated between exec()"? I feel difficult to describe the exact behavior otherwise I would probably just summarize it in words instead of examples. This is a rather subtle change and the original behavior is kind of odd. I would love any help from people who are better at documentation.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @gaogaotiantian. We can definitely iterate on this. Looking at @carljm's message, I think there are three concepts that the reader may confuse: locals(), locals argument, and frame.f_locals for functions such as exec().

I'm not sure what the best wording would be but I think we should iterate until it seems clear to folks. Mostly, I want to make sure that someone reading the PEP and using exec() has a clear idea of what the expected behavior should be.

P.S. I'm very happy that you are taking the time to make this PEP addition.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@willingc I feel that these concepts are pretty well communicated by the current draft, with my suggested changes incorporated. First the current behavior of exec() is explained (with an example), then the reason for that behavior (and the connection to locals() is explained, then some additional confusing implications of the current behavior are shown (with an example), and then the new behavior is explained (which doesn't take very long, since it's much simpler), and then examples are given to demonstrate how to accomplish some things that are currently possible, under the new behavior. Are there particular aspects of this presentation that you don't find sufficiently clear?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @carljm. Let's accept your suggestions, and I will give it another read. I'm primarily concerned that the expected behavior for 3.13+ is clear to readers. That may be so after your changes are included. I have a hard time following the wording flow until I see it as one prose document.

Copy link
Contributor

@ncoghlan ncoghlan Jun 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After my Discord comments about even PEP 558 not explaining the locals() changes particularly well, I went back to PEP 558, the related python-dev thread, and even PEP 3100 to find the material that actually went into the decision to make locals() and exec() work as PEP 558 and PEP 667 both propose.

The early iterations of PEP 558 actually tried to standardise locals() without changing it, so hopefully the new text makes it clear why that approach didn't last long :)

@carljm
Copy link
Member

carljm commented Jun 20, 2024

So should I post this on disclosure first?

Yes, this should be posted on Discourse to get community feedback, but it probably makes sense to address the existing feedback first?

@gaogaotiantian
Copy link
Member Author

Yes, this should be posted on Discourse to get community feedback, but it probably makes sense to address the existing feedback first?

Of course, just want to make sure about the procedure. I'm a bit swamped recently.

@ncoghlan
Copy link
Contributor

I ticked the SC approval box in the template, since we have that in python/steering-council#245 (comment)

@carljm
Copy link
Member

carljm commented Jun 21, 2024

since we have that

I could be misunderstanding, but I don't see an indication in that comment that the SC considered the issue of exec() at all. I thought that comment was about C APIs. My understanding is that we don't yet have SC approval (or even consideration) of the issues discussed in this PR.

peps/pep-0667.rst Outdated Show resolved Hide resolved
peps/pep-0667.rst Outdated Show resolved Hide resolved
Impact on ``exec()``
====================

Even though this PEP does not modify ``exec()`` directly, the semantic change
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@willingc I feel that these concepts are pretty well communicated by the current draft, with my suggested changes incorporated. First the current behavior of exec() is explained (with an example), then the reason for that behavior (and the connection to locals() is explained, then some additional confusing implications of the current behavior are shown (with an example), and then the new behavior is explained (which doesn't take very long, since it's much simpler), and then examples are given to demonstrate how to accomplish some things that are currently possible, under the new behavior. Are there particular aspects of this presentation that you don't find sufficiently clear?

@willingc
Copy link
Contributor

@gaogaotiantian I accepted @carljm's suggestions so that I could look at the prose as it would display to a reader. I am going to add a few suggestions re: grammar, and then I believe this will be acceptable from my viewpoint. Thanks so much for doing this. It adds important clarity for all. 💐

peps/pep-0667.rst Outdated Show resolved Hide resolved
peps/pep-0667.rst Outdated Show resolved Hide resolved
peps/pep-0667.rst Outdated Show resolved Hide resolved
peps/pep-0667.rst Outdated Show resolved Hide resolved
peps/pep-0667.rst Outdated Show resolved Hide resolved
peps/pep-0667.rst Outdated Show resolved Hide resolved
peps/pep-0667.rst Outdated Show resolved Hide resolved
peps/pep-0667.rst Outdated Show resolved Hide resolved
peps/pep-0667.rst Outdated Show resolved Hide resolved
peps/pep-0667.rst Outdated Show resolved Hide resolved
peps/pep-0667.rst Outdated Show resolved Hide resolved
peps/pep-0667.rst Outdated Show resolved Hide resolved

To work with a copy of ``locals()`` on all versions without making
redundant copies on Python 3.13+, a helper function will need to
be defined in the affected code::
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And by "affected code", do you mean "optimized scopes"?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded to hopefully be clearer


This was historically also equivalent to using the calling frame's
``frame.f_globals`` and ``frame.f_locals`` attributes, but this PEP maps
them to ``globals()`` and ``locals()`` in order to preserve the property
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"them" is ambiguous. Does it refer to frame.f_globals and frame.f_locals or the arguments to exec() and eval()?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded to remove the ambiguity

peps/pep-0667.rst Outdated Show resolved Hide resolved
``exec()`` refreshes the cached dict with the actual values on the frame.
This means that, unlike the "fake" locals created by writing back to ``locals()``
(including via previous calls to ``exec()``), the real locals known by the
compiler can't easily be modified by ``exec()``.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"easily" or at all? What would "difficult" code look like?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a reference back to the ctypes code that's part of the Rationale section above.

peps/pep-0667.rst Outdated Show resolved Hide resolved
peps/pep-0667.rst Outdated Show resolved Hide resolved
peps/pep-0667.rst Outdated Show resolved Hide resolved
Copy link
Member

@warsaw warsaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gaogaotiantian Thank you so much for this PEP update. To me, it really clarifies all the (known!) ambiguities that have been discovered, especially around exec() and eval(). Really fantastic work on this, and thanks also to everyone who has contributed comments in this PR.

I left a number of little one-off comments and suggestions. I don't think any of them are blockers, but to me would help add just a little more clarity in a few places. Please consider them.

In my role as a member of the SC, I'm approving these changes. Before merging, please wait for at least a few other SC members to weigh in. I will ping them privately and we'll reach resolution asap.

@gaogaotiantian
Copy link
Member Author

Thanks @warsaw , but the majority of the work was done by @ncoghlan so she deserves the credit.

each other. Writes to the ``f_locals`` frame atribute may not show up as
modifications to local variables if ``PyFrame_LocalsToFast()`` is never
called. Writes to local variables can get lost if the dictionary snapshot
is not refreshed before being written back to the frame.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was a bit confused here. I thought writes to local variables get lost if the snapshot is refreshed before being written back to the frame. So if you do exec("x = 1") then locals() (to refresh the snapshot), your x=1 is lost?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded to hopefully be clearer.

peps/pep-0667.rst Outdated Show resolved Hide resolved
Copy link
Contributor

@ncoghlan ncoghlan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Latest update should cover Barry & Tian's review comments (I'll go through and resolve the conversions I'm 100% sure are addressed, but there are a few more ambiguous ones that I'll leave unresolved until they've had a chance to look at the updated wording)

peps/pep-0667.rst Outdated Show resolved Hide resolved
each other. Writes to the ``f_locals`` frame atribute may not show up as
modifications to local variables if ``PyFrame_LocalsToFast()`` is never
called. Writes to local variables can get lost if the dictionary snapshot
is not refreshed before being written back to the frame.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded to hopefully be clearer.

peps/pep-0667.rst Outdated Show resolved Hide resolved

To work with a copy of ``locals()`` on all versions without making
redundant copies on Python 3.13+, a helper function will need to
be defined in the affected code::
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded to hopefully be clearer


This was historically also equivalent to using the calling frame's
``frame.f_globals`` and ``frame.f_locals`` attributes, but this PEP maps
them to ``globals()`` and ``locals()`` in order to preserve the property
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded to remove the ambiguity

``exec()`` refreshes the cached dict with the actual values on the frame.
This means that, unlike the "fake" locals created by writing back to ``locals()``
(including via previous calls to ``exec()``), the real locals known by the
compiler can't easily be modified by ``exec()``.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a reference back to the ctypes code that's part of the Rationale section above.

peps/pep-0667.rst Outdated Show resolved Hide resolved
peps/pep-0667.rst Outdated Show resolved Hide resolved
@markshannon
Copy link
Member

This PR mores than doubles the size of PEP 667. That seems excessive.
Despite that, the actual changes to eval and exec are not made explicit.

The impact on eval and exec can be specified as something like:

"""
Because this PEP changes the behavior of locals(), the behavior of eval and exec are also changed.
Assuming a function _eval which performs the job of eval with explicit arguments for globals and locals, eval can be defined as follows:

def eval(expression, the_globals=None, the_locals=None):
    if the_globals is None:
        the_globals = globals()
    if the_locals is None:
        the_locals = locals()
    return _eval(expression, the_globals, the_locals)

Similarly, for exec.
"""

All the extra history and exposition only serves to obscure the semantics, IMO.

Fleshing the out the rationale a bit seems reasonable, but not too much.


``frame.f_locals`` will return a view object on the frame that
implements the ``collections.abc.Mapping`` interface.
implements the ``collections.abc.MutableMapping`` interface.
Copy link
Member

@markshannon markshannon Jul 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is incorrect. It should implement the Mapping interface. It may be mutable, but it is not a fully mutable mapping. You can't delete some values, and only strings are supported as keys.

No harm in adding some clarification that is has some mutability, but not the full MutableMapping interface.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, that's not the current situation either. The current implementation does not allow any deletion from the object. It also supports all kinds of keys per your request : ) .

Personally, I believe it's nicer to only support pure strings (we do have an open issue about this, allowing other types is kind of a mess for the locals proxy). Originally it was implemented that way because the PEP says so, but if we are editing the PEP, should we reconsider that decision? If we still support all types of keys, we need to answer the issue mentioned - what happens if something that looks like a string got passed in?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After checking at the 3.14 interactive prompt, I moved this paragraph to a clearer location and changed it to say:

The view objects fully implement the ``collections.abc.Mapping`` interface,
and also implement the following mutable mapping operations:

* using assignment to add new key/value pairs 
* using assignment to update the value associated with a key
* conditional assignment via the `setdefault()` method
* bulk updates via the `update()` method

Removing keys with `del` statements or the `pop()` method is NOT supported.

The first three could be simplified to item assignment (including via the setdefault() method), but calling out addition and rebinding separately seemed worthwhile (since they're genuinely different proxy operations)

@ncoghlan
Copy link
Contributor

ncoghlan commented Jul 9, 2024

I had omitted exec() and eval() from the specification section since their default argument handling isn't actually changing in CPython. However, other implementations might have implemented these builtins to instead default to sys._getframe().f_globals and sys._getframe().f_locals, so you're right that it should be included.

Unfortunately, I don't see a neat way of expressing the semantics of exec() and eval() as equivalent Python code. Using globals() and locals() as suggested isn't right, as they end up being called relative to the wrong frame (it only works for the builtin function implementations because they don't create a new Python frame).

It's possible to get the semantics correct using sys._getframe(), but the resulting code seems less clear to me than the prose descriptions of:

  • "if globals is not specified, then the result of calling globals() in the calling namespace is used"; and
  • "if locals is not specified, then the result of calling locals() in the calling namespace is used"

However, I'll see how the code version looks in context, since I agree that precision is desirable here.

Edit: the semantically correct code version ended up looking fine, so I included it in the specification section (it's also useful for anyone else wanting to emulate this argument handling in their own Python code):

    FrameProxyType = type((lambda: sys._getframe().f_locals)())

    def eval(expression, the_globals=None, the_locals=None):
        if the_globals is None:
            # No globals -> use calling frame's globals
            _calling_frame = sys._getframe(1)
            the_globals = _calling_frame.f_globals
            if the_locals is None:
                # No globals or locals -> use calling frame's locals
                the_locals = _calling_frame.f_locals
                if isinstance(the_locals, FrameProxyType):
                    # Align with locals() builtin in optimized frame
                    the_locals = the_locals.copy()
        elif the_locals is None:
            # Globals but no locals -> use same namespace for both
            the_locals = the_globals
        return _eval(expression, the_globals, the_locals)

(Related: it's unfortunate it is too late in the release cycle to add a frame.locals() helper method to encapsulate calling f_locals.copy() on optimized frames and using f_locals directly otherwise. The fact there's no longer an entirely straightforward way to recreate the builtin code evaluation semantics in a pure Python function does feel like a genuine functional gap)

I agree the extra words in the backwards compatibility section aren't useful to implementers, but I do think they're potentially useful to regular Python users bitten by the change to make locals() return independent snapshots (as well as in reassuring the SC that some form of compatibility break is genuinely unavoidable, so it's a matter of choosing which one is the most acceptable rather than it being a gratuitous break).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

6 participants