diff --git a/pyscriptjs/src/main.ts b/pyscriptjs/src/main.ts
index 67c278b882f..4d6343387e5 100644
--- a/pyscriptjs/src/main.ts
+++ b/pyscriptjs/src/main.ts
@@ -3,6 +3,7 @@ import './styles/pyscript_base.css';
import { loadConfigFromElement } from './pyconfig';
import type { AppConfig } from './pyconfig';
import type { Runtime } from './runtime';
+import { type Plugin, PluginManager } from './plugin';
import { make_PyScript, initHandlers, mountElements } from './components/pyscript';
import { PyLoader } from './components/pyloader';
import { PyodideRuntime } from './pyodide';
@@ -11,6 +12,8 @@ import { handleFetchError, showWarning, globalExport } from './utils';
import { calculatePaths } from './plugins/fetch';
import { createCustomElements } from './components/elements';
import { UserError, withUserErrorHandler } from "./exceptions"
+import { type Stdio, StdioMultiplexer, DEFAULT_STDIO } from './stdio';
+import { PyTerminalPlugin } from './plugins/pyterminal';
type ImportType = { [key: string]: unknown };
type ImportMapType = {
@@ -35,6 +38,8 @@ const logger = getLogger('pyscript/main');
6. setup the environment, install packages
+ 6.5: call the Plugin.afterSetup() hook
+
7. connect the py-script web component. This causes the execution of all the
user scripts
@@ -51,16 +56,33 @@ More concretely:
- PyScriptApp.afterRuntimeLoad() implements all the points >= 5.
*/
-class PyScriptApp {
+
+
+export class PyScriptApp {
config: AppConfig;
loader: PyLoader;
runtime: Runtime;
PyScript: any; // XXX would be nice to have a more precise type for the class itself
+ plugins: PluginManager;
+ _stdioMultiplexer: StdioMultiplexer;
+
+ constructor() {
+ // initialize the builtin plugins
+ this.plugins = new PluginManager();
+ this.plugins.add(new PyTerminalPlugin(this));
+
+ this._stdioMultiplexer = new StdioMultiplexer();
+ this._stdioMultiplexer.addListener(DEFAULT_STDIO);
+ }
// lifecycle (1)
main() {
this.loadConfig();
- this.showLoader();
+ this.plugins.configure(this.config);
+
+ this.showLoader(); // this should be a plugin
+ this.plugins.beforeLaunch(this.config);
+
this.loadRuntime();
}
@@ -108,7 +130,11 @@ class PyScriptApp {
showWarning('Multiple runtimes are not supported yet.
Only the first will be used');
}
const runtime_cfg = this.config.runtimes[0];
- this.runtime = new PyodideRuntime(this.config, runtime_cfg.src, runtime_cfg.name, runtime_cfg.lang);
+ this.runtime = new PyodideRuntime(this.config,
+ this._stdioMultiplexer,
+ runtime_cfg.src,
+ runtime_cfg.name,
+ runtime_cfg.lang);
this.loader.log(`Downloading ${runtime_cfg.name}...`);
const script = document.createElement('script'); // create a script DOM node
script.src = this.runtime.src;
@@ -138,6 +164,9 @@ class PyScriptApp {
await this.setupVirtualEnv(runtime);
await mountElements(runtime);
+ // lifecycle (6.5)
+ this.plugins.afterSetup(runtime);
+
this.loader.log('Executing tags...');
this.executeScripts(runtime);
@@ -195,6 +224,12 @@ class PyScriptApp {
customElements.define('py-script', this.PyScript);
}
+ // ================= registraton API ====================
+
+ registerStdioListener(stdio: Stdio) {
+ this._stdioMultiplexer.addListener(stdio);
+ }
+
async register_importmap(runtime: Runtime) {
// make importmap ES modules available from python using 'import'.
//
diff --git a/pyscriptjs/src/plugin.ts b/pyscriptjs/src/plugin.ts
new file mode 100644
index 00000000000..3329c3253fa
--- /dev/null
+++ b/pyscriptjs/src/plugin.ts
@@ -0,0 +1,70 @@
+import type { PyScriptApp } from './main';
+import type { AppConfig } from './pyconfig';
+import type { Runtime } from './runtime';
+
+export class Plugin {
+
+ /** Validate the configuration of the plugin and handle default values.
+ *
+ * Individual plugins are expected to check that the config keys/sections
+ * which are relevant to them contains valid values, and to raise an error
+ * if they contains unknown keys.
+ *
+ * This is also a good place where set default values for those keys which
+ * are not specified by the user.
+ *
+ * This hook should **NOT** contain expensive operations, else it delays
+ * the download of the python interpreter which is initiated later.
+ */
+ configure(config: AppConfig) {
+ }
+
+ /** The preliminary initialization phase is complete and we are about to
+ * download and launch the Python interpreter.
+ *
+ * We can assume that the page is already shown to the user and that the
+ * DOM content has been loaded. This is a good place where to add tags to
+ * the DOM, if needed.
+ *
+ * This hook should **NOT** contain expensive operations, else it delays
+ * the download of the python interpreter which is initiated later.
+ */
+ beforeLaunch(config: AppConfig) {
+ }
+
+ /** The Python interpreter has been launched, the virtualenv has been
+ * installed and we are ready to execute user code.
+ *
+ * The tags will be executed after this hook.
+ */
+ afterSetup(runtime: Runtime) {
+ }
+}
+
+
+export class PluginManager {
+ _plugins: Plugin[];
+
+ constructor() {
+ this._plugins = [];
+ }
+
+ add(p: Plugin) {
+ this._plugins.push(p);
+ }
+
+ configure(config: AppConfig) {
+ for (const p of this._plugins)
+ p.configure(config);
+ }
+
+ beforeLaunch(config: AppConfig) {
+ for (const p of this._plugins)
+ p.beforeLaunch(config);
+ }
+
+ afterSetup(runtime: Runtime) {
+ for (const p of this._plugins)
+ p.afterSetup(runtime);
+ }
+}
diff --git a/pyscriptjs/src/plugins/pyterminal.ts b/pyscriptjs/src/plugins/pyterminal.ts
new file mode 100644
index 00000000000..a5a9ae60c0f
--- /dev/null
+++ b/pyscriptjs/src/plugins/pyterminal.ts
@@ -0,0 +1,123 @@
+import type { PyScriptApp } from '../main';
+import type { AppConfig } from '../pyconfig';
+import { Plugin } from '../plugin';
+import { UserError } from "../exceptions"
+import { getLogger } from '../logger';
+import { type Stdio } from '../stdio';
+
+const logger = getLogger('py-terminal');
+
+export class PyTerminalPlugin extends Plugin {
+ app: PyScriptApp;
+
+ constructor(app: PyScriptApp) {
+ super();
+ this.app = app;
+ }
+
+ configure(config: AppConfig) {
+ // validate the terminal config and handle default values
+ const t = config.terminal;
+ if (t !== undefined &&
+ t !== true &&
+ t !== false &&
+ t !== "auto") {
+ const got = JSON.stringify(t);
+ throw new UserError('Invalid value for config.terminal: the only accepted' +
+ `values are true, false and "auto", got "${got}".`);
+ }
+ if (t === undefined) {
+ config.terminal = "auto"; // default value
+ }
+ }
+
+ beforeLaunch(config: AppConfig) {
+ // if config.terminal is "yes" or "auto", let's add a to
+ // the document, unless it's already present.
+ const t = config.terminal;
+ if (t === true || t === "auto") {
+ if (document.querySelector('py-terminal') === null) {
+ logger.info("No found, adding one");
+ const termElem = document.createElement('py-terminal');
+ if (t === "auto")
+ termElem.setAttribute("auto", "");
+ document.body.appendChild(termElem);
+ }
+ }
+ }
+
+ afterSetup() {
+ // the Python interpreter has been initialized and we are ready to
+ // execute user code:
+ //
+ // 1. define the "py-terminal" custom element
+ //
+ // 2. if there is a tag on the page, it will register
+ // a Stdio listener just before the user code executes, ensuring
+ // that we capture all the output
+ //
+ // 3. everything which was written to stdout BEFORE this moment will
+ // NOT be shown on the py-terminal; in particular, pyodide
+ // startup messages will not be shown (but they will go to the
+ // console as usual). This is by design, else we would display
+ // e.g. "Python initialization complete" on every page, which we
+ // don't want.
+ //
+ // 4. (in the future we might want to add an option to start the
+ // capture earlier, but I don't think it's important now).
+ const PyTerminal = make_PyTerminal(this.app);
+ customElements.define('py-terminal', PyTerminal);
+ }
+}
+
+
+function make_PyTerminal(app: PyScriptApp) {
+
+ /** The custom element, which automatically register a stdio
+ * listener to capture and display stdout/stderr
+ */
+ class PyTerminal extends HTMLElement implements Stdio {
+ outElem: HTMLElement;
+ autoShowOnNextLine: boolean;
+
+ connectedCallback() {
+ // should we use a shadowRoot instead? It looks unnecessarily
+ // complicated to me, but I'm not really sure about the
+ // implications
+ this.outElem = document.createElement('pre');
+ this.outElem.className = 'py-terminal';
+ this.appendChild(this.outElem);
+
+ if (this.isAuto()) {
+ this.classList.add('py-terminal-hidden');
+ this.autoShowOnNextLine = true;
+ }
+ else {
+ this.autoShowOnNextLine = false;
+ }
+
+ logger.info('Registering stdio listener');
+ app.registerStdioListener(this);
+ }
+
+ isAuto() {
+ return this.hasAttribute("auto");
+ }
+
+ // implementation of the Stdio interface
+ stdout_writeline(msg: string) {
+ this.outElem.innerText += msg + "\n";
+ if (this.autoShowOnNextLine) {
+ this.classList.remove('py-terminal-hidden');
+ this.autoShowOnNextLine = false;
+ }
+ }
+
+ stderr_writeline(msg: string) {
+ this.stdout_writeline(msg);
+ }
+ // end of the Stdio interface
+ }
+
+ return PyTerminal;
+}
diff --git a/pyscriptjs/src/pyodide.ts b/pyscriptjs/src/pyodide.ts
index a3d5e234bd7..0669a010d07 100644
--- a/pyscriptjs/src/pyodide.ts
+++ b/pyscriptjs/src/pyodide.ts
@@ -5,6 +5,7 @@ import type { loadPyodide as loadPyodideDeclaration, PyodideInterface, PyProxy }
// @ts-ignore
import pyscript from './python/pyscript.py';
import type { AppConfig } from './pyconfig';
+import type { Stdio } from './stdio';
declare const loadPyodide: typeof loadPyodideDeclaration;
@@ -17,6 +18,7 @@ interface Micropip {
export class PyodideRuntime extends Runtime {
src: string;
+ stdio: Stdio;
name?: string;
lang?: string;
interpreter: PyodideInterface;
@@ -24,12 +26,14 @@ export class PyodideRuntime extends Runtime {
constructor(
config: AppConfig,
+ stdio: Stdio,
src = 'https://cdn.jsdelivr.net/pyodide/v0.21.3/full/pyodide.js',
name = 'pyodide-default',
lang = 'python',
) {
logger.info('Runtime config:', { name, lang, src });
super(config);
+ this.stdio = stdio;
this.src = src;
this.name = name;
this.lang = lang;
@@ -54,8 +58,8 @@ export class PyodideRuntime extends Runtime {
async loadInterpreter(): Promise {
logger.info('Loading pyodide');
this.interpreter = await loadPyodide({
- stdout: console.log,
- stderr: console.log,
+ stdout: (msg: string) => { this.stdio.stdout_writeline(msg); },
+ stderr: (msg: string) => { this.stdio.stderr_writeline(msg); },
fullStdLib: false,
});
diff --git a/pyscriptjs/src/stdio.ts b/pyscriptjs/src/stdio.ts
new file mode 100644
index 00000000000..051ca5004f4
--- /dev/null
+++ b/pyscriptjs/src/stdio.ts
@@ -0,0 +1,61 @@
+export interface Stdio {
+ stdout_writeline: (msg: string) => void;
+ stderr_writeline: (msg: string) => void;
+}
+
+/** Default implementation of Stdio: stdout and stderr are both sent to the
+ * console
+ */
+export const DEFAULT_STDIO: Stdio = {
+ stdout_writeline: console.log,
+ stderr_writeline: console.log
+}
+
+/** Stdio provider which captures and store the messages.
+ * Useful for tests.
+ */
+export class CaptureStdio implements Stdio {
+ captured_stdout: string;
+ captured_stderr: string;
+
+ constructor() {
+ this.reset();
+ }
+
+ reset() {
+ this.captured_stdout = "";
+ this.captured_stderr = "";
+ }
+
+ stdout_writeline(msg: string) {
+ this.captured_stdout += msg + "\n";
+ }
+
+ stderr_writeline(msg: string) {
+ this.captured_stderr += msg + "\n";
+ }
+}
+
+/** Redirect stdio streams to multiple listeners
+ */
+export class StdioMultiplexer implements Stdio {
+ _listeners: Stdio[];
+
+ constructor() {
+ this._listeners = [];
+ }
+
+ addListener(obj: Stdio) {
+ this._listeners.push(obj);
+ }
+
+ stdout_writeline(msg: string) {
+ for(const obj of this._listeners)
+ obj.stdout_writeline(msg);
+ }
+
+ stderr_writeline(msg: string) {
+ for(const obj of this._listeners)
+ obj.stderr_writeline(msg);
+ }
+}
diff --git a/pyscriptjs/src/styles/pyscript_base.css b/pyscriptjs/src/styles/pyscript_base.css
index 688db41f5b9..1075b9a947c 100644
--- a/pyscriptjs/src/styles/pyscript_base.css
+++ b/pyscriptjs/src/styles/pyscript_base.css
@@ -318,3 +318,17 @@ textarea {
.line-through {
text-decoration: line-through;
}
+
+/* ===== py-terminal plugin ===== */
+/* XXX: it would be nice if these rules were stored in e.g. pyterminal.css and
+ bundled together at build time (by rollup?) */
+
+.py-terminal {
+ min-height: 10em;
+ background-color: black;
+ color: white;
+}
+
+.py-terminal-hidden {
+ display: none;
+}
diff --git a/pyscriptjs/tests/integration/test_02_output.py b/pyscriptjs/tests/integration/test_02_output.py
index 0605677d7c1..cdae78b439c 100644
--- a/pyscriptjs/tests/integration/test_02_output.py
+++ b/pyscriptjs/tests/integration/test_02_output.py
@@ -264,19 +264,21 @@ def test_text_HTML_and_console_output(self):
self.pyscript_run(
"""
- display('0')
+ display('this goes to the DOM')
print('print from python')
console.log('print from js')
console.error('error from js');
"""
)
- inner_text = self.page.inner_text("html")
- assert "0" == inner_text
- console_text = self.console.all.lines
- assert "print from python" in console_text
- assert "print from js" in console_text
- assert "error from js" in console_text
+ inner_text = self.page.inner_text("py-script")
+ assert inner_text == "this goes to the DOM"
+ assert self.console.log.lines == [
+ self.PY_COMPLETE,
+ "print from python",
+ "print from js",
+ ]
+ assert self.console.error.lines[-1] == "error from js"
def test_console_line_break(self):
self.pyscript_run(
diff --git a/pyscriptjs/tests/integration/test_py_terminal.py b/pyscriptjs/tests/integration/test_py_terminal.py
new file mode 100644
index 00000000000..311a62f5fda
--- /dev/null
+++ b/pyscriptjs/tests/integration/test_py_terminal.py
@@ -0,0 +1,134 @@
+from playwright.sync_api import expect
+
+from .support import PyScriptTest
+
+
+class TestPyTerminal(PyScriptTest):
+ def test_py_terminal(self):
+ """
+ 1. should redirect stdout and stderr to the DOM
+
+ 2. they also go to the console as usual
+
+ 3. note that the console also contains PY_COMPLETE, which is a pyodide
+ initialization message, but py-terminal doesn't. This is by design
+ """
+ self.pyscript_run(
+ """
+
+
+
+ import sys
+ print('hello world')
+ print('this goes to stderr', file=sys.stderr)
+ print('this goes to stdout')
+
+ """
+ )
+ term = self.page.locator("py-terminal")
+ term_lines = term.inner_text().splitlines()
+ assert term_lines == [
+ "hello world",
+ "this goes to stderr",
+ "this goes to stdout",
+ ]
+ assert self.console.log.lines == [
+ self.PY_COMPLETE,
+ "hello world",
+ "this goes to stderr",
+ "this goes to stdout",
+ ]
+
+ def test_two_terminals(self):
+ """
+ Multiple s can cohexist.
+ A receives only output from the moment it is added to
+ the DOM.
+ """
+ self.pyscript_run(
+ """
+
+
+
+ import js
+ print('one')
+ term2 = js.document.createElement('py-terminal')
+ term2.id = 'term2'
+ js.document.body.append(term2)
+
+ print('two')
+ print('three')
+
+ """
+ )
+ term1 = self.page.locator("#term1")
+ term2 = self.page.locator("#term2")
+ term1_lines = term1.inner_text().splitlines()
+ term2_lines = term2.inner_text().splitlines()
+ assert term1_lines == ["one", "two", "three"]
+ assert term2_lines == ["two", "three"]
+
+ def test_auto_attribute(self):
+ self.pyscript_run(
+ """
+
+
+
+ """
+ )
+ term = self.page.locator("py-terminal")
+ expect(term).to_be_hidden()
+ self.page.locator("button").click()
+ expect(term).to_be_visible()
+ assert term.inner_text() == "hello world\n"
+
+ def test_config_auto(self):
+ """
+ config.terminal == "auto" is the default: a is
+ automatically added to the page
+ """
+ self.pyscript_run(
+ """
+
+ """
+ )
+ term = self.page.locator("py-terminal")
+ expect(term).to_be_hidden()
+ assert "No found, adding one" in self.console.info.text
+ #
+ self.page.locator("button").click()
+ expect(term).to_be_visible()
+ assert term.inner_text() == "hello world\n"
+
+ def test_config_true(self):
+ """
+ If we set config.terminal == true, a is automatically added
+ """
+ self.pyscript_run(
+ """
+
+ terminal = true
+
+
+
+ print('hello world')
+
+ """
+ )
+ term = self.page.locator("py-terminal")
+ expect(term).to_be_visible()
+ assert term.inner_text() == "hello world\n"
+
+ def test_config_false(self):
+ """
+ If we set config.terminal == false, no is added
+ """
+ self.pyscript_run(
+ """
+
+ terminal = false
+
+ """
+ )
+ term = self.page.locator("py-terminal")
+ assert term.count() == 0
diff --git a/pyscriptjs/tests/unit/runtime.test.ts b/pyscriptjs/tests/unit/pyodide.test.ts
similarity index 88%
rename from pyscriptjs/tests/unit/runtime.test.ts
rename to pyscriptjs/tests/unit/pyodide.test.ts
index d6eeacd4e99..0f92e3dcd61 100644
--- a/pyscriptjs/tests/unit/runtime.test.ts
+++ b/pyscriptjs/tests/unit/pyodide.test.ts
@@ -1,6 +1,7 @@
import type { AppConfig } from '../../src/pyconfig';
import { Runtime } from '../../src/runtime';
import { PyodideRuntime } from '../../src/pyodide';
+import { CaptureStdio } from '../../src/stdio';
import { TextEncoder, TextDecoder } from 'util'
global.TextEncoder = TextEncoder
@@ -8,9 +9,11 @@ global.TextDecoder = TextDecoder
describe('PyodideRuntime', () => {
let runtime: PyodideRuntime;
+ let stdio: CaptureStdio = new CaptureStdio();
beforeAll(async () => {
const config: AppConfig = {};
- runtime = new PyodideRuntime(config);
+ runtime = new PyodideRuntime(config, stdio);
+
/**
* Since import { loadPyodide } from 'pyodide';
* is not used inside `src/pyodide.ts`, the function
@@ -50,6 +53,12 @@ describe('PyodideRuntime', () => {
expect(await runtime.run("2+3")).toBe(5);
});
+ it('should capture stdout', async () => {
+ stdio.reset();
+ await runtime.run("print('hello')");
+ expect(stdio.captured_stdout).toBe("hello\n");
+ });
+
it('should check if runtime is able to load a package', async () => {
await runtime.loadPackage("numpy");
await runtime.run("import numpy as np");
diff --git a/pyscriptjs/tests/unit/stdio.test.ts b/pyscriptjs/tests/unit/stdio.test.ts
new file mode 100644
index 00000000000..fa052eb37e8
--- /dev/null
+++ b/pyscriptjs/tests/unit/stdio.test.ts
@@ -0,0 +1,67 @@
+import { type Stdio, CaptureStdio, StdioMultiplexer } from '../../src/stdio';
+
+describe('CaptureStdio', () => {
+ it('captured streams are initialized to empty string', () => {
+ let stdio = new CaptureStdio();
+ expect(stdio.captured_stdout).toBe("");
+ expect(stdio.captured_stderr).toBe("");
+ });
+
+ it('stdout() and stderr() captures', () => {
+ let stdio = new CaptureStdio();
+ stdio.stdout_writeline("hello");
+ stdio.stdout_writeline("world");
+ stdio.stderr_writeline("this is an error");
+ expect(stdio.captured_stdout).toBe("hello\nworld\n");
+ expect(stdio.captured_stderr).toBe("this is an error\n");
+ });
+
+ it('reset() works', () => {
+ let stdio = new CaptureStdio();
+ stdio.stdout_writeline("aaa");
+ stdio.stderr_writeline("bbb");
+ stdio.reset();
+ expect(stdio.captured_stdout).toBe("");
+ expect(stdio.captured_stderr).toBe("");
+ });
+
+});
+
+
+describe('StdioMultiplexer', () => {
+ let a: CaptureStdio;
+ let b: CaptureStdio;
+ let multi: StdioMultiplexer;
+
+ beforeEach(() => {
+ a = new CaptureStdio();
+ b = new CaptureStdio();
+ multi = new StdioMultiplexer();
+ });
+
+ it('works without listeners', () => {
+ // no listeners, messages are ignored
+ multi.stdout_writeline('out 1');
+ multi.stderr_writeline('err 1');
+ expect(a.captured_stdout).toBe("");
+ expect(a.captured_stderr).toBe("");
+ expect(b.captured_stdout).toBe("");
+ expect(b.captured_stderr).toBe("");
+ });
+
+ it('redirects to multiple listeners', () => {
+ multi.addListener(a);
+ multi.stdout_writeline('out 1');
+ multi.stderr_writeline('err 1');
+
+ multi.addListener(b);
+ multi.stdout_writeline('out 2');
+ multi.stderr_writeline('err 2');
+
+ expect(a.captured_stdout).toBe("out 1\nout 2\n");
+ expect(a.captured_stderr).toBe("err 1\nerr 2\n");
+
+ expect(b.captured_stdout).toBe("out 2\n");
+ expect(b.captured_stderr).toBe("err 2\n");
+ });
+});