Skip to content

Commit 4d64d24

Browse files
committed
Add --class-name param to prepend class names to methods
Regular methods and classmethods supported. No support for static methods. This is because the frame object doesn't refer to the function, only its code, which leaves no other option than to try to find `self` or `cls` in locals. The alternative of using the `gc` module is horrendously slow even in the same process and also impractical in a remote process.
1 parent 4fff225 commit 4d64d24

File tree

7 files changed

+299
-17
lines changed

7 files changed

+299
-17
lines changed

src/config.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ pub struct Config {
5656
#[doc(hidden)]
5757
pub lineno: LineNo,
5858
#[doc(hidden)]
59+
pub include_class_name: bool,
60+
#[doc(hidden)]
5961
pub refresh_seconds: f64,
6062
#[doc(hidden)]
6163
pub core_filename: Option<String>,
@@ -137,6 +139,7 @@ impl Default for Config {
137139
subprocesses: false,
138140
full_filenames: false,
139141
lineno: LineNo::LastInstruction,
142+
include_class_name: false,
140143
refresh_seconds: 1.0,
141144
core_filename: None,
142145
}
@@ -192,6 +195,9 @@ impl Config {
192195
let full_filenames = Arg::new("full_filenames").long("full-filenames").help(
193196
"Show full Python filenames, instead of shortening to show only the package part",
194197
);
198+
let include_class_name = Arg::new("include_class_name").long("class-name").help(
199+
"Prepend class name to method names as long as the `self`/`cls` argument naming convention is followed (doesn't work for staticmethods)",
200+
);
195201
let program = Arg::new("python_program")
196202
.help("commandline of a python program to run")
197203
.multiple_values(true);
@@ -219,6 +225,7 @@ impl Config {
219225
.arg(program.clone())
220226
.arg(pid.clone().required_unless_present("python_program"))
221227
.arg(full_filenames.clone())
228+
.arg(include_class_name.clone())
222229
.arg(
223230
Arg::new("output")
224231
.short('o')
@@ -286,6 +293,7 @@ impl Config {
286293
.arg(rate.clone())
287294
.arg(subprocesses.clone())
288295
.arg(full_filenames.clone())
296+
.arg(include_class_name.clone())
289297
.arg(gil.clone())
290298
.arg(idle.clone())
291299
.arg(top_delay.clone());
@@ -311,6 +319,7 @@ impl Config {
311319
);
312320

313321
let dump = dump.arg(full_filenames.clone())
322+
.arg(include_class_name.clone())
314323
.arg(Arg::new("locals")
315324
.short('l')
316325
.long("locals")
@@ -442,6 +451,8 @@ impl Config {
442451
});
443452

444453
config.full_filenames = matches.occurrences_of("full_filenames") > 0;
454+
config.include_class_name = matches.occurrences_of("include_class_name") > 0;
455+
445456
if cfg!(feature = "unwind") {
446457
config.native = matches.occurrences_of("native") > 0;
447458
}

src/python_data_access.rs

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,25 @@ const PY_TPFLAGS_BYTES_SUBCLASS: usize = 1 << 27;
353353
const PY_TPFLAGS_STRING_SUBCLASS: usize = 1 << 28;
354354
const PY_TPFLAGS_DICT_SUBCLASS: usize = 1 << 29;
355355

356+
const MAX_TYPE_NAME_LEN: usize = 64;
357+
358+
/// Get the type's name (truncating to MAX_TYPE_NAME_LEN bytes if longer)
359+
pub fn extract_type_name<T, P>(process: &P, value_type: &T) -> Result<String, Error>
360+
where
361+
T: TypeObject,
362+
P: ProcessMemory,
363+
{
364+
let mut value_type_name = process.copy(value_type.name() as usize, MAX_TYPE_NAME_LEN)?;
365+
let length = value_type_name
366+
.iter()
367+
.position(|&x| x == 0)
368+
.unwrap_or(MAX_TYPE_NAME_LEN);
369+
value_type_name.truncate(length);
370+
371+
let string = String::from_utf8(value_type_name)?;
372+
Ok(string)
373+
}
374+
356375
/// Converts a python variable in the other process to a human readable string
357376
pub fn format_variable<I, P>(
358377
process: &P,
@@ -373,14 +392,7 @@ where
373392
let value: I::Object = process.copy_struct(addr)?;
374393
let value_type = process.copy_pointer(value.ob_type())?;
375394

376-
// get the typename (truncating to 128 bytes if longer)
377-
let max_type_len = 128;
378-
let value_type_name = process.copy(value_type.name() as usize, max_type_len)?;
379-
let length = value_type_name
380-
.iter()
381-
.position(|&x| x == 0)
382-
.unwrap_or(max_type_len);
383-
let value_type_name = std::str::from_utf8(&value_type_name[..length])?;
395+
let value_type_name = extract_type_name(process, &value_type)?;
384396

385397
let format_int = |value: i64| {
386398
if value_type_name == "bool" {
@@ -476,7 +488,7 @@ where
476488
} else if value_type_name == "NoneType" {
477489
"None".to_owned()
478490
} else if value_type_name.starts_with("numpy.") {
479-
match value_type_name {
491+
match value_type_name.as_str() {
480492
"numpy.bool" => format_obval::<bool, P>(addr, process)?,
481493
"numpy.uint8" => format_obval::<u8, P>(addr, process)?,
482494
"numpy.uint16" => format_obval::<u16, P>(addr, process)?,

src/python_spy.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,11 +250,12 @@ impl PythonSpy {
250250
continue;
251251
}
252252

253-
let mut trace = get_stack_trace(
253+
let mut trace = get_stack_trace::<I, <I as InterpreterState>::ThreadState, Process>(
254254
&thread,
255255
&self.process,
256256
self.config.dump_locals > 0,
257257
self.config.lineno,
258+
self.config.include_class_name,
258259
)?;
259260

260261
// Try getting the native thread id

src/stack_trace.rs

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ use remoteprocess::{Pid, ProcessMemory};
66
use serde_derive::Serialize;
77

88
use crate::config::{Config, LineNo};
9-
use crate::python_data_access::{copy_bytes, copy_string};
9+
use crate::python_data_access::{copy_bytes, copy_string, extract_type_name};
1010
use crate::python_interpreters::{
11-
CodeObject, FrameObject, InterpreterState, ThreadState, TupleObject,
11+
CodeObject, FrameObject, InterpreterState, Object, ThreadState, TupleObject, TypeObject,
1212
};
1313

1414
/// Call stack for a single python thread
@@ -68,6 +68,8 @@ pub struct ProcessInfo {
6868
pub parent: Option<Box<ProcessInfo>>,
6969
}
7070

71+
const PY_TPFLAGS_TYPE_SUBCLASS: usize = 1 << 31;
72+
7173
/// Given an InterpreterState, this function returns a vector of stack traces for each thread
7274
pub fn get_stack_traces<I, P>(
7375
interpreter_address: usize,
@@ -90,13 +92,20 @@ where
9092

9193
let lineno = config.map(|c| c.lineno).unwrap_or(LineNo::NoLine);
9294
let dump_locals = config.map(|c| c.dump_locals).unwrap_or(0);
95+
let include_class_name = config.map(|c| c.include_class_name).unwrap_or(false);
9396

9497
while !threads.is_null() {
9598
let thread = process
9699
.copy_pointer(threads)
97100
.context("Failed to copy PyThreadState")?;
98101

99-
let mut trace = get_stack_trace(&thread, process, dump_locals > 0, lineno)?;
102+
let mut trace = get_stack_trace::<I, <I as InterpreterState>::ThreadState, P>(
103+
&thread,
104+
process,
105+
dump_locals > 0,
106+
lineno,
107+
include_class_name,
108+
)?;
100109
trace.owns_gil = trace.thread_id == gil_thread_id;
101110

102111
ret.push(trace);
@@ -110,13 +119,15 @@ where
110119
}
111120

112121
/// Gets a stack trace for an individual thread
113-
pub fn get_stack_trace<T, P>(
122+
pub fn get_stack_trace<I, T, P>(
114123
thread: &T,
115124
process: &P,
116125
copy_locals: bool,
117126
lineno: LineNo,
127+
include_class_name: bool,
118128
) -> Result<StackTrace, Error>
119129
where
130+
I: InterpreterState,
120131
T: ThreadState,
121132
P: ProcessMemory,
122133
{
@@ -165,7 +176,7 @@ where
165176
continue;
166177
}
167178
let filename = filename?;
168-
let name = name?;
179+
let mut name = name?;
169180

170181
// skip <shim> entries in python 3.12+
171182
// Unset file/function name in py3.13 means this is a shim.
@@ -195,11 +206,27 @@ where
195206
};
196207

197208
let locals = if copy_locals {
198-
Some(get_locals(&code, frame_ptr, &frame, process)?)
209+
Some(get_locals(&code, frame_ptr, &frame, process, false)?)
199210
} else {
200211
None
201212
};
202213

214+
// cls or self are always the first argument in methods and classmethods (and first local)
215+
if include_class_name && code.argcount() > 0 {
216+
let found_locals = match locals {
217+
Some(ref found_locals) => found_locals,
218+
None => &get_locals(&code, frame_ptr, &frame, process, true)?,
219+
};
220+
// Errors copying self/cls type name are silenced.
221+
if let Some(class_name) = found_locals
222+
.get(0)
223+
.and_then(|first_arg| get_class_name_from_arg::<I, P>(process, first_arg).ok())
224+
.flatten()
225+
{
226+
name = format!("{}.{}", class_name, name);
227+
}
228+
}
229+
203230
let is_entry = frame.is_entry();
204231

205232
frames.push(Frame {
@@ -273,6 +300,7 @@ fn get_locals<C: CodeObject, F: FrameObject, P: ProcessMemory>(
273300
frameptr: *const F,
274301
frame: &F,
275302
process: &P,
303+
first_var_only: bool,
276304
) -> Result<Vec<LocalVariable>, Error> {
277305
let local_count = code.nlocals() as usize;
278306
let argcount = code.argcount() as usize;
@@ -283,7 +311,12 @@ fn get_locals<C: CodeObject, F: FrameObject, P: ProcessMemory>(
283311

284312
let mut ret = Vec::new();
285313

286-
for i in 0..local_count {
314+
let vars_to_copy = if first_var_only {
315+
std::cmp::min(local_count, 1)
316+
} else {
317+
local_count
318+
};
319+
for i in 0..vars_to_copy {
287320
let nameptr: *const C::StringObject =
288321
process.copy_struct(varnames.address(code.varnames() as usize, i))?;
289322
let name = copy_string(nameptr, process)?;
@@ -301,6 +334,49 @@ fn get_locals<C: CodeObject, F: FrameObject, P: ProcessMemory>(
301334
Ok(ret)
302335
}
303336

337+
/// Get class from a `self` or `cls` argument, as long as its type matches expectations.
338+
fn get_class_name_from_arg<I, P>(
339+
process: &P,
340+
first_local: &LocalVariable,
341+
) -> Result<Option<String>, Error>
342+
where
343+
I: InterpreterState,
344+
P: ProcessMemory,
345+
{
346+
// If the first variable isn't an argument, there are no arguments, so the fn isn't a normal
347+
// method or a class method.
348+
if !first_local.arg {
349+
return Ok(None);
350+
}
351+
352+
let first_arg_name = &first_local.name;
353+
if first_arg_name != "self" && first_arg_name != "cls" {
354+
return Ok(None);
355+
}
356+
357+
let value: I::Object = process.copy_struct(first_local.addr)?;
358+
let mut value_type = process.copy_pointer(value.ob_type())?;
359+
let is_type = value_type.flags() & PY_TPFLAGS_TYPE_SUBCLASS != 0;
360+
361+
// validate that the first argument is:
362+
// - an instance of something else than `type` if it is called "self"
363+
// - an instance of `type` if it is called "cls"
364+
match (first_arg_name.as_str(), is_type) {
365+
("self", false) => {}
366+
("cls", true) => {
367+
// Copy the remote argument struct, but this time as PyTypeObject, rather than
368+
// PyObject. We needed to read type flags from PyObject to know that this is a
369+
// PyTypeObject.
370+
value_type = process.copy_struct(first_local.addr)?;
371+
}
372+
_ => {
373+
return Ok(None);
374+
}
375+
}
376+
377+
Ok(Some(extract_type_name(process, &value_type)?))
378+
}
379+
304380
pub fn get_gil_threadid<I: InterpreterState, P: ProcessMemory>(
305381
threadstate_address: usize,
306382
process: &P,

0 commit comments

Comments
 (0)