Files
pyscript/pyscriptjs/src/main.ts
Fabio Pliger 3e408b7baa Python Plugins (#961)
* add test and example files

* update config to include python plugins in build

* add markdown plugin

* remove full pyscript execution from pyodide

* move loading of pyscript.py from pyodide loagInterpreter to main setupVirtualEnv and add function to create python CE plugins

* add plugin class to pyscript.py

* add missing import

* fix plugin path

* add fetchPythonPlugins to PyScriptApp

* remove old comments

* fix test

* add support for python plugins beyond custom elements and add app to python namespace in main

* inject reference to PyScript app onto python plugins

* add example hook onto markdown plugin

* change plugin events logs

* remove unused PyPlugin

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix type import

* add docstring to fetchPythonPlugins

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* rename addPythonPlugin method

* address PR comment

* call python plugins on hooks after the interpreted is ready

* add test for event hooks and split the test in 2 separate plugins to isolte type of plugins tests

* change python plugins initialization and registration, to inject the app from app itself instead of on the plugins themselves

* handle case when plugin cannot load due to missing plugin attribute

* add test for fail scenario when a plugin module does not have a plugin attribute

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* add deprecation warning for pyscript objects loaded in global namespace

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* remove all from global scope

* remove create_custom_element from global scope

* rename create_custom_element to define_custom_element

* rename attributes in define_custom_element and add docstrings

* better handle connect event output

* add warning to py_markdown plugin

* remove debugging logs

* improve tests

* remove debugging log

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* remove unused import

* add executable shebang

* add pyodide mock module

* fmt and lint

* Update to pyodide.ffi.create_proxy per pyodide v21 api change

* Mock pyodide as package instead of mdoule

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* add __init__ to pyodide package

* Update pyscriptjs/src/plugin.ts

fix logger name

Co-authored-by: Antonio Cuni <anto.cuni@gmail.com>

* fix pyodide import but handling the diff in their API change

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* oops, conflict resolution blooper

* Fix failing integration tests

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Jeff Glass <glass.jeffrey@gmail.com>
Co-authored-by: Antonio Cuni <anto.cuni@gmail.com>
Co-authored-by: FabioRosado <fabiorosado@outlook.com>
2022-11-28 12:39:31 -06:00

329 lines
13 KiB
TypeScript

import './styles/pyscript_base.css';
import { loadConfigFromElement } from './pyconfig';
import type { AppConfig } from './pyconfig';
import type { Runtime } from './runtime';
import { version } from './runtime';
import { PluginManager, define_custom_element } from './plugin';
import { make_PyScript, initHandlers, mountElements } from './components/pyscript';
import { PyodideRuntime } from './pyodide';
import { getLogger } from './logger';
import { handleFetchError, showWarning, globalExport } from './utils';
import { calculatePaths } from './plugins/fetch';
import { createCustomElements } from './components/elements';
import { UserError, ErrorCode, _createAlertBanner } from "./exceptions"
import { type Stdio, StdioMultiplexer, DEFAULT_STDIO } from './stdio';
import { PyTerminalPlugin } from './plugins/pyterminal';
import { SplashscreenPlugin } from './plugins/splashscreen';
import { ImportmapPlugin } from './plugins/importmap';
// eslint-disable-next-line
// @ts-ignore
import pyscript from './python/pyscript.py';
const logger = getLogger('pyscript/main');
/* High-level overview of the lifecycle of a PyScript App:
1. pyscript.js is loaded by the browser. PyScriptApp().main() is called
2. loadConfig(): search for py-config and compute the config for the app
3. (it used to be "show the splashscreen", but now it's a plugin)
4. loadRuntime(): start downloading the actual runtime (e.g. pyodide.js)
--- wait until (4) has finished ---
5. now the pyodide src is available. Initialize the engine
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
8. initialize the rest of web components such as py-button, py-repl, etc.
More concretely:
- Points 1-4 are implemented sequentially in PyScriptApp.main().
- PyScriptApp.loadRuntime adds a <script> tag to the document to initiate
the download, and then adds an event listener for the 'load' event, which
in turns calls PyScriptApp.afterRuntimeLoad().
- PyScriptApp.afterRuntimeLoad() implements all the points >= 5.
*/
export class PyScriptApp {
config: AppConfig;
runtime: Runtime;
PyScript: ReturnType<typeof make_PyScript>;
plugins: PluginManager;
_stdioMultiplexer: StdioMultiplexer;
constructor() {
// initialize the builtin plugins
this.plugins = new PluginManager();
this.plugins.add(new SplashscreenPlugin(), new PyTerminalPlugin(this), new ImportmapPlugin());
this._stdioMultiplexer = new StdioMultiplexer();
this._stdioMultiplexer.addListener(DEFAULT_STDIO);
}
// Error handling logic: if during the execution we encounter an error
// which is ultimate responsibility of the user (e.g.: syntax error in the
// config, file not found in fetch, etc.), we can throw UserError(). It is
// responsibility of main() to catch it and show it to the user in a
// proper way (e.g. by using a banner at the top of the page).
main() {
try {
this._realMain();
} catch (error) {
this._handleUserErrorMaybe(error);
}
}
_handleUserErrorMaybe(error) {
if (error instanceof UserError) {
_createAlertBanner(error.message, 'error', error.messageType);
this.plugins.onUserError(error);
} else {
throw error;
}
}
// ============ lifecycle ============
// lifecycle (1)
_realMain() {
this.loadConfig();
this.plugins.configure(this.config);
this.plugins.beforeLaunch(this.config);
this.loadRuntime();
}
// lifecycle (2)
loadConfig() {
// find the <py-config> tag. If not found, we get null which means
// "use the default config"
// XXX: we should actively complain if there are multiple <py-config>
// and show a big error. PRs welcome :)
logger.info('searching for <py-config>');
const elements = document.getElementsByTagName('py-config');
let el: Element | null = null;
if (elements.length > 0) el = elements[0];
if (elements.length >= 2) {
showWarning(
'Multiple <py-config> tags detected. Only the first is ' +
'going to be parsed, all the others will be ignored',
);
}
this.config = loadConfigFromElement(el);
logger.info('config loaded:\n' + JSON.stringify(this.config, null, 2));
}
// lifecycle (4)
loadRuntime() {
logger.info('Initializing runtime');
if (this.config.runtimes.length == 0) {
throw new UserError(ErrorCode.BAD_CONFIG, 'Fatal error: config.runtimes is empty');
}
if (this.config.runtimes.length > 1) {
showWarning('Multiple runtimes are not supported yet.<br />Only the first will be used', 'html');
}
const runtime_cfg = this.config.runtimes[0];
this.runtime = new PyodideRuntime(
this.config,
this._stdioMultiplexer,
runtime_cfg.src,
runtime_cfg.name,
runtime_cfg.lang,
);
this.logStatus(`Downloading ${runtime_cfg.name}...`);
// download pyodide by using a <script> tag. Once it's ready, the
// "load" event will be fired and the exeuction logic will continue.
// Note that the load event is fired asynchronously and thus any
// exception which is throw inside the event handler is *NOT* caught
// by the try/catch inside main(): that's why we need to .catch() it
// explicitly and call _handleUserErrorMaybe also there.
const script = document.createElement('script'); // create a script DOM node
script.src = this.runtime.src;
script.addEventListener('load', () => {
this.afterRuntimeLoad(this.runtime).catch(error => {
this._handleUserErrorMaybe(error);
});
});
document.head.appendChild(script);
}
// lifecycle (5)
// See the overview comment above for an explanation of how we jump from
// point (4) to point (5).
//
// Invariant: this.config is set and available.
async afterRuntimeLoad(runtime: Runtime): Promise<void> {
console.assert(this.config !== undefined);
this.logStatus('Python startup...');
await runtime.loadInterpreter();
this.logStatus('Python ready!');
this.logStatus('Setting up virtual environment...');
await this.setupVirtualEnv(runtime);
mountElements(runtime);
// lifecycle (6.5)
this.plugins.afterSetup(runtime);
this.logStatus('Executing <py-script> tags...');
this.executeScripts(runtime);
this.logStatus('Initializing web components...');
// lifecycle (8)
createCustomElements(runtime);
initHandlers(runtime);
// NOTE: runtime message is used by integration tests to know that
// pyscript initialization has complete. If you change it, you need to
// change it also in tests/integration/support.py
this.logStatus('Startup complete');
this.plugins.afterStartup(runtime);
logger.info('PyScript page fully initialized');
}
// lifecycle (6)
async setupVirtualEnv(runtime: Runtime): Promise<void> {
// XXX: maybe the following calls could be parallelized, instead of
// await()ing immediately. For now I'm using await to be 100%
// compatible with the old behavior.
logger.info('importing pyscript');
// Save and load pyscript.py from FS
runtime.interpreter.FS.writeFile('pyscript.py', pyscript, { encoding: 'utf8' });
// inject `define_custom_element` it into the PyScript module scope
const pyscript_module = runtime.interpreter.pyimport('pyscript');
pyscript_module.define_custom_element = define_custom_element;
pyscript_module.PyScript.set_version_info(version);
pyscript_module.destroy();
// TODO: Currently adding the imports for backwards compatibility, we should
// remove it
await runtime.run(`
from pyscript import *
`);
logger.warn(`DEPRECATION WARNING: 'micropip', 'Element', 'console', 'document' and several other \
objects form the pyscript module (with the exception of 'display') will be \
be removed from the Python global namespace in the following release. \
To avoid errors in future releases use import from pyscript instead. For instance: \
from pyscript import micropip, Element, console, document`);
logger.info('Packages to install: ', this.config.packages);
await runtime.installPackage(this.config.packages);
await this.fetchPaths(runtime);
// Finally load plugins
await this.fetchPythonPlugins(runtime);
}
async fetchPaths(runtime: Runtime) {
// XXX this can be VASTLY improved: for each path we need to fetch a
// URL and write to the virtual filesystem: pyodide.loadFromFile does
// it in Python, which means we need to have the runtime
// initialized. But we could easily do it in JS in parallel with the
// download/startup of pyodide.
const [paths, fetchPaths] = calculatePaths(this.config.fetch);
logger.info('Paths to fetch: ', fetchPaths);
for (let i = 0; i < paths.length; i++) {
logger.info(` fetching path: ${fetchPaths[i]}`);
try {
await runtime.loadFromFile(paths[i], fetchPaths[i]);
} catch (e) {
// The 'TypeError' here happens when running pytest
// I'm not particularly happy with this solution.
if (e.name === 'FetchError' || e.name === 'TypeError') {
handleFetchError(<Error>e, fetchPaths[i]);
} else {
throw e;
}
}
}
logger.info('All paths fetched');
}
/**
* Fetches all the python plugins specified in this.config, saves them on the FS and import
* them as modules, executing any plugin define the module scope
*
* @param runtime - runtime that will execute the plugins
*/
async fetchPythonPlugins(runtime: Runtime) {
const plugins = this.config.plugins;
logger.info('Python plugins to fetch: ', plugins);
for (const singleFile of plugins) {
logger.info(` fetching plugins: ${singleFile}`);
try {
const pathArr = singleFile.split('/');
const filename = pathArr.pop();
// TODO: Would be probably be better to store plugins somewhere like /plugins/python/ or similar
const destPath = `./${filename}`;
await runtime.loadFromFile(destPath, singleFile);
const modulename = singleFile.replace(/^.*[\\/]/, '').replace('.py', '');
console.log(`importing ${modulename}`);
// TODO: This is very specific to Pyodide API and will not work for other interpreters,
// when we add support for other interpreters we will need to move this to the
// runtime (interpreter) API level and allow each one to implement it in its own way
const module = runtime.interpreter.pyimport(modulename);
if (typeof module.plugin !== 'undefined') {
const py_plugin = module.plugin;
py_plugin.init(this);
this.plugins.addPythonPlugin(py_plugin);
} else {
logger.error(`Cannot find plugin on Python module ${modulename}! Python plugins \
modules must contain a "plugin" attribute. For more information check the plugins documentation.`);
}
} catch (e) {
//Should we still export full error contents to console?
handleFetchError(<Error>e, singleFile);
}
}
logger.info('All plugins fetched');
}
// lifecycle (7)
executeScripts(runtime: Runtime) {
this.PyScript = make_PyScript(runtime);
customElements.define('py-script', this.PyScript);
}
// ================= registraton API ====================
logStatus(msg: string) {
logger.info(msg);
const ev = new CustomEvent('py-status-message', { detail: msg });
document.dispatchEvent(ev);
}
registerStdioListener(stdio: Stdio) {
this._stdioMultiplexer.addListener(stdio);
}
}
function pyscript_get_config() {
return globalApp.config;
}
globalExport('pyscript_get_config', pyscript_get_config);
// main entry point of execution
const globalApp = new PyScriptApp();
globalApp.main();
export { version };
export const runtime = globalApp.runtime;