From e55f6d622257f74ca16e7d4a32c47b096371be6f Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Thu, 3 Nov 2022 15:06:19 +0100 Subject: [PATCH 01/16] rename this file, it's about the Pyodide class not the Runtime class --- pyscriptjs/tests/unit/{runtime.test.ts => pyodide.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename pyscriptjs/tests/unit/{runtime.test.ts => pyodide.test.ts} (100%) diff --git a/pyscriptjs/tests/unit/runtime.test.ts b/pyscriptjs/tests/unit/pyodide.test.ts similarity index 100% rename from pyscriptjs/tests/unit/runtime.test.ts rename to pyscriptjs/tests/unit/pyodide.test.ts From b5af86520b68c383816ffab25c64e05ce545da5a Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Thu, 3 Nov 2022 16:50:38 +0100 Subject: [PATCH 02/16] start to add the necessary machinery to redirect pyodide stdout/stderr to some arbitrary object --- pyscriptjs/src/main.ts | 8 +++++- pyscriptjs/src/pyodide.ts | 8 ++++-- pyscriptjs/src/stdio.ts | 37 +++++++++++++++++++++++++++ pyscriptjs/tests/unit/pyodide.test.ts | 11 +++++++- pyscriptjs/tests/unit/stdio.test.ts | 28 ++++++++++++++++++++ 5 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 pyscriptjs/src/stdio.ts create mode 100644 pyscriptjs/tests/unit/stdio.test.ts diff --git a/pyscriptjs/src/main.ts b/pyscriptjs/src/main.ts index 723eef1a5f8..a452e4d5b42 100644 --- a/pyscriptjs/src/main.ts +++ b/pyscriptjs/src/main.ts @@ -9,6 +9,8 @@ import { PyodideRuntime } from './pyodide'; import { getLogger } from './logger'; import { handleFetchError, showError, globalExport } from './utils'; import { createCustomElements } from './components/elements'; +import { type Stdio, DEFAULT_STDIO } from './stdio'; + type ImportType = { [key: string]: unknown }; type ImportMapType = { @@ -107,7 +109,11 @@ class PyScriptApp { showError('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, + DEFAULT_STDIO, + 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; diff --git a/pyscriptjs/src/pyodide.ts b/pyscriptjs/src/pyodide.ts index f45154573d9..ebc18812324 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) => { this.stdio.stdout(msg); }, + stderr: (msg) => { this.stdio.stderr(msg); }, fullStdLib: false, }); diff --git a/pyscriptjs/src/stdio.ts b/pyscriptjs/src/stdio.ts new file mode 100644 index 00000000000..97ede808bbc --- /dev/null +++ b/pyscriptjs/src/stdio.ts @@ -0,0 +1,37 @@ +export interface Stdio { + stdout: (msg: string) => void; + stderr: (msg: string) => void; +} + +/** Default implementation of Stdio: stdout and stderr are both sent to the + * console + */ +export const DEFAULT_STDIO: Stdio = { + stdout: console.log, + stderr: 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(msg: string) { + this.captured_stdout += msg + "\n"; + } + + stderr(msg: string) { + this.captured_stderr += msg + "\n"; + } +} diff --git a/pyscriptjs/tests/unit/pyodide.test.ts b/pyscriptjs/tests/unit/pyodide.test.ts index d6eeacd4e99..0f92e3dcd61 100644 --- a/pyscriptjs/tests/unit/pyodide.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..c08dc63021b --- /dev/null +++ b/pyscriptjs/tests/unit/stdio.test.ts @@ -0,0 +1,28 @@ +import { type Stdio, CaptureStdio } 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("hello"); + stdio.stdout("world"); + stdio.stderr("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("aaa"); + stdio.stderr("bbb"); + stdio.reset(); + expect(stdio.captured_stdout).toBe(""); + expect(stdio.captured_stderr).toBe(""); + }); + +}); From 051f940b2168271b448e5276eb23005e77cdc339 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Thu, 3 Nov 2022 17:19:00 +0100 Subject: [PATCH 03/16] add StdioMultiplexer, which allows to redirect streams to multiple listeners --- pyscriptjs/src/stdio.ts | 24 +++++++++++++++++ pyscriptjs/tests/unit/stdio.test.ts | 41 ++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/pyscriptjs/src/stdio.ts b/pyscriptjs/src/stdio.ts index 97ede808bbc..c1b9e25313e 100644 --- a/pyscriptjs/src/stdio.ts +++ b/pyscriptjs/src/stdio.ts @@ -35,3 +35,27 @@ export class CaptureStdio implements Stdio { 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(msg: string) { + for(const obj of this._listeners) + obj.stdout(msg); + } + + stderr(msg: string) { + for(const obj of this._listeners) + obj.stderr(msg); + } +} diff --git a/pyscriptjs/tests/unit/stdio.test.ts b/pyscriptjs/tests/unit/stdio.test.ts index c08dc63021b..0d4719b8b98 100644 --- a/pyscriptjs/tests/unit/stdio.test.ts +++ b/pyscriptjs/tests/unit/stdio.test.ts @@ -1,4 +1,4 @@ -import { type Stdio, CaptureStdio } from '../../src/stdio'; +import { type Stdio, CaptureStdio, StdioMultiplexer } from '../../src/stdio'; describe('CaptureStdio', () => { it('captured streams are initialized to empty string', () => { @@ -26,3 +26,42 @@ describe('CaptureStdio', () => { }); }); + + +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('out 1'); + multi.stderr('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('out 1'); + multi.stderr('err 1'); + + multi.addListener(b); + multi.stdout('out 2'); + multi.stderr('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"); + }); +}); From 4e76376c5d81c63b5ccd63cdd2c1e49401689d63 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Thu, 3 Nov 2022 18:52:25 +0100 Subject: [PATCH 04/16] wooo, introduce the plugin --- pyscriptjs/src/main.ts | 34 ++++++-- pyscriptjs/src/plugins/pyterminal.ts | 78 +++++++++++++++++++ .../tests/integration/test_py_terminal.py | 38 +++++++++ 3 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 pyscriptjs/src/plugins/pyterminal.ts create mode 100644 pyscriptjs/tests/integration/test_py_terminal.py diff --git a/pyscriptjs/src/main.ts b/pyscriptjs/src/main.ts index a452e4d5b42..cf0159ae0ee 100644 --- a/pyscriptjs/src/main.ts +++ b/pyscriptjs/src/main.ts @@ -9,8 +9,8 @@ import { PyodideRuntime } from './pyodide'; import { getLogger } from './logger'; import { handleFetchError, showError, globalExport } from './utils'; import { createCustomElements } from './components/elements'; -import { type Stdio, DEFAULT_STDIO } from './stdio'; - +import { type Stdio, StdioMultiplexer, DEFAULT_STDIO } from './stdio'; +import { PyTerminalPlugin } from './plugins/pyterminal'; type ImportType = { [key: string]: unknown }; type ImportMapType = { @@ -35,6 +35,8 @@ const logger = getLogger('pyscript/main'); 6. setup the environment, install packages + 6.5: call the Plugin.setupComplete() hook + 7. connect the py-script web component. This causes the execution of all the user scripts @@ -51,16 +53,29 @@ 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 + _stdioMultiplexer: StdioMultiplexer; + _pyterminal: PyTerminalPlugin; // XXX temporary + + constructor() { + this._stdioMultiplexer = new StdioMultiplexer(); + this._stdioMultiplexer.addListener(DEFAULT_STDIO); + } // lifecycle (1) main() { this.loadConfig(); - this.showLoader(); + + // this is basically "instantiate all plugins" + this._pyterminal = new PyTerminalPlugin(this); + this.showLoader(); // this should be a plugin + this.loadRuntime(); } @@ -110,7 +125,7 @@ class PyScriptApp { } const runtime_cfg = this.config.runtimes[0]; this.runtime = new PyodideRuntime(this.config, - DEFAULT_STDIO, + this._stdioMultiplexer, runtime_cfg.src, runtime_cfg.name, runtime_cfg.lang); @@ -143,6 +158,9 @@ class PyScriptApp { await this.setupVirtualEnv(runtime); await mountElements(runtime); + // lifecycle (6.5) + this._pyterminal.setupComplete(); + this.loader.log('Executing tags...'); this.executeScripts(runtime); @@ -198,6 +216,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/plugins/pyterminal.ts b/pyscriptjs/src/plugins/pyterminal.ts new file mode 100644 index 00000000000..9a645ffbb5d --- /dev/null +++ b/pyscriptjs/src/plugins/pyterminal.ts @@ -0,0 +1,78 @@ +import type { PyScriptApp } from '../main'; +import { getLogger } from '../logger'; +import { type Stdio } from '../stdio'; + +const logger = getLogger('py-terminal'); + +export class PyTerminalPlugin { + app: PyScriptApp; + + constructor(app: PyScriptApp) { + this.app = app; + } + + setupComplete() { + // 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); + } +} + +/** A Stdio provider which appends messages to an HTMLElement + */ +class ElementStdio implements Stdio { + el: HTMLElement; + + constructor(el: HTMLElement) { + this.el = el; + } + + stdout(msg: string) { + this.el.innerText += msg + "\n"; + } + + stderr(msg: string) { + this.stdout(msg); + } + +} + +function make_PyTerminal(app: PyScriptApp) { + + /** The custom element, which automatically register a stdio + * listener to capture and display stdout/stderr + */ + class PyTerminal extends HTMLElement { + constructor() { + super(); + } + + connectedCallback() { + const outElem = document.createElement('pre'); + outElem.className = 'py-terminal'; + this.appendChild(outElem); + const stdio = new ElementStdio(outElem); + logger.info('Registering stdio listener'); + app.registerStdioListener(stdio); + } + } + + return PyTerminal; +} diff --git a/pyscriptjs/tests/integration/test_py_terminal.py b/pyscriptjs/tests/integration/test_py_terminal.py new file mode 100644 index 00000000000..9cc2c7610e5 --- /dev/null +++ b/pyscriptjs/tests/integration/test_py_terminal.py @@ -0,0 +1,38 @@ +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", + ] From 922677203ed9118438c58d2fee5370cf39b00b9b Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Thu, 3 Nov 2022 18:56:30 +0100 Subject: [PATCH 05/16] use white-on-black for py-terminal colors --- pyscriptjs/src/styles/pyscript_base.css | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pyscriptjs/src/styles/pyscript_base.css b/pyscriptjs/src/styles/pyscript_base.css index c00f91f5baf..a1c1b360dda 100644 --- a/pyscriptjs/src/styles/pyscript_base.css +++ b/pyscriptjs/src/styles/pyscript_base.css @@ -284,3 +284,12 @@ 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 { + background-color: black; + color: white; +} From 31ccfc8229b1575b43f180d953d88cdbf451485d Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Thu, 3 Nov 2022 19:02:34 +0100 Subject: [PATCH 06/16] make sure that the is shown even if it's empty --- pyscriptjs/src/styles/pyscript_base.css | 1 + 1 file changed, 1 insertion(+) diff --git a/pyscriptjs/src/styles/pyscript_base.css b/pyscriptjs/src/styles/pyscript_base.css index a1c1b360dda..caeaf057b6a 100644 --- a/pyscriptjs/src/styles/pyscript_base.css +++ b/pyscriptjs/src/styles/pyscript_base.css @@ -290,6 +290,7 @@ textarea { bundled together at build time (by rollup?) */ .py-terminal { + min-height: 10em; background-color: black; color: white; } From 08d9c2fbf437bf193c1f6702b34203769cb4e22b Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Fri, 4 Nov 2022 18:41:42 +0100 Subject: [PATCH 07/16] add a passing test --- .../tests/integration/test_py_terminal.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/pyscriptjs/tests/integration/test_py_terminal.py b/pyscriptjs/tests/integration/test_py_terminal.py index 9cc2c7610e5..9423b682c5f 100644 --- a/pyscriptjs/tests/integration/test_py_terminal.py +++ b/pyscriptjs/tests/integration/test_py_terminal.py @@ -36,3 +36,32 @@ def test_py_terminal(self): "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"] From 940dbe9ac7e41e7e348e4e29ccdf0365e1cb7fe6 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Mon, 7 Nov 2022 12:05:32 +0100 Subject: [PATCH 08/16] use .stdout_writeline() instead of .stdout(), it's clearer --- pyscriptjs/src/plugins/pyterminal.ts | 6 +++--- pyscriptjs/src/pyodide.ts | 4 ++-- pyscriptjs/src/stdio.ts | 20 ++++++++++---------- pyscriptjs/tests/unit/stdio.test.ts | 22 +++++++++++----------- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/pyscriptjs/src/plugins/pyterminal.ts b/pyscriptjs/src/plugins/pyterminal.ts index 9a645ffbb5d..710e7d63af9 100644 --- a/pyscriptjs/src/plugins/pyterminal.ts +++ b/pyscriptjs/src/plugins/pyterminal.ts @@ -44,12 +44,12 @@ class ElementStdio implements Stdio { this.el = el; } - stdout(msg: string) { + stdout_writeline(msg: string) { this.el.innerText += msg + "\n"; } - stderr(msg: string) { - this.stdout(msg); + stderr_writeline(msg: string) { + this.stdout_writeline(msg); } } diff --git a/pyscriptjs/src/pyodide.ts b/pyscriptjs/src/pyodide.ts index ebc18812324..7b7faa66ec8 100644 --- a/pyscriptjs/src/pyodide.ts +++ b/pyscriptjs/src/pyodide.ts @@ -58,8 +58,8 @@ export class PyodideRuntime extends Runtime { async loadInterpreter(): Promise { logger.info('Loading pyodide'); this.interpreter = await loadPyodide({ - stdout: (msg) => { this.stdio.stdout(msg); }, - stderr: (msg) => { this.stdio.stderr(msg); }, + stdout: (msg) => { this.stdio.stdout_writeline(msg); }, + stderr: (msg) => { this.stdio.stderr_writeline(msg); }, fullStdLib: false, }); diff --git a/pyscriptjs/src/stdio.ts b/pyscriptjs/src/stdio.ts index c1b9e25313e..051ca5004f4 100644 --- a/pyscriptjs/src/stdio.ts +++ b/pyscriptjs/src/stdio.ts @@ -1,14 +1,14 @@ export interface Stdio { - stdout: (msg: string) => void; - stderr: (msg: string) => void; + 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: console.log, - stderr: console.log + stdout_writeline: console.log, + stderr_writeline: console.log } /** Stdio provider which captures and store the messages. @@ -27,11 +27,11 @@ export class CaptureStdio implements Stdio { this.captured_stderr = ""; } - stdout(msg: string) { + stdout_writeline(msg: string) { this.captured_stdout += msg + "\n"; } - stderr(msg: string) { + stderr_writeline(msg: string) { this.captured_stderr += msg + "\n"; } } @@ -49,13 +49,13 @@ export class StdioMultiplexer implements Stdio { this._listeners.push(obj); } - stdout(msg: string) { + stdout_writeline(msg: string) { for(const obj of this._listeners) - obj.stdout(msg); + obj.stdout_writeline(msg); } - stderr(msg: string) { + stderr_writeline(msg: string) { for(const obj of this._listeners) - obj.stderr(msg); + obj.stderr_writeline(msg); } } diff --git a/pyscriptjs/tests/unit/stdio.test.ts b/pyscriptjs/tests/unit/stdio.test.ts index 0d4719b8b98..fa052eb37e8 100644 --- a/pyscriptjs/tests/unit/stdio.test.ts +++ b/pyscriptjs/tests/unit/stdio.test.ts @@ -9,17 +9,17 @@ describe('CaptureStdio', () => { it('stdout() and stderr() captures', () => { let stdio = new CaptureStdio(); - stdio.stdout("hello"); - stdio.stdout("world"); - stdio.stderr("this is an error"); + 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("aaa"); - stdio.stderr("bbb"); + stdio.stdout_writeline("aaa"); + stdio.stderr_writeline("bbb"); stdio.reset(); expect(stdio.captured_stdout).toBe(""); expect(stdio.captured_stderr).toBe(""); @@ -41,8 +41,8 @@ describe('StdioMultiplexer', () => { it('works without listeners', () => { // no listeners, messages are ignored - multi.stdout('out 1'); - multi.stderr('err 1'); + 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(""); @@ -51,12 +51,12 @@ describe('StdioMultiplexer', () => { it('redirects to multiple listeners', () => { multi.addListener(a); - multi.stdout('out 1'); - multi.stderr('err 1'); + multi.stdout_writeline('out 1'); + multi.stderr_writeline('err 1'); multi.addListener(b); - multi.stdout('out 2'); - multi.stderr('err 2'); + 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"); From ad8233c6ff6566033ee78afacbc0c15999976eb0 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Mon, 7 Nov 2022 12:20:19 +0100 Subject: [PATCH 09/16] implement the Stdio interface directly inside PyTerminal --- pyscriptjs/src/plugins/pyterminal.ts | 46 ++++++++++++---------------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/pyscriptjs/src/plugins/pyterminal.ts b/pyscriptjs/src/plugins/pyterminal.ts index 710e7d63af9..217a0e8e75f 100644 --- a/pyscriptjs/src/plugins/pyterminal.ts +++ b/pyscriptjs/src/plugins/pyterminal.ts @@ -35,43 +35,35 @@ export class PyTerminalPlugin { } } -/** A Stdio provider which appends messages to an HTMLElement - */ -class ElementStdio implements Stdio { - el: HTMLElement; - - constructor(el: HTMLElement) { - this.el = el; - } - - stdout_writeline(msg: string) { - this.el.innerText += msg + "\n"; - } - - stderr_writeline(msg: string) { - this.stdout_writeline(msg); - } - -} function make_PyTerminal(app: PyScriptApp) { /** The custom element, which automatically register a stdio * listener to capture and display stdout/stderr */ - class PyTerminal extends HTMLElement { - constructor() { - super(); - } + class PyTerminal extends HTMLElement implements Stdio { + outElem: HTMLElement; connectedCallback() { - const outElem = document.createElement('pre'); - outElem.className = 'py-terminal'; - this.appendChild(outElem); - const stdio = new ElementStdio(outElem); + // 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); logger.info('Registering stdio listener'); - app.registerStdioListener(stdio); + app.registerStdioListener(this); + } + + // implementation of the Stdio interface + stdout_writeline(msg: string) { + this.outElem.innerText += msg + "\n"; + } + + stderr_writeline(msg: string) { + this.stdout_writeline(msg); } + // end of the Stdio interface } return PyTerminal; From bec630509724a4cfe276c63a3c46c11ce2209191 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Mon, 7 Nov 2022 13:54:32 +0100 Subject: [PATCH 10/16] add the 'auto' attribute --- pyscriptjs/src/plugins/pyterminal.ts | 18 ++++++++++++++++++ pyscriptjs/src/styles/pyscript_base.css | 4 ++++ .../tests/integration/test_py_terminal.py | 16 ++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/pyscriptjs/src/plugins/pyterminal.ts b/pyscriptjs/src/plugins/pyterminal.ts index 217a0e8e75f..88bdfcf5635 100644 --- a/pyscriptjs/src/plugins/pyterminal.ts +++ b/pyscriptjs/src/plugins/pyterminal.ts @@ -43,6 +43,7 @@ function make_PyTerminal(app: PyScriptApp) { */ class PyTerminal extends HTMLElement implements Stdio { outElem: HTMLElement; + autoShowOnNextLine: boolean; connectedCallback() { // should we use a shadowRoot instead? It looks unnecessarily @@ -51,13 +52,30 @@ function make_PyTerminal(app: PyScriptApp) { 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) { diff --git a/pyscriptjs/src/styles/pyscript_base.css b/pyscriptjs/src/styles/pyscript_base.css index caeaf057b6a..0ecee5660e8 100644 --- a/pyscriptjs/src/styles/pyscript_base.css +++ b/pyscriptjs/src/styles/pyscript_base.css @@ -294,3 +294,7 @@ textarea { background-color: black; color: white; } + +.py-terminal-hidden { + display: none; +} diff --git a/pyscriptjs/tests/integration/test_py_terminal.py b/pyscriptjs/tests/integration/test_py_terminal.py index 9423b682c5f..08d9959cf6e 100644 --- a/pyscriptjs/tests/integration/test_py_terminal.py +++ b/pyscriptjs/tests/integration/test_py_terminal.py @@ -1,3 +1,5 @@ +from playwright.sync_api import expect + from .support import PyScriptTest @@ -65,3 +67,17 @@ def test_two_terminals(self): term2_lines = term2.inner_text().splitlines() assert term1_lines == ["one", "two", "three"] assert term2_lines == ["two", "three"] + + def test_auto(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" From 0c5413cb0a6b92a9e76568657b54885acb615f04 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Mon, 7 Nov 2022 14:28:23 +0100 Subject: [PATCH 11/16] add support for config.terminal="yes" --- pyscriptjs/src/main.ts | 1 + pyscriptjs/src/plugins/pyterminal.ts | 11 ++++++++++ .../tests/integration/test_py_terminal.py | 21 ++++++++++++++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/pyscriptjs/src/main.ts b/pyscriptjs/src/main.ts index cf0159ae0ee..8db35680448 100644 --- a/pyscriptjs/src/main.ts +++ b/pyscriptjs/src/main.ts @@ -75,6 +75,7 @@ export class PyScriptApp { // this is basically "instantiate all plugins" this._pyterminal = new PyTerminalPlugin(this); this.showLoader(); // this should be a plugin + this._pyterminal.configurationComplete(this.config); this.loadRuntime(); } diff --git a/pyscriptjs/src/plugins/pyterminal.ts b/pyscriptjs/src/plugins/pyterminal.ts index 88bdfcf5635..61ec6f16e2f 100644 --- a/pyscriptjs/src/plugins/pyterminal.ts +++ b/pyscriptjs/src/plugins/pyterminal.ts @@ -1,4 +1,5 @@ import type { PyScriptApp } from '../main'; +import type { AppConfig } from '../pyconfig'; import { getLogger } from '../logger'; import { type Stdio } from '../stdio'; @@ -11,6 +12,16 @@ export class PyTerminalPlugin { this.app = app; } + configurationComplete(config: AppConfig) { + if (config.terminal === "yes") { + if (document.querySelector('py-terminal') === null) { + // no py-terminal found, add one! + const termEl = document.createElement('py-terminal'); + document.body.appendChild(termEl); + } + } + } + setupComplete() { // the Python interpreter has been initialized and we are ready to // execute user code: diff --git a/pyscriptjs/tests/integration/test_py_terminal.py b/pyscriptjs/tests/integration/test_py_terminal.py index 08d9959cf6e..b8802ce222f 100644 --- a/pyscriptjs/tests/integration/test_py_terminal.py +++ b/pyscriptjs/tests/integration/test_py_terminal.py @@ -68,7 +68,7 @@ def test_two_terminals(self): assert term1_lines == ["one", "two", "three"] assert term2_lines == ["two", "three"] - def test_auto(self): + def test_auto_attribute(self): self.pyscript_run( """ @@ -81,3 +81,22 @@ def test_auto(self): self.page.locator("button").click() expect(term).to_be_visible() assert term.inner_text() == "hello world\n" + + def test_config_yes(self): + """ + If we set config.terminal == "yes", a is automatically added + """ + self.pyscript_run( + """ + + terminal = "yes" + + + + print('hello world') + + """ + ) + term = self.page.locator("py-terminal") + expect(term).to_be_visible() + assert term.inner_text() == "hello world\n" From a95ada00090a37366c068b114696db88031c608c Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Mon, 7 Nov 2022 15:56:12 +0100 Subject: [PATCH 12/16] add support for config.terminal --- pyscriptjs/src/main.ts | 7 +++- pyscriptjs/src/plugins/pyterminal.ts | 34 ++++++++++++++--- .../tests/integration/test_py_terminal.py | 38 +++++++++++++++++-- 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/pyscriptjs/src/main.ts b/pyscriptjs/src/main.ts index 8db35680448..c9f3f58b298 100644 --- a/pyscriptjs/src/main.ts +++ b/pyscriptjs/src/main.ts @@ -74,8 +74,11 @@ export class PyScriptApp { // this is basically "instantiate all plugins" this._pyterminal = new PyTerminalPlugin(this); + + this._pyterminal.configure(this.config); + this.showLoader(); // this should be a plugin - this._pyterminal.configurationComplete(this.config); + this._pyterminal.beforeLaunch(this.config); this.loadRuntime(); } @@ -160,7 +163,7 @@ export class PyScriptApp { await mountElements(runtime); // lifecycle (6.5) - this._pyterminal.setupComplete(); + this._pyterminal.afterSetup(); this.loader.log('Executing tags...'); this.executeScripts(runtime); diff --git a/pyscriptjs/src/plugins/pyterminal.ts b/pyscriptjs/src/plugins/pyterminal.ts index 61ec6f16e2f..7bc6213ab06 100644 --- a/pyscriptjs/src/plugins/pyterminal.ts +++ b/pyscriptjs/src/plugins/pyterminal.ts @@ -12,17 +12,39 @@ export class PyTerminalPlugin { this.app = app; } - configurationComplete(config: AppConfig) { - if (config.terminal === "yes") { + configure(config: AppConfig) { + // validate the terminal config and handle default values + const t = config.terminal; + if (t !== undefined && + t !== true && + t !== false && + t !== "auto") { + // XXX this should be a UserError as soon as we have it + const got = JSON.stringify(t); + throw new Error('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) { - // no py-terminal found, add one! - const termEl = document.createElement('py-terminal'); - document.body.appendChild(termEl); + logger.info("No found, adding one"); + const termElem = document.createElement('py-terminal'); + if (t === "auto") + termElem.setAttribute("auto", ""); + document.body.appendChild(termElem); } } } - setupComplete() { + afterSetup() { // the Python interpreter has been initialized and we are ready to // execute user code: // diff --git a/pyscriptjs/tests/integration/test_py_terminal.py b/pyscriptjs/tests/integration/test_py_terminal.py index b8802ce222f..311a62f5fda 100644 --- a/pyscriptjs/tests/integration/test_py_terminal.py +++ b/pyscriptjs/tests/integration/test_py_terminal.py @@ -82,14 +82,32 @@ def test_auto_attribute(self): expect(term).to_be_visible() assert term.inner_text() == "hello world\n" - def test_config_yes(self): + def test_config_auto(self): """ - If we set config.terminal == "yes", a is automatically added + 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 = "yes" + terminal = true @@ -100,3 +118,17 @@ def test_config_yes(self): 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 From dde0c89a1f6a52654a82aa0eb2aa3960375e7de9 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Mon, 7 Nov 2022 16:28:18 +0100 Subject: [PATCH 13/16] introduce PluginManager and make PyTerminal a real plugin --- pyscriptjs/src/main.ts | 19 ++++---- pyscriptjs/src/plugin.ts | 70 ++++++++++++++++++++++++++++ pyscriptjs/src/plugins/pyterminal.ts | 4 +- 3 files changed, 83 insertions(+), 10 deletions(-) create mode 100644 pyscriptjs/src/plugin.ts diff --git a/pyscriptjs/src/main.ts b/pyscriptjs/src/main.ts index c9f3f58b298..7c24b36bed6 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'; @@ -35,7 +36,7 @@ const logger = getLogger('pyscript/main'); 6. setup the environment, install packages - 6.5: call the Plugin.setupComplete() hook + 6.5: call the Plugin.afterSetup() hook 7. connect the py-script web component. This causes the execution of all the user scripts @@ -60,10 +61,14 @@ export class PyScriptApp { loader: PyLoader; runtime: Runtime; PyScript: any; // XXX would be nice to have a more precise type for the class itself + plugins: PluginManager; _stdioMultiplexer: StdioMultiplexer; - _pyterminal: PyTerminalPlugin; // XXX temporary constructor() { + // initialize the builtin plugins + this.plugins = new PluginManager(); + this.plugins.add(new PyTerminalPlugin(this)); + this._stdioMultiplexer = new StdioMultiplexer(); this._stdioMultiplexer.addListener(DEFAULT_STDIO); } @@ -71,14 +76,10 @@ export class PyScriptApp { // lifecycle (1) main() { this.loadConfig(); - - // this is basically "instantiate all plugins" - this._pyterminal = new PyTerminalPlugin(this); - - this._pyterminal.configure(this.config); + this.plugins.configure(this.config); this.showLoader(); // this should be a plugin - this._pyterminal.beforeLaunch(this.config); + this.plugins.beforeLaunch(this.config); this.loadRuntime(); } @@ -163,7 +164,7 @@ export class PyScriptApp { await mountElements(runtime); // lifecycle (6.5) - this._pyterminal.afterSetup(); + this.plugins.afterSetup(runtime); this.loader.log('Executing tags...'); this.executeScripts(runtime); 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 index 7bc6213ab06..e81c1d60f97 100644 --- a/pyscriptjs/src/plugins/pyterminal.ts +++ b/pyscriptjs/src/plugins/pyterminal.ts @@ -1,14 +1,16 @@ import type { PyScriptApp } from '../main'; import type { AppConfig } from '../pyconfig'; +import { Plugin } from '../plugin'; import { getLogger } from '../logger'; import { type Stdio } from '../stdio'; const logger = getLogger('py-terminal'); -export class PyTerminalPlugin { +export class PyTerminalPlugin extends Plugin { app: PyScriptApp; constructor(app: PyScriptApp) { + super(); this.app = app; } From a80c5f7eced0e7a1246e9741ec6e21ac546785d7 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Mon, 7 Nov 2022 18:46:00 +0100 Subject: [PATCH 14/16] fix this test --- pyscriptjs/tests/integration/test_02_output.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) 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( From 095f156187b6505f7bba9850803f54663173b64b Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Wed, 9 Nov 2022 12:56:40 +0100 Subject: [PATCH 15/16] use a better type --- pyscriptjs/src/pyodide.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyscriptjs/src/pyodide.ts b/pyscriptjs/src/pyodide.ts index 7b7faa66ec8..bbbe505b646 100644 --- a/pyscriptjs/src/pyodide.ts +++ b/pyscriptjs/src/pyodide.ts @@ -58,8 +58,8 @@ export class PyodideRuntime extends Runtime { async loadInterpreter(): Promise { logger.info('Loading pyodide'); this.interpreter = await loadPyodide({ - stdout: (msg) => { this.stdio.stdout_writeline(msg); }, - stderr: (msg) => { this.stdio.stderr_writeline(msg); }, + stdout: (msg: string) => { this.stdio.stdout_writeline(msg); }, + stderr: (msg: string) => { this.stdio.stderr_writeline(msg); }, fullStdLib: false, }); From fb0cb61fc49449bc6e6d63c6d8a3b27c812167b6 Mon Sep 17 00:00:00 2001 From: Antonio Cuni Date: Wed, 9 Nov 2022 13:04:30 +0100 Subject: [PATCH 16/16] use UserError --- pyscriptjs/src/plugins/pyterminal.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyscriptjs/src/plugins/pyterminal.ts b/pyscriptjs/src/plugins/pyterminal.ts index e81c1d60f97..a5a9ae60c0f 100644 --- a/pyscriptjs/src/plugins/pyterminal.ts +++ b/pyscriptjs/src/plugins/pyterminal.ts @@ -1,6 +1,7 @@ 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'; @@ -21,10 +22,9 @@ export class PyTerminalPlugin extends Plugin { t !== true && t !== false && t !== "auto") { - // XXX this should be a UserError as soon as we have it const got = JSON.stringify(t); - throw new Error('Invalid value for config.terminal: the only accepted' + - `values are true, false and "auto", got "${got}".`); + 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