Skip to content

Commit

Permalink
Clone of @virtuald's PR #2112 with minor enhancements. (#3215)
Browse files Browse the repository at this point in the history
* 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 <dustin@virtualroadside.com>
  • Loading branch information
Ralf W. Grosse-Kunstleve and virtuald authored Aug 24, 2021
1 parent 6cbabc4 commit c8ce4b8
Show file tree
Hide file tree
Showing 6 changed files with 136 additions and 0 deletions.
28 changes: 28 additions & 0 deletions docs/advanced/exceptions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,34 @@ Alternately, to ignore the error, call `PyErr_Clear
Any Python error must be thrown or cleared, or Python/pybind11 will be left in
an invalid state.

Chaining exceptions ('raise from')
==================================

In Python 3.3 a mechanism for indicating that exceptions were caused by other
exceptions was introduced:

.. code-block:: py
try:
print(1 / 0)
except Exception as exc:
raise RuntimeError("could not divide by zero") from exc
To do a similar thing in pybind11, you can use the ``py::raise_from`` function. It
sets the current python error indicator, so to continue propagating the exception
you should ``throw py::error_already_set()`` (Python 3 only).

.. code-block:: cpp
try {
py::eval("print(1 / 0"));
} catch (py::error_already_set &e) {
py::raise_from(e, PyExc_RuntimeError, "could not divide by zero");
throw py::error_already_set();
}
.. versionadded:: 2.8

.. _unraisable_exceptions:

Handling unraisable exceptions
Expand Down
15 changes: 15 additions & 0 deletions include/pybind11/detail/common.h
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,19 @@ extern "C" {
} \
}

#if PY_VERSION_HEX >= 0x03030000

#define PYBIND11_CATCH_INIT_EXCEPTIONS \
catch (pybind11::error_already_set &e) { \
pybind11::raise_from(e, PyExc_ImportError, "initialization failed"); \
return nullptr; \
} catch (const std::exception &e) { \
PyErr_SetString(PyExc_ImportError, e.what()); \
return nullptr; \
} \

#else

#define PYBIND11_CATCH_INIT_EXCEPTIONS \
catch (pybind11::error_already_set &e) { \
PyErr_SetString(PyExc_ImportError, e.what()); \
Expand All @@ -324,6 +337,8 @@ extern "C" {
return nullptr; \
} \

#endif

/** \rst
***Deprecated in favor of PYBIND11_MODULE***
Expand Down
41 changes: 41 additions & 0 deletions include/pybind11/pytypes.h
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,47 @@ class PYBIND11_EXPORT_EXCEPTION error_already_set : public std::runtime_error {
# pragma warning(pop)
#endif

#if PY_VERSION_HEX >= 0x03030000

/// Replaces the current Python error indicator with the chosen error, performing a
/// 'raise from' to indicate that the chosen error was caused by the original error.
inline void raise_from(PyObject *type, const char *message) {
// Based on _PyErr_FormatVFromCause:
// https://github.com/python/cpython/blob/467ab194fc6189d9f7310c89937c51abeac56839/Python/errors.c#L405
// See https://github.com/pybind/pybind11/pull/2112 for details.
PyObject *exc = nullptr, *val = nullptr, *val2 = nullptr, *tb = nullptr;

assert(PyErr_Occurred());
PyErr_Fetch(&exc, &val, &tb);
PyErr_NormalizeException(&exc, &val, &tb);
if (tb != nullptr) {
PyException_SetTraceback(val, tb);
Py_DECREF(tb);
}
Py_DECREF(exc);
assert(!PyErr_Occurred());

PyErr_SetString(type, message);

PyErr_Fetch(&exc, &val2, &tb);
PyErr_NormalizeException(&exc, &val2, &tb);
Py_INCREF(val);
PyException_SetCause(val2, val);
PyException_SetContext(val2, val);
PyErr_Restore(exc, val2, tb);
}

/// Sets the current Python error indicator with the chosen error, performing a 'raise from'
/// from the error contained in error_already_set to indicate that the chosen error was
/// caused by the original error. After this function is called error_already_set will
/// no longer contain an error.
inline void raise_from(error_already_set& err, PyObject *type, const char *message) {
err.restore();
raise_from(type, message);
}

#endif

/** \defgroup python_builtins _
Unless stated otherwise, the following C++ functions behave the same
as their Python counterparts.
Expand Down
16 changes: 16 additions & 0 deletions tests/test_embed/test_interpreter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,24 @@ TEST_CASE("Import error handling") {
REQUIRE_NOTHROW(py::module_::import("widget_module"));
REQUIRE_THROWS_WITH(py::module_::import("throw_exception"),
"ImportError: C++ Error");
#if PY_VERSION_HEX >= 0x03030000
REQUIRE_THROWS_WITH(py::module_::import("throw_error_already_set"),
Catch::Contains("ImportError: initialization failed"));

auto locals = py::dict("is_keyerror"_a=false, "message"_a="not set");
py::exec(R"(
try:
import throw_error_already_set
except ImportError as e:
is_keyerror = type(e.__cause__) == KeyError
message = str(e.__cause__)
)", py::globals(), locals);
REQUIRE(locals["is_keyerror"].cast<bool>() == true);
REQUIRE(locals["message"].cast<std::string>() == "'missing'");
#else
REQUIRE_THROWS_WITH(py::module_::import("throw_error_already_set"),
Catch::Contains("ImportError: KeyError"));
#endif
}

TEST_CASE("There can be only one interpreter") {
Expand Down
20 changes: 20 additions & 0 deletions tests/test_exceptions.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -262,4 +262,24 @@ TEST_SUBMODULE(exceptions, m) {
m.def("simple_bool_passthrough", [](bool x) {return x;});

m.def("throw_should_be_translated_to_key_error", []() { throw shared_exception(); });

#if PY_VERSION_HEX >= 0x03030000

m.def("raise_from", []() {
PyErr_SetString(PyExc_ValueError, "inner");
py::raise_from(PyExc_ValueError, "outer");
throw py::error_already_set();
});

m.def("raise_from_already_set", []() {
try {
PyErr_SetString(PyExc_ValueError, "inner");
throw py::error_already_set();
} catch (py::error_already_set& e) {
py::raise_from(e, PyExc_ValueError, "outer");
throw py::error_already_set();
}
});

#endif
}
16 changes: 16 additions & 0 deletions tests/test_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@ def test_error_already_set(msg):
assert msg(excinfo.value) == "foo"


@pytest.mark.skipif("env.PY2")
def test_raise_from(msg):
with pytest.raises(ValueError) as excinfo:
m.raise_from()
assert msg(excinfo.value) == "outer"
assert msg(excinfo.value.__cause__) == "inner"


@pytest.mark.skipif("env.PY2")
def test_raise_from_already_set(msg):
with pytest.raises(ValueError) as excinfo:
m.raise_from_already_set()
assert msg(excinfo.value) == "outer"
assert msg(excinfo.value.__cause__) == "inner"


def test_cross_module_exceptions(msg):
with pytest.raises(RuntimeError) as excinfo:
cm.raise_runtime_error()
Expand Down

0 comments on commit c8ce4b8

Please sign in to comment.