Introduce a Plugin system, implement <py-terminal> as a plugin (#917)

This PR does two things:

1. introduce the first minimal version of a Plugin system. Plugins are subclasses of the Plugin class, and pyscript will call the relevant methods/hooks at the right time. Currently, I have added only the minimal sets of hooks which were needed for this PR.

2. Implement <py-terminal> as a plugin.
This commit is contained in:
Antonio Cuni
2022-11-09 16:17:21 +01:00
committed by GitHub
parent f0a6fb913f
commit 0d3c3eef4e
10 changed files with 532 additions and 13 deletions

View File

@@ -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.<br />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 <py-script> 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'.
//

70
pyscriptjs/src/plugin.ts Normal file
View File

@@ -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 <py-script> 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);
}
}

View File

@@ -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 <py-terminal> 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 <py-terminal> 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 <py-terminal> 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 <py-terminal> 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;
}

View File

@@ -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<void> {
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,
});

61
pyscriptjs/src/stdio.ts Normal file
View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -264,19 +264,21 @@ class TestOutput(PyScriptTest):
self.pyscript_run(
"""
<py-script>
display('0')
display('this goes to the DOM')
print('print from python')
console.log('print from js')
console.error('error from js');
</py-script>
"""
)
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(

View File

@@ -0,0 +1,134 @@
from playwright.sync_api import expect
from .support import PyScriptTest
class TestPyTerminal(PyScriptTest):
def test_py_terminal(self):
"""
1. <py-terminal> 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(
"""
<py-terminal></py-terminal>
<py-script>
import sys
print('hello world')
print('this goes to stderr', file=sys.stderr)
print('this goes to stdout')
</py-script>
"""
)
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 <py-terminal>s can cohexist.
A <py-terminal> receives only output from the moment it is added to
the DOM.
"""
self.pyscript_run(
"""
<py-terminal id="term1"></py-terminal>
<py-script>
import js
print('one')
term2 = js.document.createElement('py-terminal')
term2.id = 'term2'
js.document.body.append(term2)
print('two')
print('three')
</py-script>
"""
)
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(
"""
<py-terminal auto></py-terminal>
<button id="my-button" py-onClick="print('hello world')">Click me</button>
"""
)
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 <py-terminal auto> is
automatically added to the page
"""
self.pyscript_run(
"""
<button id="my-button" py-onClick="print('hello world')">Click me</button>
"""
)
term = self.page.locator("py-terminal")
expect(term).to_be_hidden()
assert "No <py-terminal> 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 <py-terminal> is automatically added
"""
self.pyscript_run(
"""
<py-config>
terminal = true
</py-config>
<py-script>
print('hello world')
</py-script>
"""
)
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 <py-terminal> is added
"""
self.pyscript_run(
"""
<py-config>
terminal = false
</py-config>
"""
)
term = self.page.locator("py-terminal")
assert term.count() == 0

View File

@@ -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");

View File

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