diff --git a/.agents/tasks/2025/08/21-0939-codetype-interface b/.agents/tasks/2025/08/21-0939-codetype-interface index e629873..e9669d7 100644 --- a/.agents/tasks/2025/08/21-0939-codetype-interface +++ b/.agents/tasks/2025/08/21-0939-codetype-interface @@ -17,3 +17,22 @@ Here's relevant information: * design-docs/design-001.md - shows how we write design documentation * https://docs.python.org/3/library/sys.monitoring.html - Documentation of the Python sys.montoring API * https://docs.python.org/3/reference/datamodel.html#code-objects - Description of Python Code Objects. + +--- FOLLOW UP TASK --- +Please address any inline comments on the diff, as well as any additional instructions below. + +According to the PyO3 documentation it is preferred to use instead of Py. Is it possible that the code object wrapper takes that into account? Here is relevant info: +* https://pyo3.rs/v0.25.1/types.html +* https://docs.rs/pyo3/0.25.1/pyo3/index.html + +Also please add usage examples to the design documentation +--- FOLLOW UP TASK --- +Please address any inline comments on the diff, as well as any additional instructions below. + +According to the PyO3 documentation it is preferred to use `Bound<'_, T>` instead of Py. Is it possible that the code object wrapper takes that into account? Here is relevant info: +* https://pyo3.rs/v0.25.1/types.html +* https://docs.rs/pyo3/0.25.1/pyo3/index.html + +Also please add usage examples to the design documentation +--- FOLLOW UP TASK --- +Implement the CodeObjectWrapper as designed. Update the Tracer trait as well as the callback_xxx functions accordingly. Write a comprehensive unit tests for CodeObjectWrapper. \ No newline at end of file diff --git a/codetracer-python-recorder/Cargo.lock b/codetracer-python-recorder/Cargo.lock index e0c1fa0..85f939e 100644 --- a/codetracer-python-recorder/Cargo.lock +++ b/codetracer-python-recorder/Cargo.lock @@ -69,6 +69,7 @@ name = "codetracer-python-recorder" version = "0.1.0" dependencies = [ "bitflags", + "once_cell", "pyo3", "runtime_tracing", ] diff --git a/codetracer-python-recorder/Cargo.toml b/codetracer-python-recorder/Cargo.toml index b25b86c..8acbb13 100644 --- a/codetracer-python-recorder/Cargo.toml +++ b/codetracer-python-recorder/Cargo.toml @@ -18,6 +18,7 @@ default = ["extension-module"] pyo3 = { version = "0.25.1" } runtime_tracing = "0.14.0" bitflags = "2.4" +once_cell = "1.19" [dev-dependencies] pyo3 = { version = "0.25.1", features = ["auto-initialize"] } diff --git a/codetracer-python-recorder/src/code_object.rs b/codetracer-python-recorder/src/code_object.rs new file mode 100644 index 0000000..eaf2296 --- /dev/null +++ b/codetracer-python-recorder/src/code_object.rs @@ -0,0 +1,130 @@ +use once_cell::sync::OnceCell; +use pyo3::prelude::*; +use pyo3::types::PyCode; + +/// A wrapper around Python `code` objects providing cached access to +/// common attributes and line information. +pub struct CodeObjectWrapper { + obj: Py, + id: usize, + cache: CodeObjectCache, +} + +#[derive(Default)] +struct CodeObjectCache { + filename: OnceCell, + qualname: OnceCell, + firstlineno: OnceCell, + argcount: OnceCell, + flags: OnceCell, + lines: OnceCell>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LineEntry { + pub offset: u32, + pub line: u32, +} + +impl CodeObjectWrapper { + /// Construct from a `CodeType` object. Computes `id` eagerly. + pub fn new(_py: Python<'_>, obj: &Bound<'_, PyCode>) -> Self { + let id = obj.as_ptr() as usize; + Self { + obj: obj.clone().unbind(), + id, + cache: CodeObjectCache::default(), + } + } + + /// Borrow the owned `Py` as a `Bound<'py, PyCode>`. + pub fn as_bound<'py>(&'py self, py: Python<'py>) -> &Bound<'py, PyCode> { + self.obj.bind(py) + } + + /// Return the stable identity of the code object (equivalent to `id(code)`). + pub fn id(&self) -> usize { + self.id + } + + pub fn filename<'py>(&'py self, py: Python<'py>) -> PyResult<&'py str> { + let value = self.cache.filename.get_or_try_init(|| -> PyResult { + let s: String = self + .as_bound(py) + .getattr("co_filename")? + .extract()?; + Ok(s) + })?; + Ok(value.as_str()) + } + + pub fn qualname<'py>(&'py self, py: Python<'py>) -> PyResult<&'py str> { + let value = self.cache.qualname.get_or_try_init(|| -> PyResult { + let s: String = self + .as_bound(py) + .getattr("co_qualname")? + .extract()?; + Ok(s) + })?; + Ok(value.as_str()) + } + + pub fn first_line(&self, py: Python<'_>) -> PyResult { + let value = *self.cache.firstlineno.get_or_try_init(|| -> PyResult { + let v: u32 = self + .as_bound(py) + .getattr("co_firstlineno")? + .extract()?; + Ok(v) + })?; + Ok(value) + } + + pub fn arg_count(&self, py: Python<'_>) -> PyResult { + let value = *self.cache.argcount.get_or_try_init(|| -> PyResult { + let v: u16 = self + .as_bound(py) + .getattr("co_argcount")? + .extract()?; + Ok(v) + })?; + Ok(value) + } + + pub fn flags(&self, py: Python<'_>) -> PyResult { + let value = *self.cache.flags.get_or_try_init(|| -> PyResult { + let v: u32 = self + .as_bound(py) + .getattr("co_flags")? + .extract()?; + Ok(v) + })?; + Ok(value) + } + + fn lines<'py>(&'py self, py: Python<'py>) -> PyResult<&'py [LineEntry]> { + let vec = self.cache.lines.get_or_try_init(|| -> PyResult> { + let mut entries = Vec::new(); + let iter = self.as_bound(py).call_method0("co_lines")?; + let iter = iter.try_iter()?; + for item in iter { + let (start, _end, line): (u32, u32, Option) = item?.extract()?; + if let Some(line) = line { + entries.push(LineEntry { offset: start, line }); + } + } + Ok(entries) + })?; + Ok(vec.as_slice()) + } + + /// Return the source line for a given instruction offset using a binary search. + pub fn line_for_offset(&self, py: Python<'_>, offset: u32) -> PyResult> { + let lines = self.lines(py)?; + match lines.binary_search_by_key(&offset, |e| e.offset) { + Ok(idx) => Ok(Some(lines[idx].line)), + Err(0) => Ok(None), + Err(idx) => Ok(Some(lines[idx - 1].line)), + } + } +} diff --git a/codetracer-python-recorder/src/lib.rs b/codetracer-python-recorder/src/lib.rs index 31d6b52..a1e1354 100644 --- a/codetracer-python-recorder/src/lib.rs +++ b/codetracer-python-recorder/src/lib.rs @@ -3,7 +3,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; +pub mod code_object; pub mod tracer; +pub use crate::code_object::CodeObjectWrapper; pub use crate::tracer::{install_tracer, uninstall_tracer, EventSet, Tracer}; /// Global flag tracking whether tracing is active. diff --git a/codetracer-python-recorder/src/tracer.rs b/codetracer-python-recorder/src/tracer.rs index d40626b..c185208 100644 --- a/codetracer-python-recorder/src/tracer.rs +++ b/codetracer-python-recorder/src/tracer.rs @@ -2,8 +2,9 @@ use std::sync::{Mutex, OnceLock}; use pyo3::{ exceptions::PyRuntimeError, prelude::*, - types::{PyAny, PyCFunction, PyModule}, + types::{PyAny, PyCFunction, PyCode, PyModule}, }; +use crate::code_object::CodeObjectWrapper; const MONITORING_TOOL_NAME: &str = "codetracer"; @@ -150,7 +151,7 @@ pub trait Tracer: Send { fn on_call( &mut self, _py: Python<'_>, - _code: &Bound<'_, PyAny>, + _code: &CodeObjectWrapper, _offset: i32, _callable: &Bound<'_, PyAny>, _arg0: Option<&Bound<'_, PyAny>>, @@ -158,16 +159,16 @@ pub trait Tracer: Send { } /// Called on line execution. - fn on_line(&mut self, _py: Python<'_>, _code: &Bound<'_, PyAny>, _lineno: u32) {} + fn on_line(&mut self, _py: Python<'_>, _code: &CodeObjectWrapper, _lineno: u32) {} /// Called when an instruction is about to be executed (by offset). - fn on_instruction(&mut self, _py: Python<'_>, _code: &Bound<'_, PyAny>, _offset: i32) {} + fn on_instruction(&mut self, _py: Python<'_>, _code: &CodeObjectWrapper, _offset: i32) {} /// Called when a jump in the control flow graph is made. fn on_jump( &mut self, _py: Python<'_>, - _code: &Bound<'_, PyAny>, + _code: &CodeObjectWrapper, _offset: i32, _destination_offset: i32, ) { @@ -177,23 +178,23 @@ pub trait Tracer: Send { fn on_branch( &mut self, _py: Python<'_>, - _code: &Bound<'_, PyAny>, + _code: &CodeObjectWrapper, _offset: i32, _destination_offset: i32, ) { } /// Called at start of a Python function (frame on stack). - fn on_py_start(&mut self, _py: Python<'_>, _code: &Bound<'_, PyAny>, _offset: i32) {} + fn on_py_start(&mut self, _py: Python<'_>, _code: &CodeObjectWrapper, _offset: i32) {} /// Called on resumption of a generator/coroutine (not via throw()). - fn on_py_resume(&mut self, _py: Python<'_>, _code: &Bound<'_, PyAny>, _offset: i32) {} + fn on_py_resume(&mut self, _py: Python<'_>, _code: &CodeObjectWrapper, _offset: i32) {} /// Called immediately before a Python function returns. fn on_py_return( &mut self, _py: Python<'_>, - _code: &Bound<'_, PyAny>, + _code: &CodeObjectWrapper, _offset: i32, _retval: &Bound<'_, PyAny>, ) { @@ -203,7 +204,7 @@ pub trait Tracer: Send { fn on_py_yield( &mut self, _py: Python<'_>, - _code: &Bound<'_, PyAny>, + _code: &CodeObjectWrapper, _offset: i32, _retval: &Bound<'_, PyAny>, ) { @@ -213,7 +214,7 @@ pub trait Tracer: Send { fn on_py_throw( &mut self, _py: Python<'_>, - _code: &Bound<'_, PyAny>, + _code: &CodeObjectWrapper, _offset: i32, _exception: &Bound<'_, PyAny>, ) { @@ -223,7 +224,7 @@ pub trait Tracer: Send { fn on_py_unwind( &mut self, _py: Python<'_>, - _code: &Bound<'_, PyAny>, + _code: &CodeObjectWrapper, _offset: i32, _exception: &Bound<'_, PyAny>, ) { @@ -233,7 +234,7 @@ pub trait Tracer: Send { fn on_raise( &mut self, _py: Python<'_>, - _code: &Bound<'_, PyAny>, + _code: &CodeObjectWrapper, _offset: i32, _exception: &Bound<'_, PyAny>, ) { @@ -243,7 +244,7 @@ pub trait Tracer: Send { fn on_reraise( &mut self, _py: Python<'_>, - _code: &Bound<'_, PyAny>, + _code: &CodeObjectWrapper, _offset: i32, _exception: &Bound<'_, PyAny>, ) { @@ -253,7 +254,7 @@ pub trait Tracer: Send { fn on_exception_handled( &mut self, _py: Python<'_>, - _code: &Bound<'_, PyAny>, + _code: &CodeObjectWrapper, _offset: i32, _exception: &Bound<'_, PyAny>, ) { @@ -267,7 +268,7 @@ pub trait Tracer: Send { // fn on_stop_iteration( // &mut self, // _py: Python<'_>, - // _code: &Bound<'_, PyAny>, + // _code: &CodeObjectWrapper, // _offset: i32, // _exception: &Bound<'_, PyAny>, // ) { @@ -277,7 +278,7 @@ pub trait Tracer: Send { fn on_c_return( &mut self, _py: Python<'_>, - _code: &Bound<'_, PyAny>, + _code: &CodeObjectWrapper, _offset: i32, _callable: &Bound<'_, PyAny>, _arg0: Option<&Bound<'_, PyAny>>, @@ -288,7 +289,7 @@ pub trait Tracer: Send { fn on_c_raise( &mut self, _py: Python<'_>, - _code: &Bound<'_, PyAny>, + _code: &CodeObjectWrapper, _offset: i32, _callable: &Bound<'_, PyAny>, _arg0: Option<&Bound<'_, PyAny>>, @@ -465,29 +466,32 @@ pub fn uninstall_tracer(py: Python<'_>) -> PyResult<()> { #[pyfunction] fn callback_call( py: Python<'_>, - code: Bound<'_, PyAny>, + code: Bound<'_, PyCode>, offset: i32, callable: Bound<'_, PyAny>, arg0: Option>, ) -> PyResult<()> { if let Some(global) = GLOBAL.lock().unwrap().as_mut() { - global.tracer.on_call(py, &code, offset, &callable, arg0.as_ref()); + let wrapper = CodeObjectWrapper::new(py, &code); + global.tracer.on_call(py, &wrapper, offset, &callable, arg0.as_ref()); } Ok(()) } #[pyfunction] -fn callback_line(py: Python<'_>, code: Bound<'_, PyAny>, lineno: u32) -> PyResult<()> { +fn callback_line(py: Python<'_>, code: Bound<'_, PyCode>, lineno: u32) -> PyResult<()> { if let Some(global) = GLOBAL.lock().unwrap().as_mut() { - global.tracer.on_line(py, &code, lineno); + let wrapper = CodeObjectWrapper::new(py, &code); + global.tracer.on_line(py, &wrapper, lineno); } Ok(()) } #[pyfunction] -fn callback_instruction(py: Python<'_>, code: Bound<'_, PyAny>, instruction_offset: i32) -> PyResult<()> { +fn callback_instruction(py: Python<'_>, code: Bound<'_, PyCode>, instruction_offset: i32) -> PyResult<()> { if let Some(global) = GLOBAL.lock().unwrap().as_mut() { - global.tracer.on_instruction(py, &code, instruction_offset); + let wrapper = CodeObjectWrapper::new(py, &code); + global.tracer.on_instruction(py, &wrapper, instruction_offset); } Ok(()) } @@ -495,14 +499,15 @@ fn callback_instruction(py: Python<'_>, code: Bound<'_, PyAny>, instruction_offs #[pyfunction] fn callback_jump( py: Python<'_>, - code: Bound<'_, PyAny>, + code: Bound<'_, PyCode>, instruction_offset: i32, destination_offset: i32, ) -> PyResult<()> { if let Some(global) = GLOBAL.lock().unwrap().as_mut() { + let wrapper = CodeObjectWrapper::new(py, &code); global .tracer - .on_jump(py, &code, instruction_offset, destination_offset); + .on_jump(py, &wrapper, instruction_offset, destination_offset); } Ok(()) } @@ -510,30 +515,33 @@ fn callback_jump( #[pyfunction] fn callback_branch( py: Python<'_>, - code: Bound<'_, PyAny>, + code: Bound<'_, PyCode>, instruction_offset: i32, destination_offset: i32, ) -> PyResult<()> { if let Some(global) = GLOBAL.lock().unwrap().as_mut() { + let wrapper = CodeObjectWrapper::new(py, &code); global .tracer - .on_branch(py, &code, instruction_offset, destination_offset); + .on_branch(py, &wrapper, instruction_offset, destination_offset); } Ok(()) } #[pyfunction] -fn callback_py_start(py: Python<'_>, code: Bound<'_, PyAny>, instruction_offset: i32) -> PyResult<()> { +fn callback_py_start(py: Python<'_>, code: Bound<'_, PyCode>, instruction_offset: i32) -> PyResult<()> { if let Some(global) = GLOBAL.lock().unwrap().as_mut() { - global.tracer.on_py_start(py, &code, instruction_offset); + let wrapper = CodeObjectWrapper::new(py, &code); + global.tracer.on_py_start(py, &wrapper, instruction_offset); } Ok(()) } #[pyfunction] -fn callback_py_resume(py: Python<'_>, code: Bound<'_, PyAny>, instruction_offset: i32) -> PyResult<()> { +fn callback_py_resume(py: Python<'_>, code: Bound<'_, PyCode>, instruction_offset: i32) -> PyResult<()> { if let Some(global) = GLOBAL.lock().unwrap().as_mut() { - global.tracer.on_py_resume(py, &code, instruction_offset); + let wrapper = CodeObjectWrapper::new(py, &code); + global.tracer.on_py_resume(py, &wrapper, instruction_offset); } Ok(()) } @@ -541,12 +549,13 @@ fn callback_py_resume(py: Python<'_>, code: Bound<'_, PyAny>, instruction_offset #[pyfunction] fn callback_py_return( py: Python<'_>, - code: Bound<'_, PyAny>, + code: Bound<'_, PyCode>, instruction_offset: i32, retval: Bound<'_, PyAny>, ) -> PyResult<()> { if let Some(global) = GLOBAL.lock().unwrap().as_mut() { - global.tracer.on_py_return(py, &code, instruction_offset, &retval); + let wrapper = CodeObjectWrapper::new(py, &code); + global.tracer.on_py_return(py, &wrapper, instruction_offset, &retval); } Ok(()) } @@ -554,12 +563,13 @@ fn callback_py_return( #[pyfunction] fn callback_py_yield( py: Python<'_>, - code: Bound<'_, PyAny>, + code: Bound<'_, PyCode>, instruction_offset: i32, retval: Bound<'_, PyAny>, ) -> PyResult<()> { if let Some(global) = GLOBAL.lock().unwrap().as_mut() { - global.tracer.on_py_yield(py, &code, instruction_offset, &retval); + let wrapper = CodeObjectWrapper::new(py, &code); + global.tracer.on_py_yield(py, &wrapper, instruction_offset, &retval); } Ok(()) } @@ -567,12 +577,13 @@ fn callback_py_yield( #[pyfunction] fn callback_py_throw( py: Python<'_>, - code: Bound<'_, PyAny>, + code: Bound<'_, PyCode>, instruction_offset: i32, exception: Bound<'_, PyAny>, ) -> PyResult<()> { if let Some(global) = GLOBAL.lock().unwrap().as_mut() { - global.tracer.on_py_throw(py, &code, instruction_offset, &exception); + let wrapper = CodeObjectWrapper::new(py, &code); + global.tracer.on_py_throw(py, &wrapper, instruction_offset, &exception); } Ok(()) } @@ -580,12 +591,13 @@ fn callback_py_throw( #[pyfunction] fn callback_py_unwind( py: Python<'_>, - code: Bound<'_, PyAny>, + code: Bound<'_, PyCode>, instruction_offset: i32, exception: Bound<'_, PyAny>, ) -> PyResult<()> { if let Some(global) = GLOBAL.lock().unwrap().as_mut() { - global.tracer.on_py_unwind(py, &code, instruction_offset, &exception); + let wrapper = CodeObjectWrapper::new(py, &code); + global.tracer.on_py_unwind(py, &wrapper, instruction_offset, &exception); } Ok(()) } @@ -593,12 +605,13 @@ fn callback_py_unwind( #[pyfunction] fn callback_raise( py: Python<'_>, - code: Bound<'_, PyAny>, + code: Bound<'_, PyCode>, instruction_offset: i32, exception: Bound<'_, PyAny>, ) -> PyResult<()> { if let Some(global) = GLOBAL.lock().unwrap().as_mut() { - global.tracer.on_raise(py, &code, instruction_offset, &exception); + let wrapper = CodeObjectWrapper::new(py, &code); + global.tracer.on_raise(py, &wrapper, instruction_offset, &exception); } Ok(()) } @@ -606,12 +619,13 @@ fn callback_raise( #[pyfunction] fn callback_reraise( py: Python<'_>, - code: Bound<'_, PyAny>, + code: Bound<'_, PyCode>, instruction_offset: i32, exception: Bound<'_, PyAny>, ) -> PyResult<()> { if let Some(global) = GLOBAL.lock().unwrap().as_mut() { - global.tracer.on_reraise(py, &code, instruction_offset, &exception); + let wrapper = CodeObjectWrapper::new(py, &code); + global.tracer.on_reraise(py, &wrapper, instruction_offset, &exception); } Ok(()) } @@ -619,14 +633,15 @@ fn callback_reraise( #[pyfunction] fn callback_exception_handled( py: Python<'_>, - code: Bound<'_, PyAny>, + code: Bound<'_, PyCode>, instruction_offset: i32, exception: Bound<'_, PyAny>, ) -> PyResult<()> { if let Some(global) = GLOBAL.lock().unwrap().as_mut() { + let wrapper = CodeObjectWrapper::new(py, &code); global .tracer - .on_exception_handled(py, &code, instruction_offset, &exception); + .on_exception_handled(py, &wrapper, instruction_offset, &exception); } Ok(()) } @@ -650,15 +665,16 @@ fn callback_exception_handled( #[pyfunction] fn callback_c_return( py: Python<'_>, - code: Bound<'_, PyAny>, + code: Bound<'_, PyCode>, offset: i32, callable: Bound<'_, PyAny>, arg0: Option>, ) -> PyResult<()> { if let Some(global) = GLOBAL.lock().unwrap().as_mut() { + let wrapper = CodeObjectWrapper::new(py, &code); global .tracer - .on_c_return(py, &code, offset, &callable, arg0.as_ref()); + .on_c_return(py, &wrapper, offset, &callable, arg0.as_ref()); } Ok(()) } @@ -666,15 +682,16 @@ fn callback_c_return( #[pyfunction] fn callback_c_raise( py: Python<'_>, - code: Bound<'_, PyAny>, + code: Bound<'_, PyCode>, offset: i32, callable: Bound<'_, PyAny>, arg0: Option>, ) -> PyResult<()> { if let Some(global) = GLOBAL.lock().unwrap().as_mut() { + let wrapper = CodeObjectWrapper::new(py, &code); global .tracer - .on_c_raise(py, &code, offset, &callable, arg0.as_ref()); + .on_c_raise(py, &wrapper, offset, &callable, arg0.as_ref()); } Ok(()) } diff --git a/codetracer-python-recorder/tests/code_object_wrapper.rs b/codetracer-python-recorder/tests/code_object_wrapper.rs new file mode 100644 index 0000000..a22a0aa --- /dev/null +++ b/codetracer-python-recorder/tests/code_object_wrapper.rs @@ -0,0 +1,53 @@ +use codetracer_python_recorder::CodeObjectWrapper; +use pyo3::prelude::*; +use pyo3::types::{PyCode, PyModule}; +use std::ffi::CString; + +#[test] +fn wrapper_basic_attributes() { + Python::with_gil(|py| { + let src = CString::new("def f(x):\n return x + 1\n").unwrap(); + let filename = CString::new("").unwrap(); + let module = CString::new("m").unwrap(); + let m = PyModule::from_code(py, src.as_c_str(), filename.as_c_str(), module.as_c_str()).unwrap(); + let func = m.getattr("f").unwrap(); + let code: Bound<'_, PyCode> = func + .getattr("__code__") + .unwrap() + .downcast_into() + .unwrap(); + let wrapper = CodeObjectWrapper::new(py, &code); + assert_eq!(wrapper.arg_count(py).unwrap(), 1); + assert_eq!(wrapper.filename(py).unwrap(), ""); + assert_eq!(wrapper.qualname(py).unwrap(), "f"); + assert!(wrapper.flags(py).unwrap() > 0); + }); +} + +#[test] +fn wrapper_line_for_offset() { + Python::with_gil(|py| { + let src = CString::new("def g():\n a = 1\n b = 2\n return a + b\n").unwrap(); + let filename = CString::new("").unwrap(); + let module = CString::new("m2").unwrap(); + let m = PyModule::from_code(py, src.as_c_str(), filename.as_c_str(), module.as_c_str()).unwrap(); + let func = m.getattr("g").unwrap(); + let code: Bound<'_, PyCode> = func + .getattr("__code__") + .unwrap() + .downcast_into() + .unwrap(); + let wrapper = CodeObjectWrapper::new(py, &code); + let lines = code.call_method0("co_lines").unwrap(); + let iter = lines.try_iter().unwrap(); + let mut last_line = None; + for item in iter { + let (start, _end, line): (u32, u32, Option) = item.unwrap().extract().unwrap(); + if let Some(l) = line { + assert_eq!(wrapper.line_for_offset(py, start).unwrap(), Some(l)); + last_line = Some(l); + } + } + assert_eq!(wrapper.line_for_offset(py, 10_000).unwrap(), last_line); + }); +} diff --git a/codetracer-python-recorder/tests/print_tracer.rs b/codetracer-python-recorder/tests/print_tracer.rs index a1cccc0..834bb70 100644 --- a/codetracer-python-recorder/tests/print_tracer.rs +++ b/codetracer-python-recorder/tests/print_tracer.rs @@ -1,4 +1,4 @@ -use codetracer_python_recorder::{install_tracer, uninstall_tracer, EventSet, Tracer}; +use codetracer_python_recorder::{install_tracer, uninstall_tracer, EventSet, Tracer, CodeObjectWrapper}; use codetracer_python_recorder::tracer::{MonitoringEvents, events_union}; use pyo3::prelude::*; use std::ffi::CString; @@ -9,17 +9,17 @@ static CALL_COUNT: AtomicUsize = AtomicUsize::new(0); struct PrintTracer; impl Tracer for PrintTracer { - fn interest(&self, events:&MonitoringEvents) -> EventSet { - events_union(&[events.CALL]) + fn interest(&self, events: &MonitoringEvents) -> EventSet { + events_union(&[events.CALL]) } fn on_call( &mut self, _py: Python<'_>, - _code: &pyo3::Bound<'_, pyo3::types::PyAny>, + _code: &CodeObjectWrapper, _offset: i32, - _callable: &pyo3::Bound<'_, pyo3::types::PyAny>, - _arg0: Option<&pyo3::Bound<'_, pyo3::types::PyAny>>, + _callable: &Bound<'_, PyAny>, + _arg0: Option<&Bound<'_, PyAny>>, ) { CALL_COUNT.fetch_add(1, Ordering::SeqCst); } @@ -29,19 +29,12 @@ impl Tracer for PrintTracer { fn tracer_prints_on_call() { Python::with_gil(|py| { CALL_COUNT.store(0, Ordering::SeqCst); - if let Err(e) = install_tracer(py, Box::new(PrintTracer)) { - e.print(py); - panic!("Install Tracer failed"); - } - let code = CString::new("def foo():\n return 1\nfoo()").expect("CString::new failed"); - if let Err(e) = py.run(code.as_c_str(), None, None) { - e.print(py); - uninstall_tracer(py).ok(); - panic!("Python raised an exception"); - } + uninstall_tracer(py).ok(); + install_tracer(py, Box::new(PrintTracer)).unwrap(); + let code = CString::new("def foo():\n return 1\nfoo()").unwrap(); + py.run(code.as_c_str(), None, None).unwrap(); uninstall_tracer(py).unwrap(); - let count = CALL_COUNT.load(Ordering::SeqCst); - assert!(count >= 1, "expected at least one CALL event, got {}", count); + assert!(CALL_COUNT.load(Ordering::SeqCst) >= 1); }); } @@ -58,28 +51,11 @@ static PY_UNWIND_COUNT: AtomicUsize = AtomicUsize::new(0); static RAISE_COUNT: AtomicUsize = AtomicUsize::new(0); static RERAISE_COUNT: AtomicUsize = AtomicUsize::new(0); static EXCEPTION_HANDLED_COUNT: AtomicUsize = AtomicUsize::new(0); -//static STOP_ITERATION_COUNT: AtomicUsize = AtomicUsize::new(0); static C_RETURN_COUNT: AtomicUsize = AtomicUsize::new(0); static C_RAISE_COUNT: AtomicUsize = AtomicUsize::new(0); struct CountingTracer; -fn offset_to_line(code: &pyo3::Bound<'_, pyo3::types::PyAny>, offset: i32) -> Option { - if offset < 0 { - return None; - } - let lines_iter = code.call_method0("co_lines").ok()?; - let iter = lines_iter.try_iter().ok()?; - for line_info in iter { - let line_info = line_info.ok()?; - let (start, end, line): (i32, i32, i32) = line_info.extract().ok()?; - if offset >= start && offset < end { - return Some(line); - } - } - None -} - impl Tracer for CountingTracer { fn interest(&self, events: &MonitoringEvents) -> EventSet { events_union(&[ @@ -94,7 +70,6 @@ impl Tracer for CountingTracer { events.PY_YIELD, events.PY_THROW, events.PY_UNWIND, - //events.STOP_ITERATION, events.RAISE, events.RERAISE, events.EXCEPTION_HANDLED, @@ -103,206 +78,105 @@ impl Tracer for CountingTracer { ]) } - fn on_line( - &mut self, - _py: Python<'_>, - _code: &pyo3::Bound<'_, pyo3::types::PyAny>, - lineno: u32, - ) { + fn on_line(&mut self, _py: Python<'_>, _code: &CodeObjectWrapper, lineno: u32) { LINE_COUNT.fetch_add(1, Ordering::SeqCst); println!("LINE at {}", lineno); } - fn on_instruction( - &mut self, - _py: Python<'_>, - code: &pyo3::Bound<'_, pyo3::types::PyAny>, - offset: i32, - ) { + fn on_instruction(&mut self, py: Python<'_>, code: &CodeObjectWrapper, offset: i32) { INSTRUCTION_COUNT.fetch_add(1, Ordering::SeqCst); - if let Some(line) = offset_to_line(code, offset) { + if let Ok(Some(line)) = code.line_for_offset(py, offset as u32) { println!("INSTRUCTION at {}", line); } } - fn on_jump( - &mut self, - _py: Python<'_>, - code: &pyo3::Bound<'_, pyo3::types::PyAny>, - offset: i32, - _destination_offset: i32, - ) { + fn on_jump(&mut self, py: Python<'_>, code: &CodeObjectWrapper, offset: i32, _dest: i32) { JUMP_COUNT.fetch_add(1, Ordering::SeqCst); - if let Some(line) = offset_to_line(code, offset) { + if let Ok(Some(line)) = code.line_for_offset(py, offset as u32) { println!("JUMP at {}", line); } } - fn on_branch( - &mut self, - _py: Python<'_>, - code: &pyo3::Bound<'_, pyo3::types::PyAny>, - offset: i32, - _destination_offset: i32, - ) { + fn on_branch(&mut self, py: Python<'_>, code: &CodeObjectWrapper, offset: i32, _dest: i32) { BRANCH_COUNT.fetch_add(1, Ordering::SeqCst); - if let Some(line) = offset_to_line(code, offset) { + if let Ok(Some(line)) = code.line_for_offset(py, offset as u32) { println!("BRANCH at {}", line); } } - fn on_py_start( - &mut self, - _py: Python<'_>, - code: &pyo3::Bound<'_, pyo3::types::PyAny>, - offset: i32, - ) { + fn on_py_start(&mut self, py: Python<'_>, code: &CodeObjectWrapper, offset: i32) { PY_START_COUNT.fetch_add(1, Ordering::SeqCst); - if let Some(line) = offset_to_line(code, offset) { + if let Ok(Some(line)) = code.line_for_offset(py, offset as u32) { println!("PY_START at {}", line); } } - fn on_py_resume( - &mut self, - _py: Python<'_>, - code: &pyo3::Bound<'_, pyo3::types::PyAny>, - offset: i32, - ) { + fn on_py_resume(&mut self, py: Python<'_>, code: &CodeObjectWrapper, offset: i32) { PY_RESUME_COUNT.fetch_add(1, Ordering::SeqCst); - if let Some(line) = offset_to_line(code, offset) { + if let Ok(Some(line)) = code.line_for_offset(py, offset as u32) { println!("PY_RESUME at {}", line); } } - fn on_py_return( - &mut self, - _py: Python<'_>, - code: &pyo3::Bound<'_, pyo3::types::PyAny>, - offset: i32, - _retval: &pyo3::Bound<'_, pyo3::types::PyAny>, - ) { + fn on_py_return(&mut self, py: Python<'_>, code: &CodeObjectWrapper, offset: i32, _retval: &Bound<'_, PyAny>) { PY_RETURN_COUNT.fetch_add(1, Ordering::SeqCst); - if let Some(line) = offset_to_line(code, offset) { + if let Ok(Some(line)) = code.line_for_offset(py, offset as u32) { println!("PY_RETURN at {}", line); } } - fn on_py_yield( - &mut self, - _py: Python<'_>, - code: &pyo3::Bound<'_, pyo3::types::PyAny>, - offset: i32, - _retval: &pyo3::Bound<'_, pyo3::types::PyAny>, - ) { + fn on_py_yield(&mut self, py: Python<'_>, code: &CodeObjectWrapper, offset: i32, _retval: &Bound<'_, PyAny>) { PY_YIELD_COUNT.fetch_add(1, Ordering::SeqCst); - if let Some(line) = offset_to_line(code, offset) { + if let Ok(Some(line)) = code.line_for_offset(py, offset as u32) { println!("PY_YIELD at {}", line); } } - fn on_py_throw( - &mut self, - _py: Python<'_>, - code: &pyo3::Bound<'_, pyo3::types::PyAny>, - offset: i32, - _exception: &pyo3::Bound<'_, pyo3::types::PyAny>, - ) { + fn on_py_throw(&mut self, py: Python<'_>, code: &CodeObjectWrapper, offset: i32, _exc: &Bound<'_, PyAny>) { PY_THROW_COUNT.fetch_add(1, Ordering::SeqCst); - if let Some(line) = offset_to_line(code, offset) { + if let Ok(Some(line)) = code.line_for_offset(py, offset as u32) { println!("PY_THROW at {}", line); } } - fn on_py_unwind( - &mut self, - _py: Python<'_>, - code: &pyo3::Bound<'_, pyo3::types::PyAny>, - offset: i32, - _exception: &pyo3::Bound<'_, pyo3::types::PyAny>, - ) { + fn on_py_unwind(&mut self, py: Python<'_>, code: &CodeObjectWrapper, offset: i32, _exc: &Bound<'_, PyAny>) { PY_UNWIND_COUNT.fetch_add(1, Ordering::SeqCst); - if let Some(line) = offset_to_line(code, offset) { + if let Ok(Some(line)) = code.line_for_offset(py, offset as u32) { println!("PY_UNWIND at {}", line); } } - fn on_raise( - &mut self, - _py: Python<'_>, - code: &pyo3::Bound<'_, pyo3::types::PyAny>, - offset: i32, - _exception: &pyo3::Bound<'_, pyo3::types::PyAny>, - ) { + fn on_raise(&mut self, py: Python<'_>, code: &CodeObjectWrapper, offset: i32, _exc: &Bound<'_, PyAny>) { RAISE_COUNT.fetch_add(1, Ordering::SeqCst); - if let Some(line) = offset_to_line(code, offset) { + if let Ok(Some(line)) = code.line_for_offset(py, offset as u32) { println!("RAISE at {}", line); } } - fn on_reraise( - &mut self, - _py: Python<'_>, - code: &pyo3::Bound<'_, pyo3::types::PyAny>, - offset: i32, - _exception: &pyo3::Bound<'_, pyo3::types::PyAny>, - ) { + fn on_reraise(&mut self, py: Python<'_>, code: &CodeObjectWrapper, offset: i32, _exc: &Bound<'_, PyAny>) { RERAISE_COUNT.fetch_add(1, Ordering::SeqCst); - if let Some(line) = offset_to_line(code, offset) { + if let Ok(Some(line)) = code.line_for_offset(py, offset as u32) { println!("RERAISE at {}", line); } } - fn on_exception_handled( - &mut self, - _py: Python<'_>, - code: &pyo3::Bound<'_, pyo3::types::PyAny>, - offset: i32, - _exception: &pyo3::Bound<'_, pyo3::types::PyAny>, - ) { + fn on_exception_handled(&mut self, py: Python<'_>, code: &CodeObjectWrapper, offset: i32, _exc: &Bound<'_, PyAny>) { EXCEPTION_HANDLED_COUNT.fetch_add(1, Ordering::SeqCst); - if let Some(line) = offset_to_line(code, offset) { + if let Ok(Some(line)) = code.line_for_offset(py, offset as u32) { println!("EXCEPTION_HANDLED at {}", line); } } - // fn on_stop_iteration( - // &mut self, - // _py: Python<'_>, - // code: &pyo3::Bound<'_, pyo3::types::PyAny>, - // offset: i32, - // _exception: &pyo3::Bound<'_, pyo3::types::PyAny>, - // ) { - // STOP_ITERATION_COUNT.fetch_add(1, Ordering::SeqCst); - // if let Some(line) = offset_to_line(code, offset) { - // println!("STOP_ITERATION at {}", line); - // } - // } - - fn on_c_return( - &mut self, - _py: Python<'_>, - code: &pyo3::Bound<'_, pyo3::types::PyAny>, - offset: i32, - _callable: &pyo3::Bound<'_, pyo3::types::PyAny>, - _arg0: Option<&pyo3::Bound<'_, pyo3::types::PyAny>>, - ) { + fn on_c_return(&mut self, py: Python<'_>, code: &CodeObjectWrapper, offset: i32, _call: &Bound<'_, PyAny>, _arg0: Option<&Bound<'_, PyAny>>) { C_RETURN_COUNT.fetch_add(1, Ordering::SeqCst); - if let Some(line) = offset_to_line(code, offset) { + if let Ok(Some(line)) = code.line_for_offset(py, offset as u32) { println!("C_RETURN at {}", line); } } - fn on_c_raise( - &mut self, - _py: Python<'_>, - code: &pyo3::Bound<'_, pyo3::types::PyAny>, - offset: i32, - _callable: &pyo3::Bound<'_, pyo3::types::PyAny>, - _arg0: Option<&pyo3::Bound<'_, pyo3::types::PyAny>>, - ) { + fn on_c_raise(&mut self, py: Python<'_>, code: &CodeObjectWrapper, offset: i32, _call: &Bound<'_, PyAny>, _arg0: Option<&Bound<'_, PyAny>>) { C_RAISE_COUNT.fetch_add(1, Ordering::SeqCst); - if let Some(line) = offset_to_line(code, offset) { + if let Ok(Some(line)) = code.line_for_offset(py, offset as u32) { println!("C_RAISE at {}", line); } } @@ -324,94 +198,27 @@ fn tracer_handles_all_events() { RAISE_COUNT.store(0, Ordering::SeqCst); RERAISE_COUNT.store(0, Ordering::SeqCst); EXCEPTION_HANDLED_COUNT.store(0, Ordering::SeqCst); - // STOP_ITERATION_COUNT.store(0, Ordering::SeqCst); //ISSUE: We can't figure out how to triger this event C_RETURN_COUNT.store(0, Ordering::SeqCst); C_RAISE_COUNT.store(0, Ordering::SeqCst); - if let Err(e) = install_tracer(py, Box::new(CountingTracer)) { - e.print(py); - panic!("Install Tracer failed"); - } - let code = CString::new(r#" -def test_all(): - x = 0 - if x == 0: - x += 1 - for i in range(1): - x += i - def foo(): - return 1 - foo() - try: - raise ValueError("err") - except ValueError: - pass - def gen(): - try: - yield 1 - yield 2 - except ValueError: - pass - g = gen() - next(g) - next(g) - try: - g.throw(ValueError()) - except StopIteration: - pass - for _ in []: - pass - def gen2(): - yield 1 - return 2 - for _ in gen2(): - pass - len("abc") - try: - int("a") - except ValueError: - pass - def f_unwind(): - raise KeyError - try: - f_unwind() - except KeyError: - pass - try: - try: - raise OSError() - except OSError: - raise - except OSError: - pass -test_all() -def only_stop_iter(): - if False: - yield -for _ in only_stop_iter(): - pass -"#).expect("CString::new failed"); - if let Err(e) = py.run(code.as_c_str(), None, None) { - e.print(py); - uninstall_tracer(py).ok(); - panic!("Python raised an exception"); - } + install_tracer(py, Box::new(CountingTracer)).unwrap(); + let code = CString::new( +"def test_all():\n x = 0\n if x == 0:\n x += 1\n for i in range(1):\n x += i\n def foo():\n return 1\n foo()\n try:\n raise ValueError('err')\n except ValueError:\n pass\n def gen():\n try:\n yield 1\n yield 2\n except ValueError:\n pass\n g = gen()\n next(g)\n next(g)\n try:\n g.throw(ValueError())\n except StopIteration:\n pass\n for _ in []:\n pass\n def gen2():\n yield 1\n return 2\n for _ in gen2():\n pass\n len('abc')\n try:\n int('a')\n except ValueError:\n pass\n def f_unwind():\n raise KeyError\n try:\n f_unwind()\n except KeyError:\n pass\n try:\n try:\n raise OSError()\n except OSError:\n raise\n except OSError:\n pass\n\ntest_all()\n").unwrap(); + py.run(code.as_c_str(), None, None).unwrap(); uninstall_tracer(py).unwrap(); - assert!(LINE_COUNT.load(Ordering::SeqCst) >= 1, "expected at least one LINE event, got {}", LINE_COUNT.load(Ordering::SeqCst)); - assert!(INSTRUCTION_COUNT.load(Ordering::SeqCst) >= 1, "expected at least one INSTRUCTION event, got {}", INSTRUCTION_COUNT.load(Ordering::SeqCst)); - assert!(JUMP_COUNT.load(Ordering::SeqCst) >= 1, "expected at least one JUMP event, got {}", JUMP_COUNT.load(Ordering::SeqCst)); - assert!(BRANCH_COUNT.load(Ordering::SeqCst) >= 1, "expected at least one BRANCH event, got {}", BRANCH_COUNT.load(Ordering::SeqCst)); - assert!(PY_START_COUNT.load(Ordering::SeqCst) >= 1, "expected at least one PY_START event, got {}", PY_START_COUNT.load(Ordering::SeqCst)); - assert!(PY_RESUME_COUNT.load(Ordering::SeqCst) >= 1, "expected at least one PY_RESUME event, got {}", PY_RESUME_COUNT.load(Ordering::SeqCst)); - assert!(PY_RETURN_COUNT.load(Ordering::SeqCst) >= 1, "expected at least one PY_RETURN event, got {}", PY_RETURN_COUNT.load(Ordering::SeqCst)); - assert!(PY_YIELD_COUNT.load(Ordering::SeqCst) >= 1, "expected at least one PY_YIELD event, got {}", PY_YIELD_COUNT.load(Ordering::SeqCst)); - assert!(PY_THROW_COUNT.load(Ordering::SeqCst) >= 1, "expected at least one PY_THROW event, got {}", PY_THROW_COUNT.load(Ordering::SeqCst)); - assert!(PY_UNWIND_COUNT.load(Ordering::SeqCst) >= 1, "expected at least one PY_UNWIND event, got {}", PY_UNWIND_COUNT.load(Ordering::SeqCst)); - assert!(RAISE_COUNT.load(Ordering::SeqCst) >= 1, "expected at least one RAISE event, got {}", RAISE_COUNT.load(Ordering::SeqCst)); - assert!(RERAISE_COUNT.load(Ordering::SeqCst) >= 1, "expected at least one RERAISE event, got {}", RERAISE_COUNT.load(Ordering::SeqCst)); - assert!(EXCEPTION_HANDLED_COUNT.load(Ordering::SeqCst) >= 1, "expected at least one EXCEPTION_HANDLED event, got {}", EXCEPTION_HANDLED_COUNT.load(Ordering::SeqCst)); - // assert!(STOP_ITERATION_COUNT.load(Ordering::SeqCst) >= 1, "expected at least one STOP_ITERATION event, got {}", STOP_ITERATION_COUNT.load(Ordering::SeqCst)); //Issue - assert!(C_RETURN_COUNT.load(Ordering::SeqCst) >= 1, "expected at least one C_RETURN event, got {}", C_RETURN_COUNT.load(Ordering::SeqCst)); - assert!(C_RAISE_COUNT.load(Ordering::SeqCst) >= 1, "expected at least one C_RAISE event, got {}", C_RAISE_COUNT.load(Ordering::SeqCst)); + assert!(LINE_COUNT.load(Ordering::SeqCst) >= 1); + assert!(INSTRUCTION_COUNT.load(Ordering::SeqCst) >= 1); + assert!(JUMP_COUNT.load(Ordering::SeqCst) >= 1); + assert!(BRANCH_COUNT.load(Ordering::SeqCst) >= 1); + assert!(PY_START_COUNT.load(Ordering::SeqCst) >= 1); + assert!(PY_RESUME_COUNT.load(Ordering::SeqCst) >= 1); + assert!(PY_RETURN_COUNT.load(Ordering::SeqCst) >= 1); + assert!(PY_YIELD_COUNT.load(Ordering::SeqCst) >= 1); + assert!(PY_THROW_COUNT.load(Ordering::SeqCst) >= 1); + assert!(PY_UNWIND_COUNT.load(Ordering::SeqCst) >= 1); + assert!(RAISE_COUNT.load(Ordering::SeqCst) >= 1); + assert!(RERAISE_COUNT.load(Ordering::SeqCst) >= 1); + assert!(EXCEPTION_HANDLED_COUNT.load(Ordering::SeqCst) >= 1); + assert!(C_RETURN_COUNT.load(Ordering::SeqCst) >= 1); + assert!(C_RAISE_COUNT.load(Ordering::SeqCst) >= 1); }); } - diff --git a/design-docs/code-object.md b/design-docs/code-object.md new file mode 100644 index 0000000..7fd4568 --- /dev/null +++ b/design-docs/code-object.md @@ -0,0 +1,116 @@ +# Code Object Wrapper Design + +## Overview + +The Python Monitoring API delivers a generic `CodeType` object to every tracing callback. The current `Tracer` trait surfaces this object as `&Bound<'_, PyAny>`, forcing every implementation to perform attribute lookups and type conversions manually. This document proposes a `CodeObjectWrapper` type that exposes a stable, typed interface to the underlying code object while minimizing per-event overhead. + +## Goals +- Provide a strongly typed API for common `CodeType` attributes needed by tracers and recorders. +- Ensure lookups are cheap by caching values and avoiding repeated Python attribute access. +- Maintain a stable identity for each code object to correlate events across callbacks. +- Avoid relying on the unstable `PyCodeObject` layout from the C API. + +## Non-Goals +- Full re‑implementation of every `CodeType` attribute. Only the fields required for tracing and time‑travel debugging are exposed. +- Direct mutation of `CodeType` objects. The wrapper offers read‑only access. + +## Proposed API + +```rs +pub struct CodeObjectWrapper { + /// Owned reference to the Python `CodeType` object. + /// Stored as `Py` so it can be held outside the GIL. + obj: Py, + /// Stable identity equivalent to `id(code)`. + id: usize, + /// Lazily populated cache for expensive lookups. + cache: CodeObjectCache, +} + +pub struct CodeObjectCache { + filename: OnceCell, + qualname: OnceCell, + firstlineno: OnceCell, + argcount: OnceCell, + flags: OnceCell, + /// Mapping of instruction offsets to line numbers. + lines: OnceCell>, +} + +pub struct LineEntry { + pub offset: u32, + pub line: u32, +} + +impl CodeObjectWrapper { + /// Construct from a `CodeType` object. Computes `id` eagerly. + pub fn new(py: Python<'_>, obj: &Bound<'_, PyCode>) -> Self; + + /// Borrow the owned `Py` as a `Bound<'py, PyCode>`. + /// This follows PyO3's recommendation to prefer `Bound<'_, T>` over `Py` + /// for object manipulation. + pub fn as_bound<'py>(&'py self, py: Python<'py>) -> Bound<'py, PyCode>; + + /// Accessors fetch from the cache or perform a one‑time lookup under the GIL. + pub fn filename<'py>(&'py self, py: Python<'py>) -> PyResult<&'py str>; + pub fn qualname<'py>(&'py self, py: Python<'py>) -> PyResult<&'py str>; + pub fn first_line(&self, py: Python<'_>) -> PyResult; + pub fn arg_count(&self, py: Python<'_>) -> PyResult; + pub fn flags(&self, py: Python<'_>) -> PyResult; + + /// Return the source line for a given instruction offset using a binary search on `lines`. + pub fn line_for_offset(&self, py: Python<'_>, offset: u32) -> PyResult>; + + /// Expose the stable identity for cross‑event correlation. + pub fn id(&self) -> usize; +} +``` + +### Trait Integration + +The `Tracer` trait will be adjusted so every callback receives `&CodeObjectWrapper` instead of a generic `&Bound<'_, PyAny>`: + +```rs +fn on_line(&mut self, py: Python<'_>, code: &CodeObjectWrapper, lineno: u32); +fn on_py_start(&mut self, py: Python<'_>, code: &CodeObjectWrapper, offset: i32); +// ...and similarly for the remaining callbacks. +``` + +## Usage Examples + +### Constructing the wrapper inside a tracer + +```rs +fn on_line(&mut self, py: Python<'_>, code: &Bound<'_, PyCode>, lineno: u32) { + let wrapper = CodeObjectWrapper::new(py, code); + let filename = wrapper.filename(py).unwrap_or(""); + eprintln!("{}:{}", filename, lineno); +} +``` + +### Reusing a cached wrapper + +```rs +let wrapper = CodeObjectWrapper::new(py, code); +cache.insert(wrapper.id(), wrapper.clone()); + +if let Some(saved) = cache.get(&wrapper.id()) { + let qualname = saved.qualname(py)?; + println!("qualified name: {}", qualname); +} +``` + +## Performance Considerations +- `Py` allows cloning the wrapper without holding the GIL, enabling cheap event propagation. +- Methods bind the owned reference to `Bound<'py, PyCode>` on demand, following PyO3's `Bound`‑first guidance and avoiding accidental `Py` clones. +- Fields are loaded lazily and stored inside `OnceCell` containers to avoid repeated attribute lookups. +- `line_for_offset` memoizes the full line table the first time it is requested; subsequent calls perform an in‑memory binary search. +- Storing strings and small integers directly in the cache eliminates conversion cost on hot paths. + +## Open Questions +- Additional attributes such as `co_consts` or `co_varnames` may be required for richer debugging features; these can be added later as new `OnceCell` fields. +- Thread‑safety requirements may necessitate wrapping the cache in `UnsafeCell` or providing internal mutability strategies compatible with `Send`/`Sync`. + +## References +- [Python `CodeType` objects](https://docs.python.org/3/reference/datamodel.html#code-objects) +- [Python monitoring API](https://docs.python.org/3/library/sys.monitoring.html)