Skip to content

Commit c8ce4b8

Browse files
rwgkvirtuald
andauthored
Clone of @virtuald's PR #2112 with minor enhancements. (#3215)
* Add py::raise_from to enable chaining exceptions on Python 3.3+ * Use 'raise from' in initialization * Documenting the exact base version of _PyErr_FormatVFromCause, adding back `assert`s. Co-authored-by: Dustin Spicuzza <[email protected]>
1 parent 6cbabc4 commit c8ce4b8

File tree

6 files changed

+136
-0
lines changed

6 files changed

+136
-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/detail/common.h

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,19 @@ extern "C" {
315315
} \
316316
}
317317

318+
#if PY_VERSION_HEX >= 0x03030000
319+
320+
#define PYBIND11_CATCH_INIT_EXCEPTIONS \
321+
catch (pybind11::error_already_set &e) { \
322+
pybind11::raise_from(e, PyExc_ImportError, "initialization failed"); \
323+
return nullptr; \
324+
} catch (const std::exception &e) { \
325+
PyErr_SetString(PyExc_ImportError, e.what()); \
326+
return nullptr; \
327+
} \
328+
329+
#else
330+
318331
#define PYBIND11_CATCH_INIT_EXCEPTIONS \
319332
catch (pybind11::error_already_set &e) { \
320333
PyErr_SetString(PyExc_ImportError, e.what()); \
@@ -324,6 +337,8 @@ extern "C" {
324337
return nullptr; \
325338
} \
326339

340+
#endif
341+
327342
/** \rst
328343
***Deprecated in favor of PYBIND11_MODULE***
329344

include/pybind11/pytypes.h

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,47 @@ class PYBIND11_EXPORT_EXCEPTION error_already_set : public std::runtime_error {
382382
# pragma warning(pop)
383383
#endif
384384

385+
#if PY_VERSION_HEX >= 0x03030000
386+
387+
/// Replaces the current Python error indicator with the chosen error, performing a
388+
/// 'raise from' to indicate that the chosen error was caused by the original error.
389+
inline void raise_from(PyObject *type, const char *message) {
390+
// Based on _PyErr_FormatVFromCause:
391+
// https://github.com/python/cpython/blob/467ab194fc6189d9f7310c89937c51abeac56839/Python/errors.c#L405
392+
// See https://github.com/pybind/pybind11/pull/2112 for details.
393+
PyObject *exc = nullptr, *val = nullptr, *val2 = nullptr, *tb = nullptr;
394+
395+
assert(PyErr_Occurred());
396+
PyErr_Fetch(&exc, &val, &tb);
397+
PyErr_NormalizeException(&exc, &val, &tb);
398+
if (tb != nullptr) {
399+
PyException_SetTraceback(val, tb);
400+
Py_DECREF(tb);
401+
}
402+
Py_DECREF(exc);
403+
assert(!PyErr_Occurred());
404+
405+
PyErr_SetString(type, message);
406+
407+
PyErr_Fetch(&exc, &val2, &tb);
408+
PyErr_NormalizeException(&exc, &val2, &tb);
409+
Py_INCREF(val);
410+
PyException_SetCause(val2, val);
411+
PyException_SetContext(val2, val);
412+
PyErr_Restore(exc, val2, tb);
413+
}
414+
415+
/// Sets the current Python error indicator with the chosen error, performing a 'raise from'
416+
/// from the error contained in error_already_set to indicate that the chosen error was
417+
/// caused by the original error. After this function is called error_already_set will
418+
/// no longer contain an error.
419+
inline void raise_from(error_already_set& err, PyObject *type, const char *message) {
420+
err.restore();
421+
raise_from(type, message);
422+
}
423+
424+
#endif
425+
385426
/** \defgroup python_builtins _
386427
Unless stated otherwise, the following C++ functions behave the same
387428
as their Python counterparts.

tests/test_embed/test_interpreter.cpp

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,24 @@ TEST_CASE("Import error handling") {
7474
REQUIRE_NOTHROW(py::module_::import("widget_module"));
7575
REQUIRE_THROWS_WITH(py::module_::import("throw_exception"),
7676
"ImportError: C++ Error");
77+
#if PY_VERSION_HEX >= 0x03030000
78+
REQUIRE_THROWS_WITH(py::module_::import("throw_error_already_set"),
79+
Catch::Contains("ImportError: initialization failed"));
80+
81+
auto locals = py::dict("is_keyerror"_a=false, "message"_a="not set");
82+
py::exec(R"(
83+
try:
84+
import throw_error_already_set
85+
except ImportError as e:
86+
is_keyerror = type(e.__cause__) == KeyError
87+
message = str(e.__cause__)
88+
)", py::globals(), locals);
89+
REQUIRE(locals["is_keyerror"].cast<bool>() == true);
90+
REQUIRE(locals["message"].cast<std::string>() == "'missing'");
91+
#else
7792
REQUIRE_THROWS_WITH(py::module_::import("throw_error_already_set"),
7893
Catch::Contains("ImportError: KeyError"));
94+
#endif
7995
}
8096

8197
TEST_CASE("There can be only one interpreter") {

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
@@ -24,6 +24,22 @@ def test_error_already_set(msg):
2424
assert msg(excinfo.value) == "foo"
2525

2626

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

0 commit comments

Comments
 (0)