Skip to content
This repository was archived by the owner on Jan 1, 2026. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 101 additions & 2 deletions module.c
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ typedef struct {
JSContext *context;
int has_time_limit;
clock_t time_limit;
PyObject *module_loader;
// Used when releasing the GIL.
PyThreadState *thread_state;
InterruptData interrupt_data;
Expand All @@ -45,6 +46,46 @@ static PyObject *StackOverflow = NULL;
//
// Takes ownership of the JSValue and will deallocate it (refcount reduced by 1).
static PyObject *quickjs_to_python(ContextData *context_obj, JSValue value);
// This method is always called in a context before running JS code in QuickJS. It sets up time
// limites, releases the GIL etc.
static void prepare_call_js(ContextData *context);
// This method is called right after returning from running JS code. Aquires the GIL etc.
static void end_call_js(ContextData *context);

static JSModuleDef *js_module_loader(JSContext *ctx, const char *module_name, void *opaque) {
ContextData *context = (ContextData *)opaque;
JSModuleDef *module = NULL;

end_call_js(context);
if (context->module_loader == NULL || !PyCallable_Check(context->module_loader)) {
JS_ThrowReferenceError(ctx, "Could not load module \"%s\": no loader.", module_name);
} else {
PyObject *result = PyObject_CallFunction(context->module_loader, "s", module_name);

if (result == NULL) {
JS_ThrowReferenceError(
ctx, "Could not load module \"%s\": loader failed.", module_name);
} else if (!PyUnicode_Check(result)) {
JS_ThrowReferenceError(
ctx, "Could not load module \"%s\": loader did not return a string.", module_name);
} else {
const char *code = PyUnicode_AsUTF8(result);
JSValue js_value = JS_Eval(ctx,
code,
strlen(code),
module_name,
JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY);
if (!JS_IsException(js_value)) {
module = JS_VALUE_GET_PTR(js_value);
JS_FreeValue(ctx, js_value);
}
}

Py_XDECREF(result);
}
prepare_call_js(context);
return module;
}

// Returns nonzero if we should stop due to a time limit.
static int js_interrupt_handler(JSRuntime *rt, void *opaque) {
Expand Down Expand Up @@ -135,9 +176,41 @@ static PyObject *object_json(ObjectData *self) {
return quickjs_to_python(self->context, json_string);
}

// _quickjs.Object.get
//
// Retrieves a property from the JS object.
static PyObject *object_get(ObjectData *self, PyObject *args) {
const char *name;
if (!PyArg_ParseTuple(args, "s", &name)) {
return NULL;
}
JSValue value = JS_GetPropertyStr(self->context->context, self->object, name);
return quickjs_to_python(self->context, value);
}

// _quickjs.Object.dir
//
// Returns a list of all properties of a JS object.
static PyObject *object_dir(ObjectData *self) {
JSPropertyEnum *props = 0;
uint32_t len;
JS_GetOwnPropertyNames(self->context->context, &props, &len, self->object, JS_GPN_STRING_MASK);

PyObject *list = PyList_New(len);
for (int i = 0; i < len; ++i) {
const char *prop = JS_AtomToCString(self->context->context, props[i].atom);
PyList_SetItem(list, i, Py_BuildValue("s", prop));
JS_FreeCString(self->context->context, prop);
}
js_free(self->context->context, props);
return list;
}

// All methods of the _quickjs.Object class.
static PyMethodDef object_methods[] = {
{"json", (PyCFunction)object_json, METH_NOARGS, "Converts to a JSON string."},
{"get", (PyCFunction)object_get, METH_VARARGS, "Retrieves a property."},
{"dir", (PyCFunction)object_dir, METH_NOARGS, "Returns a list of all properties."},
{NULL} /* Sentinel */
};

Expand Down Expand Up @@ -319,15 +392,18 @@ struct module_state {};

// Creates an instance of the _quickjs.Context class.
static PyObject *context_new(PyTypeObject *type, PyObject *args, PyObject *kwds) {
ContextData *self;
self = (ContextData *)type->tp_alloc(type, 0);
ContextData *self = (ContextData *)type->tp_alloc(type, 0);
if (self != NULL) {
// We never have different contexts for the same runtime. This way, different
// _quickjs.Context can be used concurrently.
self->runtime = JS_NewRuntime();
self->context = JS_NewContext(self->runtime);
self->has_time_limit = 0;
self->time_limit = 0;

self->module_loader = NULL;
JS_SetModuleLoaderFunc(self->runtime, NULL, js_module_loader, self);

self->thread_state = NULL;
self->python_callables = NULL;
JS_SetContextOpaque(self->context, self);
Expand All @@ -339,6 +415,7 @@ static PyObject *context_new(PyTypeObject *type, PyObject *args, PyObject *kwds)
static void context_dealloc(ContextData *self) {
JS_FreeContext(self->context);
JS_FreeRuntime(self->runtime);
Py_XDECREF(self->module_loader);
Py_TYPE(self)->tp_free((PyObject *)self);
PythonCallableNode *node = self->python_callables;
self->python_callables = NULL;
Expand Down Expand Up @@ -450,6 +527,24 @@ static PyObject *context_set_max_stack_size(ContextData *self, PyObject *args) {
Py_RETURN_NONE;
}

// _quickjs.Context.set_module_loade
//
// Sets the max stack size in bytes.
static PyObject *context_set_module_loader(ContextData *self, PyObject *args) {
PyObject *loader;
if (!PyArg_ParseTuple(args, "O", &loader)) {
return NULL;
}
if (!PyCallable_Check(loader)) {
PyErr_SetString(PyExc_TypeError, "Module loader must be callable.");
return NULL;
}
Py_XDECREF(self->module_loader);
self->module_loader = loader;
Py_INCREF(self->module_loader);
Py_RETURN_NONE;
}

// _quickjs.Context.memory
//
// Sets the CPU time limit of the context. This will be used in an interrupt handler.
Expand Down Expand Up @@ -619,6 +714,10 @@ static PyMethodDef context_methods[] = {
(PyCFunction)context_set_max_stack_size,
METH_VARARGS,
"Sets the maximum stack size in bytes. Default is 256kB."},
{"set_module_loader",
(PyCFunction)context_set_module_loader,
METH_VARARGS,
"Sets the Python function that takes a module name and returns its source code."},
{"memory", (PyCFunction)context_memory, METH_NOARGS, "Returns the memory usage as a dict."},
{"gc", (PyCFunction)context_gc, METH_NOARGS, "Runs garbage collection."},
{"add_callable", (PyCFunction)context_add_callable, METH_VARARGS, "Wraps a Python callable."},
Expand Down
60 changes: 59 additions & 1 deletion test_quickjs.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,61 @@ def test_get(self):
self.assertEqual(self.context.get("y"), "foo")
self.assertEqual(self.context.get("z"), None)

def test_module(self):
def test_run_module(self):
self.context.module("""
export function test() {
return 42;
}
""")

def test_import_module_without_loader(self):
with self.assertRaisesRegex(quickjs.JSException, "no loader"):
self.context.module('import { foo } from "petter";')

def test_set_module_loader_to_non_callable(self):
with self.assertRaises(TypeError):
self.context.set_module_loader(2)

def test_import_module_loader_failed(self):
self.context.set_module_loader(lambda: "")
with self.assertRaisesRegex(quickjs.JSException, "loader failed"):
self.context.module('import { foo } from "petter";')

def test_module_does_not_return_code(self):
self.context.set_module_loader(lambda name: 1)
with self.assertRaisesRegex(quickjs.JSException, "did not return a string"):
self.context.module('import { foo } from "petter";')

def _module_loader(self, name):
"""Helper method that for module loading."""

if name == "mymodule":
return """
export function foo() {
return 42;
}
"""
else:
raise ValueError("Unknown module.")

def test_import_unknown_module(self):
self.context.set_module_loader(self._module_loader)
with self.assertRaisesRegex(quickjs.JSException, "loader failed"):
self.context.module('import { foo } from "somewhere_else";')

def test_import_module(self):
self.context.set_module_loader(self._module_loader)
self.context.module("""
import { foo } from "mymodule";

function test() {
return foo();
}

globalThis.test = test;
""")
self.assertEqual(self.context.get("test")(), 42)

def test_error(self):
with self.assertRaisesRegex(quickjs.JSException, "ReferenceError: missing is not defined"):
self.context.eval("missing + missing")
Expand Down Expand Up @@ -224,6 +272,7 @@ def test_time_limit_disallowed(self):
with self.assertRaises(quickjs.JSException):
self.context.eval("f(40)")


class Object(unittest.TestCase):
def setUp(self):
self.context = quickjs.Context()
Expand Down Expand Up @@ -318,6 +367,15 @@ def test_wrong_context(self):
with self.assertRaisesRegex(ValueError, "Can not mix JS objects from different contexts."):
f(d)

def test_dir(self):
d = self.context.eval("({a: 1, b: 2})")
self.assertEqual(d.dir(), ["a", "b"])

def test_get(self):
d = self.context.eval("({a: 1, b: 2})")
self.assertEqual(d.get("a"), 1)
self.assertEqual(d.get("b"), 2)


class FunctionTest(unittest.TestCase):
def test_adder(self):
Expand Down