diff --git a/src/main/c/Include/convert_p2j.h b/src/main/c/Include/convert_p2j.h index e9dfa269..aaeed4c1 100644 --- a/src/main/c/Include/convert_p2j.h +++ b/src/main/c/Include/convert_p2j.h @@ -86,6 +86,13 @@ jstring PyObject_As_jstring(JNIEnv*, PyObject*); */ jobject PyObject_As_jobject(JNIEnv*, PyObject*, jclass); +/* + * Wraps a PyObject* in a Java jep.python.PyObject instance. Increments the + * Python refcount so the object survives until the Java wrapper is GC'd. + * NULL will be returned and a Java exception will be set if the call fails. + */ +jobject PyObject_As_JPyObject(JNIEnv*, PyObject*); + /* * Use PyErr_Occurred() after this to check for errors */ diff --git a/src/main/c/Include/jep_util.h b/src/main/c/Include/jep_util.h index 11afd522..40e7c935 100644 --- a/src/main/c/Include/jep_util.h +++ b/src/main/c/Include/jep_util.h @@ -155,6 +155,7 @@ extern jclass JDOUBLE_ARRAY_TYPE; F(OUTOFMEMORY_EXC_TYPE, "java/lang/OutOfMemoryError") \ F(ASSERTION_EXC_TYPE, "java/lang/AssertionError") \ F(JEP_EXC_TYPE, "jep/JepException") \ + F(PYTHON_EXC_TYPE, "jep/PythonException") \ F(JPYOBJECT_TYPE, "jep/python/PyObject") \ F(JPYCALLABLE_TYPE, "jep/python/PyCallable") \ F(JPYMETHOD_TYPE, "jep/PyMethod") \ diff --git a/src/main/c/Jep/convert_p2j.c b/src/main/c/Jep/convert_p2j.c index 7b878676..57466218 100644 --- a/src/main/c/Jep/convert_p2j.c +++ b/src/main/c/Jep/convert_p2j.c @@ -67,7 +67,7 @@ static void raiseTypeError(JNIEnv *env, PyObject *pyobject, jclass expectedType) (*env)->DeleteLocalRef(env, expTypeJavaName); } -static jobject PyObject_As_JPyObject(JNIEnv *env, PyObject *pyobject) +jobject PyObject_As_JPyObject(JNIEnv *env, PyObject *pyobject) { jobject jpyobject; diff --git a/src/main/c/Jep/jep_exceptions.c b/src/main/c/Jep/jep_exceptions.c index 1e72bb78..c971cada 100644 --- a/src/main/c/Jep/jep_exceptions.c +++ b/src/main/c/Jep/jep_exceptions.c @@ -29,10 +29,118 @@ static PyObject* pyerrtype_from_throwable(JNIEnv*, jthrowable); +/* + * Builds a Java String containing the formatted Python traceback by calling + * traceback.format_exception(ptype, pvalue, ptrace) and returns + * a JNI local reference to a jstring, or NULL if formatting failed or no + * exception info was provided. Caller releases with DeleteLocalRef. + * + * Any errors inside the traceback module are printed to stderr and cleared so + * they cannot leak into the caller's exception processing. + */ +static jstring format_python_traceback(JNIEnv *env, + PyObject *ptype, + PyObject *pvalue, + PyObject *ptrace) +{ + PyObject *modTB = NULL; + PyObject *formatFunc = NULL; + PyObject *tbList = NULL; + PyObject *sep = NULL; + PyObject *joined = NULL; + const char *utf8 = NULL; + jstring result = NULL; + + if (ptype == NULL && pvalue == NULL && ptrace == NULL) { + return NULL; + } + + modTB = PyImport_ImportModule("traceback"); + if (modTB == NULL) { + if (PyErr_Occurred()) { + PyErr_Print(); + } + return NULL; + } + + formatFunc = PyObject_GetAttrString(modTB, "format_exception"); + if (formatFunc != NULL) { + tbList = PyObject_CallFunctionObjArgs(formatFunc, + ptype ? ptype : Py_None, + pvalue ? pvalue : Py_None, + ptrace ? ptrace : Py_None, + NULL); + if (tbList != NULL) { + sep = PyUnicode_FromString(""); + if (sep != NULL) { + joined = PyUnicode_Join(sep, tbList); + if (joined != NULL) { + utf8 = PyUnicode_AsUTF8(joined); + if (utf8 != NULL) { + result = (*env)->NewStringUTF(env, utf8); + } + } + } + } + if (PyErr_Occurred()) { + PyErr_Print(); + } + } else if (PyErr_Occurred()) { + PyErr_Print(); + } + + Py_XDECREF(modTB); + Py_XDECREF(formatFunc); + Py_XDECREF(tbList); + Py_XDECREF(sep); + Py_XDECREF(joined); + return result; +} + +/* + * Extracts the frame list from a Python traceback via traceback.extract_tb(ptrace). + * Returns a new reference to a list of frame tuples, or NULL if ptrace is NULL + * or the call failed. Caller calls Py_DECREF on the result. + * + * Any errors inside the traceback module are printed to stderr and cleared so + * they cannot leak into the caller's exception processing. + */ +static PyObject* extract_python_stack(PyObject *ptrace) +{ + PyObject *modTB = NULL; + PyObject *extractFunc = NULL; + PyObject *pystack = NULL; + + if (ptrace == NULL) { + return NULL; + } + + modTB = PyImport_ImportModule("traceback"); + if (modTB == NULL) { + if (PyErr_Occurred()) { + PyErr_Print(); + } + return NULL; + } + + extractFunc = PyObject_GetAttrString(modTB, "extract_tb"); + if (extractFunc != NULL) { + pystack = PyObject_CallFunctionObjArgs(extractFunc, ptrace, NULL); + } + + if (PyErr_Occurred()) { + PyErr_Print(); + } + + Py_XDECREF(modTB); + Py_XDECREF(extractFunc); + return pystack; +} + // exception handling -jmethodID jepExcInitStrLong = 0; -jmethodID jepExcInitStrThrow = 0; -jmethodID stackTraceElemInit = 0; +jmethodID pythonExcInitStrLongPyObjStr = 0; // PythonException(String, long, PyObject, String) +jmethodID pythonExcInitStrThrowPyObjStr = 0; // PythonException(String, Throwable, PyObject, String) +jmethodID stackTraceElemInit = 0; /* * Converts a Python exception to a JepException. Returns true if an @@ -47,6 +155,7 @@ int process_py_exception(JNIEnv *env) PyJObject *jexc = NULL; jobject jepException = NULL; jstring jmsg; + jstring jtraceback = NULL; if (!PyErr_Occurred()) { return 0; @@ -100,6 +209,39 @@ int process_py_exception(JNIEnv *env) } } + /* + * Process the full Python traceback before any of the downstream logic mutates + * pvalue. Normalize first so format_exception always sees a proper exception + * instance, which matters for the Python 3.11+ source-line and caret rendering. + */ + PyErr_NormalizeException(&ptype, &pvalue, &ptrace); + if (PyErr_Occurred()) { + PyErr_Print(); + } + if (ptrace != NULL && pvalue != NULL) { + if (PyException_SetTraceback(pvalue, ptrace) < 0) { + PyErr_Print(); + } + } + + pystack = extract_python_stack(ptrace); + jtraceback = format_python_traceback(env, ptype, pvalue, ptrace); + + /* + * Capture pvalue as a Java PyObject now, before the block below may + * replace pvalue with args[0] (the string message). The Py_INCREF + * inside PyObject_As_JPyObject keeps the exception object alive + * independently of what happens to pvalue afterwards. + */ + jobject jpyException = NULL; + if (pvalue != NULL) { + jpyException = PyObject_As_JPyObject(env, pvalue); + if ((*env)->ExceptionCheck(env)) { + (*env)->ExceptionClear(env); + jpyException = NULL; + } + } + message = PyObject_Str(ptype); if (pvalue) { @@ -156,65 +298,42 @@ int process_py_exception(JNIEnv *env) } m = PyUnicode_AsUTF8(message); - // make a JepException + // make a PythonException jmsg = (*env)->NewStringUTF(env, (const char *) m); if (jexc != NULL) { - // constructor JepException(String, Throwable) - if (jepExcInitStrThrow == 0) { - jepExcInitStrThrow = (*env)->GetMethodID(env, JEP_EXC_TYPE, - "", - "(Ljava/lang/String;Ljava/lang/Throwable;)V"); + // constructor PythonException(String, Throwable, PyObject, String) + if (pythonExcInitStrThrowPyObjStr == 0) { + pythonExcInitStrThrowPyObjStr = (*env)->GetMethodID(env, PYTHON_EXC_TYPE, + "", + "(Ljava/lang/String;Ljava/lang/Throwable;Ljep/python/PyObject;Ljava/lang/String;)V"); } - jepException = (*env)->NewObject(env, JEP_EXC_TYPE, - jepExcInitStrThrow, jmsg, jexc->object); + jepException = (*env)->NewObject(env, PYTHON_EXC_TYPE, + pythonExcInitStrThrowPyObjStr, + jmsg, jexc->object, jpyException, jtraceback); } else { - // constructor JepException(String, long) - if (jepExcInitStrLong == 0) { - jepExcInitStrLong = (*env)->GetMethodID(env, JEP_EXC_TYPE, - "", "(Ljava/lang/String;J)V"); + // constructor PythonException(String, long, PyObject, String) + if (pythonExcInitStrLongPyObjStr == 0) { + pythonExcInitStrLongPyObjStr = (*env)->GetMethodID(env, PYTHON_EXC_TYPE, + "", + "(Ljava/lang/String;JLjep/python/PyObject;Ljava/lang/String;)V"); } - jepException = (*env)->NewObject(env, JEP_EXC_TYPE, - jepExcInitStrLong, jmsg, (jlong) ptype); + jepException = (*env)->NewObject(env, PYTHON_EXC_TYPE, + pythonExcInitStrLongPyObjStr, + jmsg, (jlong) ptype, jpyException, jtraceback); } (*env)->DeleteLocalRef(env, jmsg); + if (jpyException != NULL) { + (*env)->DeleteLocalRef(env, jpyException); + } + if (jtraceback != NULL) { + (*env)->DeleteLocalRef(env, jtraceback); + } if ((*env)->ExceptionCheck(env) || !jepException) { PyErr_SetString(PyExc_RuntimeError, - "creating jep.JepException failed."); + "creating jep.PythonException failed."); return 1; } - /* - * Attempt to get the Python traceback. - */ - if (ptrace) { - PyObject *modTB, *extract = NULL; - modTB = PyImport_ImportModule("traceback"); - if (modTB == NULL) { - printf("Error importing python traceback module\n"); - } - extract = PyUnicode_FromString("extract_tb"); - if (extract == NULL) { - printf("Error making PyString 'extract_tb'\n"); - } - if (modTB != NULL && extract != NULL) { - pystack = PyObject_CallMethodObjArgs(modTB, extract, ptrace, - NULL); - } - if (PyErr_Occurred()) { - /* - * Well this isn't good, we got an error while we're trying - * to process errors, let's just print it out. - */ - PyErr_Print(); - } - Py_XDECREF(modTB); - Py_XDECREF(extract); - } - - /* - * This could go in the above if statement but I got tired of - * incrementing so far to the right. - */ if (pystack != NULL) { Py_ssize_t stackSize, i, count; jsize index, javaStackLength; @@ -247,20 +366,24 @@ int process_py_exception(JNIEnv *env) */ count = 0; for (i = 0; i < stackSize; i++) { - PyObject *stackEntry, *pyLine; + PyObject *stackEntry, *pyFileObj, *pyLineNumObj, *pyFuncObj, *pyLine; const char *charPyFile, *charPyFunc = NULL; int pyLineNum; stackEntry = PyList_GetItem(pystack, i); // java order is classname, method name, filename, line number // python order is filename, line number, function name, line - charPyFile = PyUnicode_AsUTF8( - PySequence_GetItem(stackEntry, 0)); - pyLineNum = (int) PyLong_AsLongLong( - PySequence_GetItem(stackEntry, 1)); - charPyFunc = PyUnicode_AsUTF8( - PySequence_GetItem(stackEntry, 2)); - pyLine = PySequence_GetItem(stackEntry, 3); + pyFileObj = PySequence_GetItem(stackEntry, 0); + pyLineNumObj = PySequence_GetItem(stackEntry, 1); + pyFuncObj = PySequence_GetItem(stackEntry, 2); + pyLine = PySequence_GetItem(stackEntry, 3); + + charPyFile = PyUnicode_AsUTF8(pyFileObj); + + pyLineNum = (int) PyLong_AsLongLong(pyLineNumObj); + Py_DECREF(pyLineNumObj); + + charPyFunc = PyUnicode_AsUTF8(pyFuncObj); /* * If pyLine is None, this seems to imply it was an eval, @@ -313,6 +436,9 @@ int process_py_exception(JNIEnv *env) charPyFile, pyLineNum); free(charPyFileNoDir); free(charPyFileNoExt); + Py_DECREF(pyFileObj); + Py_DECREF(pyFuncObj); + Py_DECREF(pyLine); Py_DECREF(pystack); return 1; } @@ -326,6 +452,10 @@ int process_py_exception(JNIEnv *env) (*env)->DeleteLocalRef(env, pyFunc); (*env)->DeleteLocalRef(env, element); } + + Py_DECREF(pyFileObj); + Py_DECREF(pyFuncObj); + Py_DECREF(pyLine); } // end of looping over Python traceback items Py_DECREF(pystack); diff --git a/src/main/java/jep/JepException.java b/src/main/java/jep/JepException.java index 7dcae793..a7c04ff8 100644 --- a/src/main/java/jep/JepException.java +++ b/src/main/java/jep/JepException.java @@ -44,7 +44,7 @@ public class JepException extends RuntimeException { /** * Creates a new JepException instance. - * + * */ public JepException() { super(); @@ -53,7 +53,7 @@ public JepException() { /** * Creates a new JepException instance. - * + * * @param s * a String value */ @@ -64,7 +64,7 @@ public JepException(String s) { /** * Creates a new JepException instance. - * + * * @param t * a Throwable value */ @@ -75,13 +75,12 @@ public JepException(Throwable t) { this.pythonType = j.pythonType; } else { this.pythonType = 0; - } } /** * Creates a new JepException instance. - * + * * @param s * a String value * @param t diff --git a/src/main/java/jep/PythonException.java b/src/main/java/jep/PythonException.java new file mode 100644 index 00000000..95842363 --- /dev/null +++ b/src/main/java/jep/PythonException.java @@ -0,0 +1,119 @@ +/** + * Copyright (c) 2004-2022 JEP AUTHORS. + * + * This file is licensed under the the zlib/libpng License. + * + * This software is provided 'as-is', without any express or implied + * warranty. In no event will the authors be held liable for any + * damages arising from the use of this software. + * + * Permission is granted to anyone to use this software for any + * purpose, including commercial applications, and to alter it and + * redistribute it freely, subject to the following restrictions: + * + * 1. The origin of this software must not be misrepresented; you + * must not claim that you wrote the original software. If you use + * this software in a product, an acknowledgment in the product + * documentation would be appreciated but is not required. + * + * 2. Altered source versions must be plainly marked as such, and + * must not be misrepresented as being the original software. + * + * 3. This notice may not be removed or altered from any source + * distribution. + */ +package jep; + +import jep.python.PyObject; + +/** + * A {@link JepException} thrown whenever a Python exception is active, whether + * the root cause was Python code or a Java exception that surfaced through + * Python. Carries the Python exception instance as a {@link PyObject} proxy, + * allowing callers to inspect attributes (e.g. {@code .args}, {@code __traceback__}, + * custom fields) on the original Python exception object. + * + *

The wrapped {@link PyObject} is subject to the same constraints as all + * Jep proxy objects: it is only valid while the originating {@link Jep} + * interpreter is open and on the thread that owns it. This exception will + * always be thrown inside the scope of the Interpreter. + * + * @author Michael Quindt + */ +public class PythonException extends JepException { + + private static final long serialVersionUID = 1L; + + /** + * The original Python exception instance, or {@code null} if it could not + * be captured (e.g. interpreter state was unavailable at throw time). + */ + private final PyObject pythonException; + + /** + * The formatted Python traceback as a string, or {@code null} if it could + * not be computed at throw time. + */ + private final String pythonTraceback; + + /** + * Called from native code for pure-Python exceptions (no Java cause). + * + * @param s + * error message + * @param pythonType + * address of the Python exception type + * @param pythonException + * the Python exception instance, or {@code null} + * @param pythonTraceback + * formatted traceback string, or {@code null} + */ + protected PythonException(String s, long pythonType, PyObject pythonException, + String pythonTraceback) { + super(s, pythonType); + this.pythonException = pythonException; + this.pythonTraceback = pythonTraceback; + } + + /** + * Called from native code when a Java exception surfaces through Python. + * Preserves the Java exception as the cause while carrying the Python + * exception object that wraps it. + * + * @param s + * error message + * @param cause + * the originating Java exception + * @param pythonException + * the Python exception instance wrapping the Java cause, or {@code null} + * @param pythonTraceback + * formatted traceback string, or {@code null} + */ + protected PythonException(String s, Throwable cause, PyObject pythonException, + String pythonTraceback) { + super(s, cause); + this.pythonException = pythonException; + this.pythonTraceback = pythonTraceback; + } + + /** + * Returns the original Python exception instance, or {@code null} if it + * was not available when the exception was thrown. + * + * @return the Python exception as a {@link PyObject}, or {@code null} + */ + public PyObject getPythonException() { + return pythonException; + } + + /** + * Returns the formatted Python traceback as produced by + * {@code traceback.format_exception()}, or {@code null} if it was not + * available when the exception was thrown. + * + * @return the formatted traceback string, or {@code null} + */ + public String getPythonTraceback() { + return pythonTraceback; + } +} diff --git a/src/test/java/jep/test/TestExceptionCause.java b/src/test/java/jep/test/TestExceptionCause.java deleted file mode 100644 index c315f3af..00000000 --- a/src/test/java/jep/test/TestExceptionCause.java +++ /dev/null @@ -1,40 +0,0 @@ -package jep.test; - -import jep.Interpreter; -import jep.JepConfig; -import jep.JepException; -import jep.SubInterpreter; - -/** - * Tests that a java exception thrown while executing Python is set as the cause - * of the JepException. - * - * Created: July 2017 - * - * @author Ben Steffensmeier - */ -public class TestExceptionCause { - - public static void main(String[] args) throws JepException { - JepConfig config = new JepConfig().addIncludePaths("."); - try (Interpreter interp = new SubInterpreter(config)) { - interp.eval("from java.util import ArrayList"); - try { - interp.eval("ArrayList().get(0)"); - } catch (JepException e) { - if (!(e.getCause() instanceof IndexOutOfBoundsException)) { - throw e; - } - } - try { - interp.eval( - "try:\n ArrayList().get(0)\nexcept AttributeError:\n pass"); - } catch (JepException e) { - if (!(e.getCause() instanceof IndexOutOfBoundsException)) { - throw e; - } - } - } - } - -} diff --git a/src/test/java/jep/test/TestJepException.java b/src/test/java/jep/test/TestJepException.java new file mode 100644 index 00000000..400b88d7 --- /dev/null +++ b/src/test/java/jep/test/TestJepException.java @@ -0,0 +1,82 @@ +package jep.test; + +import jep.Interpreter; +import jep.JepConfig; +import jep.JepException; +import jep.PythonException; +import jep.SubInterpreter; +import jep.python.PyObject; + +/** + * Tests the behavior of {@link JepException} and {@link PythonException} + * across the three exception origins: pure Python, Java surfaced through + * Python, and directly constructed in Java. + * + * Created: May 2026 + * + * @author Michael Quindt + */ +public class TestJepException { + + public static void main(String[] args) throws JepException { + JepConfig config = new JepConfig().addIncludePaths("."); + try (Interpreter interp = new SubInterpreter(config)) { + // A pure Python exception must be a PythonException with a non-null PyObject proxy. + try { + interp.eval("raise ValueError('test traceback')"); + throw new RuntimeException("Expected JepException was not thrown"); + } catch (JepException e) { + testPythonException(e, null); + PythonException pe = (PythonException) e; + // Verify the attached exception object actually has a traceback attribute. + PyObject pyObj = pe.getPythonException() + .getAttr("__traceback__", PyObject.class); + String tbType = pyObj.getAttr("__class__", PyObject.class) + .getAttr("__name__", String.class); + if (!tbType.equals("traceback")) { + throw new RuntimeException( + "Expected traceback type, got: " + tbType); + } + } + + // A Java exception surfaced through Python must also be a PythonException, + // preserving the original Java exception as the cause. + interp.eval("from java.util import ArrayList"); + try { + interp.eval("ArrayList().get(0)"); + throw new RuntimeException("Expected JepException was not thrown"); + } catch (JepException e) { + testPythonException(e, IndexOutOfBoundsException.class); + } + try { + interp.eval( + "try:\n ArrayList().get(0)\nexcept AttributeError:\n pass"); + throw new RuntimeException("Expected JepException was not thrown"); + } catch (JepException e) { + testPythonException(e, IndexOutOfBoundsException.class); + } + } + } + + private static void testPythonException(JepException e, + Class expectedCause) { + if (!(e instanceof PythonException)) { + throw new RuntimeException( + "Expected PythonException but got " + e.getClass().getName()); + } + PythonException pe = (PythonException) e; + if (pe.getPythonException() == null) { + throw new RuntimeException( + "getPythonException() returned null"); + } + if (pe.getPythonTraceback() == null) { + throw new RuntimeException( + "getPythonTraceback() returned null"); + } + if (expectedCause != null && !(expectedCause.isInstance(e.getCause()))) { + throw new RuntimeException( + "Expected cause " + expectedCause.getName() + + " but got " + (e.getCause() == null ? "null" : e.getCause().getClass().getName())); + } + } +} diff --git a/src/test/python/test_exceptions.py b/src/test/python/test_exceptions.py index d82dda02..bff4caca 100644 --- a/src/test/python/test_exceptions.py +++ b/src/test/python/test_exceptions.py @@ -80,7 +80,7 @@ def test_arithmetic_error(self): pass def test_exception_cause(self): - jep_pipe(build_java_process_cmd('jep.test.TestExceptionCause')) + jep_pipe(build_java_process_cmd('jep.test.TestJepException')) # TODO come up with a way to test MemoryError and AssertionError given # I coded support for that.