Skip to content

Commit ce46778

Browse files
committed
Add py::raise_from to enable chaining exceptions on Python 3.3+
1 parent e58c689 commit ce46778

File tree

4 files changed

+100
-0
lines changed

4 files changed

+100
-0
lines changed

docs/advanced/exceptions.rst

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

326+
Chaining exceptions ('raise from')
327+
==================================
328+
329+
In Python 3.3 a mechanism for indicating that exceptions were caused by other
330+
exceptions was introduced:
331+
332+
.. code-block:: py
333+
334+
try:
335+
print(1 / 0)
336+
except Exception as exc:
337+
raise RuntimeError("could not divide by zero") from exc
338+
339+
To do a similar thing in pybind11, you can use the ``py::raise_from`` function. It
340+
sets the current python error indicator, so to continue propagating the exception
341+
you should ``throw py::error_already_set()`` (Python 3 only).
342+
343+
.. code-block:: cpp
344+
345+
try {
346+
py::eval("print(1 / 0"));
347+
} catch (py::error_already_set &e) {
348+
py::raise_from(e, PyExc_RuntimeError, "could not divide by zero");
349+
throw py::error_already_set();
350+
}
351+
352+
.. versionadded:: 2.8
353+
326354
.. _unraisable_exceptions:
327355

328356
Handling unraisable exceptions

include/pybind11/pytypes.h

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,42 @@ class PYBIND11_EXPORT error_already_set : public std::runtime_error {
380380
# pragma warning(pop)
381381
#endif
382382

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

tests/test_exceptions.cpp

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,4 +262,24 @@ TEST_SUBMODULE(exceptions, m) {
262262
m.def("simple_bool_passthrough", [](bool x) {return x;});
263263

264264
m.def("throw_should_be_translated_to_key_error", []() { throw shared_exception(); });
265+
266+
#if PY_VERSION_HEX >= 0x03030000
267+
268+
m.def("raise_from", []() {
269+
PyErr_SetString(PyExc_ValueError, "inner");
270+
py::raise_from(PyExc_ValueError, "outer");
271+
throw py::error_already_set();
272+
});
273+
274+
m.def("raise_from_already_set", []() {
275+
try {
276+
PyErr_SetString(PyExc_ValueError, "inner");
277+
throw py::error_already_set();
278+
} catch (py::error_already_set& e) {
279+
py::raise_from(e, PyExc_ValueError, "outer");
280+
throw py::error_already_set();
281+
}
282+
});
283+
284+
#endif
265285
}

tests/test_exceptions.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,22 @@ def test_error_already_set(msg):
2525
assert msg(excinfo.value) == "foo"
2626

2727

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+
2844
def test_cross_module_exceptions(msg):
2945
with pytest.raises(RuntimeError) as excinfo:
3046
cm.raise_runtime_error()

0 commit comments

Comments
 (0)