Reputation: 7098
Given a C Python frame pointer, how do I look at arbitrary evaluation stack entries? (Some specific stack entries can be found via locals()
, I'm talking about other stack entries.)
I asked a broader question like this a while ago:
getting the C python exec argument string or accessing the evaluation stack
but here I want to focus on being able to read CPython stack entries at runtime.
I'll take a solution that works on CPython 2.7 or any Python later than Python 3.3. However if you have things that work outside of that, share that and, if there is no better solution I'll accept that.
I'd prefer not modifying the C Python code. In Ruby, I have in fact done this to get what I want. I can speak from experience that this is probably not the way we want to work. But again, if there's no better solution, I'll take that. (My understanding wrt to SO points is that I lose it in the bounty either way. So I'm happy go see it go to the person who has shown the most good spirit and willingness to look at this, assuming it works.)
update: See the comment by user2357112 tldr; Basically this is hard-to-impossible to do. (Still, if you think you have the gumption to try, by all means do so.)
So instead, let me narrow the scope to this simpler problem which I think is doable:
Given a python stack frame, like inspect.currentframe()
, find the beginning of the evaluation stack. In the C version of the structure, this is f_valuestack
. From that we then need a way in Python to read off the Python values/objects from there.
update 2 well the time period for a bounty is over and no one (including my own summary answer) has offered concrete code. I feel this is a good start though and I now understand the situation much more than I had. In the obligatory "describe why you think there should be a bounty" I had listed one of the proffered choices "to draw more attention to this problem" and to that extent where there had been something less than a dozen views of the prior incarnation of the problem, as I type this it has been viewed a little under 190 times. So this is a success. However...
If someone in the future decides to carry this further, contact me and I'll set up another bounty.
Thanks all.
Upvotes: 12
Views: 2217
Reputation: 963
I think this is possible now because CPython introduced a stack frame evaluation API in PEP523. PyTorch's Dynamo compiler uses this API to rewrite code objects with torch-specific operators.
You can use CPython's API to install a hook and observe frames right before VM execution. You can suspend Python's main thread by acquiring and releasing the GIL. While the interpreter's frame itself is immutable, you can rewrite its contents (the execution unit/Code object). Also, make sure to handle ref counting of objects that's owned by the Python's runtime to avoid leaks or crashes.
#include <Python.h>
#include <stdio.h>
#include "internal/pycore_frame.h"
static PyObject* my_frame_eval(PyThreadState* tstate, struct _PyInterpreterFrame* frame, int flag) {
PyCodeObject* code = frame->f_code;
if (code) {
printf("Executing frame: %s", PyUnicode_AsUTF8(code->co_name));
}
return _PyEval_EvalFrameDefault(tstate, frame, flag);
}
static PyObject* install_hook_py(PyObject* self, PyObject* args) {
PyThreadState* tstate = PyThreadState_Get();
_PyInterpreterState_SetEvalFrameFunc(tstate->interp, my_frame_eval);
Py_RETURN_NONE;
}
static PyMethodDef _methods[] = {
{"install_hook", install_hook_py, METH_NOARGS, NULL}
};
static struct PyModuleDef evalstack = {
PyModuleDef_HEAD_INIT,
"evalstack",
NULL,
-1,
_methods
};
PyMODINIT_FUNC PyInit_evalstack(void) {
return PyModule_Create(&evalstack);
}
on macOS compile and link with python's version specific headers
clang -shared -o evalstack.so evalstack.c $(python3.12-config --includes) /opt/homebrew/opt/[email protected]/Frameworks/Python.framework/Versions/3.12/Python
import evalstack
evalstack.install_hook()
def foo(): print("hello from python world")
foo()
Upvotes: 1
Reputation: 7098
Note added later: See crusaderky's get_stack.py which might be worked into a solution here.
Here are two potential solution partial solutions, since this problem has no simple obvious answer, short of:
Thanks to user2357112 for enlightenment on the difficulty of the problem, and for descriptions of:
Now to potential solutions...
The first solution is to write a C extension to access f_valuestack
which is the bottom (not top) of a frame. From that you can access values, and that too would have to go in the C extension. The main problem here, since this is the stack bottom, is to understand which entry is the top or one you are interested in. The code records the maximum stack depth in the function.
The C extension would wrap the PyFrameObject so it can get access to the unexposed field f_valuestack
. Although the PyFrameObject can change from Python version to Python version (so the extension might have to check which python version is running), it still is doable.
From that use an Abstract Virtual Machine to figure out which entry position you'd be at for a given offset stored in last_i
.
Something similar for my purposes would for my purposes would be to use a real but alternative VM, like Ned Batchhelder's byterun. It runs a Python bytecode interpreter in Python.
Note added later: I have made some largish revisions in order to support Python 2.5 .. 3.7 or so and this is now called x-python
The advantage here would be that since this acts as a second VM so stores don't change the running of the current and real CPython VM. However you'd still need to deal with the fact of interacting with external persistent state (e.g. calls across sockets or changes to files). And byterun would need to be extended to cover all opcodes and Python versions that potentially might be needed.
By the way, for multi-version access to bytecode in a uniform way (since not only does bytecode change a bit, but also the collections of routines to access it), see xdis.
So although this isn't a general solution it could probably work for the special case of trying to figure out the value of say an EXEC
up which appear briefly on the evaluation stack.
Upvotes: 2
Reputation: 11
I wrote some code to do this. It seems to work so I'll add it to this question.
How it does it is by disassembling the instructions, and using dis.stack_effect
to get the effect of each instruction on stack depth. If there's a jump, it sets the stack level at the jump target.
I think stack level is deterministic, i.e. it is always the same at any given bytecode instruction in a piece of code no matter how it was reached. So you can get stack depth at a particular bytecode by looking at the bytecode disassembly directly.
There's a slight catch which is that if you are in an active call, the code position is shown as last instruction being the call, but the stack state is actually that before the call. This is good because it means you can recreate the call arguments from the stack, but you need to be aware that if the instruction is a call that is ongoing, the stack will be at the level of the previous instruction.
Here's the code from my resumable exception thing that does this:
cdef get_stack_pos_after(object code,int target,logger):
stack_levels={}
jump_levels={}
cur_stack=0
for i in dis.get_instructions(code):
offset=i.offset
argval=i.argval
arg=i.arg
opcode=i.opcode
if offset in jump_levels:
cur_stack=jump_levels[offset]
no_jump=dis.stack_effect(opcode,arg,jump=False)
if opcode in dis.hasjabs or opcode in dis.hasjrel:
# a jump - mark the stack level at jump target
yes_jump=dis.stack_effect(opcode,arg,jump=True)
if not argval in jump_levels:
jump_levels[argval]=cur_stack+yes_jump
cur_stack+=no_jump
stack_levels[offset]=cur_stack
logger(offset,i.opname,argval,cur_stack)
return stack_levels[target]
https://github.com/joemarshall/unthrow
Upvotes: 1
Reputation: 281748
This is sometimes possible, with ctypes for direct C struct member access, but it gets messy fast.
First off, there's no public API for this, on the C side or the Python side, so that's out. We'll have to dig into the undocumented insides of the C implementation. I'll be focusing on the CPython 3.8 implementation; the details should be similar, though likely different, in other versions.
A PyFrameObject struct has an f_valuestack
member that points to the bottom of its evaluation stack. It also has an f_stacktop
member that points to the top of its evaluation stack... sometimes. During execution of a frame, Python actually keeps track of the top of the stack using a stack_pointer
local variable in _PyEval_EvalFrameDefault
:
stack_pointer = f->f_stacktop;
assert(stack_pointer != NULL);
f->f_stacktop = NULL; /* remains NULL unless yield suspends frame */
There are two cases in which f_stacktop
is restored. One is if the frame is suspended by a yield
(or yield from
, or any of the multiple constructs that suspend coroutines through the same mechanism). The other is right before calling a trace function for a 'line'
or 'opcode'
trace event. f_stacktop
is cleared again when the frame unsuspends, or after the trace function finishes.
That means that if
'line'
or 'opcode'
event for a framethen you can access the f_valuestack
and f_stacktop
pointers with ctypes to find the lower and upper bounds of the frame's evaluation stack and access the PyObject *
pointers stored in that range. You can even get a superset of the stack contents without ctypes with gc.get_referents(frame_object)
, although this will contain other referents that aren't on the frame's stack.
Debuggers use trace functions, so this gets you value stack entries for the top stack frame while debugging, most of the time. It does not get you value stack entries for any other stack frames on the call stack, and it doesn't get you value stack entries while tracing an 'exception'
event or any other trace events.
When f_stacktop
is NULL, determining the frame's stack contents is close to impossible. You can still see where the stack begins with f_valuestack
, but you can't see where it ends. The stack top is stored in a C-level stack_pointer
local variable that's really hard to access.
co_stacksize
, which gives an upper bound on the stack size, but it doesn't give the actual stack size.gc.get_referents
doesn't return value stack entries when f_stacktop
is null. It doesn't know how to retrieve stack entries safely in this case either (and it doesn't need to, because if f_stacktop
is null and stack entries exist, the frame is guaranteed reachable).f_lasti
to determine the last bytecode instruction it was on and try to figure out where that instruction would leave the stack, but that would take a lot of intimate knowledge of Python bytecode and the bytecode evaluation loop, and it's still ambiguous sometimes (because the frame might be halfway through an instruction). This would at least give you a lower bound on the current stack size, though, letting you safely inspect at least some of it.stack_pointer
local variable with some GDB magic or something, but it'd be a mess.Upvotes: 14
Reputation: 2485
I've tried to do this in this package. As others point out, the main difficulty is in determining the top of the Python stack. I try to do this with some heuristics, which I've documented here.
The overall idea is that by the time my snapshotting function is called, the stack consists of the locals (as you point out), the iterators of nested for loops, and any exception triplets currently being handled. There's enough information in Python 3.6 & 3.7 to recover these states and therefore the stacktop.
I also relied on a tip from user2357112 to pave a way to making this work in Python 3.8.
Upvotes: 0