Skip to content

Commit 0415208

Browse files
committed
Support for raising chained exceptions
Note: the interface is not the same as that of pybind11: ``nb::raise_from`` takes a printf-style varargs input and it furthermore re-raises ``nb::python_error``. A lower-level interface (``nb::chain_error``) is also available.
1 parent 36751cb commit 0415208

File tree

8 files changed

+191
-4
lines changed

8 files changed

+191
-4
lines changed

docs/api_core.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,6 +1087,11 @@ the reference section on :ref:`class binding <class_binding>`.
10871087
Example use case: handling a Python error that occurs in a C++
10881088
destructor where you cannot raise a C++ exception.
10891089

1090+
.. cpp:function:: void discard_as_unraisable(const char * context) noexcept
1091+
1092+
Convenience wrapper around the above function, which takes a C-style
1093+
string for the ``context`` argument.
1094+
10901095
.. cpp:function:: handle type() const
10911096

10921097
Returns a handle to the exception type
@@ -1207,6 +1212,24 @@ the reference section on :ref:`class binding <class_binding>`.
12071212
interface provided by :cpp:class:`exception` class. This function provides
12081213
an escape hatch for more specialized use cases.
12091214

1215+
.. cpp:function:: void chain_error(handle type, const char * fmt, ...) noexcept
1216+
1217+
Raise a Python error of type ``type`` using the format string ``fmt``
1218+
interpreted by ``PyErr_FormatV``.
1219+
1220+
This newly created error is chained on top of an already existing error
1221+
status that must be set before calling the function.
1222+
1223+
.. cpp:function:: void raise_from(python_error &e, handle type, const char * fmt, ...)
1224+
1225+
Convenience wrapper around :cpp:func:`chain_error <chain_error>`. It takes
1226+
an existing Python error (e.g. caught in a ``catch`` block) and creates an
1227+
additional Python exception with the current error as cause. It then
1228+
re-raises :cpp:class:`python_error`. The argument ``fmt`` is a
1229+
``printf``-style format string interpreted by ``PyErr_FormatV``.
1230+
1231+
Usage of this function is explained in the documentation section on
1232+
:ref:`exception chaining <exception_chaining>`.
12101233

12111234
Casting
12121235
-------

docs/exceptions.rst

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,74 @@ Alternately, to ignore the error, call `PyErr_Clear()
218218
<https://docs.python.org/3/c-api/exceptions.html#c.PyErr_Clear>`__. Any
219219
Python error must be thrown or cleared, or nanobind will be left in an
220220
invalid state.
221+
222+
.. _exception_chaining:
223+
224+
Chaining exceptions ('raise from')
225+
----------------------------------
226+
227+
Python has a mechanism for indicating that exceptions were caused by other
228+
exceptions:
229+
230+
.. code-block:: py
231+
232+
try:
233+
print(1 / 0)
234+
except Exception as exc:
235+
raise RuntimeError("could not divide by zero") from exc
236+
237+
To do a similar thing in pybind11, you can use the :cpp:func:`nb::raise_from
238+
<raise_from>` function, which requires a :cpp:class:`nb::python_error
239+
<python_error>` and re-raises it with a chained exception object.
240+
241+
.. code-block:: cpp
242+
243+
nb::callable f = ...;
244+
int arg = 123;
245+
try {
246+
f(arg);
247+
} catch (nb::python_error &e) {
248+
nb::raise_from(e, PyExc_RuntimeError, "Could not call 'f' with %i", arg);
249+
}
250+
251+
The function is internally based on the Python function ``PyErr_FormatV`` and
252+
takes ``printf``-style arguments following the format descriptor.
253+
254+
An even lower-level interface is available via :cpp:func:`nb::chain_error
255+
<chain_error>`.
256+
257+
Handling unraisable exceptions
258+
------------------------------
259+
260+
If a Python function invoked from a C++ destructor or any function marked
261+
``noexcept(true)`` (collectively, "noexcept functions") throws an exception, there
262+
is no way to propagate the exception, as such functions may not throw.
263+
Should they throw or fail to catch any exceptions in their call graph,
264+
the C++ runtime calls ``std::terminate()`` to abort immediately.
265+
266+
Similarly, Python exceptions raised in a class's ``__del__`` method do not
267+
propagate, but are logged by Python as an unraisable error. In Python 3.8+, a
268+
`system hook is triggered
269+
<https://docs.python.org/3/library/sys.html#sys.unraisablehook>`_
270+
and an auditing event is logged.
271+
272+
Any noexcept function should have a try-catch block that traps
273+
:cpp:class:`nb::python_error <python_error>` (or any other exception that can
274+
occur). A useful approach is to convert them to Python exceptions and then
275+
``discard_as_unraisable`` as shown below.
276+
277+
.. code-block:: cpp
278+
279+
void nonthrowing_func() noexcept(true) {
280+
try {
281+
// ...
282+
} catch (nb::python_error &e) {
283+
// Discard the Python error using Python APIs, using the C++ magic
284+
// variable __func__. Python already knows the type and value and of the
285+
// exception object.
286+
e.discard_as_unraisable(__func__);
287+
} catch (const std::exception &e) {
288+
// Log and discard C++ exceptions.
289+
third_party::log(e);
290+
}
291+
}

