https://github.com/python/cpython/commit/b5bffb9b4ce8f4515df1e0474c8f439e9f580d65
commit: b5bffb9b4ce8f4515df1e0474c8f439e9f580d65
branch: main
author: Guilherme Leobas <[email protected]>
committer: encukou <[email protected]>
date: 2026-11-05T18:03:44+01:00
summary:

gh-156133: Add `PyUnstable_InterpreterFrame_GetLocal` (GH-156134)

Also, add :c:func:`PyUnstable_Code_GetLocalPlusNames` to get the matching 
variable names.

files:
A Misc/NEWS.d/next/C_API/2026-08-20-12-00-00.gh-issue-156133.GetLoc.rst
M Doc/c-api/code.rst
M Doc/c-api/frame.rst
M Doc/whatsnew/3.16.rst
M Include/cpython/code.h
M Include/cpython/pyframe.h
M Lib/test/test_capi/test_misc.py
M Modules/_testinternalcapi.c
M Objects/frameobject.c

diff --git a/Doc/c-api/code.rst b/Doc/c-api/code.rst
index 6f0b7838d6cfed..48d9b83f437ff7 100644
--- a/Doc/c-api/code.rst
+++ b/Doc/c-api/code.rst
@@ -44,6 +44,14 @@ bound into a function.
       The old name is deprecated, but will remain available until the
       signature changes again.
 
+.. c:function:: PyObject* PyUnstable_Code_GetLocalPlusNames(PyCodeObject *co)
+
+   Return a :term:`strong reference` to a tuple of names of the local, cell and
+   free variables of a code object. The names are in the same order as the
+   values returned by :c:func:`PyUnstable_InterpreterFrame_GetLocal`.
+
+   .. versionadded:: next
+
 .. c:function:: PyCodeObject* PyUnstable_Code_New(int argcount, int 
kwonlyargcount, int nlocals, int stacksize, int flags, PyObject *code, PyObject 
*consts, PyObject *names, PyObject *varnames, PyObject *freevars, PyObject 
*cellvars, PyObject *filename, PyObject *name, PyObject *qualname, int 
firstlineno, PyObject *linetable, PyObject *exceptiontable)
 
    Return a new code object.  If you need a dummy code object to create a 
frame,
diff --git a/Doc/c-api/frame.rst b/Doc/c-api/frame.rst
index 9e15804c941ce5..ea684630627355 100644
--- a/Doc/c-api/frame.rst
+++ b/Doc/c-api/frame.rst
@@ -241,3 +241,21 @@ Unless using :pep:`523`, you will not need this.
    Return the currently executing line number, or -1 if there is no line 
number.
 
    .. versionadded:: 3.12
+
+
+.. c:function:: int PyUnstable_InterpreterFrame_GetLocal(struct 
_PyInterpreterFrame *frame, Py_ssize_t index, PyObject **result)
+
+   Retrieve the local variable at *index*.
+   To determine the index of a particular variable, use
+   :c:func:`PyUnstable_InterpreterFrame_GetCode` and
+   :c:func:`PyUnstable_Code_GetLocalPlusNames`.
+
+   Cell and free variables are unboxed to their contents.  Free variables are
+   resolved from the function closure, so this also works on a frame that has
+   not started executing.
+
+   * On success, store a new :term:`strong reference` in *result* and return 
``1``.
+   * If the slot is unset or hidden, store ``NULL`` in *result* and return 
``0``.
+   * On error, raise an exception, store ``NULL`` in *result* and return 
``-1``.
+
+   .. versionadded:: next
diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst
index 514ab2605e3716..ce622d37e3c3f4 100644
--- a/Doc/whatsnew/3.16.rst
+++ b/Doc/whatsnew/3.16.rst
@@ -1394,6 +1394,12 @@ New features
 * Add the :ref:`PyUnicodeWriter API <pyunicodewriter>` to the limited C API.
   (Contributed by Victor Stinner in :gh:`161172`.)
 
+* Add :c:func:`PyUnstable_InterpreterFrame_GetLocal` to read a local variable
+  of an internal interpreter frame by its localsplus index, and
+  :c:func:`PyUnstable_Code_GetLocalPlusNames` to get the matching variable
+  names.
+  (Contributed by Guilherme Leobas in :gh:`156133`.)
+
 Porting to Python 3.16
 ----------------------
 
