Skip to content

Commit

Permalink
Raise Exceptions when js2python is applied to javascript errors (pyod…
Browse files Browse the repository at this point in the history
  • Loading branch information
Hood Chatham authored and joemarshall committed Jan 3, 2021
1 parent bdee37f commit 89684f7
Show file tree
Hide file tree
Showing 9 changed files with 236 additions and 8 deletions.
3 changes: 2 additions & 1 deletion docs/api_reference.md
Expand Up @@ -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
```


Expand Down
3 changes: 3 additions & 0 deletions docs/changelog.md
Expand Up @@ -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*

Expand Down
11 changes: 9 additions & 2 deletions 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",
]
12 changes: 12 additions & 0 deletions src/pyodide-py/pyodide/_base.py
@@ -1,13 +1,25 @@
"""
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
from textwrap import dedent
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
Expand Down
36 changes: 36 additions & 0 deletions src/tests/test_typeconversions.py
Expand Up @@ -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
"""
)
24 changes: 20 additions & 4 deletions src/type_conversion/js2python.c
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
Expand Down
132 changes: 131 additions & 1 deletion src/type_conversion/jsproxy.c
Expand Up @@ -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);

Expand Down Expand Up @@ -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
//
Expand Down Expand Up @@ -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;
}
21 changes: 21 additions & 0 deletions src/type_conversion/jsproxy.h
Expand Up @@ -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.
Expand All @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions src/type_conversion/python2js.c
Expand Up @@ -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)) {
Expand Down

0 comments on commit 89684f7

Please sign in to comment.