PEP: 667
Title: Consistent views of namespaces
Author: Mark Shannon <mark@hotpy.org>,
        Tian Gao <gaogaotiantian@hotmail.com>
Discussions-To: https://discuss.python.org/t/46631
Status: Final
Type: Standards Track
Created: 30-Jul-2021
Python-Version: 3.13
Post-History: 20-Aug-2021, 22-Feb-2024
Resolution:  `25-Apr-2024 <https://discuss.python.org/t/46631/25>`__

.. canonical-doc:: :external+py3.13:func:`locals`

Abstract
========

In early versions of Python all namespaces, whether in functions,
classes or modules, were all implemented the same way: as a dictionary.

For performance reasons, the implementation of function namespaces was
changed. Unfortunately this meant that accessing these namespaces through
``locals()`` and ``frame.f_locals`` ceased to be consistent and some
odd bugs crept in over the years as threads, generators and coroutines
were added.

This PEP proposes making these namespaces consistent once more.
Modifications to ``frame.f_locals`` will always be visible in
the underlying variables. Modifications to local variables will
immediately be visible in ``frame.f_locals``, and they will be
consistent regardless of threading or coroutines.

The ``locals()`` function will act the same as it does now for class
and modules scopes. For function scopes it will return an instantaneous
snapshot of the underlying ``frame.f_locals`` rather than implicitly
refreshing a single shared dictionary cached on the frame object.

.. _pep-667-motivation:

Motivation
==========

The implementation of ``locals()`` and ``frame.f_locals`` in releases up to and
including Python 3.12 is slow, inconsistent and buggy.
We want to make it faster, consistent, and most importantly fix the bugs.

For example, when attempting to manipulate local variables via frame objects::

    class C:
        x = 1
        sys._getframe().f_locals['x'] = 2
        print(x)

prints ``2``, but::

    def f():
        x = 1
        sys._getframe().f_locals['x'] = 2
        print(x)
    f()

prints ``1``.

This is inconsistent, and confusing. Worse than that, the Python 3.12 behavior can
result in strange `bugs <https://github.com/python/cpython/issues/74929>`__.

With this PEP both examples would print ``2`` as the function level
change would be written directly to the optimized local variables in
the frame rather than to a cached dictionary snapshot.

There are no compensating advantages for the Python 3.12 behavior;
it is unreliable and slow.

The ``locals()`` builtin has its own undesirable behaviours. Refer to :pep:`558`
for additional details on those concerns.


.. _pep-667-rationale:

Rationale
=========

Making the ``frame.f_locals`` attribute a write-through proxy
-------------------------------------------------------------

The Python 3.12 implementation of ``frame.f_locals`` returns a dictionary
that is created on the fly from the array of local variables. The
``PyFrame_LocalsToFast()`` C API is then called by debuggers and trace
functions that want to write their changes back to the array (until
Python 3.11, this API was called implicitly after every trace function
invocation rather than being called explicitly by the trace functions).

This can result in the array and dictionary getting out of sync with
each other. Writes to the ``f_locals`` frame attribute may not show up as
modifications to local variables if ``PyFrame_LocalsToFast()`` is never
called. Writes to local variables can get lost if a dictionary snapshot
created before the variables were modified is written back to the frame
(since *every* known variable stored in the snapshot is written back to
the frame, even if the value stored on the frame had changed since the
snapshot was taken).

By making ``frame.f_locals`` return a view on the
underlying frame, these problems go away. ``frame.f_locals`` is always in
sync with the frame because it is a view of it, not a copy of it.

Making the ``locals()`` builtin return independent snapshots
------------------------------------------------------------

:pep:`558` considered three potential options for standardising the behavior of the
``locals()`` builtin in :term:`optimized scopes <py3.13:optimized scope>`:

* retain the historical behaviour of having each call to ``locals()`` on a given frame
  update a single shared snapshot of the local variables
* make ``locals()`` return write-through proxy instances (similar
  to ``frame.f_locals``)
