Skip to content

Commit 9b775ce

Browse files
Enhance MicroPython Terminal on both Main and Worker (#2083)
* Allow MicroPython Terminal on Main * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 66f72ed commit 9b775ce

File tree

14 files changed

+618
-425
lines changed

14 files changed

+618
-425
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ coverage/
142142
test_results
143143

144144
# @pyscript/core npm artifacts
145+
pyscript.core/test-results/*
145146
pyscript.core/core.*
146147
pyscript.core/dist
147148
pyscript.core/dist.zip

pyscript.core/package-lock.json

Lines changed: 151 additions & 149 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyscript.core/package.json

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@pyscript/core",
3-
"version": "0.4.34",
3+
"version": "0.4.38",
44
"type": "module",
55
"description": "PyScript",
66
"module": "./index.js",
@@ -43,7 +43,7 @@
4343
"dependencies": {
4444
"@ungap/with-resolvers": "^0.1.0",
4545
"basic-devtools": "^0.1.6",
46-
"polyscript": "^0.12.10",
46+
"polyscript": "^0.12.12",
4747
"sticky-module": "^0.1.1",
4848
"to-json-callback": "^0.1.1",
4949
"type-checked-collections": "^0.1.7"
@@ -54,18 +54,18 @@
5454
"@codemirror/language": "^6.10.1",
5555
"@codemirror/state": "^6.4.1",
5656
"@codemirror/view": "^6.26.3",
57-
"@playwright/test": "^1.44.0",
58-
"@rollup/plugin-commonjs": "^25.0.7",
57+
"@playwright/test": "^1.44.1",
58+
"@rollup/plugin-commonjs": "^25.0.8",
5959
"@rollup/plugin-node-resolve": "^15.2.3",
6060
"@rollup/plugin-terser": "^0.4.4",
6161
"@webreflection/toml-j0.4": "^1.1.3",
6262
"@xterm/addon-fit": "^0.10.0",
6363
"@xterm/addon-web-links": "^0.11.0",
64-
"bun": "^1.1.8",
64+
"bun": "^1.1.10",
6565
"chokidar": "^3.6.0",
6666
"codemirror": "^6.0.1",
67-
"eslint": "^9.2.0",
68-
"rollup": "^4.17.2",
67+
"eslint": "^9.3.0",
68+
"rollup": "^4.18.0",
6969
"rollup-plugin-postcss": "^4.0.2",
7070
"rollup-plugin-string": "^3.0.0",
7171
"static-handler": "^0.4.3",

pyscript.core/src/core.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,17 @@ import sync from "./sync.js";
2424
import bootstrapNodeAndPlugins from "./plugins-helper.js";
2525
import { ErrorCode } from "./exceptions.js";
2626
import { robustFetch as fetch, getText } from "./fetch.js";
27-
import { hooks, main, worker, codeFor, createFunction } from "./hooks.js";
27+
import {
28+
hooks,
29+
main,
30+
worker,
31+
codeFor,
32+
createFunction,
33+
inputFailure,
34+
} from "./hooks.js";
2835

2936
import { stdlib, optional } from "./stdlib.js";
30-
export { stdlib, optional };
37+
export { stdlib, optional, inputFailure };
3138

3239
// generic helper to disambiguate between custom element and script
3340
const isScript = ({ tagName }) => tagName === "SCRIPT";

pyscript.core/src/hooks.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export const createFunction = (self, name) => {
4646
const SetFunction = typedSet({ typeof: "function" });
4747
const SetString = typedSet({ typeof: "string" });
4848

49-
const inputFailure = `
49+
export const inputFailure = `
5050
import builtins
5151
def input(prompt=""):
5252
raise Exception("\\n ".join([
Lines changed: 23 additions & 260 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
// PyScript py-terminal plugin
2-
import { TYPES, hooks } from "../core.js";
2+
import { TYPES } from "../core.js";
33
import { notify } from "./error.js";
4-
import { customObserver, defineProperties } from "polyscript/exports";
4+
import { customObserver } from "polyscript/exports";
55

66
// will contain all valid selectors
77
const SELECTORS = [];
88

9+
// avoid processing same elements twice
10+
const processed = new WeakSet();
11+
912
// show the error on main and
1013
// stops the module from keep executing
1114
const notifyAndThrow = (message) => {
@@ -15,265 +18,10 @@ const notifyAndThrow = (message) => {
1518

1619
const onceOnMain = ({ attributes: { worker } }) => !worker;
1720

18-
const bootstrapped = new WeakSet();
19-
2021
let addStyle = true;
2122

22-
// this callback will be serialized as string and it never needs
23-
// to be invoked multiple times. Each xworker here is bootstrapped
24-
// only once thanks to the `sync.is_pyterminal()` check.
25-
const workerReady = ({ interpreter, io, run, type }, { sync }) => {
26-
if (!sync.is_pyterminal()) return;
27-
28-
// in workers it's always safe to grab the polyscript currentScript
29-
// the ugly `_` dance is due MicroPython not able to import via:
30-
// `from polyscript.currentScript import terminal as __terminal__`
31-
run(
32-
"from polyscript import currentScript as _; __terminal__ = _.terminal; del _",
33-
);
34-
35-
let data = "";
36-
const { pyterminal_read, pyterminal_write } = sync;
37-
const decoder = new TextDecoder();
38-
const generic = {
39-
isatty: false,
40-
write(buffer) {
41-
data = decoder.decode(buffer);
42-
pyterminal_write(data);
43-
return buffer.length;
44-
},
45-
};
46-
47-
// This part works already in both Pyodide and MicroPython
48-
io.stderr = (error) => {
49-
pyterminal_write(String(error.message || error));
50-
};
51-
52-
// MicroPython has no code or code.interact()
53-
// This part patches it in a way that simulates
54-
// the code.interact() module in Pyodide.
55-
if (type === "mpy") {
56-
// monkey patch global input otherwise broken in MicroPython
57-
interpreter.registerJsModule("_pyscript_input", {
58-
input: pyterminal_read,
59-
});
60-
run("from _pyscript_input import input");
61-
62-
// this is needed to avoid truncated unicode in MicroPython
63-
// the reason is that `linebuffer` false just send one byte
64-
// per time and readline here doesn't like it much.
65-
// MicroPython also has issues with code-points and
66-
// replProcessChar(byte) but that function accepts only
67-
// one byte per time so ... we have an issue!
68-
// @see https://github.com/pyscript/pyscript/pull/2018
69-
// @see https://github.com/WebReflection/buffer-points
70-
const bufferPoints = (stdio) => {
71-
const bytes = [];
72-
let needed = 0;
73-
return (buffer) => {
74-
let written = 0;
75-
for (const byte of buffer) {
76-
bytes.push(byte);
77-
// @see https://encoding.spec.whatwg.org/#utf-8-bytes-needed
78-
if (needed) needed--;
79-
else if (0xc2 <= byte && byte <= 0xdf) needed = 1;
80-
else if (0xe0 <= byte && byte <= 0xef) needed = 2;
81-
else if (0xf0 <= byte && byte <= 0xf4) needed = 3;
82-
if (!needed) {
83-
written += bytes.length;
84-
stdio(new Uint8Array(bytes.splice(0)));
85-
}
86-
}
87-
return written;
88-
};
89-
};
90-
91-
io.stdout = bufferPoints(generic.write);
92-
93-
// tiny shim of the code module with only interact
94-
// to bootstrap a REPL like environment
95-
interpreter.registerJsModule("code", {
96-
interact() {
97-
let input = "";
98-
let length = 1;
99-
100-
const encoder = new TextEncoder();
101-
const acc = [];
102-
const handlePoints = bufferPoints((buffer) => {
103-
acc.push(...buffer);
104-
pyterminal_write(decoder.decode(buffer));
105-
});
106-
107-
// avoid duplicating the output produced by the input
108-
io.stdout = (buffer) =>
109-
length++ > input.length ? handlePoints(buffer) : 0;
110-
111-
interpreter.replInit();
112-
113-
// loop forever waiting for user inputs
114-
(function repl() {
115-
const out = decoder.decode(new Uint8Array(acc.splice(0)));
116-
// print in current line only the last line produced by the REPL
117-
const data = `${pyterminal_read(out.split("\n").at(-1))}\r`;
118-
length = 0;
119-
input = encoder.encode(data);
120-
for (const c of input) interpreter.replProcessChar(c);
121-
repl();
122-
})();
123-
},
124-
});
125-
} else {
126-
interpreter.setStdout(generic);
127-
interpreter.setStderr(generic);
128-
interpreter.setStdin({
129-
isatty: false,
130-
stdin: () => pyterminal_read(data),
131-
});
132-
}
133-
};
134-
135-
const pyTerminal = async (element) => {
136-
// lazy load these only when a valid terminal is found
137-
const [{ Terminal }, { Readline }, { FitAddon }, { WebLinksAddon }] =
138-
await Promise.all([
139-
import(/* webpackIgnore: true */ "../3rd-party/xterm.js"),
140-
import(/* webpackIgnore: true */ "../3rd-party/xterm-readline.js"),
141-
import(/* webpackIgnore: true */ "../3rd-party/xterm_addon-fit.js"),
142-
import(
143-
/* webpackIgnore: true */ "../3rd-party/xterm_addon-web-links.js"
144-
),
145-
]);
146-
147-
const readline = new Readline();
148-
149-
// common main thread initialization for both worker
150-
// or main case, bootstrapping the terminal on its target
151-
const init = (options) => {
152-
let target = element;
153-
const selector = element.getAttribute("target");
154-
if (selector) {
155-
target =
156-
document.getElementById(selector) ||
157-
document.querySelector(selector);
158-
if (!target) throw new Error(`Unknown target ${selector}`);
159-
} else {
160-
target = document.createElement("py-terminal");
161-
target.style.display = "block";
162-
element.after(target);
163-
}
164-
const terminal = new Terminal({
165-
theme: {
166-
background: "#191A19",
167-
foreground: "#F5F2E7",
168-
},
169-
...options,
170-
});
171-
const fitAddon = new FitAddon();
172-
terminal.loadAddon(fitAddon);
173-
terminal.loadAddon(readline);
174-
terminal.loadAddon(new WebLinksAddon());
175-
terminal.open(target);
176-
fitAddon.fit();
177-
terminal.focus();
178-
defineProperties(element, {
179-
terminal: { value: terminal },
180-
process: {
181-
value: async (code) => {
182-
// this loop is the only way I could find to actually simulate
183-
// the user input char after char in a way that works in both
184-
// MicroPython and Pyodide
185-
for (const line of code.split(/(?:\r|\n|\r\n)/)) {
186-
terminal.paste(`${line}\n`);
187-
do {
188-
await new Promise((resolve) =>
189-
setTimeout(resolve, 0),
190-
);
191-
} while (!readline.activeRead?.resolve);
192-
readline.activeRead.resolve(line);
193-
}
194-
},
195-
},
196-
});
197-
return terminal;
198-
};
199-
200-
// branch logic for the worker
201-
if (element.hasAttribute("worker")) {
202-
// add a hook on the main thread to setup all sync helpers
203-
// also bootstrapping the XTerm target on main *BUT* ...
204-
hooks.main.onWorker.add(function worker(_, xworker) {
205-
// ... as multiple workers will add multiple callbacks
206-
// be sure no xworker is ever initialized twice!
207-
if (bootstrapped.has(xworker)) return;
208-
bootstrapped.add(xworker);
209-
210-
// still cleanup this callback for future scripts/workers
211-
hooks.main.onWorker.delete(worker);
212-
213-
init({
214-
disableStdin: false,
215-
cursorBlink: true,
216-
cursorStyle: "block",
217-
});
218-
219-
xworker.sync.is_pyterminal = () => true;
220-
xworker.sync.pyterminal_read = readline.read.bind(readline);
221-
xworker.sync.pyterminal_write = readline.write.bind(readline);
222-
});
223-
224-
// setup remote thread JS/Python code for whenever the
225-
// worker is ready to become a terminal
226-
hooks.worker.onReady.add(workerReady);
227-
} else {
228-
// in the main case, just bootstrap XTerm without
229-
// allowing any input as that's not possible / awkward
230-
hooks.main.onReady.add(function main({ interpreter, io, run, type }) {
231-
console.warn("py-terminal is read only on main thread");
232-
hooks.main.onReady.delete(main);
233-
234-
// on main, it's easy to trash and clean the current terminal
235-
globalThis.__py_terminal__ = init({
236-
disableStdin: true,
237-
cursorBlink: false,
238-
cursorStyle: "underline",
239-
});
240-
run("from js import __py_terminal__ as __terminal__");
241-
delete globalThis.__py_terminal__;
242-
243-
io.stderr = (error) => {
244-
readline.write(String(error.message || error));
245-
};
246-
247-
if (type === "mpy") {
248-
interpreter.setStdin = Object; // as no-op
249-
interpreter.setStderr = Object; // as no-op
250-
interpreter.setStdout = ({ write }) => {
251-
io.stdout = write;
252-
};
253-
}
254-
255-
let data = "";
256-
const decoder = new TextDecoder();
257-
const generic = {
258-
isatty: false,
259-
write(buffer) {
260-
data = decoder.decode(buffer);
261-
readline.write(data);
262-
return buffer.length;
263-
},
264-
};
265-
interpreter.setStdout(generic);
266-
interpreter.setStderr(generic);
267-
interpreter.setStdin({
268-
isatty: false,
269-
stdin: () => readline.read(data),
270-
});
271-
});
272-
}
273-
};
274-
275-
for (const key of TYPES.keys()) {
276-
const selector = `script[type="${key}"][terminal],${key}-script[terminal]`;
23+
for (const type of TYPES.keys()) {
24+
const selector = `script[type="${type}"][terminal],${type}-script[terminal]`;
27725
SELECTORS.push(selector);
27826
customObserver.set(selector, async (element) => {
27927
// we currently support only one terminal on main as in "classic"
@@ -292,6 +40,21 @@ for (const key of TYPES.keys()) {
29240
);
29341
}
29442

295-
await pyTerminal(element);
43+
if (processed.has(element)) return;
44+
processed.add(element);
45+
46+
const bootstrap = (module) => module.default(element);
47+
48+
// we can't be smart with template literals for the dynamic import
49+
// or bundlers are incapable of producing multiple files around
50+
if (type === "mpy") {
51+
await import(/* webpackIgnore: true */ "./py-terminal/mpy.js").then(
52+
bootstrap,
53+
);
54+
} else {
55+
await import(/* webpackIgnore: true */ "./py-terminal/py.js").then(
56+
bootstrap,
57+
);
58+
}
29659
});
29760
}

0 commit comments

Comments
 (0)