8000 Introduce a Plugin system, implement <py-terminal> as a plugin by antocuni · Pull Request #917 · pyscript/pyscript · GitHub
[go: up one dir, main page]

Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Nov 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions pyscriptjs/src/main.ts
10000
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -11,6 +12,8 @@ import { handleFetchError, showWarning, globalExport } from './utils';
10000 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 = {
Expand All @@ -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

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

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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'.
//
Expand Down
70 changes: 70 additions & 0 deletions pyscriptjs/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
123 changes: 123 additions & 0 deletions pyscriptjs/src/plugins/pyterminal.ts
EDBE
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a nit, but would be nice to have a more discriptive name although the code is small enough to not be too confusing 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm following this rule here. The point is precisely that the code is short, and having a longer name would make it harder to read IMHO.
The reason why I introduce the extra var was to avoid this:

        // validate the terminal config and handle default values
        if (config.terminal !== undefined &&
            config.terminal !== true &&
            config.terminal !== false &&
            config.terminal !== "auto") {
            // XXX this should be a UserError as soon as we have it
            const got = JSON.stringify(config.terminal);
            throw new Error('Invalid value for config.terminal: the only accepted'  +
                            `values are true, false and "auto", got "${got}".`);
        }
        if (config.terminal === undefined) {
            config.terminal = "auto"; // default value
        }

That said, I'm happy to change t into a longer name if you think it leads to more readable code: what do you think it should be?
I'm serious here: an external reviewer is a better judge w.r.t readability than the original author of the code, so happy to hear your suggestions.

Copy link
Contributor
@WebReflection WebReflection Mar 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI in that part there is no t anymore :)

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;
}
8 changes: 6 additions & 2 deletions pyscriptjs/src/pyodide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -17,19 +18,22 @@ interface Micropip {

export class PyodideRuntime extends Runtime {
src: string;
stdio: Stdio;
name?: string;
lang?: string;
interpreter: PyodideInterface;
globals: PyProxy;

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

Expand Down
61 changes: 61 additions & 0 deletions pyscriptjs/src/stdio.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
0