* make ``locals()`` return genuinely independent snapshots so that
  attempts to change the values of local variables via ``exec()``
  would be *consistently* ignored rather than being accepted in some circumstances

The last option was chosen as the one which could most easily be explained in the
language reference, and memorised by users:

* the ``locals()`` builtin gives an instantaneous snapshot of the local variables in
  optimized scopes, and read/write access in other scopes; and
* ``frame.f_locals`` gives read/write access to the local variables in all scopes,
  including optimized scopes

This approach allows the intent of a piece of code to be clearer than it would be if both
APIs granted full read/write access in optimized scopes, even when write access wasn't
needed or desired. For additional details on this design decision, refer to :pep:`558`,
especially the :ref:`pep-558-motivation` section and :ref:`pep-558-exec-eval-impact`.

This approach is not without its drawbacks, which are covered
in the Backwards Compatibility section below.

Specification
=============

Python API
----------

.. _pep-667-f_locals-spec:

The ``frame.f_locals`` attribute
''''''''''''''''''''''''''''''''

For module and class scopes (including ``exec()`` and ``eval()``
invocations), ``frame.f_locals`` is a direct
reference to the local variable namespace used in code execution.

For function scopes (and other :term:`optimized scopes <py3.13:optimized scope>`)
it will be an instance of a new write-through proxy type that can directly modify
the optimized local variable storage array in the underlying frame, as well as the
contents of any cell references to non-local variables.

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

Views of different frames compare unequal even if they have the same contents.

All writes to the ``f_locals`` mapping will be immediately visible
in the underlying variables. All changes to the underlying variables
will be immediately visible in the mapping.

The ``f_locals`` object will be a full mapping, and can have arbitrary
key-value pairs added to it. New names added via the proxies
will be stored in a dedicated shared dictionary stored on the
underlying frame object (so all proxy instances for a given frame
will be able to access any names added this way).

Extra keys (which do not correspond to local variables on the underlying
frame) may be removed as usual with ``del`` statements or the ``pop()``
method.

Using ``del``, or the ``pop()`` method, to remove keys that correspond to local
variables on the underlying frame is NOT supported, and attempting to do so
will raise ``ValueError``.
Local variables can only be set to ``None`` (or some other value) via the proxy,
they cannot be unbound completely.

The ``clear()`` method is NOT implemented on the write-through proxies, as it
is unclear how it should handle the inability to delete entries corresponding
to local variables.

To maintain backwards compatibility, proxy APIs that need to produce a
new mapping (such as ``copy()``) will produce regular builtin ``dict``
instances, rather than write-through proxy instances.

To avoid introducing a circular reference between frame objects and the
write-through proxies, each access to ``frame.f_locals`` returns a *new*
write-through proxy instance.

The ``locals()`` builtin
''''''''''''''''''''''''

``locals()`` will be defined as::

    def locals():
        frame = sys._getframe(1)
        f_locals = frame.f_locals
        if frame._is_optimized(): # Not an actual frame method
            f_locals = dict(f_locals)
        return f_locals

For module and class scopes (including ``exec()`` and ``eval()``
invocations), ``locals()`` continues to return a direct
reference to the local variable namespace used in code execution
(which is also the same value reported by ``frame.f_locals``).

In :term:`optimized scopes <py3.13:optimized scope>`,
each call to ``locals()`` will produce an *independent*
snapshot of the local variables.

The ``eval()`` and ``exec()`` builtins
''''''''''''''''''''''''''''''''''''''

Because this PEP changes the behavior of ``locals()``, the
behavior of ``eval()`` and ``exec()`` also changes.

Assuming a function ``_eval()`` which performs the job of
``eval()`` with explicit namespace arguments, ``eval()``
can be defined as follows::

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

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

The specified argument handling for ``exec()`` is similarly updated.