docs/porting.rst

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,6 @@ Removed features include:
327327
pybind11, however.
328328
- ● Buffer protocol binding (``.def_buffer()``) was removed in favor of
329329
:cpp:class:`nb::ndarray\<..\> <nanobind::ndarray>`.
330-
- ● Nested exceptions are not supported.
331330
- ● Features to facilitate pickling and unpickling were removed.
332331
- ● Support for evaluating Python code strings was removed.
333332

include/nanobind/nb_error.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ class NB_EXPORT python_error : public std::exception {
5656
PyErr_WriteUnraisable(context.ptr());
5757
}
5858

59+
void discard_as_unraisable(const char *context) noexcept {
60+
object context_s = steal(PyUnicode_FromString(context));
61+
discard_as_unraisable(context_s);
62+
}
63+
5964
handle value() const { return m_value; }
6065

6166
#if PY_VERSION_HEX < 0x030C0000
@@ -142,4 +147,7 @@ class exception : public object {
142147
}
143148
};
144149

150+
NB_CORE void chain_error(handle type, const char *fmt, ...) noexcept;
151+
NB_CORE void raise_from(python_error &e, handle type, const char *fmt, ...);
152+
145153
NAMESPACE_END(NB_NAMESPACE)

src/error.cpp

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

1010
#include <nanobind/nanobind.h>
11+
#include <cstdarg>
1112
#include "buffer.h"
1213
#include "nb_internals.h"
1314

@@ -248,4 +249,70 @@ NB_CORE PyObject *exception_new(PyObject *scope, const char *name,
248249
}
249250