diff --git a/Include/cpython/code.h b/Include/cpython/code.h
index 5238447070ee54..5ab7e6586ec8a8 100644
--- a/Include/cpython/code.h
+++ b/Include/cpython/code.h
@@ -177,6 +177,11 @@ static inline int 
PyUnstable_Code_GetFirstFree(PyCodeObject *op) {
     return op->co_nlocalsplus - op->co_nfreevars;
 }
 
+static inline PyObject *PyUnstable_Code_GetLocalPlusNames(PyCodeObject *op) {
+    assert(PyCode_Check(op));
+    return Py_NewRef(op->co_localsplusnames);
+}
+
 Py_DEPRECATED(3.13) static inline int PyCode_GetFirstFree(PyCodeObject *op) {
     return PyUnstable_Code_GetFirstFree(op);
 }
diff --git a/Include/cpython/pyframe.h b/Include/cpython/pyframe.h
index 24a947de1ede1e..88f074a9ac8d86 100644
--- a/Include/cpython/pyframe.h
+++ b/Include/cpython/pyframe.h
@@ -35,3 +35,8 @@ PyAPI_FUNC(int) PyUnstable_InterpreterFrame_GetLasti(struct 
_PyInterpreterFrame
 /* Returns the currently executing line number, or -1 if there is no line 
number.
  * Does not raise an exception. */
 PyAPI_FUNC(int) PyUnstable_InterpreterFrame_GetLine(struct _PyInterpreterFrame 
*frame);
+
+/* Returns a new (strong) reference to the local variable at `index` in the
+ * frame's localsplus array. */
+PyAPI_FUNC(int) PyUnstable_InterpreterFrame_GetLocal(
+    struct _PyInterpreterFrame *frame, Py_ssize_t index, PyObject **result);
diff --git a/Lib/test/test_capi/test_misc.py b/Lib/test/test_capi/test_misc.py
index b9c6882444ab76..b8f78144a380da 100644
--- a/Lib/test/test_capi/test_misc.py
+++ b/Lib/test/test_capi/test_misc.py
@@ -2913,6 +2913,80 @@ def test_line(self):
         firstline = self.func.__code__.co_firstlineno
         self.assertEqual(line, firstline + 2)
 
+    # get_frame_locals() returns the caller frame's locals as a name -> value
+    # dict via PyUnstable_InterpreterFrame_GetLocal (one strong reference per
+    # localsplus index).
+    def helper_plain(self, a, b):
+        c = a + b
+        return _testinternalcapi.get_frame_locals()
+
+    def test_get_local_plain(self):
+        d = self.helper_plain(3, 4)
+        self.assertEqual(d, {'a': 3, 'b': 4, 'c': 7, 'self': self})
+
+    def test_get_local_cell(self):
+        # y is a cell variable of this frame because inner closes over it.
+        y = 100
+
+        def inner():
+            return y
+
+        d = _testinternalcapi.get_frame_locals()
+        self.assertEqual(d['y'], 100)
+        self.assertIs(d['inner'], inner)
+
+    def test_get_local_free(self):
+        # z is a free variable of inner, read from the closure.
+        z = 7
+
+        def inner():
+            _ = z
+            return _testinternalcapi.get_frame_locals()
+
+        d = inner()
+        self.assertEqual(d['z'], 7)
+
+    def test_get_local_index_out_of_range(self):
+        def f(index):
+            return _testinternalcapi.get_frame_local(index)
+
+        n = len(_testinternalcapi.code_get_localsplus_names(f.__code__))
+        for index in (-1, n, n + 1):
+            with self.subTest(index=index):
+                with self.assertRaises(IndexError):
+                    f(index)
+
+    def test_get_local_unset(self):
+        def f():
+            if False:
+                unset = 1
+            names = _testinternalcapi.code_get_localsplus_names(f.__code__)
+            return _testinternalcapi.get_frame_local(names.index('unset'))
+
+        self.assertEqual(f(), (0, None))
+
+    def test_get_local_set(self):
+        x = 5
+        code = self.test_get_local_set.__code__
+        names = _testinternalcapi.code_get_localsplus_names(code)
+        index = names.index('x')
+        self.assertEqual(_testinternalcapi.get_frame_local(index), (1, 5))
+
+    def test_code_get_localsplus_names(self):
+        def outer(a, b):
+            c = a
+            def inner():
+                return a, d
+            d = b
+            return inner
+
+        names = _testinternalcapi.code_get_localsplus_names(outer.__code__)
+        self.assertIsInstance(names, tuple)
+        self.assertCountEqual(names, ('a', 'b', 'c', 'd', 'inner'))
+        inner = outer(1, 2)
+        names = _testinternalcapi.code_get_localsplus_names(inner.__code__)
+        self.assertCountEqual(names, ('a', 'd'))
+
 
 SUFFICIENT_TO_DEOPT_AND_SPECIALIZE = 100
 
diff --git 
a/Misc/NEWS.d/next/C_API/2026-08-20-12-00-00.gh-issue-156133.GetLoc.rst 
b/Misc/NEWS.d/next/C_API/2026-08-20-12-00-00.gh-issue-156133.GetLoc.rst
new file mode 100644
index 00000000000000..be780cba1214d3
--- /dev/null
+++ b/Misc/NEWS.d/next/C_API/2026-08-20-12-00-00.gh-issue-156133.GetLoc.rst
@@ -0,0 +1,3 @@
+Add :c:func:`PyUnstable_InterpreterFrame_GetLocal` to read a local variable of
+an internal interpreter frame by its localsplus index, and
+:c:func:`PyUnstable_Code_GetLocalPlusNames` to get the matching variable names.
diff --git a/Modules/_testinternalcapi.c b/Modules/_testinternalcapi.c
index 3d0058fc11c452..3efb70f662698b 100644
--- a/Modules/_testinternalcapi.c
+++ b/Modules/_testinternalcapi.c
@@ -1603,6 +1603,80 @@ iframe_getlasti(PyObject *self, PyObject *frame)
     return PyLong_FromLong(PyUnstable_InterpreterFrame_GetLasti(f));
 }
 