(In Python 3.12 and earlier, it was not possible to provide ``locals``
to ``eval()`` or ``exec()`` without also providing ``globals`` as these
were previously positional-only arguments. Independently of this
PEP, Python 3.13 updated these builtins to accept keyword arguments)

C API
-----

Additions to the ``PyEval`` C API
'''''''''''''''''''''''''''''''''

Three new C-API functions will be added::

    PyObject *PyEval_GetFrameLocals(void)
    PyObject *PyEval_GetFrameGlobals(void)
    PyObject *PyEval_GetFrameBuiltins(void)

``PyEval_GetFrameLocals()`` is equivalent to: ``locals()``.
``PyEval_GetFrameGlobals()`` is equivalent to: ``globals()``.

All of these functions will return a new reference.

``PyFrame_GetLocals`` C API
'''''''''''''''''''''''''''

The existing ``PyFrame_GetLocals(f)`` C API is equivalent to ``f.f_locals``.
Its return value will be as described above for accessing ``f.f_locals``.

This function returns a new reference, so it is able to accommodate the
creation of a new write-through proxy instance on each call in an
optimized scope.

Deprecated C APIs
'''''''''''''''''

The following C API functions will be deprecated, as they return borrowed references::

   PyEval_GetLocals()
   PyEval_GetGlobals()
   PyEval_GetBuiltins()

The following functions (which return new references) should be used instead::

   PyEval_GetFrameLocals()
   PyEval_GetFrameGlobals()
   PyEval_GetFrameBuiltins()

The following C API functions will become no-ops, and will be deprecated without
replacement::

    PyFrame_FastToLocalsWithError()
    PyFrame_FastToLocals()
    PyFrame_LocalsToFast()

All of the deprecated functions will be marked as deprecated in the Python 3.13 documentation.

Of these functions, only ``PyEval_GetLocals()`` poses any significant maintenance burden.
Accordingly, calls to ``PyEval_GetLocals()`` will emit ``DeprecationWarning`` in Python
3.14, with a target removal date of Python 3.16 (two releases after Python 3.14).
Alternatives are recommended as described in :ref:`pep-667-pyeval-getlocals-compatibility`.

Summary of Changes
==================

This section summarises how the specified behaviour in Python 3.13 and later
differs from the historical behaviour in Python 3.12 and earlier versions.

Python API changes
------------------

``frame.f_locals`` changes
''''''''''''''''''''''''''

Consider the following example::

    def l():
        "Get the locals of caller"
        return sys._getframe(1).f_locals

    def test():
        if 0: y = 1 # Make 'y' a local variable
        x = 1
        l()['x'] = 2
        l()['y'] = 4
        l()['z'] = 5
        y
        print(locals(), x)

Given the changes in this PEP,
``test()`` will print ``{'x': 2, 'y': 4, 'z': 5} 2``.

In Python 3.12, this example will fail with an ``UnboundLocalError``,
as the definition of ``y`` by ``l()['y'] = 4`` is lost.

If the second-to-last line were changed from ``y`` to ``z``, this will still
raise ``NameError``, as it does in Python 3.12.
Keys added to ``frame.f_locals`` that are not lexically local variables
remain visible in ``frame.f_locals``,
but do not dynamically become local variables.

.. _pep-667-locals-changes:

``locals()`` changes
''''''''''''''''''''

Consider the following example::

    def f():
        exec("x = 1")
        print(locals().get("x"))
    f()

Given the changes in this PEP, this will *always* print ``None``
(regardless of whether ``x`` is a defined local variable in the function),
as the explicit call to ``locals()`` produces a distinct snapshot from
the one implicitly used in the ``exec()`` call.

In Python 3.12, the exact example shown would print ``1``, but seemingly
unrelated changes to the definition of the function involved could make
it print ``None`` instead (:ref:`pep-558-exec-eval-impact` in PEP 558
goes into more detail on that topic).

``eval()`` and ``exec()`` changes
'''''''''''''''''''''''''''''''''

