From 608356e315b895024527023aba2cf7c414ff16b9 Mon Sep 17 00:00:00 2001 From: Guilherme Leobas Date: Thu, 20 Aug 2026 19:00:53 +0000 Subject: [PATCH 1/2] 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/2] 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; }