+// Reads the locals of the Python frame that called this C function using
+// PyUnstable_InterpreterFrame_GetLocal and returns them as a name -> value
+// dict, skipping NULL (unset or hidden) slots.
+static PyObject *
+get_frame_locals(PyObject *self, PyObject *Py_UNUSED(ignored))
+{
+    PyThreadState *tstate = _PyThreadState_GET();
+    _PyInterpreterFrame *frame = _PyThreadState_GetFrame(tstate);
+    assert(frame != NULL); // there must be a caller frame
+    PyCodeObject *co = _PyFrame_GetCode(frame);
+    Py_ssize_t n = co->co_nlocalsplus;
+    PyObject *dict = PyDict_New();
+    if (dict == NULL) {
+        return NULL;
+    }
+    PyObject *names = PyUnstable_Code_GetLocalPlusNames(co);
+    for (Py_ssize_t i = 0; i < n; i++) {
+        PyObject *value;
+        int rc = PyUnstable_InterpreterFrame_GetLocal(frame, i, &value);
+        if (rc < 0) {
+            Py_DECREF(names);
+            Py_DECREF(dict);
+            assert(value == NULL);
+            return NULL;
+        }
+        if (rc == 0) {
+            assert(value == NULL);
+            continue;  // unset or hidden slot
+        }
+        PyObject *name = PyTuple_GET_ITEM(names, i);
+        int err = PyDict_SetItem(dict, name, value);
+        Py_DECREF(value);
+        if (err < 0) {
+            Py_DECREF(names);
+            Py_DECREF(dict);
+            return NULL;
+        }
+    }
+    Py_DECREF(names);
+    return dict;
+}
+
+// Calls PyUnstable_InterpreterFrame_GetLocal on the caller's frame for a 
single
+// index and returns (rc, value), or raises if rc is -1.
+static PyObject *
+get_frame_local(PyObject *self, PyObject *arg)
+{
+    Py_ssize_t index = PyLong_AsSsize_t(arg);
+    if (index == -1 && PyErr_Occurred()) {
+        return NULL;
+    }
+    PyThreadState *tstate = _PyThreadState_GET();
+    _PyInterpreterFrame *frame = _PyThreadState_GetFrame(tstate);
+    assert(frame != NULL); // there must be a caller frame
+    PyObject *value;
+    int rc = PyUnstable_InterpreterFrame_GetLocal(frame, index, &value);
+    if (rc < 0) {
+        assert(value == NULL);
+        return NULL;
+    }
+    if (rc == 0) {
+        assert(value == NULL);
+        return Py_BuildValue("iO", rc, Py_None);
+    }
+    return Py_BuildValue("iN", rc, value);
+}
+
+static PyObject *
+code_get_localsplus_names(PyObject *self, PyObject *arg)
+{
+    assert(PyCode_Check(arg));
+    return PyUnstable_Code_GetLocalPlusNames((PyCodeObject *)arg);
+}
+
 static PyObject *
 code_returns_only_none(PyObject *self, PyObject *arg)
 {
@@ -3434,6 +3508,9 @@ static PyMethodDef module_functions[] = {
     {"iframe_getcode", iframe_getcode, METH_O, NULL},
     {"iframe_getline", iframe_getline, METH_O, NULL},
     {"iframe_getlasti", iframe_getlasti, METH_O, NULL},
+    {"get_frame_locals", get_frame_locals, METH_NOARGS, NULL},
+    {"get_frame_local", get_frame_local, METH_O, NULL},
+    {"code_get_localsplus_names", code_get_localsplus_names, METH_O, NULL},
     {"code_returns_only_none", code_returns_only_none, METH_O, NULL},
     {"get_co_framesize", get_co_framesize, METH_O, NULL},
     {"get_co_localskinds", get_co_localskinds, METH_O, NULL},
diff --git a/Objects/frameobject.c b/Objects/frameobject.c
index 61be2beca22d15..475be76dc3da21 100644
--- a/Objects/frameobject.c
+++ b/Objects/frameobject.c
@@ -2327,6 +2327,53 @@ frame_get_var(_PyInterpreterFrame *frame, PyCodeObject 
*co, int i,
 }
 
 
+int
+PyUnstable_InterpreterFrame_GetLocal(_PyInterpreterFrame *frame,
+                                     Py_ssize_t index, PyObject **result)
+{
+    PyCodeObject *co = _PyFrame_GetCode(frame);
+    *result = NULL;
+    if (index < 0 || index >= co->co_nlocalsplus) {
+        PyErr_Format(
+            PyExc_IndexError,
+            "PyUnstable_InterpreterFrame_GetLocal: index %zd out of range [0, 
%d)",
+            index, co->co_nlocalsplus);
+        return -1;
+    }
+
+    int offset = PyUnstable_Code_GetFirstFree(co);  // co_nlocalsplus - 
co_nfreevars
+    if (index < offset) {
+        // Local or cell variable. frame_get_var unboxes cells and copes with
+        // not-yet-started frames and arguments not yet promoted by MAKE_CELL.
+        if (_PyLocals_GetKind(co->co_localspluskinds, (int)index) & 
CO_FAST_HIDDEN) {
+            return 0;
+        }
+        PyObject *value = NULL;
+        frame_get_var(frame, co, (int)index, &value);
+        if (value == NULL) {
+            return 0;
+        }
+        *result = value;  // strong reference
+        return 1;
+    }
+
+    // Free variable: read from the function closure rather than localsplus.
+    if ((co->co_flags & CO_OPTIMIZED)
+         && PyStackRef_FunctionCheck(frame->f_funcobj))
+    {
+        PyFunctionObject *func = _PyFrame_GetFunction(frame);
+        PyObject *cell = PyTuple_GET_ITEM(func->func_closure, index - offset);
+        PyObject *value = PyCell_GetRef((PyCellObject *)cell);
+        if (value == NULL) {
+            return 0;
+        }
+        *result = value;
+        return 1;
+    }
+    return 0;
+}
+
+
 bool
 _PyFrame_HasHiddenLocals(_PyInterpreterFrame *frame)
 {

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to