Skip to content

Commit

Permalink
add preload function
Browse files Browse the repository at this point in the history
It makes sense to start loading the runner scripts as soon as the exam
starts, instead of waiting for the student to run code.

`Numbas.extensions.programming.preload(language, packages)` starts
loading the given language, and the optional list of packages.
  • Loading branch information
christianp committed Nov 23, 2023
1 parent ebf9748 commit 50ae81d
Show file tree
Hide file tree
Showing 3 changed files with 131 additions and 43 deletions.
17 changes: 16 additions & 1 deletion README.md
Expand Up @@ -33,6 +33,21 @@ pre_submit:
]
```

## Pre-loading a language

If the scripts for a language have not already been loaded when you try to run some code, then they are loaded automatically.
However, this can take a long time, depending on the speed of the student's internet connection.

You can pre-load a language with the function `Numbas.extensions.programming.preload(language, packages)`. The list of packages to load is optional.

For example, if your question will use R with the packages `ggplot2` and `dplyr`, put this line in your question's JavaScript preamble:

```
Numbas.extensions.programming.preload('webr', ['ggplot2', 'dply']);
```

The necessary files will start loading as soon as part of the exam loading process, so will usually be ready to use by the time the student submits some code.

## The code editor input method

The extension provides the [Ace editor](https://ace.c9.io/) as an input method for custom part types.
Expand All @@ -46,7 +61,7 @@ It has three options:

### `run_code(language,codes)`

* `language` is a string containing the name of the code runner to use.
* `language` is a string containing the name of the code runner to use. The available code runners are `"pyodide"`, for Python, and `"webr"`, for R.
* `codes` is a list of strings of code.

Run some blocks of code and return the results in a `promise`.
Expand Down
111 changes: 83 additions & 28 deletions programming.js
Expand Up @@ -293,6 +293,13 @@ Numbas.addExtension('programming', ['display', 'util', 'jme'], function(programm
this.clear_buffers();
}

/** Preload the files necessary to run code in this language, and optionally install a list of packages.
*
* @param {Array.<string>} [packages] - Names of packages to install.
*/
async preload(packages) {
}

/** Clear the STDOUT and STDERR buffers.
*/
clear_buffers() {
Expand Down Expand Up @@ -444,41 +451,73 @@ Numbas.addExtension('programming', ['display', 'util', 'jme'], function(programm
class PyodideRunner extends CodeRunner {
constructor() {
super();
var worker = this.worker = new Worker(Numbas.getStandaloneFileURL('programming', 'pyodide_worker.js'));
}

/* // Needs a cross-origin isolated context, which I can't work out how to achieve.
this.interruptBuffer = new Uint8Array(new SharedArrayBuffer(1));
worker.postMessage({
command: "setInterruptBuffer",
interruptBuffer: this.interruptBuffer
});
*/

worker.onmessage = (event) => {
const job_id = event.data.job_id;
const job = this.get_job(job_id);
if(event.data.error) {
if(event.data.error_name == 'ConversionError') {
job.resolve({
result: null,
job_id,
stdout: event.data.stdout,
stderr: event.data.stderr
});
load_pyodide(packages) {
if(!this.pyodidePromise) {
this.pyodidePromise = new Promise((resolve, reject) => {
var worker = this.worker = new Worker(Numbas.getStandaloneFileURL('programming', 'pyodide_worker.js'));

/* // Needs a cross-origin isolated context, which I can't work out how to achieve.
this.interruptBuffer = new Uint8Array(new SharedArrayBuffer(1));
worker.postMessage({
command: "setInterruptBuffer",
interruptBuffer: this.interruptBuffer
});
*/
worker.postMessage({
command: 'init',
options: {
packages: packages || []
}
});

worker.onmessage = (event) => {
const job_id = event.data.job_id;
const job = this.get_job(job_id);
if(event.data.error) {
if(event.data.error_name == 'ConversionError') {
job.resolve({
result: null,
job_id,
stdout: event.data.stdout,
stderr: event.data.stderr
});
}
job.reject(event.data);
} else {
job.resolve(event.data);
}
}
job.reject(event.data);
} else {
job.resolve(event.data);

resolve(worker);
});
} else {
if(packages) {
this.pyodidePromise.then(worker => {
worker.postMessage({
command: 'loadPackages',
packages: packages
});
});
}
}
return this.pyodidePromise;
}

async preload(packages) {
const worker = await this.load_pyodide(packages);
}

run_code(code, session) {
const job = this.new_job();
this.worker.postMessage({
command: 'runPython',
job_id: job.id,
namespace_id: session.namespace_id,
code: code
this.load_pyodide().then(worker => {
worker.postMessage({
command: 'runPython',
job_id: job.id,
namespace_id: session.namespace_id,
code: code
});
});
return job;
}
Expand Down Expand Up @@ -538,6 +577,17 @@ Numbas.addExtension('programming', ['display', 'util', 'jme'], function(programm
return this.webRPromise;
}

/** Preload the files necessary to run code in this language, and optionally install a list of packages.
*
* @param {Array.<string>} [packages] - Names of packages to install.
*/
async preload(packages) {
const webR = await this.load_webR();
if(packages !== undefined) {
await webR.installPackages(packages);
}
}

async r_to_js(obj) {
switch(await obj.type()) {
case "list":
Expand Down Expand Up @@ -697,6 +747,11 @@ Numbas.addExtension('programming', ['display', 'util', 'jme'], function(programm
}
}

/** Preload the given language, and load the given list of packages.
*/
var preload = programming.preload = function(language, packages) {
language_runners[language].preload(packages);
};

//////////////////////////// SERIALIZE JME TO OTHER LANGUAGES

Expand Down
46 changes: 32 additions & 14 deletions standalone_scripts/pyodide_worker.js
@@ -1,23 +1,31 @@
importScripts("https://cdn.jsdelivr.net/pyodide/v0.21.0/full/pyodide.js");
const pyodide_version = 'v0.24.1';
const pyodide_indexURL = `https://cdn.jsdelivr.net/pyodide/${pyodide_version}/full/`;
importScripts(pyodide_indexURL + 'pyodide.js');

