Skip to content

Commit 1eae407

Browse files
authored
Hack open function to write files in main thread (#1146)
## Things that need doing - [x] Support `w` mode - [x] Support `a` mode - [x] Support `x` mode - [x] Support creating files when the specified file name does not match an existing file - [x] Support `with open(filename) as f` pattern (currently returning `CustomFile does not support the context manager protocol`) - [x] Re-enable `pyodide-http` patch - [x] Think about limiting the number of files the user can create to avoid overloading the server - [x] Ensure that file size limit applies to generated files
1 parent b0a0a7f commit 1eae407

File tree

11 files changed

+509
-91
lines changed

11 files changed

+509
-91
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
88

99
### Added
1010

11+
- Ability to write to files in `python` (#1146)
1112
- Support for the `outputPanels` attribute in the `PyodideRunner` (#1157)
1213
- Downloading project instructions (#1160)
1314

cypress/e2e/spec-wc-pyodide.cy.js

+52
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,58 @@ describe("Running the code with pyodide", () => {
9191
.should("contain", "Hello Lois");
9292
});
9393

94+
it("runs a simple program to write to a file", () => {
95+
runCode('with open("output.txt", "w") as f:\n\tf.write("Hello world")');
96+
cy.get("editor-wc")
97+
.shadow()
98+
.contains(".files-list-item", "output.txt")
99+
.click();
100+
cy.get("editor-wc")
101+
.shadow()
102+
.find(".cm-editor")
103+
.should("contain", "Hello world");
104+
});
105+
106+
it("errors when trying to write to an existing file in 'x' mode", () => {
107+
runCode('with open("output.txt", "w") as f:\n\tf.write("Hello world")');
108+
cy.get("editor-wc")
109+
.shadow()
110+
.find(".files-list-item")
111+
.should("contain", "output.txt");
112+
runCode('with open("output.txt", "x") as f:\n\tf.write("Something else")');
113+
cy.get("editor-wc")
114+
.shadow()
115+
.find(".error-message__content")
116+
.should(
117+
"contain",
118+
"FileExistsError: File 'output.txt' already exists on line 1 of main.py",
119+
);
120+
});
121+
122+
it("updates the file in the editor when the content is updated programatically", () => {
123+
runCode('with open("output.txt", "w") as f:\n\tf.write("Hello world")');
124+
cy.get("editor-wc")
125+
.shadow()
126+
.find("div[class=cm-content]")
127+
.invoke(
128+
"text",
129+
'with open("output.txt", "a") as f:\n\tf.write("Hello again world")',
130+
);
131+
cy.get("editor-wc")
132+
.shadow()
133+
.contains(".files-list-item", "output.txt")
134+
.click();
135+
cy.get("editor-wc")
136+
.shadow()
137+
.find(".btn--run")
138+
.should("not.be.disabled")
139+
.click();
140+
cy.get("editor-wc")
141+
.shadow()
142+
.find(".cm-editor")
143+
.should("contain", "Hello again world");
144+
});
145+
94146
it("runs a simple program with a built-in python module", () => {
95147
runCode("from math import floor, pi\nprint(floor(pi))");
96148
cy.get("editor-wc")

src/PyodideWorker.js

+78-8
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,6 @@ const PyodideWorker = () => {
6060

6161
const runPython = async (python) => {
6262
stopped = false;
63-
await pyodide.loadPackage("pyodide_http");
64-
65-
await pyodide.runPythonAsync(`
66-
import pyodide_http
67-
pyodide_http.patch_all()
68-
`);
6963

7064
try {
7165
await withSupportForPackages(python, async () => {
@@ -98,6 +92,52 @@ const PyodideWorker = () => {
9892
await pyodide.loadPackagesFromImports(python);
9993

10094
checkIfStopped();
95+
await pyodide.runPythonAsync(
96+
`
97+
import basthon
98+
import builtins
99+
import os
100+
101+
MAX_FILES = 100
102+
MAX_FILE_SIZE = 8500000
103+
104+
def _custom_open(filename, mode="r", *args, **kwargs):
105+
if "x" in mode and os.path.exists(filename):
106+
raise FileExistsError(f"File '{filename}' already exists")
107+
if ("w" in mode or "a" in mode or "x" in mode) and "b" not in mode:
108+
if len(os.listdir()) > MAX_FILES and not os.path.exists(filename):
109+
raise OSError(f"File system limit reached, no more than {MAX_FILES} files allowed")
110+
class CustomFile:
111+
def __init__(self, filename):
112+
self.filename = filename
113+
self.content = ""
114+
115+
def write(self, content):
116+
self.content += content
117+
if len(self.content) > MAX_FILE_SIZE:
118+
raise OSError(f"File '{self.filename}' exceeds maximum file size of {MAX_FILE_SIZE} bytes")
119+
with _original_open(self.filename, "w") as f:
120+
f.write(self.content)
121+
basthon.kernel.write_file({ "filename": self.filename, "content": self.content, "mode": mode })
122+
123+
def close(self):
124+
pass
125+
126+
def __enter__(self):
127+
return self
128+
129+
def __exit__(self, exc_type, exc_val, exc_tb):
130+
self.close()
131+
132+
return CustomFile(filename)
133+
else:
134+
return _original_open(filename, mode, *args, **kwargs)
135+
136+
# Override the built-in open function
137+
builtins.open = _custom_open
138+
`,
139+
{ filename: "__custom_open__.py" },
140+
);
101141
await runPythonFn();
102142

103143
for (let name of imports) {
@@ -337,6 +377,12 @@ const PyodideWorker = () => {
337377

338378
postMessage({ method: "handleVisual", origin, content });
339379
},
380+
write_file: (event) => {
381+
const filename = event.toJs().get("filename");
382+
const content = event.toJs().get("content");
383+
const mode = event.toJs().get("mode");
384+
postMessage({ method: "handleFileWrite", filename, content, mode });
385+
},
340386
locals: () => pyodide.runPython("globals()"),
341387
},
342388
};
@@ -346,7 +392,7 @@ const PyodideWorker = () => {
346392
await pyodide.runPythonAsync(`
347393
# Clear all user-defined variables and modules
348394
for name in dir():
349-
if not name.startswith('_'):
395+
if not name.startswith('_') and not name=='basthon':
350396
del globals()[name]
351397
`);
352398
postMessage({ method: "handleLoaded", stdinBuffer, interruptBuffer });
@@ -364,6 +410,8 @@ const PyodideWorker = () => {
364410

365411
pyodide = await pyodidePromise;
366412

413+
pyodide.registerJsModule("basthon", fakeBasthonPackage);
414+
367415
await pyodide.runPythonAsync(`
368416
__old_input__ = input
369417
def __patched_input__(prompt=False):
@@ -373,6 +421,18 @@ const PyodideWorker = () => {
373421
__builtins__.input = __patched_input__
374422
`);
375423

424+
await pyodide.runPythonAsync(`
425+
import builtins
426+
# Save the original open function
427+
_original_open = builtins.open
428+
`);
429+
430+
await pyodide.loadPackage("pyodide-http");
431+
await pyodide.runPythonAsync(`
432+
import pyodide_http
433+
pyodide_http.patch_all()
434+
`);
435+
376436
if (supportsAllFeatures) {
377437
stdinBuffer =
378438
stdinBuffer || new Int32Array(new SharedArrayBuffer(1024 * 1024)); // 1 MiB
@@ -416,6 +476,14 @@ const PyodideWorker = () => {
416476

417477
const lines = trace.split("\n");
418478

479+
// if the third from last line matches /File "__custom_open__\.py", line (\d+)/g then strip off the last three lines
480+
if (
481+
lines.length > 3 &&
482+
/File "__custom_open__\.py", line (\d+)/g.test(lines[lines.length - 3])
483+
) {
484+
lines.splice(-3, 3);
485+
}
486+
419487
const snippetLine = lines[lines.length - 2]; // print("hi")invalid
420488
const caretLine = lines[lines.length - 1]; // ^^^^^^^
421489

@@ -424,7 +492,9 @@ const PyodideWorker = () => {
424492
? [snippetLine.slice(4), caretLine.slice(4)].join("\n")
425493
: "";
426494

427-
const matches = [...trace.matchAll(/File "(.*)", line (\d+)/g)];
495+
const matches = [
496+
...trace.matchAll(/File "(?!__custom_open__\.py)(.*)", line (\d+)/g),
497+
];
428498
const match = matches[matches.length - 1];
429499

430500
const path = match ? match[1] : "";

src/components/Editor/EditorPanel/EditorPanel.jsx

+31-6
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
import "../../../assets/stylesheets/EditorPanel.scss";
33
import React, { useRef, useEffect, useContext, useState } from "react";
44
import { useSelector, useDispatch } from "react-redux";
5-
import { updateProjectComponent } from "../../../redux/EditorSlice";
5+
import {
6+
setCascadeUpdate,
7+
updateProjectComponent,
8+
} from "../../../redux/EditorSlice";
69
import { useCookies } from "react-cookie";
710
import { useTranslation } from "react-i18next";
811
import { basicSetup } from "codemirror";
@@ -27,8 +30,10 @@ const MAX_CHARACTERS = 8500000;
2730

2831
const EditorPanel = ({ extension = "html", fileName = "index" }) => {
2932
const editor = useRef();
33+
const editorViewRef = useRef();
3034
const project = useSelector((state) => state.editor.project);
3135
const readOnly = useSelector((state) => state.editor.readOnly);
36+
const cascadeUpdate = useSelector((state) => state.editor.cascadeUpdate);
3237
const [cookies] = useCookies(["theme", "fontSize"]);
3338
const dispatch = useDispatch();
3439
const { t } = useTranslation();
@@ -40,7 +45,8 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => {
4045
updateProjectComponent({
4146
extension: extension,
4247
name: fileName,
43-
code: content,
48+
content,
49+
cascadeUpdate: false,
4450
}),
4551
);
4652
};
@@ -74,11 +80,11 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => {
7480
window.matchMedia("(prefers-color-scheme:dark)").matches);
7581
const editorTheme = isDarkMode ? editorDarkTheme : editorLightTheme;
7682

77-
useEffect(() => {
78-
const file = project.components.find(
79-
(item) => item.extension === extension && item.name === fileName,
80-
);
83+
const file = project.components.find(
84+
(item) => item.extension === extension && item.name === fileName,
85+
);
8186

87+
useEffect(() => {
8288
if (!file) {
8389
return;
8490
}
@@ -123,6 +129,8 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => {
123129
parent: editor.current,
124130
});
125131

132+
editorViewRef.current = view;
133+
126134
// 'aria-hidden' to fix keyboard access accessibility error
127135
view.scrollDOM.setAttribute("aria-hidden", "true");
128136

@@ -138,6 +146,23 @@ const EditorPanel = ({ extension = "html", fileName = "index" }) => {
138146
};
139147
}, [cookies]);
140148

149+
useEffect(() => {
150+
if (
151+
cascadeUpdate &&
152+
editorViewRef.current &&
153+
file.content !== editorViewRef.current.state.doc.toString()
154+
) {
155+
editorViewRef.current.dispatch({
156+
changes: {
157+
from: 0,
158+
to: editorViewRef.current.state.doc.length,
159+
insert: file.content,
160+
},
161+
});
162+
dispatch(setCascadeUpdate(false));
163+
}
164+
}, [file, cascadeUpdate, editorViewRef]);
165+
141166
return (
142167
<>
143168
<div className={`editor editor--${settings.fontSize}`} ref={editor}></div>

src/components/Editor/Output/Output.test.js

+2
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ describe("Output component", () => {
2626
project: {
2727
components: [],
2828
},
29+
focussedFileIndices: [0],
30+
openFiles: [["main.py"]],
2931
},
3032
auth: {
3133
user,

src/components/Editor/Runners/PythonRunner/PyodideRunner/PyodideRunner.jsx

+46-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
setError,
99
codeRunHandled,
1010
setLoadedRunner,
11+
updateProjectComponent,
12+
addProjectComponent,
1113
} from "../../../../../redux/EditorSlice";
1214
import { Tab, Tabs, TabList, TabPanel } from "react-tabs";
1315
import { useMediaQuery } from "react-responsive";
@@ -51,6 +53,10 @@ const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => {
5153
const projectImages = useSelector((s) => s.editor.project.image_list);
5254
const projectCode = useSelector((s) => s.editor.project.components);
5355
const projectIdentifier = useSelector((s) => s.editor.project.identifier);
56+
const focussedFileIndex = useSelector(
57+
(state) => state.editor.focussedFileIndices,
58+
)[0];
59+
const openFiles = useSelector((state) => state.editor.openFiles)[0];
5460
const user = useSelector((s) => s.auth.user);
5561
const userId = user?.profile?.user;
5662
const isSplitView = useSelector((s) => s.editor.isSplitView);
@@ -97,6 +103,16 @@ const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => {
97103
data.info,
98104
);
99105
break;
106+
case "handleFileWrite":
107+
const cascadeUpdate =
108+
openFiles[focussedFileIndex] === data.filename;
109+
handleFileWrite(
110+
data.filename,
111+
data.content,
112+
data.mode,
113+
cascadeUpdate,
114+
);
115+
break;
100116
case "handleVisual":
101117
handleVisual(data.origin, data.content);
102118
break;
@@ -108,7 +124,7 @@ const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => {
108124
}
109125
};
110126
}
111-
}, [pyodideWorker]);
127+
}, [pyodideWorker, projectCode, openFiles, focussedFileIndex]);
112128

113129
useEffect(() => {
114130
if (codeRunTriggered && active && output.current) {
@@ -197,6 +213,35 @@ const PyodideRunner = ({ active, outputPanels = ["text", "visual"] }) => {
197213
disableInput();
198214
};
199215

216+
const handleFileWrite = (filename, content, mode, cascadeUpdate) => {
217+
const [name, extension] = filename.split(".");
218+
const componentToUpdate = projectCode.find(
219+
(item) => item.extension === extension && item.name === name,
220+
);
221+
let updatedContent;
222+
if (mode === "w" || mode === "x") {
223+
updatedContent = content;
224+
} else if (mode === "a") {
225+
updatedContent =
226+
(componentToUpdate ? componentToUpdate.content + "\n" : "") + content;
227+
}
228+
229+
if (componentToUpdate) {
230+
dispatch(
231+
updateProjectComponent({
232+
extension,
233+
name,
234+
content: updatedContent,
235+
cascadeUpdate,
236+
}),
237+
);
238+
} else {
239+
dispatch(
240+
addProjectComponent({ name, extension, content: updatedContent }),
241+
);
242+
}
243+
};
244+
200245
const handleVisual = (origin, content) => {
201246
if (showVisualOutputPanel) {
202247
setHasVisual(true);

0 commit comments

Comments
 (0)