diff --git a/docs/api_reference.md b/docs/api_reference.md index fc2c7af6481..ba517764034 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -10,12 +10,13 @@ Backward compatibility of the API is not guaranteed at this point. .. autosummary:: :toctree: ./python-api/ - + pyodide.as_nested_list pyodide.eval_code pyodide.find_imports pyodide.get_completions pyodide.open_url + pyodide.JsException ``` diff --git a/docs/changelog.md b/docs/changelog.md index 6d1b9d174f3..8f8df137ff4 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -38,9 +38,12 @@ ``` before loading it. [#855](https://github.com/iodide-project/pyodide/pull/855) +- Javascript exceptions can now be raised and caught in Python. They are wrapped in pyodide.JsException. + [#872](https://github.com/iodide-project/pyodide/pull/872) - Build runtime C libraries (e.g. libxml) via package build system with correct dependency resolution + ## Version 0.15.0 *May 19, 2020* diff --git a/src/pyodide-py/pyodide/__init__.py b/src/pyodide-py/pyodide/__init__.py index e9e09dd53d0..82856f1cc8c 100644 --- a/src/pyodide-py/pyodide/__init__.py +++ b/src/pyodide-py/pyodide/__init__.py @@ -1,6 +1,13 @@ -from ._base import open_url, eval_code, find_imports, as_nested_list +from ._base import open_url, eval_code, find_imports, as_nested_list, JsException from .console import get_completions __version__ = "0.15.0" -__all__ = ["open_url", "eval_code", "find_imports", "as_nested_list", "get_completions"] +__all__ = [ + "open_url", + "eval_code", + "find_imports", + "as_nested_list", + "get_completions", + "JsException", +] diff --git a/src/pyodide-py/pyodide/_base.py b/src/pyodide-py/pyodide/_base.py index 4c3e9a90690..692bf221bc7 100644 --- a/src/pyodide-py/pyodide/_base.py +++ b/src/pyodide-py/pyodide/_base.py @@ -1,6 +1,8 @@ """ A library of helper utilities for connecting Python to the browser environment. """ +# Added by C: +# JsException (from jsproxy.c) import ast from io import StringIO @@ -8,6 +10,16 @@ from typing import Dict, List, Any +class JsException(Exception): + """ + A wrapper around a Javascript Error to allow the Error to be thrown in Python. + """ + + # This gets overwritten in jsproxy.c, it is just here for autodoc and humans + # reading this file. + pass + + def open_url(url: str) -> StringIO: """ Fetches a given URL diff --git a/src/tests/test_typeconversions.py b/src/tests/test_typeconversions.py index 40e1a1b7dff..b2d9cd0be3f 100644 --- a/src/tests/test_typeconversions.py +++ b/src/tests/test_typeconversions.py @@ -271,3 +271,39 @@ class Point { with pytest.raises(WebDriverException, match=msg): selenium.run("point.y") assert selenium.run_js("return point.y;") is None + + +def test_javascript_error(selenium): + msg = "JsException: Error: This is a js error" + with pytest.raises(WebDriverException, match=msg): + selenium.run( + """ + from js import Error + err = Error.new("This is a js error") + err2 = Error.new("This is another js error") + raise err + """ + ) + + +def test_javascript_error_back_to_js(selenium): + selenium.run_js( + """ + window.err = new Error("This is a js error") + """ + ) + assert ( + selenium.run( + """ + from js import err + py_err = err + type(py_err).__name__ + """ + ) + == "JsException" + ) + assert selenium.run_js( + """ + return pyodide.globals["py_err"] === err + """ + ) diff --git a/src/type_conversion/js2python.c b/src/type_conversion/js2python.c index f4c8f131a01..e1a2de3bb07 100644 --- a/src/type_conversion/js2python.c +++ b/src/type_conversion/js2python.c @@ -72,13 +72,17 @@ _js2python_jsproxy(int id) return (int)JsProxy_cnew(id); } +int +_js2python_error(int id) +{ + return (int)JsProxy_new_error(id); +} + // TODO: Add some meaningful order EM_JS(int, __js2python, (int id), { - // clang-format off - var value = Module.hiwire.get_value(id); - var type = typeof value; - if (type === 'string') { + function __js2python_string(value) + { // The general idea here is to allocate a Python string and then // have Javascript write directly into its buffer. We first need // to determine if is needs to be a 1-, 2- or 4-byte string, since @@ -124,6 +128,16 @@ EM_JS(int, __js2python, (int id), { } return result; + } + + // From https://stackoverflow.com/a/45496068 + function is_error(value) { return value && value.stack && value.message; } + + // clang-format off + var value = Module.hiwire.get_value(id); + var type = typeof value; + if (type === 'string') { + return __js2python_string(value); } else if (type === 'number') { return __js2python_number(value); } else if (value === undefined || value === null) { @@ -136,6 +150,8 @@ EM_JS(int, __js2python, (int id), { return __js2python_pyproxy(Module.PyProxy.getPtr(value)); } else if (value['byteLength'] !== undefined) { return __js2python_memoryview(id); + } else if (is_error(value)) { + return __js2python_error(id); } else { return __js2python_jsproxy(id); } diff --git a/src/type_conversion/jsproxy.c b/src/type_conversion/jsproxy.c index cdc6950d291..9b497da9f62 100644 --- a/src/type_conversion/jsproxy.c +++ b/src/type_conversion/jsproxy.c @@ -4,6 +4,11 @@ #include "js2python.h" #include "python2js.h" +#include "Python.h" +#include "structmember.h" + +static PyTypeObject* PyExc_BaseException_Type; + static PyObject* JsBoundMethod_cnew(int this_, const char* name); @@ -467,6 +472,96 @@ JsProxy_cnew(int idobj) return (PyObject*)self; } +typedef struct +{ + PyException_HEAD PyObject* js_error; +} JsExceptionObject; + +static PyMemberDef JsException_members[] = { + { "js_error", + T_OBJECT_EX, + offsetof(JsExceptionObject, js_error), + READONLY, + PyDoc_STR("A wrapper around a Javascript Error to allow the Error to be " + "thrown in Python.") }, + { NULL } /* Sentinel */ +}; + +static int +JsException_init(JsExceptionObject* self, PyObject* args, PyObject* kwds) +{ + Py_ssize_t size = PyTuple_GET_SIZE(args); + PyObject* js_error; + if (size == 0) { + PyErr_SetString( + PyExc_TypeError, + "__init__() missing 1 required positional argument: 'js_error'."); + return -1; + } + + js_error = PyTuple_GET_ITEM(args, 0); + if (!PyObject_TypeCheck(js_error, &JsProxyType)) { + PyErr_SetString(PyExc_TypeError, + "Argument 'js_error' must be an instance of JsProxy."); + return -1; + } + + if (PyExc_BaseException_Type->tp_init((PyObject*)self, args, kwds) == -1) + return -1; + + Py_CLEAR(self->js_error); + Py_INCREF(js_error); + self->js_error = js_error; + return 0; +} + +static int +JsException_clear(JsExceptionObject* self) +{ + Py_CLEAR(self->js_error); + return PyExc_BaseException_Type->tp_clear((PyObject*)self); +} + +static void +JsException_dealloc(JsExceptionObject* self) +{ + JsException_clear(self); + PyExc_BaseException_Type->tp_free((PyObject*)self); +} + +static int +JsException_traverse(JsExceptionObject* self, visitproc visit, void* arg) +{ + Py_VISIT(self->js_error); + return PyExc_BaseException_Type->tp_traverse((PyObject*)self, visit, arg); +} + +static PyTypeObject _Exc_JsException = { + PyVarObject_HEAD_INIT(NULL, 0) "JsException", + .tp_basicsize = sizeof(JsExceptionObject), + .tp_dealloc = (destructor)JsException_dealloc, + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC, + .tp_doc = + PyDoc_STR("An exception which wraps a Javascript error. The js_error field " + "contains a JsProxy for the wrapped error."), + .tp_traverse = (traverseproc)JsException_traverse, + .tp_clear = (inquiry)JsException_clear, + .tp_members = JsException_members, + // PyExc_Exception isn't static so we fill in .tp_base in JsProxy_init + // .tp_base = (PyTypeObject *)PyExc_Exception, + .tp_dictoffset = offsetof(JsExceptionObject, dict), + .tp_init = (initproc)JsException_init +}; +static PyObject* Exc_JsException = (PyObject*)&_Exc_JsException; + +PyObject* +JsProxy_new_error(int idobj) +{ + PyObject* proxy = JsProxy_cnew(idobj); + PyObject* result = PyObject_CallFunctionObjArgs(Exc_JsException, proxy, NULL); + return result; +} + //////////////////////////////////////////////////////////// // JsBoundMethod // @@ -544,8 +639,43 @@ JsProxy_AsJs(PyObject* x) return hiwire_incref(js_proxy->js); } +int +JsException_Check(PyObject* x) +{ + return PyObject_TypeCheck(x, (PyTypeObject*)Exc_JsException); +} + +int +JsException_AsJs(PyObject* err) +{ + JsExceptionObject* err_obj = (JsExceptionObject*)err; + JsProxy* js_error = (JsProxy*)(err_obj->js_error); + return hiwire_incref(js_error->js); +} + int JsProxy_init() { - return (PyType_Ready(&JsProxyType) || PyType_Ready(&JsBoundMethodType)); + PyExc_BaseException_Type = (PyTypeObject*)PyExc_BaseException; + _Exc_JsException.tp_base = (PyTypeObject*)PyExc_Exception; + + PyObject* module; + PyObject* exc; + + // Add JsException to the pyodide module so people can catch it if they want. + module = PyImport_ImportModule("pyodide"); + if (module == NULL) { + goto fail; + } + if (PyObject_SetAttrString(module, "JsException", Exc_JsException)) { + goto fail; + } + + Py_CLEAR(module); + return (PyType_Ready(&JsProxyType) || PyType_Ready(&JsBoundMethodType) || + PyType_Ready(&_Exc_JsException)); + +fail: + Py_CLEAR(module); + return -1; } diff --git a/src/type_conversion/jsproxy.h b/src/type_conversion/jsproxy.h index 3974a3e1151..0fcd32792a6 100644 --- a/src/type_conversion/jsproxy.h +++ b/src/type_conversion/jsproxy.h @@ -14,6 +14,13 @@ PyObject* JsProxy_cnew(int v); +/** Make a new JsProxy Error. + * \param v The Javascript error object. + * \return The Python error object wrapping the Javascript error object. + */ +PyObject* +JsProxy_new_error(int v); + /** Check if a Python object is a JsProxy object. * \param x The Python object * \return 1 if the object is a JsProxy object. @@ -28,6 +35,20 @@ JsProxy_Check(PyObject* x); int JsProxy_AsJs(PyObject* x); +/** Check if a Python object is a JsException object. + * \param x The Python object + * \return 1 if the object is a JsException object. + */ +int +JsException_Check(PyObject* x); + +/** Grab the underlying Javascript error from the JsException object. + * \param x The JsProxy object. Must confirm that it is a JsException object + * using JsProxy_Check. \return The Javascript object. + */ +int +JsException_AsJs(PyObject* x); + /** Initialize global state for the JsProxy functionality. */ int JsProxy_init(); diff --git a/src/type_conversion/python2js.c b/src/type_conversion/python2js.c index 1b6fed764d0..a9be546dc60 100644 --- a/src/type_conversion/python2js.c +++ b/src/type_conversion/python2js.c @@ -252,6 +252,8 @@ _python2js(PyObject* x, PyObject* map) return _python2js_bytes(x); } else if (JsProxy_Check(x)) { return JsProxy_AsJs(x); + } else if (JsException_Check(x)) { + return JsException_AsJs(x); } else if (PyList_Check(x) || PyTuple_Check(x)) { return _python2js_sequence(x, map); } else if (PyDict_Check(x)) {