Skip to content

Commit 56e88e9

Browse files
committed
Add py::raise_from to enable chaining exceptions on Python 3.3+
1 parent cbae6d5 commit 56e88e9

File tree

4 files changed

+102
-0
lines changed

4 files changed

+102
-0
lines changed

docs/advanced/exceptions.rst

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,34 @@ Alternately, to ignore the error, call `PyErr_Clear
261261
Any Python error must be thrown or cleared, or Python/pybind11 will be left in
262262
an invalid state.
263263

264+
Chaining exceptions ('raise from')
265+
==================================
266+
267+
In Python 3.3 a mechanism for indicating that exceptions were caused by other
268+
exceptions was introduced:
269+
270+
.. code-block:: py
271+
272+
try:
273+
print(1 / 0)
274+
except Exception as exc:
275+
raise RuntimeError("could not divide by zero") from exc
276+
277+
To do a similar thing in pybind11, you can use the ``py::raise_from`` function. It
278+
sets the current python error indicator, so to continue propagating the exception
279+
you should ``throw py::error_already_set()``.
280+
281+
.. code-block:: cpp
282+
283+
try {
284+
py::eval("print(1 / 0"));
285+
} catch (py::error_already_set &e) {
286+
py::raise_from(e, PyExc_RuntimeError, "could not divide by zero");
287+
throw py::error_already_set();
288+
}
289+
290+
.. versionadded:: 2.6
291+
264292
.. _unraisable_exceptions:
265293

266294
Handling unraisable exceptions

include/pybind11/pytypes.h

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,42 @@ class error_already_set : public std::runtime_error {
372372
object m_type, m_value, m_trace;
373373
};
374374

375+
#if PY_VERSION_HEX >= 0x03030000
376+
377+
/// Replaces the current Python error indicator with the chosen error, performing a
378+
/// 'raise from' to indicate that the chosen error was caused by the original error
379+
inline void raise_from(PyObject *type, const char *message) {
380+
// from cpython/errors.c _PyErr_FormatVFromCause
381+
PyObject *exc, *val, *val2, *tb;
382+
PyErr_Fetch(&exc, &val, &tb);
383+
384+
PyErr_NormalizeException(&exc, &val, &tb);
385+
if (tb != nullptr) {
386+
PyException_SetTraceback(val, tb);
387+
Py_DECREF(tb);
388+
}
389+
Py_DECREF(exc);
390+
391+
PyErr_SetString(type, message);
392+
PyErr_Fetch(&exc, &val2, &tb);
393+
PyErr_NormalizeException(&exc, &val2, &tb);
394+
Py_INCREF(val);
395+
PyException_SetCause(val2, val);
396+
PyException_SetContext(val2, val);
397+
PyErr_Restore(exc, val2, tb);
398+
}
399+
400+
/// Sets the current Python error indicator with the chosen error, performing a 'raise from'
401+
/// from the error contained in error_already_set to indicate that the chosen error was
402+
/// caused by the original error. After this function is called error_already_set will
403+
/// no longer contain an error.
404+
inline void raise_from(error_already_set& err, PyObject *type, const char *message) {
405+
err.restore();
406+
raise_from(type, message);
407+
}
408+
409+
#endif
410+
375411
/** \defgroup python_builtins _
376412
Unless stated otherwise, the following C++ functions behave the same
377413
as their Python counterparts.

tests/test_exceptions.cpp

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,26 @@ TEST_SUBMODULE(exceptions, m) {
198198
throw py::error_already_set();
199199
});
200200

201+
#if PY_VERSION_HEX >= 0x03030000
202+
203+
m.def("raise_from", []() {
204+
PyErr_SetString(PyExc_ValueError, "inner");
205+
py::raise_from(PyExc_ValueError, "outer");
206+
throw py::error_already_set();
207+
});
208+
209+
m.def("raise_from_already_set", []() {
210+
try {
211+
PyErr_SetString(PyExc_ValueError, "inner");
212+
throw py::error_already_set();
213+
} catch (py::error_already_set& e) {
214+
py::raise_from(e, PyExc_ValueError, "outer");
215+
throw py::error_already_set();
216+
}
217+
});
218+
219+
#endif
220+
201221
m.def("python_call_in_destructor", [](py::dict d) {
202222
try {
203223
PythonCallInDestructor set_dict_in_destructor(d);

tests/test_exceptions.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
import pytest
55

6+
import env # noqa: F401
7+
68
from pybind11_tests import exceptions as m
79
import pybind11_cross_module_tests as cm
810

@@ -23,6 +25,22 @@ def test_error_already_set(msg):
2325
assert msg(excinfo.value) == "foo"
2426

2527

28+
@pytest.mark.skipif("env.PY2")
29+
def test_raise_from(msg):
30+
with pytest.raises(ValueError) as excinfo:
31+
m.raise_from()
32+
assert msg(excinfo.value) == "outer"
33+
assert msg(excinfo.value.__cause__) == "inner"
34+
35+
36+
@pytest.mark.skipif("env.PY2")
37+
def test_raise_from_already_set(msg):
38+
with pytest.raises(ValueError) as excinfo:
39+
m.raise_from_already_set()
40+
assert msg(excinfo.value) == "outer"
41+
assert msg(excinfo.value.__cause__) == "inner"
42+
43+
2644
def test_cross_module_exceptions():
2745
with pytest.raises(RuntimeError) as excinfo:
2846
cm.raise_runtime_error()

0 commit comments

Comments
 (0)