Skip to content

Commit

Permalink
Cache pyodide modules (#1113)
Browse files Browse the repository at this point in the history
Refactored the `PyodideWorker` so that `pyodide` is not completely
reloaded on each code run, but rather the globals representing
user-defined variables and user-imported modules are cleared. This
allows the modules that have already been loaded into `pyodide` to be
cached, massively reducing the latency on subsequent code runs.
  • Loading branch information
loiswells97 authored Oct 22, 2024
1 parent 9be0e5d commit 01a4405
Show file tree
Hide file tree
Showing 4 changed files with 74 additions and 22 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Tests for running simple programs in `pyodide` and `skulpt` (#1100)
- Fall back to `skulpt` if the host is not `crossOriginIsolated` (#1107)
- `Pyodide` `seaborn` support (#1106)
- `Pyodide` module caching (#1113)

### Changed

Expand Down
39 changes: 39 additions & 0 deletions cypress/e2e/spec-wc-pyodide.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,43 @@ describe("Running the code with pyodide", () => {
"ModuleNotFoundError: No module named 'i_do_not_exist' on line 1 of main.py",
);
});

it("clears user-defined variables between code runs", () => {
runCode("a = 1\nprint(a)");
cy.get("editor-wc")
.shadow()
.find(".pythonrunner-console-output-line")
.should("contain", "1");
runCode("print(a)");
cy.get("editor-wc")
.shadow()
.find(".error-message__content")
.should("contain", "NameError: name 'a' is not defined");
});

it("clears user-defined functions between code runs", () => {
runCode("def my_function():\n\treturn 1\nprint(my_function())");
cy.get("editor-wc")
.shadow()
.find(".pythonrunner-console-output-line")
.should("contain", "1");
runCode("print(my_function())");
cy.get("editor-wc")
.shadow()
.find(".error-message__content")
.should("contain", "NameError: name 'my_function' is not defined");
});

it("clears user-imported modules between code runs", () => {
runCode("import math\nprint(math.floor(math.pi))");
cy.get("editor-wc")
.shadow()
.find(".pythonrunner-console-output-line")
.should("contain", "3");
runCode("print(math.floor(math.pi))");
cy.get("editor-wc")
.shadow()
.find(".error-message__content")
.should("contain", "NameError: name 'math' is not defined");
});
});
43 changes: 27 additions & 16 deletions src/PyodideWorker.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,7 @@ const PyodideWorker = () => {
await pyodide.runPythonAsync(`
import pyodide_http
pyodide_http.patch_all()
old_input = input
def patched_input(prompt=False):
if (prompt):
print(prompt)
return old_input()
__builtins__.input = patched_input
`);
`);

try {
await withSupportForPackages(python, async () => {
Expand All @@ -87,7 +78,7 @@ const PyodideWorker = () => {
postMessage({ method: "handleError", ...parsePythonError(error) });
}

await reloadPyodideToClearState();
await clearPyodideData();
};

const checkIfStopped = () => {
Expand Down Expand Up @@ -241,12 +232,12 @@ const PyodideWorker = () => {
pyodide.runPython(`
import js
class DummyDocument:
class __DummyDocument__:
def __init__(self, *args, **kwargs) -> None:
return
def __getattr__(self, __name: str):
return DummyDocument
js.document = DummyDocument()
return __DummyDocument__
js.document = __DummyDocument__()
`);
await pyodide.loadPackage("matplotlib")?.catch(() => {});
let pyodidePackage;
Expand Down Expand Up @@ -334,7 +325,18 @@ const PyodideWorker = () => {
},
};

const reloadPyodideToClearState = async () => {
const clearPyodideData = async () => {
postMessage({ method: "handleLoading" });
await pyodide.runPythonAsync(`
# Clear all user-defined variables and modules
for name in dir():
if not name.startswith('_'):
del globals()[name]
`);
postMessage({ method: "handleLoaded", stdinBuffer, interruptBuffer });
};

const initialisePyodide = async () => {
postMessage({ method: "handleLoading" });

pyodidePromise = loadPyodide({
Expand All @@ -346,6 +348,15 @@ const PyodideWorker = () => {

pyodide = await pyodidePromise;

await pyodide.runPythonAsync(`
__old_input__ = input
def __patched_input__(prompt=False):
if (prompt):
print(prompt)
return __old_input__()
__builtins__.input = __patched_input__
`);

if (supportsAllFeatures) {
stdinBuffer =
stdinBuffer || new Int32Array(new SharedArrayBuffer(1024 * 1024)); // 1 MiB
Expand Down Expand Up @@ -409,7 +420,7 @@ const PyodideWorker = () => {
return { file, line, mistake, type, info };
};

reloadPyodideToClearState();
initialisePyodide();

return {
postMessage,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ describe("PyodideWorker", () => {
},
});
expect(pyodide.runPythonAsync).toHaveBeenCalledWith(
expect.stringMatching(/__builtins__.input = patched_input/),
expect.stringMatching(/__builtins__.input = __patched_input__/),
);
});

Expand Down Expand Up @@ -190,17 +190,18 @@ describe("PyodideWorker", () => {
});
});

test("it reloads pyodide after running the code", async () => {
global.loadPyodide.mockClear();
test("it clears the pyodide variables after running the code", async () => {
await worker.onmessage({
data: {
method: "runPython",
python: "print('hello')",
},
});
await waitFor(() => {
expect(global.loadPyodide).toHaveBeenCalled();
});
await waitFor(() =>
expect(pyodide.runPythonAsync).toHaveBeenCalledWith(
expect.stringContaining("del globals()[name]"),
),
);
});

test("it handles stopping by notifying component of an error", async () => {
Expand Down

0 comments on commit 01a4405

Please sign in to comment.