From 608356e315b895024527023aba2cf7c414ff16b9 Mon Sep 17 00:00:00 2001 From: Guilherme Leobas Date: Thu, 20 Aug 2026 19:00:53 +0000 Subject: [PATCH 1/4] Add PyUnstable_InterpreterFrame_GetLocal Add an unstable C API that returns a strong reference to a single local variable of an internal interpreter frame, addressed by its localsplus index, with cell and free variables unboxed to their contents. Free variables are resolved from the function closure, so the API also works on a frame that has not started executing (before COPY_FREE_VARS runs) -- the case that motivated it -- and it does not modify the frame. Includes the PEP 689 deliverables: reference documentation in Doc/c-api/frame.rst, a What's New entry for 3.16, a Misc/NEWS.d blurb, and tests in Lib/test/test_capi/test_misc.py (TestInternalFrameApi) covering plain locals, a cell variable, and a free variable. Authored with the assistance of an AI coding agent (Claude Opus) --- Doc/c-api/frame.rst | 14 +++++++ Doc/whatsnew/3.16.rst | 4 +- Include/cpython/pyframe.h | 5 +++ Lib/test/test_capi/test_misc.py | 36 +++++++++++++++++ ...-08-20-12-00-00.gh-issue-156133.GetLoc.rst | 2 + Modules/_testinternalcapi.c | 39 +++++++++++++++++++ Objects/frameobject.c | 36 +++++++++++++++++ 7 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/C_API/2026-08-20-12-00-00.gh-issue-156133.GetLoc.rst diff --git a/Doc/c-api/frame.rst b/Doc/c-api/frame.rst index 4159ff6e5965fbd..7b524b8f0510b55 100644 --- a/Doc/c-api/frame.rst +++ b/Doc/c-api/frame.rst @@ -243,3 +243,17 @@ 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:: PyObject* PyUnstable_InterpreterFrame_GetLocal(struct _PyInterpreterFrame *frame, Py_ssize_t index) + + Return a new :term:`strong reference` to the local variable at *index* in the + frame's localsplus array, with cell and free variables unboxed to their + contents. Free variables are resolved from the function closure, so this + also works on a frame that has not started executing. + + *index* must be in range ``[0, co_nlocalsplus)``. Return ``NULL`` with an + :exc:`IndexError` set if it is out of range, or ``NULL`` without an exception + set if the slot is unset or hidden. + + .. versionadded:: 3.16 diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 063755e1eadcb53..5d645be8470b103 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -896,7 +896,9 @@ C API changes New features ------------ -* TODO +* Add :c:func:`PyUnstable_InterpreterFrame_GetLocal` to read a local variable + of an internal interpreter frame by its localsplus index. + (Contributed by Guilherme Leobas in :gh:`156133`.) Porting to Python 3.16 ---------------------- diff --git a/Include/cpython/pyframe.h b/Include/cpython/pyframe.h index 24a947de1ede1e1..a8d187862213165 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(PyObject *) PyUnstable_InterpreterFrame_GetLocal( + struct _PyInterpreterFrame *frame, Py_ssize_t index); diff --git a/Lib/test/test_capi/test_misc.py b/Lib/test/test_capi/test_misc.py index 7d668843d07debc..691a6c25125b2ff 100644 --- a/Lib/test/test_capi/test_misc.py +++ b/Lib/test/test_capi/test_misc.py @@ -2800,6 +2800,42 @@ 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) + self.assertEqual(d['b'], 4) + self.assertEqual(d['c'], 7) + self.assertIs(d['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) + 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 000000000000000..4a02830d97ecb06 --- /dev/null +++ b/Misc/NEWS.d/next/C_API/2026-08-20-12-00-00.gh-issue-156133.GetLoc.rst @@ -0,0 +1,2 @@ +Add :c:func:`PyUnstable_InterpreterFrame_GetLocal` to read a local variable of +an internal interpreter frame by its localsplus index. diff --git a/Modules/_testinternalcapi.c b/Modules/_testinternalcapi.c index e9950bb232431c6..97c45b8b544e1e3 100644 --- a/Modules/_testinternalcapi.c +++ b/Modules/_testinternalcapi.c @@ -1514,6 +1514,44 @@ 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_GetLocals 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); + if (frame == NULL) { + PyErr_SetString(PyExc_RuntimeError, "no caller frame"); + return NULL; + } + PyCodeObject *co = _PyFrame_GetCode(frame); + Py_ssize_t n = co->co_nlocalsplus; + PyObject *dict = PyDict_New(); + if (dict == NULL) { + return NULL; + } + for (Py_ssize_t i = 0; i < n; i++) { + PyObject *value = PyUnstable_InterpreterFrame_GetLocal(frame, i); + if (value == NULL) { + if (PyErr_Occurred()) { + Py_DECREF(dict); + return NULL; + } + continue; // unset or hidden slot + } + PyObject *name = PyTuple_GET_ITEM(co->co_localsplusnames, i); + int err = PyDict_SetItem(dict, name, value); + Py_DECREF(value); + if (err < 0) { + Py_DECREF(dict); + return NULL; + } + } + return dict; +} + static PyObject * code_returns_only_none(PyObject *self, PyObject *arg) { @@ -3305,6 +3343,7 @@ 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}, {"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 5889cdaf2aa1652..96502be5eb4f2ed 100644 --- a/Objects/frameobject.c +++ b/Objects/frameobject.c @@ -2247,6 +2247,42 @@ frame_get_var(_PyInterpreterFrame *frame, PyCodeObject *co, int i, } +PyObject * +PyUnstable_InterpreterFrame_GetLocal(_PyInterpreterFrame *frame, + Py_ssize_t index) +{ + PyCodeObject *co = _PyFrame_GetCode(frame); + 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 NULL; + } + + 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 NULL; + } + PyObject *value = NULL; + frame_get_var(frame, co, (int)index, &value); + return value; // strong reference, or NULL if unset + } + + // 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); + return Py_XNewRef(PyCell_GET(cell)); + } + return NULL; +} + + bool _PyFrame_HasHiddenLocals(_PyInterpreterFrame *frame) { From d29acd613eb9eea5ba2f86ffa7e2a9181a6b9921 Mon Sep 17 00:00:00 2001 From: Guilherme Leobas Date: Mon, 24 Aug 2026 13:52:15 +0000 Subject: [PATCH 2/4] Address reviewer comments --- Modules/_testinternalcapi.c | 2 +- Objects/frameobject.c | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Modules/_testinternalcapi.c b/Modules/_testinternalcapi.c index 97c45b8b544e1e3..2127f2b7fd41368 100644 --- a/Modules/_testinternalcapi.c +++ b/Modules/_testinternalcapi.c @@ -1515,7 +1515,7 @@ iframe_getlasti(PyObject *self, PyObject *frame) } // Reads the locals of the Python frame that called this C function using -// PyUnstable_InterpreterFrame_GetLocals and returns them as a name -> value +// 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)) diff --git a/Objects/frameobject.c b/Objects/frameobject.c index 96502be5eb4f2ed..80306e13e94a5d4 100644 --- a/Objects/frameobject.c +++ b/Objects/frameobject.c @@ -2274,10 +2274,11 @@ PyUnstable_InterpreterFrame_GetLocal(_PyInterpreterFrame *frame, // Free variable: read from the function closure rather than localsplus. if ((co->co_flags & CO_OPTIMIZED) - && PyStackRef_FunctionCheck(frame->f_funcobj)) { + && PyStackRef_FunctionCheck(frame->f_funcobj)) + { PyFunctionObject *func = _PyFrame_GetFunction(frame); PyObject *cell = PyTuple_GET_ITEM(func->func_closure, index - offset); - return Py_XNewRef(PyCell_GET(cell)); + return PyCell_GetRef((PyCellObject *)cell); } return NULL; } From f7f33ad6ab765a8e625eea072d399142ef77d121 Mon Sep 17 00:00:00 2001 From: Guilherme Leobas Date: Thu, 17 Sep 2026 21:28:37 -0300 Subject: [PATCH 3/4] use "next" in versionadded --- Doc/c-api/frame.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/c-api/frame.rst b/Doc/c-api/frame.rst index 7b524b8f0510b55..bd087cf584e7572 100644 --- a/Doc/c-api/frame.rst +++ b/Doc/c-api/frame.rst @@ -256,4 +256,4 @@ Unless using :pep:`523`, you will not need this. :exc:`IndexError` set if it is out of range, or ``NULL`` without an exception set if the slot is unset or hidden. - .. versionadded:: 3.16 + .. versionadded:: next From 507520171b187360c8e27e1ae4aafb524058199e Mon Sep 17 00:00:00 2001 From: Guilherme Leobas Date: Thu, 17 Sep 2026 21:37:03 -0300 Subject: [PATCH 4/4] Update `PyUnstable_InterpreterFrame_GetLocal` to follow `PyDict_GetItemRef` convention --- Doc/c-api/frame.rst | 16 ++++++++-------- Include/cpython/pyframe.h | 4 ++-- Modules/_testinternalcapi.c | 13 +++++++------ Objects/frameobject.c | 24 +++++++++++++++++------- 4 files changed, 34 insertions(+), 23 deletions(-) diff --git a/Doc/c-api/frame.rst b/Doc/c-api/frame.rst index bd087cf584e7572..b4136c0b101a5c2 100644 --- a/Doc/c-api/frame.rst +++ b/Doc/c-api/frame.rst @@ -245,15 +245,15 @@ Unless using :pep:`523`, you will not need this. .. versionadded:: 3.12 -.. c:function:: PyObject* PyUnstable_InterpreterFrame_GetLocal(struct _PyInterpreterFrame *frame, Py_ssize_t index) +.. c:function:: int PyUnstable_InterpreterFrame_GetLocal(struct _PyInterpreterFrame *frame, Py_ssize_t index, PyObject **result) - Return a new :term:`strong reference` to the local variable at *index* in the - frame's localsplus array, with cell and free variables unboxed to their - contents. Free variables are resolved from the function closure, so this - also works on a frame that has not started executing. + Retrieve the local variable at *index* in the frame's localsplus array, with + cell and free variables unboxed to their contents. Free variables are + resolved from the function closure, so this also works on a frame that has + not started executing. - *index* must be in range ``[0, co_nlocalsplus)``. Return ``NULL`` with an - :exc:`IndexError` set if it is out of range, or ``NULL`` without an exception - set if the slot is unset or hidden. + * 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/Include/cpython/pyframe.h b/Include/cpython/pyframe.h index a8d187862213165..88f074a9ac8d86c 100644 --- a/Include/cpython/pyframe.h +++ b/Include/cpython/pyframe.h @@ -38,5 +38,5 @@ PyAPI_FUNC(int) PyUnstable_InterpreterFrame_GetLine(struct _PyInterpreterFrame * /* Returns a new (strong) reference to the local variable at `index` in the * frame's localsplus array. */ -PyAPI_FUNC(PyObject *) PyUnstable_InterpreterFrame_GetLocal( - struct _PyInterpreterFrame *frame, Py_ssize_t index); +PyAPI_FUNC(int) PyUnstable_InterpreterFrame_GetLocal( + struct _PyInterpreterFrame *frame, Py_ssize_t index, PyObject **result); diff --git a/Modules/_testinternalcapi.c b/Modules/_testinternalcapi.c index ce63ff606a4ab74..6da011098315b73 100644 --- a/Modules/_testinternalcapi.c +++ b/Modules/_testinternalcapi.c @@ -1533,12 +1533,13 @@ get_frame_locals(PyObject *self, PyObject *Py_UNUSED(ignored)) return NULL; } for (Py_ssize_t i = 0; i < n; i++) { - PyObject *value = PyUnstable_InterpreterFrame_GetLocal(frame, i); - if (value == NULL) { - if (PyErr_Occurred()) { - Py_DECREF(dict); - return NULL; - } + PyObject *value; + int rc = PyUnstable_InterpreterFrame_GetLocal(frame, i, &value); + if (rc < 0) { + Py_DECREF(dict); + return NULL; + } + if (rc == 0) { continue; // unset or hidden slot } PyObject *name = PyTuple_GET_ITEM(co->co_localsplusnames, i); diff --git a/Objects/frameobject.c b/Objects/frameobject.c index 80306e13e94a5d4..7b53baf07941414 100644 --- a/Objects/frameobject.c +++ b/Objects/frameobject.c @@ -2247,17 +2247,18 @@ frame_get_var(_PyInterpreterFrame *frame, PyCodeObject *co, int i, } -PyObject * +int PyUnstable_InterpreterFrame_GetLocal(_PyInterpreterFrame *frame, - Py_ssize_t index) + 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 NULL; + return -1; } int offset = PyUnstable_Code_GetFirstFree(co); // co_nlocalsplus - co_nfreevars @@ -2265,11 +2266,15 @@ PyUnstable_InterpreterFrame_GetLocal(_PyInterpreterFrame *frame, // 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 NULL; + return 0; } PyObject *value = NULL; frame_get_var(frame, co, (int)index, &value); - return value; // strong reference, or NULL if unset + if (value == NULL) { + return 0; + } + *result = value; // strong reference + return 1; } // Free variable: read from the function closure rather than localsplus. @@ -2278,9 +2283,14 @@ PyUnstable_InterpreterFrame_GetLocal(_PyInterpreterFrame *frame, { PyFunctionObject *func = _PyFrame_GetFunction(frame); PyObject *cell = PyTuple_GET_ITEM(func->func_closure, index - offset); - return PyCell_GetRef((PyCellObject *)cell); + PyObject *value = PyCell_GetRef((PyCellObject *)cell); + if (value == NULL) { + return 0; + } + *result = value; + return 1; } - return NULL; + return 0; }