The primary change affecting ``eval()`` and ``exec()`` is shown
in the ":ref:`pep-667-locals-changes`" example: repeatedly
accessing ``locals()`` in an optimized scope will no longer
implicitly share a common underlying namespace.

C API changes
-------------

``PyFrame_GetLocals`` change
''''''''''''''''''''''''''''

``PyFrame_GetLocals`` can already return arbitrary mappings in Python 3.12,
as ``exec()`` and ``eval()`` accept arbitrary mappings as their ``locals`` argument,
and metaclasses may return arbitrary mappings from their ``__prepare__`` methods.

Returning a frame locals proxy in optimized scopes just adds another case where
something other than a builtin dictionary will be returned.

``PyEval_GetLocals`` change
'''''''''''''''''''''''''''

The semantics of ``PyEval_GetLocals()`` are technically unchanged, but they do change in
practice as the dictionary cached on optimized frames is no longer shared with other
mechanisms for accessing the frame locals (``locals()`` builtin, ``PyFrame_GetLocals``
function, frame ``f_locals`` attributes).

Backwards Compatibility
=======================

Python API compatibility
------------------------

The implementation used in versions up to and including Python 3.12 has many
corner cases and oddities. Code that works around those may need to be changed.
Code that uses ``locals()`` for simple templating, or print debugging,
will continue to work correctly. Debuggers and other tools that use
``f_locals`` to modify local variables, will now work correctly,
even in the presence of threaded code, coroutines and generators.

``frame.f_locals`` compatibility
--------------------------------

Although ``f.f_locals`` behaves as if it were the namespace of the function,
there will be some observable differences.
For example, ``f.f_locals is f.f_locals`` will be ``False`` for optimized
frames, as each access to the attribute produces a new write-through proxy
instance.

However ``f.f_locals == f.f_locals`` will be ``True``, and
all changes to the underlying variables, by any means, including the
addition of new variable names as mapping keys, will always be visible.

``locals()`` compatibility
''''''''''''''''''''''''''

``locals() is locals()`` will be ``False`` for optimized frames, so
code like the following will raise ``KeyError`` instead of returning
``1``::

    def f():
        locals()["x"] = 1
        return locals()["x"]

To continue working, such code will need to explicitly store the namespace
to be modified in a local variable, rather than relying on the previous
implicit caching on the frame object::

    def f():
        ns = {}
        ns["x"] = 1
        return ns["x"]

While this technically isn't a formal backwards compatibility break
(since the behaviour of writing back to ``locals()`` was explicitly
documented as undefined), there is definitely some code that relies
on the existing behaviour. Accordingly, the updated behaviour will
be explicitly noted in the documentation as a change and it will be
covered in the Python 3.13 porting guide.

To work with a copy of ``locals()`` in optimized scopes on all
versions without making redundant copies on Python 3.13+, users
will need to define a version-dependent helper function that only
makes an explicit copy on Python versions prior to Python 3.13::

    if sys.version_info >= (3, 13):
        def _ensure_func_snapshot(d):
            return d # 3.13+ locals() already returns a snapshot
    else:
        def _ensure_func_snapshot(d):
            return dict(d) # Create snapshot on older versions

    def f():
        ns = _ensure_func_snapshot(locals())
        ns["x"] = 1
        return ns

In other scopes, ``locals().copy()`` can continue to be called
unconditionally without introducing any redundant copies.

