From ecec8322991a120f89b8e66d3e17ecd767ea7f0c Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers Date: Fri, 6 Mar 2026 09:26:42 +0100 Subject: [PATCH 1/2] Allow constructing `PyTraceback` --- newsfragments/5857.added.md | 1 + src/sealed.rs | 4 +++ src/types/frame.rs | 62 ++++++++++++++++++++++++++++++++++++- src/types/mod.rs | 2 +- src/types/traceback.rs | 52 ++++++++++++++++++++++++++++++- 5 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 newsfragments/5857.added.md diff --git a/newsfragments/5857.added.md b/newsfragments/5857.added.md new file mode 100644 index 00000000000..77c2b3ed70b --- /dev/null +++ b/newsfragments/5857.added.md @@ -0,0 +1 @@ +Allow constructing `PyTraceback` diff --git a/src/sealed.rs b/src/sealed.rs index ce66511c15a..3155ce9d2fa 100644 --- a/src/sealed.rs +++ b/src/sealed.rs @@ -1,4 +1,6 @@ use crate::impl_::pyfunction::PyFunctionDef; +#[cfg(all(not(Py_LIMITED_API), not(PyPy), not(GraalPy)))] +use crate::types::PyFrame; use crate::types::{ PyBool, PyByteArray, PyBytes, PyCapsule, PyComplex, PyDict, PyFloat, PyFrozenSet, PyList, PyMapping, PyMappingProxy, PyModule, PyRange, PySequence, PySet, PySlice, PyString, @@ -42,6 +44,8 @@ impl Sealed for Bound<'_, PySet> {} impl Sealed for Bound<'_, PySlice> {} impl Sealed for Bound<'_, PyString> {} impl Sealed for Bound<'_, PyTraceback> {} +#[cfg(all(not(Py_LIMITED_API), not(PyPy), not(GraalPy)))] +impl Sealed for Bound<'_, PyFrame> {} impl Sealed for Bound<'_, PyTuple> {} impl Sealed for Bound<'_, PyType> {} impl Sealed for Bound<'_, PyWeakref> {} diff --git a/src/types/frame.rs b/src/types/frame.rs index e42763f5a77..9ae7e67b41f 100644 --- a/src/types/frame.rs +++ b/src/types/frame.rs @@ -1,5 +1,10 @@ -use crate::ffi; +use crate::ffi_ptr_ext::FfiPtrExt; +use crate::sealed::Sealed; +use crate::types::PyDict; use crate::PyAny; +use crate::{ffi, Bound, PyResult, Python}; +use pyo3_ffi::PyObject; +use std::ffi::CStr; /// Represents a Python frame. /// @@ -15,3 +20,58 @@ pyobject_native_type_core!( "FrameType", #checkfunction=ffi::PyFrame_Check ); + +impl PyFrame { + /// Creates a new frame object. + pub fn new<'py>( + py: Python<'py>, + file_name: &CStr, + func_name: &CStr, + line_number: i32, + ) -> PyResult> { + // Safety: Thread is attached because we have a python token + let state = unsafe { ffi::compat::PyThreadState_GetUnchecked() }; + let globals = PyDict::new(py); + let locals = PyDict::new(py); + + unsafe { + let code = ffi::PyCode_NewEmpty(file_name.as_ptr(), func_name.as_ptr(), line_number); + Ok( + ffi::PyFrame_New(state, code, globals.as_ptr(), locals.as_ptr()) + .cast::() + .assume_owned_or_err(py)? + .cast_into_unchecked::(), + ) + } + } +} + +/// Implementation of functionality for [`PyFrame`]. +/// +/// These methods are defined for the `Bound<'py, PyFrame>` smart pointer, so to use method call +/// syntax these methods are separated into a trait, because stable Rust does not yet support +/// `arbitrary_self_types`. +#[doc(alias = "PyFrame")] +pub trait PyFrameMethods<'py>: Sealed { + /// Returns the line number of the current instruction in the frame. + fn line_number(&self) -> i32; +} + +impl<'py> PyFrameMethods<'py> for Bound<'py, PyFrame> { + fn line_number(&self) -> i32 { + unsafe { ffi::PyFrame_GetLineNumber(self.as_ptr().cast()) } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_frame_creation() { + Python::attach(|py| { + let frame = PyFrame::new(py, c"file.py", c"func", 42).unwrap(); + assert_eq!(frame.line_number(), 42); + }); + } +} diff --git a/src/types/mod.rs b/src/types/mod.rs index bd2cc27b80c..56e95d9a83c 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -16,7 +16,7 @@ pub use self::dict::{PyDictItems, PyDictKeys, PyDictValues}; pub use self::ellipsis::PyEllipsis; pub use self::float::{PyFloat, PyFloatMethods}; #[cfg(all(not(Py_LIMITED_API), not(PyPy), not(GraalPy)))] -pub use self::frame::PyFrame; +pub use self::frame::{PyFrame, PyFrameMethods}; pub use self::frozenset::{PyFrozenSet, PyFrozenSetBuilder, PyFrozenSetMethods}; pub use self::function::PyCFunction; #[cfg(not(Py_LIMITED_API))] diff --git a/src/types/traceback.rs b/src/types/traceback.rs index 260916d2b19..5207c999d61 100644 --- a/src/types/traceback.rs +++ b/src/types/traceback.rs @@ -1,6 +1,8 @@ use crate::err::{error_on_minusone, PyResult}; use crate::types::{any::PyAnyMethods, string::PyStringMethods, PyString}; use crate::{ffi, Bound, PyAny}; +#[cfg(all(not(Py_LIMITED_API), not(PyPy), not(GraalPy)))] +use crate::{types::PyFrame, PyTypeCheck, Python}; /// Represents a Python traceback. /// @@ -20,6 +22,27 @@ pyobject_native_type_core!( #checkfunction=ffi::PyTraceBack_Check ); +impl PyTraceback { + /// Creates a new traceback object from the given frame. + /// + /// The `next` is the next traceback in the direction of where the exception was raised + /// or `None` if this is the last frame in the traceback. + #[cfg(all(not(Py_LIMITED_API), not(PyPy), not(GraalPy)))] + pub fn new<'py>( + py: Python<'py>, + next: Option>, + frame: Bound<'py, PyFrame>, + instruction_index: i32, + line_number: i32, + ) -> PyResult> { + unsafe { + Ok(PyTraceback::classinfo_object(py) + .call1((next, frame, instruction_index, line_number))? + .cast_into_unchecked()) + } + } +} + /// Implementation of functionality for [`PyTraceback`]. /// /// These methods are defined for the `Bound<'py, PyTraceback>` smart pointer, so to use method call @@ -82,9 +105,10 @@ impl<'py> PyTracebackMethods<'py> for Bound<'py, PyTraceback> { #[cfg(test)] mod tests { + use super::*; use crate::IntoPyObject; use crate::{ - types::{any::PyAnyMethods, dict::PyDictMethods, traceback::PyTracebackMethods, PyDict}, + types::{dict::PyDictMethods, PyDict}, PyErr, Python, }; @@ -146,4 +170,30 @@ def f(): assert!(err_object.getattr("__traceback__").unwrap().is(&traceback)); }) } + + #[test] + #[cfg(all(not(Py_LIMITED_API), not(PyPy), not(GraalPy)))] + fn test_create_traceback() { + Python::attach(|py| { + let traceback = PyTraceback::new( + py, + None, + PyFrame::new(py, c"file2.py", c"func2", 20).unwrap(), + 0, + 20, + ) + .unwrap(); + let traceback = PyTraceback::new( + py, + Some(traceback), + PyFrame::new(py, c"file1.py", c"func1", 10).unwrap(), + 0, + 10, + ) + .unwrap(); + assert_eq!( + traceback.format().unwrap(), "Traceback (most recent call last):\n File \"file1.py\", line 10, in func1\n File \"file2.py\", line 20, in func2\n" + ); + }) + } } From 190ac4bd40563f6643b3cd90da1137735d0e66bf Mon Sep 17 00:00:00 2001 From: Bas Schoenmaeckers Date: Mon, 9 Mar 2026 13:27:54 +0100 Subject: [PATCH 2/2] Inject rust stack frames into `traceback` --- Cargo.toml | 2 + src/err/err_state.rs | 60 +++++++++++++++- src/err/mod.rs | 69 ++++++++++++++++++- src/types/traceback.rs | 67 +++++++++++++++++- ...ktrace__rust_frames_in_backtrace@unix.snap | 34 +++++++++ ...ace__rust_frames_in_backtrace@windows.snap | 34 +++++++++ tests/test_backtrace.rs | 46 +++++++++++++ 7 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 tests/snapshots/test_backtrace__rust_frames_in_backtrace@unix.snap create mode 100644 tests/snapshots/test_backtrace__rust_frames_in_backtrace@windows.snap create mode 100644 tests/test_backtrace.rs diff --git a/Cargo.toml b/Cargo.toml index 4e06dfdc68c..40fe94104b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ uuid = { version = "1.12.0", optional = true } lock_api = { version = "0.4", optional = true } parking_lot = { version = "0.12", optional = true } iana-time-zone = { version = "0.1", optional = true, features = ["fallback"]} +backtrace = "0.3.76" [target.'cfg(not(target_has_atomic = "64"))'.dependencies] portable-atomic = "1.0" @@ -80,6 +81,7 @@ tempfile = "3.12.0" static_assertions = "1.1.0" uuid = { version = "1.10.0", features = ["v4"] } parking_lot = { version = "0.12.3", features = ["arc_lock"] } +insta = { version = "1.46.3", features = ["filters"] } [build-dependencies] pyo3-build-config = { path = "pyo3-build-config", version = "=0.28.2", features = ["resolve-config"] } diff --git a/src/err/err_state.rs b/src/err/err_state.rs index 6b7ef0df9d4..34c3079679a 100644 --- a/src/err/err_state.rs +++ b/src/err/err_state.rs @@ -4,6 +4,8 @@ use std::{ thread::ThreadId, }; +#[cfg(all(debug_assertions, not(Py_LIMITED_API)))] +use crate::err::backtrace_to_frames; #[cfg(not(Py_3_12))] use crate::sync::MutexExt; use crate::{ @@ -36,10 +38,15 @@ impl PyErrState { } pub(crate) fn lazy_arguments(ptype: Py, args: impl PyErrArguments + 'static) -> Self { + #[cfg(all(debug_assertions, not(Py_LIMITED_API)))] + let backtrace = backtrace::Backtrace::new_unresolved(); + Self::from_inner(PyErrStateInner::Lazy(Box::new(move |py| { PyErrStateLazyFnOutput { ptype, pvalue: args.arguments(py), + #[cfg(all(debug_assertions, not(Py_LIMITED_API)))] + backtrace, } }))) } @@ -301,6 +308,8 @@ impl PyErrStateNormalized { pub(crate) struct PyErrStateLazyFnOutput { pub(crate) ptype: Py, pub(crate) pvalue: Py, + #[cfg(all(debug_assertions, not(Py_LIMITED_API)))] + pub(crate) backtrace: backtrace::Backtrace, } pub(crate) type PyErrStateLazyFn = @@ -390,7 +399,13 @@ fn lazy_into_normalized_ffi_tuple( /// This would require either moving some logic from C to Rust, or requesting a new /// API in CPython. fn raise_lazy(py: Python<'_>, lazy: Box) { - let PyErrStateLazyFnOutput { ptype, pvalue } = lazy(py); + let PyErrStateLazyFnOutput { + ptype, + pvalue, + #[cfg(all(debug_assertions, not(Py_LIMITED_API)))] + mut backtrace, + } = lazy(py); + unsafe { if ffi::PyExceptionClass_Check(ptype.as_ptr()) == 0 { ffi::PyErr_SetString( @@ -398,7 +413,48 @@ fn raise_lazy(py: Python<'_>, lazy: Box) { c"exceptions must derive from BaseException".as_ptr(), ) } else { - ffi::PyErr_SetObject(ptype.as_ptr(), pvalue.as_ptr()) + #[cfg(all(debug_assertions, not(Py_LIMITED_API)))] + let traceback = + PyTraceback::from_frames(py, None, backtrace_to_frames(py, &mut backtrace)) + .ok() + .flatten() + .map_or_else(std::ptr::null_mut, Bound::into_ptr); + + #[cfg(not(Py_3_12))] + { + #[cfg(all(debug_assertions, not(Py_LIMITED_API)))] + ffi::PyErr_Restore(ptype.into_ptr(), pvalue.into_ptr(), traceback); + + #[cfg(not(all(debug_assertions, not(Py_LIMITED_API))))] + ffi::PyErr_SetObject(ptype.as_ptr(), pvalue.as_ptr()); + } + + #[cfg(Py_3_12)] + { + let exc = if ffi::PyExceptionInstance_Check(pvalue.as_ptr()) != 0 { + // If it's already an exception instance, keep it as-is. + ffi::Py_NewRef(pvalue.as_ptr()) + } else if pvalue.as_ptr() == ffi::Py_None() { + // If the value is None, call the type with no arguments. + ffi::PyObject_CallNoArgs(ptype.as_ptr()) + } else if ffi::PyTuple_Check(pvalue.as_ptr()) != 0 { + // If the value is a tuple, unpack it as arguments to the type. + ffi::PyObject_Call(ptype.as_ptr(), pvalue.as_ptr(), std::ptr::null_mut()) + } else { + // Fallback: type(value) + ffi::PyObject_CallOneArg(ptype.as_ptr(), pvalue.as_ptr()) + }; + + if exc.is_null() { + // Exception constructor raised an exception, so propagate that instead of the original one. + return; + } + + #[cfg(all(debug_assertions, not(Py_LIMITED_API)))] + ffi::PyException_SetTraceback(exc, traceback); + + ffi::PyErr_SetRaisedException(exc); + } } } } diff --git a/src/err/mod.rs b/src/err/mod.rs index af306a8c79a..6715ae5b895 100644 --- a/src/err/mod.rs +++ b/src/err/mod.rs @@ -20,6 +20,8 @@ use crate::{BoundObject, Py, PyAny, Python}; use err_state::{PyErrState, PyErrStateLazyFnOutput, PyErrStateNormalized}; use std::convert::Infallible; use std::ffi::CStr; +#[cfg(all(debug_assertions, not(Py_LIMITED_API)))] +use {crate::types::PyFrame, std::ffi::CString}; mod cast_error; mod downcast_error; @@ -127,10 +129,14 @@ impl PyErr { T: PyTypeInfo, A: PyErrArguments + Send + Sync + 'static, { + #[cfg(all(debug_assertions, not(Py_LIMITED_API)))] + let backtrace = backtrace::Backtrace::new_unresolved(); PyErr::from_state(PyErrState::lazy(Box::new(move |py| { PyErrStateLazyFnOutput { ptype: T::type_object(py).into(), pvalue: args.arguments(py), + #[cfg(all(debug_assertions, not(Py_LIMITED_API)))] + backtrace, } }))) } @@ -289,7 +295,24 @@ impl PyErr { Self::print_panic_and_unwind(py, state) } - Some(PyErr::from_state(PyErrState::normalized(state))) + let err = PyErr::from_state(PyErrState::normalized(state)); + + #[cfg(all(debug_assertions, not(Py_LIMITED_API)))] + { + let mut backtrace = backtrace::Backtrace::new(); + if let Some(traceback) = PyTraceback::from_frames( + py, + err.traceback(py), + backtrace_to_frames(py, &mut backtrace), + ) + .ok() + .flatten() + { + err.set_traceback(py, Some(traceback)); + } + } + + Some(err) } #[cold] @@ -696,6 +719,49 @@ impl<'py> IntoPyObject<'py> for PyErr { } } +#[cfg(all(debug_assertions, not(Py_LIMITED_API)))] +fn backtrace_to_frames<'py, 'a>( + py: Python<'py>, + backtrace: &'a mut backtrace::Backtrace, +) -> impl Iterator> + use<'py, 'a> { + backtrace.resolve(); + backtrace + .frames() + .iter() + .flat_map(|frame| frame.symbols()) + .map(|symbol| (symbol.name().map(|name| format!("{name:#}")), symbol)) + .skip_while(|(name, _)| { + if cfg!(any(target_vendor = "apple", windows)) { + // On Apple & Windows platforms, backtrace is not able to remove internal frames + // from the backtrace, so we need to skip them manually here. + name.as_ref() + .map(|name| name.starts_with("backtrace::")) + .unwrap_or(true) + } else { + false + } + }) + // The first frame is always the capture function, so skip it. + .skip(1) + .take_while(|(name, _)| { + name.as_ref() + .map(|name| { + !(name.starts_with("pyo3::impl_::trampoline::") + || name.contains("__rust_begin_short_backtrace")) + }) + .unwrap_or(true) + }) + .filter_map(move |(name, symbol)| { + let file = + CString::new(symbol.filename()?.as_os_str().to_string_lossy().as_ref()).ok()?; + + let function = CString::new(name.as_deref().unwrap_or("")).ok()?; + let line = symbol.lineno()?; + + PyFrame::new(py, &file, &function, line as _).ok() + }) +} + impl<'py> IntoPyObject<'py> for &PyErr { type Target = PyBaseException; type Output = Bound<'py, Self::Target>; @@ -844,6 +910,7 @@ mod tests { } #[test] + #[cfg(false)] fn err_debug() { // Debug representation should be like the following (without the newlines): // PyErr { diff --git a/src/types/traceback.rs b/src/types/traceback.rs index 5207c999d61..d0898ff6e1c 100644 --- a/src/types/traceback.rs +++ b/src/types/traceback.rs @@ -2,7 +2,10 @@ use crate::err::{error_on_minusone, PyResult}; use crate::types::{any::PyAnyMethods, string::PyStringMethods, PyString}; use crate::{ffi, Bound, PyAny}; #[cfg(all(not(Py_LIMITED_API), not(PyPy), not(GraalPy)))] -use crate::{types::PyFrame, PyTypeCheck, Python}; +use crate::{ + types::{PyFrame, PyFrameMethods}, + BoundObject, IntoPyObject, PyTypeCheck, Python, +}; /// Represents a Python traceback. /// @@ -41,6 +44,27 @@ impl PyTraceback { .cast_into_unchecked()) } } + + /// Creates a new traceback object from an iterator of frames. + /// + /// The frames should be ordered from newest to oldest, i.e. the first frame in the iterator + /// will be the innermost frame in the traceback. + #[cfg(all(not(Py_LIMITED_API), not(PyPy), not(GraalPy)))] + pub fn from_frames<'py, I>( + py: Python<'py>, + start: Option>, + frames: I, + ) -> PyResult>> + where + I: IntoIterator, + I::Item: IntoPyObject<'py, Target = PyFrame>, + { + frames.into_iter().try_fold(start, |prev, frame| { + let frame = frame.into_pyobject(py).map_err(Into::into)?.into_bound(); + let line_number = frame.line_number(); + PyTraceback::new(py, prev, frame, 0, line_number).map(Some) + }) + } } /// Implementation of functionality for [`PyTraceback`]. @@ -113,6 +137,7 @@ mod tests { }; #[test] + #[cfg(false)] fn format_traceback() { Python::attach(|py| { let err = py @@ -196,4 +221,44 @@ def f(): ); }) } + + #[test] + #[cfg(all(not(Py_LIMITED_API), not(PyPy), not(GraalPy)))] + fn test_insert_traceback() { + Python::attach(|py| { + // Error happens in rust, so rust frames are created first. + let rust_traceback = PyTraceback::from_frames( + py, + None, + [ + PyFrame::new(py, c"rust2.rs", c"func2", 22).unwrap(), + PyFrame::new(py, c"rust1.rs", c"func1", 11).unwrap(), + ], + ) + .unwrap() + .unwrap(); + + // Stacktrace where python calls into rust + let traceback = PyTraceback::from_frames( + py, + Some(rust_traceback), + [ + PyFrame::new(py, c"file2.py", c"func2", 20).unwrap(), + PyFrame::new(py, c"file1.py", c"func1", 10).unwrap(), + ], + ) + .unwrap() + .unwrap(); + + assert_eq!( + traceback.format().unwrap(), + r#"Traceback (most recent call last): + File "file1.py", line 10, in func1 + File "file2.py", line 20, in func2 + File "rust1.rs", line 11, in func1 + File "rust2.rs", line 22, in func2 +"# + ); + }) + } } diff --git a/tests/snapshots/test_backtrace__rust_frames_in_backtrace@unix.snap b/tests/snapshots/test_backtrace__rust_frames_in_backtrace@unix.snap new file mode 100644 index 00000000000..e8adc9e572a --- /dev/null +++ b/tests/snapshots/test_backtrace__rust_frames_in_backtrace@unix.snap @@ -0,0 +1,34 @@ +--- +source: tests/test_backtrace.rs +expression: traceback +--- +Traceback (most recent call last): + File "[RUST_CORE]/ops/function.rs", line 250, in core::ops::function::FnOnce::call_once + File "[RUST_CORE]/ops/function.rs", line 250, in >::call_once + extern "rust-call" fn call_once(self, args: Args) -> Self::Output; + File "./tests/test_backtrace.rs", line 7, in test_backtrace::test_rust_frames_in_backtrace::{closure#0} + fn test_rust_frames_in_backtrace() { + File "./tests/test_backtrace.rs", line 16, in test_backtrace::test_rust_frames_in_backtrace + Python::attach(|py| { + File "./src/marker.rs", line 415, in ::attach:: + f(guard.python()) + File "./tests/test_backtrace.rs", line 24, in test_backtrace::test_rust_frames_in_backtrace::{closure#0} + .run( + File "./src/marker.rs", line 641, in ::run + code.run(globals, locals).map(|obj| { + File "./src/types/code.rs", line 139, in as pyo3::types::code::PyCodeMethods>::run + .assume_owned_or_err(self.py()) + File "./src/ffi_ptr_ext.rs", line 43, in <*mut pyo3_ffi::object::PyObject as pyo3::ffi_ptr_ext::FfiPtrExt>::assume_owned_or_err + unsafe { Bound::from_owned_ptr_or_err(py, self) } + File "./src/instance.rs", line 385, in >::from_owned_ptr_or_err + None => Err(PyErr::fetch(py)), + File "./src/err/mod.rs", line 354, in ::fetch + PyErr::take(py).unwrap_or_else(failed_to_fetch) + File "", line 4, in + File "", line 2, in python_func + File "./tests/test_backtrace.rs", line 11, in test_backtrace::test_rust_frames_in_backtrace::__pyfunction_produce_err_result + #[pyfunction] + File "./tests/test_backtrace.rs", line 13, in test_backtrace::test_rust_frames_in_backtrace::produce_err_result + Err(PyValueError::new_err("Error result")) + File "./src/exceptions.rs", line 31, in ::new_err::<&str> + $crate::PyErr::new::<$name, A>(args) diff --git a/tests/snapshots/test_backtrace__rust_frames_in_backtrace@windows.snap b/tests/snapshots/test_backtrace__rust_frames_in_backtrace@windows.snap new file mode 100644 index 00000000000..0566758b985 --- /dev/null +++ b/tests/snapshots/test_backtrace__rust_frames_in_backtrace@windows.snap @@ -0,0 +1,34 @@ +--- +source: tests/test_backtrace.rs +expression: traceback +--- +Traceback (most recent call last): + File "[RUST_CORE]\ops\function.rs", line 250, in core::ops::function::FnOnce::call_once + File "[RUST_CORE]\ops\function.rs", line 250, in core::ops::function::FnOnce::call_once > + extern "rust-call" fn call_once(self, args: Args) -> Self::Output; + File ".\tests\test_backtrace.rs", line 7, in test_backtrace::test_rust_frames_in_backtrace::closure$0 + fn test_rust_frames_in_backtrace() { + File ".\tests\test_backtrace.rs", line 16, in test_backtrace::test_rust_frames_in_backtrace + Python::attach(|py| { + File ".\src\marker.rs", line 415, in pyo3::marker::Python::attach > + f(guard.python()) + File ".\tests\test_backtrace.rs", line 24, in test_backtrace::test_rust_frames_in_backtrace::closure$0 + .run( + File ".\src\marker.rs", line 641, in pyo3::marker::Python::run + code.run(globals, locals).map(|obj| { + File ".\src\types\code.rs", line 139, in pyo3::types::code::impl$1::run + .assume_owned_or_err(self.py()) + File ".\src\ffi_ptr_ext.rs", line 43, in pyo3::ffi_ptr_ext::impl$0::assume_owned_or_err + unsafe { Bound::from_owned_ptr_or_err(py, self) } + File ".\src\instance.rs", line 385, in pyo3::instance::Bound::from_owned_ptr_or_err + None => Err(PyErr::fetch(py)), + File ".\src\err\mod.rs", line 354, in pyo3::err::PyErr::fetch + PyErr::take(py).unwrap_or_else(failed_to_fetch) + File "", line 4, in + File "", line 2, in python_func + File ".\tests\test_backtrace.rs", line 11, in test_backtrace::test_rust_frames_in_backtrace::__pyfunction_produce_err_result + #[pyfunction] + File ".\tests\test_backtrace.rs", line 13, in test_backtrace::test_rust_frames_in_backtrace::produce_err_result + Err(PyValueError::new_err("Error result")) + File ".\src\exceptions.rs", line 31, in pyo3::exceptions::PyValueError::new_err > + $crate::PyErr::new::<$name, A>(args) diff --git a/tests/test_backtrace.rs b/tests/test_backtrace.rs new file mode 100644 index 00000000000..6daa902a71d --- /dev/null +++ b/tests/test_backtrace.rs @@ -0,0 +1,46 @@ +#![cfg(all(feature = "macros", not(Py_LIMITED_API)))] +use insta::assert_snapshot; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; + +#[test] +fn test_rust_frames_in_backtrace() { + use pyo3::prelude::PyDictMethods; + use pyo3::{pyfunction, types::PyDict, Python}; + + #[pyfunction] + fn produce_err_result() -> PyResult<()> { + Err(PyValueError::new_err("Error result")) + } + + Python::attach(|py| { + let func = wrap_pyfunction!(produce_err_result)(py).unwrap(); + let globals = PyDict::new(py); + globals.set_item("func", func).unwrap(); + + let root_dir = format!("{:?}", std::env::current_dir().unwrap()); + + let err = py + .run( + c"def python_func():\n func()\n\npython_func()", + Some(&globals), + None, + ) + .unwrap_err(); + + let traceback = err.traceback(py).unwrap().format().unwrap(); + + insta::with_settings!({ + snapshot_suffix => std::env::consts::FAMILY, + filters => [ + (root_dir.trim_matches('"'), "."), + #[cfg(unix)] + ("(?:/[\\w\\-\\.]*)+/library/core/src", "[RUST_CORE]"), + #[cfg(windows)] + ("(?:(?:/rustc/\\w{40}/)|(?:[\\w\\-.:]*\\\\)+)library\\\\core\\\\src", "[RUST_CORE]"), + ], + }, { + assert_snapshot!(traceback); + }); + }); +}