self.stdout = [];
self.stderr = [];

self.pyodidePromise;
self.pyodidePromise = null;

async function init() {
if(self.pyodidePromise) {
return self.pyodidePromise;
}
self.pyodidePromise = new Promise(async (resolve,reject) => {
self.pyodide = await loadPyodide({
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.21.0/full/",
async function init(options, preamble) {
if(!self.pyodidePromise) {
preamble = preamble || "import os; os.environ['MPLBACKEND'] = 'AGG'";

const default_options = {
indexURL: pyodide_indexURL,
stdout: s => self.stdout.push(s),
stderr: s => self.stderr.push(s)
};
const final_options = Object.assign(default_options, options);

self.pyodidePromise = new Promise((resolve,reject) => {
loadPyodide(final_options).then(pyodide => {
self.pyodide = pyodide;
self.pyodide.runPython(preamble);
resolve(self.pyodide);
});
});
self.pyodide.runPython("import os; os.environ['MPLBACKEND'] = 'AGG'");
resolve(self.pyodide);
});
}
return self.pyodidePromise;
}

Expand All @@ -32,15 +40,25 @@ self.get_namespace = async function(id) {
}

self.onmessage = async (event) => {
await init();

const {command} = event.data;

switch(command) {
case 'init':
await init(event.data.options);
break;

case 'loadPackages':
await init();
self.pyodide.loadPackage(event.data.packages);
break;

case 'setInterruptBuffer':
await init();
self.pyodide.setInterruptBuffer(event.data.interruptBuffer);
break;

case 'runPython':
await init();
const { job_id, code, namespace_id } = event.data;

self.stdout = [];
Expand Down

0 comments on commit 50ae81d

Please sign in to comment.