Impact on ``exec()`` and ``eval()``
'''''''''''''''''''''''''''''''''''

Even though this PEP does not modify ``exec()`` or ``eval()`` directly,
the semantic change to ``locals()`` impacts the behavior of ``exec()``
and ``eval()`` as they default to running code in the calling namespace.

This poses a potential compatibility issue for some code, as with the
previous implementation that returns the same dict when ``locals()`` is called
multiple times in function scope, the following code usually worked due to
the implicitly shared local variable namespace::

    def f():
        exec('a = 0')  # equivalent to exec('a = 0', globals(), locals())
        exec('print(a)')  # equivalent to exec('print(a)', globals(), locals())
        print(locals())  # {'a': 0}
        # However, print(a) will not work here
    f()

With the semantic changes to ``locals()`` in this PEP, the ``exec('print(a)')'`` call
will fail with ``NameError``, and ``print(locals())`` will report an empty dictionary, as
each line will be using its own distinct snapshot of the local variables rather than
implicitly sharing a single cached snapshot stored on the frame object.

A shared namespace across ``exec()`` calls can still be obtained by using explicit
namespaces rather than relying on the previously implicitly shared frame namespace::

    def f():
        ns = {}
        exec('a = 0', locals=ns)
        exec('print(a)', locals=ns)  # 0
    f()

You can even reliably change the variables in the local scope by explicitly using
``frame.f_locals``, which was not possible before (even using ``ctypes`` to
invoke ``PyFrame_LocalsToFast`` was subject to the state inconsistency problems
discussed elsewhere in this PEP)::

    def f():
        a = None
        exec('a = 0', locals=sys._getframe().f_locals)
        print(a)  # 0
    f()

The behavior of ``exec()`` and ``eval()`` for module and class scopes (including
nested invocations) is not changed, as the behaviour of ``locals()`` in those
scopes is not changing.

Impact on other code execution APIs in the standard library
'''''''''''''''''''''''''''''''''''''''''''''''''''''''''''

``pdb`` and ``bdb`` use the ``frame.f_locals`` API, and hence will be able to
reliably update local variables even in optimized frames. Implementing this
PEP will resolve several longstanding bugs in these modules relating to threads,
generators, coroutines, and other mechanisms that allow concurrent code execution
while the debugger is active.

Other code execution APIs in the standard library (such as the ``code`` module)
do not implicitly access ``locals()`` *or* ``frame.f_locals``, but the behaviour
of explicitly passing these namespaces will change as described in the rest of
this PEP (passing ``locals()`` in optimized scopes will no longer implicitly
share the code execution namespace across calls, passing ``frame.f_locals``
in optimized scopes will allow reliable modification of local variables and
nonlocal cell references).

C API compatibility
-------------------

.. _pep-667-pyeval-getlocals-compatibility:

