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"); + }); +});