250251
NAMESPACE_END(detail)
252+
253+
static void chain_error_v(handle type, const char *fmt, va_list args) noexcept {
254+
#if PY_VERSION_HEX >= 0x030C0000
255+
PyObject *value = PyErr_GetRaisedException();
256+
check(value, "nanobind::detail::raise_from(): error status is not set!");
257+
#else
258+
PyObject *tp = nullptr, *value = nullptr, *traceback = nullptr;
259+
260+
PyErr_Fetch(&tp, &value, &traceback);
261+
check(tp, "nanobind::detail::raise_from(): error status is not set!");
262+
263+
PyErr_NormalizeException(&tp, &value, &traceback);
264+
if (traceback) {
265+
PyException_SetTraceback(value, traceback);
266+
Py_DECREF(traceback);
267+
}
268+
269+
Py_DECREF(tp);
270+
#endif
271+
272+
#if !defined(PYPY_VERSION)
273+
PyErr_FormatV(type.ptr(), fmt, args);
274+
#else
275+
PyObject *exc_str = PyUnicode_FromFormatV(fmt, args);
276+
check(exc_str, "nanobind::detail::raise_from(): PyUnicode_FromFormatV() failed!");
277+
PyErr_SetObject(type.ptr(), exc_str);
278+
Py_DECREF(exc_str);
279+
#endif
280+
281+
PyObject *value_2 = nullptr;
282+
#if PY_VERSION_HEX >= 0x030C0000
283+
value_2 = PyErr_GetRaisedException();
284+
#else
285+
PyErr_Fetch(&tp, &value_2, &traceback);
286+
PyErr_NormalizeException(&tp, &value_2, &traceback);
287+
#endif
288+
289+
Py_INCREF(value);
290+
PyException_SetCause(value_2, value); // steals
291+
PyException_SetContext(value_2, value); // steals
292+
293+
#if PY_VERSION_HEX >= 0x030C0000
294+
PyErr_SetRaisedException(value_2);
295+
#else
296+
PyErr_Restore(tp, value_2, traceback);
297+
#endif
298+
}
299+
300+
void chain_error(handle type, const char *fmt, ...) noexcept {
301+
va_list args;
302+
va_start(args, fmt);
303+
chain_error_v(type, fmt, args);
304+
va_end(args);
305+
}
306+
307+
void raise_from(python_error &e, handle type, const char *fmt, ...) {
308+
e.restore();
309+
310+
va_list args;
311+
va_start(args, fmt);
312+
chain_error_v(type, fmt, args);
313+
va_end(args);
314+
315+
detail::raise_python_error();
316+
}
317+
251318
NAMESPACE_END(NB_NAMESPACE)

src/nb_type.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1000,7 +1000,7 @@ bool nb_type_get(const std::type_info *cpp_type, PyObject *src, uint8_t flags,
10001000
if (NB_LIKELY(valid)) {
10011001
nb_inst *inst = (nb_inst *) src;
10021002

1003-
if (NB_UNLIKELY(((flags & (uint8_t) cast_flags::construct) != 0) == inst->ready)) {
1003+
if (NB_UNLIKELY(((flags & (uint8_t) cast_flags::construct) != 0) == (bool) inst->ready)) {
10041004
PyErr_WarnFormat(
10051005
PyExc_RuntimeWarning, 1, "nanobind: %s of type '%s'!\n",
10061006
inst->ready
@@ -1386,8 +1386,8 @@ static void nb_type_put_unique_finalize(PyObject *o,
13861386
nb_inst *inst = (nb_inst *) o;
13871387

13881388
if (cpp_delete) {
1389-
check(inst->ready == is_new && inst->destruct == is_new &&
1390-
inst->cpp_delete == is_new,
1389+
check((bool) inst->ready == is_new && (bool) inst->destruct == is_new &&
1390+
(bool) inst->cpp_delete == is_new,
13911391
"nanobind::detail::nb_type_put_unique(type='%s', cpp_delete=%i): "
13921392
"unexpected status flags! (ready=%i, destruct=%i, cpp_delete=%i)",
13931393
type_name(cpp_type), cpp_delete, inst->ready, inst->destruct,

tests/test_exception.cpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,14 @@ NB_MODULE(test_exception_ext, m) {
4949

5050
nb::exception<MyError3>(m, "MyError3");
5151
m.def("raise_my_error_3", [] { throw MyError3(); });
52+
53+
m.def("raise_nested", [](nb::callable c) {
54+
int arg = 123;
55+
try {
56+
c(arg);
57+
} catch (nb::python_error &e) {
58+
nb::raise_from(e, PyExc_RuntimeError, "Call with value %i failed", arg);
59+
}
60+
}
61+
);
5262
}

tests/test_exception.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,12 @@ def test19_raise_my_error_3():
9393
with pytest.raises(t.MyError3) as excinfo:
9494
assert t.raise_my_error_3()
9595
assert str(excinfo.value) == 'MyError3'
96+
97+
def test20_nested():
98+
def foo(arg):
99+
return arg / 0
100+
with pytest.raises(RuntimeError) as excinfo:
101+
t.raise_nested(foo)
102+
assert str(excinfo.value) == 'Call with value 123 failed'
103+
assert str(excinfo.value.__cause__) == 'division by zero'
104+

0 commit comments

Comments
 (0)