``PyEval_GetLocals`` compatibility
''''''''''''''''''''''''''''''''''

``PyEval_GetLocals()`` has never historically distinguished between whether it was
emulating ``locals()`` or ``sys._getframe().f_locals`` at the Python level, as they all
returned references to the same shared cache of the local variable bindings.

With this PEP, ``locals()`` changes to return independent snapshots on each call for
optimized frames, and ``frame.f_locals`` (along with ``PyFrame_GetLocals``) changes to
return new write-through proxy instances.

Because ``PyEval_GetLocals()`` returns a borrowed reference, it isn't possible to update
its semantics to align with either of those alternatives, leaving it as the only remaining
API that requires a shared cache dictionary stored on the frame object.

While this technically leaves the semantics of the function unchanged, it no longer allows
extra dict entries to be made visible to users of the other APIs, as those APIs are no longer
accessing the same underlying cache dictionary.

When ``PyEval_GetLocals()`` is being used as an equivalent to the Python ``locals()``
builtin, ``PyEval_GetFrameLocals()`` should be used instead.

This code::

    locals = PyEval_GetLocals();
    if (locals == NULL) {
        goto error_handler;
    }
    Py_INCREF(locals);

should be replaced with::

    // Equivalent to "locals()" in Python code
    locals = PyEval_GetFrameLocals();
    if (locals == NULL) {
        goto error_handler;
    }

When ``PyEval_GetLocals()`` is being used as an equivalent to calling
``sys._getframe().f_locals`` in Python, it should be replaced by calling
``PyFrame_GetLocals()`` on the result of ``PyEval_GetFrame()``.

In these cases, the original code should be replaced with::

    // Equivalent to "sys._getframe()" in Python code
    frame = PyEval_GetFrame();
    if (frame == NULL) {
        goto error_handler;
    }
    // Equivalent to "frame.f_locals" in Python code
    locals = PyFrame_GetLocals(frame);
    frame = NULL; // Minimise visibility of borrowed reference
    if (locals == NULL) {
        goto error_handler;
    }

Impact on PEP 709 inlined comprehensions
----------------------------------------

For inlined comprehensions within a function, ``locals()`` currently behaves the
same inside or outside of the comprehension, and this will not change. The
behavior of ``locals()`` inside functions will generally change as specified in
the rest of this PEP.

For inlined comprehensions at module or class scope, calling ``locals()`` within
the inlined comprehension returns a new dictionary for each call. This PEP will
make ``locals()`` within a function also always return a new dictionary for each
call, improving consistency; class or module scope inlined comprehensions will
appear to behave as if the inlined comprehension is still a distinct function.

Implementation
==============

Each read of ``frame.f_locals`` will create a new proxy object that gives
the appearance of being the mapping of local (including cell and free)
variable names to the values of those local variables.

A possible implementation is sketched out below.
All attributes that start with an underscore are invisible and
cannot be accessed directly.
They serve only to illustrate the proposed design.

::

    NULL: Object # NULL is a singleton representing the absence of a value.

    class CodeType:

        _name_to_offset_mapping_impl: dict | NULL
        _cells: frozenset # Set of indexes of cell and free variables
        ...

        def __init__(self, ...):
            self._name_to_offset_mapping_impl = NULL
            self._variable_names = deduplicate(
                self.co_varnames + self.co_cellvars + self.co_freevars
            )
            ...

        @property
        def _name_to_offset_mapping(self):
            "Mapping of names to offsets in local variable array."
            if self._name_to_offset_mapping_impl is NULL:
                self._name_to_offset_mapping_impl = {
                    name: index for (index, name) in enumerate(self._variable_names)
                }
            return self._name_to_offset_mapping_impl

    class FrameType:

        _locals : array[Object] # The values of the local variables, items may be NULL.
        _extra_locals: dict | NULL # Dictionary for storing extra locals not in _locals.
        _locals_cache: FrameLocalsProxy | NULL # required to support PyEval_GetLocals()

        def __init__(self, ...):
            self._extra_locals = NULL
            self._locals_cache = NULL
            ...

        @property
        def f_locals(self):
            return FrameLocalsProxy(self)

    class FrameLocalsProxy:
        "Implements collections.MutableMapping."

        __slots__ = ("_frame", )

        def __init__(self, frame:FrameType):
            self._frame = frame

        def __getitem__(self, name):
            f = self._frame
            co = f.f_code
            if name in co._name_to_offset_mapping:
                index = co._name_to_offset_mapping[name]
                val = f._locals[index]
                if val is NULL:
                    raise KeyError(name)
                if index in co._cells
                    val = val.cell_contents
                    if val is NULL:
                        raise KeyError(name)
                return val
            else:
                if f._extra_locals is NULL:
                    raise KeyError(name)
                return f._extra_locals[name]

        def __setitem__(self, name, value):
            f = self._frame
            co = f.f_code
            if name in co._name_to_offset_mapping:
                index = co._name_to_offset_mapping[name]
                kind = co._local_kinds[index]
                if index in co._cells
                    cell = f._locals[index]
                    cell.cell_contents = val
                else:
                    f._locals[index] = val
            else:
                if f._extra_locals is NULL:
                    f._extra_locals = {}
                f._extra_locals[name] = val

        def __iter__(self):
            f = self._frame
            co = f.f_code
            yield from iter(f._extra_locals)
            for index, name in enumerate(co._variable_names):
                val = f._locals[index]
                if val is NULL:
                    continue
                if index in co._cells:
                    val = val.cell_contents
                    if val is NULL:
                        continue
                yield name

        def __contains__(self, item):
            f = self._frame
            if item in f._extra_locals:
                return True
            return item in co._variable_names

        def __len__(self):
            f = self._frame
            co = f.f_code
            res = 0
            for index, _ in enumerate(co._variable_names):
                val = f._locals[index]
                if val is NULL:
                    continue
                if index in co._cells:
                    if val.cell_contents is NULL:
                        continue
                res += 1
            return len(self._extra_locals) + res

C API
-----

``PyEval_GetLocals()`` will be implemented roughly as follows::

    PyObject *PyEval_GetLocals(void) {
        PyFrameObject * = ...; // Get the current frame.
        if (frame->_locals_cache == NULL) {
            frame->_locals_cache = PyEval_GetFrameLocals();
        } else {
            PyDict_Update(frame->_locals_cache, PyFrame_GetLocals(frame));
        }
        return frame->_locals_cache;
    }

As with all functions that return a borrowed reference, care must be taken to
ensure that the reference is not used beyond the lifetime of the object.

Implementation Notes
====================

When accepted, the PEP text suggested that ``PyEval_GetLocals`` would start returning a
cached instance of the new write-through proxy, while the implementation sketch indicated
it would continue to return a dictionary snapshot cached on the frame instance. This
discrepancy was identified while implementing the PEP, and
`resolved by the Steering Council <https://github.com/python/steering-council/issues/245#issuecomment-2179005461>`__
in favour of retaining the Python 3.12 behaviour of returning a dictionary snapshot
cached on the frame instance.
The PEP text has been updated accordingly.

During the discussions of the C API clarification, it also became apparent that the
rationale behind ``locals()`` being updated to return independent snapshots in
:term:`optimized scopes <py3.13:optimized scope>` wasn't clear, as it had been inherited
from the original :pep:`558` discussions rather than being independently covered in this
PEP. The PEP text has been updated to better cover this change, with additional updates
to the Specification and Backwards Compatibility sections to cover the impact on code
execution APIs that default to executing code in the ``locals()`` namespace. Additional
motivation and rationale details have also been added to :pep:`558`.

In 3.13.0, the write-through proxies did not allow deletion of even extra variables
with ``del`` and ``pop()``. This was subsequently reported as a
`compatibility regression <https://github.com/python/cpython/issues/125590>`__,
and `resolved <https://github.com/python/cpython/pull/125616>`__ as now described
in :ref:`pep-667-f_locals-spec`.

Comparison with PEP 558
=======================

This PEP and :pep:`558` shared a common goal:
to make the semantics of  ``locals()`` and ``frame.f_locals()``
intelligible, and their operation reliable.

The key difference between this PEP and PEP 558 is that
PEP 558 attempted to store extra variables inside a full
internal dictionary copy of the local variables in an effort
to improve backwards compatibility with the legacy
``PyEval_GetLocals()`` API, whereas this PEP does not (it stores
the extra local variables in a dedicated dictionary accessed
solely via the new frame proxy objects, and copies them to the
``PyEval_GetLocals()`` shared dict only when requested).

PEP 558 did not specify exactly when that internal copy was
updated, making the behavior of PEP 558 impossible to reason
about in several cases where this PEP remains well specified.

PEP 558 also proposed the introduction of some additional Python
scope introspection interfaces to the C API that would allow
extension modules to more easily determine whether the currently
active Python scope is optimized or not, and hence whether
the C API's ``locals()`` equivalent returns a direct reference
to the frame's local execution namespace or a shallow copy of
the frame's local variables and nonlocal cell references.
Whether or not to add such introspection APIs is independent
of the proposed changes to ``locals()`` and ``frame.f_locals``
and hence no such proposals have been included in this PEP.

PEP 558 was
:pep:`ultimately withdrawn <558#pep-withdrawal>`
in favour of this PEP.

Reference Implementation
========================

The implementation is in development as a `draft pull request on GitHub
<https://github.com/python/cpython/pull/115153>`__.

Copyright
=========

This document is placed in the public domain or under the
CC0-1.0-Universal license, whichever is more permissive.
