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>
This commit is contained in:
Fabio Pliger
2022-11-28 12:39:31 -06:00
committed by GitHub
parent 446c131ccb
commit 3e408b7baa
35 changed files with 2378 additions and 1216 deletions

View File

@@ -12,7 +12,8 @@ const production = !process.env.ROLLUP_WATCH || (process.env.NODE_ENV === "produ
const copy_targets = {
targets: [
{ src: 'public/index.html', dest: 'build' }
{ src: 'public/index.html', dest: 'build' },
{ src: 'src/plugins/*', dest: 'build/plugins' }
]
}

View File

@@ -20,11 +20,10 @@ export class PyBox extends HTMLElement {
}
connectedCallback() {
const deprecationMessage = (
const deprecationMessage =
'The element <py-box> is deprecated, you should create a ' +
'div with "py-box" class name instead. For example: <div class="py-box">'
)
createDeprecationWarning(deprecationMessage, "py-box")
'div with "py-box" class name instead. For example: <div class="py-box">';
createDeprecationWarning(deprecationMessage, 'py-box');
const mainDiv = document.createElement('div');
addClasses(mainDiv, ['py-box']);

View File

@@ -42,11 +42,10 @@ export function make_PyButton(runtime: Runtime) {
}
connectedCallback() {
const deprecationMessage = (
const deprecationMessage =
'The element <py-button> is deprecated, create a function with your ' +
'inline code and use <button py-click="function()" class="py-button"> instead.'
)
createDeprecationWarning(deprecationMessage, "py-button")
'inline code and use <button py-click="function()" class="py-button"> instead.';
createDeprecationWarning(deprecationMessage, 'py-button');
ensureUniqueId(this);
this.code = htmlDecode(this.innerHTML) || '';

View File

@@ -21,11 +21,9 @@ export function make_PyInputBox(runtime: Runtime) {
}
connectedCallback() {
const deprecationMessage = (
'The element <py-input> is deprecated, ' +
'use <input class="py-input"> instead.'
)
createDeprecationWarning(deprecationMessage, "py-input")
const deprecationMessage =
'The element <py-input> is deprecated, ' + 'use <input class="py-input"> instead.';
createDeprecationWarning(deprecationMessage, 'py-input');
ensureUniqueId(this);
this.code = htmlDecode(this.innerHTML);
this.mount_name = this.id.split('-').join('_');

View File

@@ -9,10 +9,8 @@ export class PyTitle extends HTMLElement {
}
connectedCallback() {
const deprecationMessage = (
'The element <py-title> is deprecated, please use an <h1> tag instead.'
)
createDeprecationWarning(deprecationMessage, "py-title")
const deprecationMessage = 'The element <py-title> is deprecated, please use an <h1> tag instead.';
createDeprecationWarning(deprecationMessage, 'py-title');
this.label = htmlDecode(this.innerHTML);
this.mount_name = this.id.split('-').join('_');
this.innerHTML = '';

View File

@@ -1,6 +1,6 @@
const CLOSEBUTTON = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill="currentColor" width="12px"><path d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/></svg>`;
type MessageType = "text" | "html";
type MessageType = 'text' | 'html';
/*
These error codes are used to identify the type of error that occurred.
@@ -49,41 +49,41 @@ export class FetchError extends Error {
}
export function _createAlertBanner(
message: string,
level: "error" | "warning" = "error",
messageType: MessageType = "text",
logMessage = true) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
switch (`log-${level}-${logMessage}`) {
case "log-error-true":
console.error(message);
break;
case "log-warning-true":
console.warn(message)
break;
}
message: string,
level: 'error' | 'warning' = 'error',
messageType: MessageType = 'text',
logMessage = true,
) {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
switch (`log-${level}-${logMessage}`) {
case 'log-error-true':
console.error(message);
break;
case 'log-warning-true':
console.warn(message);
break;
}
const banner = document.createElement("div")
banner.className = `alert-banner py-${level}`
const banner = document.createElement('div');
banner.className = `alert-banner py-${level}`;
if (messageType === "html") {
banner.innerHTML = message;
}
else {
banner.textContent = message;
}
if (messageType === 'html') {
banner.innerHTML = message;
} else {
banner.textContent = message;
}
if (level === "warning") {
const closeButton = document.createElement("button");
if (level === 'warning') {
const closeButton = document.createElement('button');
closeButton.id = "alert-close-button"
closeButton.addEventListener("click", () => {
banner.remove();
})
closeButton.innerHTML = CLOSEBUTTON;
closeButton.id = 'alert-close-button';
closeButton.addEventListener('click', () => {
banner.remove();
});
closeButton.innerHTML = CLOSEBUTTON;
banner.appendChild(closeButton);
}
banner.appendChild(closeButton);
}
document.body.prepend(banner);
document.body.prepend(banner);
}

View File

@@ -4,7 +4,7 @@ import { loadConfigFromElement } from './pyconfig';
import type { AppConfig } from './pyconfig';
import type { Runtime } from './runtime';
import { version } from './runtime';
import { PluginManager } from './plugin';
import { PluginManager, define_custom_element } from './plugin';
import { make_PyScript, initHandlers, mountElements } from './components/pyscript';
import { PyodideRuntime } from './pyodide';
import { getLogger } from './logger';
@@ -16,6 +16,9 @@ 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');
@@ -53,8 +56,6 @@ More concretely:
- PyScriptApp.afterRuntimeLoad() implements all the points >= 5.
*/
export class PyScriptApp {
config: AppConfig;
runtime: Runtime;
@@ -65,11 +66,7 @@ export class PyScriptApp {
constructor() {
// initialize the builtin plugins
this.plugins = new PluginManager();
this.plugins.add(
new SplashscreenPlugin(),
new PyTerminalPlugin(this),
new ImportmapPlugin(),
);
this.plugins.add(new SplashscreenPlugin(), new PyTerminalPlugin(this), new ImportmapPlugin());
this._stdioMultiplexer = new StdioMultiplexer();
this._stdioMultiplexer.addListener(DEFAULT_STDIO);
@@ -83,18 +80,16 @@ export class PyScriptApp {
main() {
try {
this._realMain();
}
catch(error) {
} catch (error) {
this._handleUserErrorMaybe(error);
}
}
_handleUserErrorMaybe(error) {
if (error instanceof UserError) {
_createAlertBanner(error.message, "error", error.messageType);
_createAlertBanner(error.message, 'error', error.messageType);
this.plugins.onUserError(error);
}
else {
} else {
throw error;
}
}
@@ -122,7 +117,7 @@ export class PyScriptApp {
if (elements.length >= 2) {
showWarning(
'Multiple <py-config> tags detected. Only the first is ' +
'going to be parsed, all the others will be ignored',
'going to be parsed, all the others will be ignored',
);
}
this.config = loadConfigFromElement(el);
@@ -137,14 +132,16 @@ export class PyScriptApp {
}
if (this.config.runtimes.length > 1) {
showWarning('Multiple runtimes are not supported yet.<br />Only the first will be used', "html");
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.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
@@ -156,7 +153,7 @@ export class PyScriptApp {
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.afterRuntimeLoad(this.runtime).catch(error => {
this._handleUserErrorMaybe(error);
});
});
@@ -194,7 +191,7 @@ export class PyScriptApp {
// 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.logStatus('Startup complete');
this.plugins.afterStartup(runtime);
logger.info('PyScript page fully initialized');
}
@@ -204,9 +201,34 @@ export class PyScriptApp {
// 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) {
@@ -217,23 +239,63 @@ export class PyScriptApp {
// 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++) {
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") {
if (e.name === 'FetchError' || e.name === 'TypeError') {
handleFetchError(<Error>e, fetchPaths[i]);
} else {
throw e
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);
@@ -244,7 +306,7 @@ export class PyScriptApp {
logStatus(msg: string) {
logger.info(msg);
const ev = new CustomEvent("py-status-message", { detail: msg });
const ev = new CustomEvent('py-status-message', { detail: msg });
document.dispatchEvent(ev);
}
@@ -262,5 +324,5 @@ globalExport('pyscript_get_config', pyscript_get_config);
const globalApp = new PyScriptApp();
globalApp.main();
export { version }
export { version };
export const runtime = globalApp.runtime;

View File

@@ -1,9 +1,11 @@
import type { AppConfig } from './pyconfig';
import type { Runtime } from './runtime';
import type { UserError } from './exceptions';
import { getLogger } from './logger';
const logger = getLogger('plugin');
export class Plugin {
/** Validate the configuration of the plugin and handle default values.
*
* Individual plugins are expected to check that the config keys/sections
@@ -16,8 +18,7 @@ export class Plugin {
* This hook should **NOT** contain expensive operations, else it delays
* the download of the python interpreter which is initiated later.
*/
configure(config: AppConfig) {
}
configure(config: AppConfig) {}
/** The preliminary initialization phase is complete and we are about to
* download and launch the Python interpreter.
@@ -29,66 +30,107 @@ export class Plugin {
* This hook should **NOT** contain expensive operations, else it delays
* the download of the python interpreter which is initiated later.
*/
beforeLaunch(config: AppConfig) {
}
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) {
}
* installed and we are ready to execute user code.
*
* The <py-script> tags will be executed after this hook.
*/
afterSetup(runtime: Runtime) {}
/** Startup complete. The interpreter is initialized and ready, user
* scripts have been executed: the main initialization logic ends here and
* the page is ready to accept user interactions.
*/
afterStartup(runtime: Runtime) {
}
afterStartup(runtime: Runtime) {}
/** Called when an UserError is raised
*/
onUserError(error: UserError) {
}
onUserError(error: UserError) {}
}
export class PluginManager {
_plugins: Plugin[];
_pythonPlugins: any[];
constructor() {
this._plugins = [];
this._pythonPlugins = [];
}
add(...plugins: Plugin[]) {
for (const p of plugins)
this._plugins.push(p);
for (const p of plugins) this._plugins.push(p);
}
addPythonPlugin(plugin: any) {
this._pythonPlugins.push(plugin);
}
configure(config: AppConfig) {
for (const p of this._plugins)
p.configure(config);
for (const p of this._plugins) p.configure(config);
for (const p of this._pythonPlugins) p.configure?.(config);
}
beforeLaunch(config: AppConfig) {
for (const p of this._plugins)
p.beforeLaunch(config);
for (const p of this._plugins) p.beforeLaunch(config);
}
afterSetup(runtime: Runtime) {
for (const p of this._plugins)
p.afterSetup(runtime);
for (const p of this._plugins) p.afterSetup(runtime);
for (const p of this._pythonPlugins) p.afterSetup?.(runtime);
}
afterStartup(runtime: Runtime) {
for (const p of this._plugins)
p.afterStartup(runtime);
for (const p of this._plugins) p.afterStartup(runtime);
for (const p of this._pythonPlugins) p.afterStartup?.(runtime);
}
onUserError(error: UserError) {
for (const p of this._plugins)
p.onUserError(error);
for (const p of this._plugins) p.onUserError(error);
for (const p of this._pythonPlugins) p.onUserError?.(error);
}
}
/**
* Defines a new CustomElement (via customElement.defines) with `tag`,
* where the new CustomElement is a proxy that delegates the logic to
* pyPluginClass.
*
* @param tag - tag that will be used to define the new CustomElement (i.e: "py-script")
* @param pyPluginClass - class that will be used to create instance to be
* used as CustomElement logic handler. Any DOM event
* received by the newly created CustomElement will be
* delegated to that instance.
*/
export function define_custom_element(tag: string, pyPluginClass: any): any {
logger.info(`creating plugin: ${tag}`);
class ProxyCustomElement extends HTMLElement {
shadow: ShadowRoot;
wrapper: HTMLElement;
pyPluginInstance: any;
originalInnerHTML: string;
constructor() {
logger.debug(`creating ${tag} plugin instance`);
super();
this.shadow = this.attachShadow({ mode: 'open' });
this.wrapper = document.createElement('slot');
this.shadow.appendChild(this.wrapper);
this.originalInnerHTML = this.innerHTML;
this.pyPluginInstance = pyPluginClass(this);
}
connectedCallback() {
const innerHTML = this.pyPluginInstance.connect();
if (typeof innerHTML === 'string') this.innerHTML = innerHTML;
}
}
customElements.define(tag, ProxyCustomElement);
}

View File

@@ -6,8 +6,8 @@ export function calculatePaths(fetch_cfg: FetchConfig[]) {
const fetchPaths: string[] = [];
const paths: string[] = [];
fetch_cfg.forEach(function (each_fetch_cfg: FetchConfig) {
const from = each_fetch_cfg.from || "";
const to_folder = each_fetch_cfg.to_folder || ".";
const from = each_fetch_cfg.from || '';
const to_folder = each_fetch_cfg.to_folder || '.';
const to_file = each_fetch_cfg.to_file;
const files = each_fetch_cfg.files;
if (files !== undefined)
@@ -19,16 +19,13 @@ export function calculatePaths(fetch_cfg: FetchConfig[]) {
`Cannot use 'to_file' and 'files' parameters together!`
);
}
for (const each_f of files)
{
for (const each_f of files) {
const each_fetch_path = joinPaths([from, each_f]);
fetchPaths.push(each_fetch_path);
const each_path = joinPaths([to_folder, each_f]);
paths.push(each_path);
}
}
else
{
} else {
fetchPaths.push(from);
const filename = to_file || from.split('/').pop();
if (filename === '') {

View File

@@ -11,7 +11,6 @@ type ImportMapType = {
};
export class ImportmapPlugin extends Plugin {
async afterSetup(runtime: Runtime) {
// make importmap ES modules available from python using 'import'.
//
@@ -26,9 +25,8 @@ export class ImportmapPlugin extends Plugin {
const importmap: ImportMapType = (() => {
try {
return JSON.parse(node.textContent) as ImportMapType;
}
catch(error) {
showWarning("Failed to parse import map: " + error.message);
} catch (error) {
showWarning('Failed to parse import map: ' + error.message);
}
})();
@@ -47,10 +45,9 @@ export class ImportmapPlugin extends Plugin {
continue;
}
logger.info("Registering JS module", name);
logger.info('Registering JS module', name);
runtime.registerJsModule(name, exports);
}
}
}
}

View File

@@ -19,10 +19,7 @@ export class PyTerminalPlugin extends Plugin {
configure(config: AppConfig) {
// validate the terminal config and handle default values
const t = config.terminal;
if (t !== undefined &&
t !== true &&
t !== false &&
t !== "auto") {
if (t !== undefined && t !== true && t !== false && t !== 'auto') {
const got = JSON.stringify(t);
throw new UserError(
ErrorCode.BAD_CONFIG,
@@ -31,7 +28,7 @@ export class PyTerminalPlugin extends Plugin {
);
}
if (t === undefined) {
config.terminal = "auto"; // default value
config.terminal = 'auto'; // default value
}
}
@@ -39,12 +36,11 @@ export class PyTerminalPlugin extends Plugin {
// 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 (t === true || t === 'auto') {
if (document.querySelector('py-terminal') === null) {
logger.info("No <py-terminal> found, adding one");
logger.info('No <py-terminal> found, adding one');
const termElem = document.createElement('py-terminal');
if (t === "auto")
termElem.setAttribute("auto", "");
if (t === 'auto') termElem.setAttribute('auto', '');
document.body.appendChild(termElem);
}
}
@@ -74,9 +70,7 @@ export class PyTerminalPlugin extends Plugin {
}
}
function make_PyTerminal(app: PyScriptApp) {
/** The <py-terminal> custom element, which automatically register a stdio
* listener to capture and display stdout/stderr
*/
@@ -95,8 +89,7 @@ function make_PyTerminal(app: PyScriptApp) {
if (this.isAuto()) {
this.classList.add('py-terminal-hidden');
this.autoShowOnNextLine = true;
}
else {
} else {
this.autoShowOnNextLine = false;
}
@@ -105,12 +98,12 @@ function make_PyTerminal(app: PyScriptApp) {
}
isAuto() {
return this.hasAttribute("auto");
return this.hasAttribute('auto');
}
// implementation of the Stdio interface
stdout_writeline(msg: string) {
this.outElem.innerText += msg + "\n";
this.outElem.innerText += msg + '\n';
if (this.autoShowOnNextLine) {
this.classList.remove('py-terminal-hidden');
this.autoShowOnNextLine = false;

View File

@@ -0,0 +1,31 @@
from textwrap import dedent
from markdown import markdown
from pyscript import Plugin, console
console.warning(
"WARNING: This plugin is still in a very experimental phase and will likely change"
" and potentially break in the future releases. Use it with caution."
)
class MyPlugin(Plugin):
def configure(self, config):
console.log(f"configuration received: {config}")
def afterStartup(self, runtime):
console.log(f"runtime received: {runtime}")
plugin = MyPlugin("py-markdown")
@plugin.register_custom_element("py-md")
class PyMarkdown:
def __init__(self, element):
self.element = element
def connect(self):
self.element.innerHTML = markdown(
dedent(self.element.source), extensions=["fenced_code"]
)

View File

@@ -27,7 +27,7 @@ export class SplashscreenPlugin extends Plugin {
// deprecation warning)
this.autoclose = true;
if ("autoclose_loader" in config) {
if ('autoclose_loader' in config) {
this.autoclose = config.autoclose_loader;
showWarning(AUTOCLOSE_LOADER_DEPRECATED);
}

View File

@@ -198,8 +198,7 @@ function validateConfig(configText: string, configType = 'toml') {
}
finalConfig[item].push(runtimeConfig);
});
}
else if (item === 'fetch') {
} else if (item === 'fetch') {
finalConfig[item] = [];
const fetchList = config[item] as FetchConfig[];
fetchList.forEach(function (eachFetch: FetchConfig) {
@@ -212,8 +211,7 @@ function validateConfig(configText: string, configType = 'toml') {
}
finalConfig[item].push(eachFetchConfig);
});
}
else {
} else {
finalConfig[item] = config[item];
}
}

View File

@@ -11,10 +11,10 @@ export function pyExec(runtime: Runtime, pysrc: string, outElem: HTMLElement) {
ensureUniqueId(outElem);
set_current_display_target(outElem.id);
//This is the python function defined in pyscript.py
const usesTopLevelAwait = runtime.globals.get('uses_top_level_await')
const usesTopLevelAwait = runtime.globals.get('uses_top_level_await');
try {
try {
if (usesTopLevelAwait(pysrc)){
if (usesTopLevelAwait(pysrc)) {
throw new UserError(
ErrorCode.TOP_LEVEL_AWAIT,
'The use of top-level "await", "async for", and ' +

View File

@@ -59,8 +59,12 @@ export class PyodideRuntime extends Runtime {
async loadInterpreter(): Promise<void> {
logger.info('Loading pyodide');
this.interpreter = await loadPyodide({
stdout: (msg: string) => { this.stdio.stdout_writeline(msg); },
stderr: (msg: string) => { this.stdio.stderr_writeline(msg); },
stdout: (msg: string) => {
this.stdio.stdout_writeline(msg);
},
stderr: (msg: string) => {
this.stdio.stderr_writeline(msg);
},
fullStdLib: false,
});
@@ -68,11 +72,6 @@ export class PyodideRuntime extends Runtime {
// XXX: ideally, we should load micropip only if we actually need it
await this.loadPackage('micropip');
logger.info('importing pyscript.py');
this.run(pyscript as string);
this.run(`PyScript.set_version_info('${version}')`)
logger.info('pyodide loaded and initialized');
}

View File

@@ -10,6 +10,11 @@ from textwrap import dedent
import micropip # noqa: F401
from js import console, document
try:
from pyodide import create_proxy
except ImportError:
from pyodide.ffi import create_proxy
loop = asyncio.get_event_loop()
MIME_METHODS = {
@@ -463,4 +468,27 @@ def uses_top_level_await(source: str) -> bool:
return TopLevelAsyncFinder().is_source_top_level_await(source)
class Plugin:
def __init__(self, name=None):
if not name:
name = self.__class__.__name__
self.name = name
def init(self, app):
self.app = app
self.app.plugins.addPythonPlugin(create_proxy(self))
def register_custom_element(self, tag):
# TODO: Ideally would be better to use the logger.
console.info(f"Defining new custom element {tag}")
def wrapper(class_):
# TODO: this is very pyodide specific but will have to do
# until we have JS interface that works across interpreters
define_custom_element(tag, create_proxy(class_)) # noqa: F821
return create_proxy(wrapper)
pyscript = PyScript()

View File

@@ -8,8 +8,8 @@ export interface Stdio {
*/
export const DEFAULT_STDIO: Stdio = {
stdout_writeline: console.log,
stderr_writeline: console.log
}
stderr_writeline: console.log,
};
/** Stdio provider which captures and store the messages.
* Useful for tests.
@@ -23,16 +23,16 @@ export class CaptureStdio implements Stdio {
}
reset() {
this.captured_stdout = "";
this.captured_stderr = "";
this.captured_stdout = '';
this.captured_stderr = '';
}
stdout_writeline(msg: string) {
this.captured_stdout += msg + "\n";
this.captured_stdout += msg + '\n';
}
stderr_writeline(msg: string) {
this.captured_stderr += msg + "\n";
this.captured_stderr += msg + '\n';
}
}
@@ -50,12 +50,10 @@ export class StdioMultiplexer implements Stdio {
}
stdout_writeline(msg: string) {
for(const obj of this._listeners)
obj.stdout_writeline(msg);
for (const obj of this._listeners) obj.stdout_writeline(msg);
}
stderr_writeline(msg: string) {
for(const obj of this._listeners)
obj.stderr_writeline(msg);
for (const obj of this._listeners) obj.stderr_writeline(msg);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -41,8 +41,8 @@ export function ensureUniqueId(el: HTMLElement) {
if (el.id === '') el.id = `py-internal-${_uniqueIdCounter++}`;
}
export function showWarning(msg: string, messageType: "text" | "html" = "text"): void {
_createAlertBanner(msg, "warning", messageType);
export function showWarning(msg: string, messageType: 'text' | 'html' = 'text'): void {
_createAlertBanner(msg, 'warning', messageType);
}
export function handleFetchError(e: Error, singleFile: string) {
@@ -64,7 +64,7 @@ export function handleFetchError(e: Error, singleFile: string) {
} else {
errorContent = `PyScript encountered an error while loading from file: ${e.message}`;
}
throw new UserError(ErrorCode.FETCH_ERROR, errorContent, "html");
throw new UserError(ErrorCode.FETCH_ERROR, errorContent, 'html');
}
export function readTextFromPath(path: string) {
@@ -98,10 +98,14 @@ export function getAttribute(el: Element, attr: string): string | null {
}
export function joinPaths(parts: string[], separator = '/') {
const res = parts.map(function(part) { return part.trim().replace(/(^[/]*|[/]*$)/g, ''); }).filter(p => p!== "").join(separator || '/');
if (parts[0].startsWith('/'))
{
return '/'+res;
const res = parts
.map(function (part) {
return part.trim().replace(/(^[/]*|[/]*$)/g, '');
})
.filter(p => p !== '')
.join(separator || '/');
if (parts[0].startsWith('/')) {
return '/' + res;
}
return res;
}
@@ -115,6 +119,6 @@ export function createDeprecationWarning(msg: string, elementName: string): void
}
}
if (bannerCount == 0) {
_createAlertBanner(msg, "warning");
_createAlertBanner(msg, 'warning');
}
}

View File

@@ -14,10 +14,8 @@ class TestBasic(PyScriptTest):
</py-script>
"""
)
assert self.console.log.lines == [
self.PY_COMPLETE,
"hello pyscript",
]
assert self.console.log.lines[0] == self.PY_COMPLETE
assert self.console.log.lines[-1] == "hello pyscript"
def test_python_exception(self):
self.pyscript_run(
@@ -28,7 +26,8 @@ class TestBasic(PyScriptTest):
</py-script>
"""
)
assert self.console.log.lines == [self.PY_COMPLETE, "hello pyscript"]
assert self.console.log.lines[0] == self.PY_COMPLETE
assert "hello pyscript" in self.console.log.lines
# check that we sent the traceback to the console
tb_lines = self.console.error.lines[-1].splitlines()
assert tb_lines[0] == "[pyexec] Python exception:"
@@ -57,8 +56,8 @@ class TestBasic(PyScriptTest):
<py-script>js.console.log('four')</py-script>
"""
)
assert self.console.log.lines == [
self.PY_COMPLETE,
assert self.console.log.lines[0] == self.PY_COMPLETE
assert self.console.log.lines[-4:] == [
"one",
"two",
"three",
@@ -75,7 +74,9 @@ class TestBasic(PyScriptTest):
<py-script>js.console.log("<div></div>")</py-script>
"""
)
assert self.console.log.lines == [self.PY_COMPLETE, "true false", "<div></div>"]
assert self.console.log.lines[0] == self.PY_COMPLETE
assert self.console.log.lines[-2:] == ["true false", "<div></div>"]
def test_packages(self):
self.pyscript_run(
@@ -92,8 +93,9 @@ class TestBasic(PyScriptTest):
</py-script>
"""
)
assert self.console.log.lines == [
self.PY_COMPLETE,
assert self.console.log.lines[0] == self.PY_COMPLETE
assert self.console.log.lines[-3:] == [
"Loading asciitree", # printed by pyodide
"Loaded asciitree", # printed by pyodide
"hello asciitree", # printed by us
@@ -114,10 +116,9 @@ class TestBasic(PyScriptTest):
)
self.page.locator("button").click()
self.page.locator("py-script") # wait until <py-script> appears
assert self.console.log.lines == [
self.PY_COMPLETE,
"hello world",
]
assert self.console.log.lines[0] == self.PY_COMPLETE
assert self.console.log.lines[-1] == "hello world"
def test_py_script_src_attribute(self):
self.writefile("foo.py", "print('hello from foo')")
@@ -126,10 +127,8 @@ class TestBasic(PyScriptTest):
<py-script src="foo.py"></py-script>
"""
)
assert self.console.log.lines == [
self.PY_COMPLETE,
"hello from foo",
]
assert self.console.log.lines[0] == self.PY_COMPLETE
assert self.console.log.lines[-1] == "hello from foo"
def test_py_script_src_not_found(self):
self.pyscript_run(
@@ -137,9 +136,8 @@ class TestBasic(PyScriptTest):
<py-script src="foo.py"></py-script>
"""
)
assert self.console.log.lines == [
self.PY_COMPLETE,
]
assert self.PY_COMPLETE in self.console.log.lines
assert "Failed to load resource" in self.console.error.lines[0]
with pytest.raises(JsErrors) as exc:
self.check_js_errors()
@@ -192,3 +190,28 @@ class TestBasic(PyScriptTest):
)
is not None
)
def test_python_modules_deprecated(self):
# GIVEN a py-script tag
self.pyscript_run(
"""
<py-script>
print('hello pyscript')
raise Exception('this is an error')
</py-script>
"""
)
# TODO: Adding a quick check that the deprecation warning is logged. Not spending
# to much time to make it perfect since we'll remove this right after the
# release. (Anyone wanting to improve it, please feel free to)
warning_msg = (
"[pyscript/main] 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"
)
# we EXPECTED to find a deprecation warning about what will be removed from the Python
# global namespace in the next releases
assert warning_msg in self.console.warning.lines

View File

@@ -301,8 +301,8 @@ class TestOutput(PyScriptTest):
)
inner_text = self.page.inner_text("py-script")
assert inner_text == "this goes to the DOM"
assert self.console.log.lines == [
self.PY_COMPLETE,
assert self.console.log.lines[0] == self.PY_COMPLETE
assert self.console.log.lines[-2:] == [
"print from python",
"print from js",
]

View File

@@ -20,12 +20,14 @@ class TestAsync(PyScriptTest):
def test_asyncio_ensure_future(self):
self.pyscript_run(self.coroutine_script.format(func="ensure_future"))
self.wait_for_console("third")
assert self.console.log.lines == [self.PY_COMPLETE, "first", "second", "third"]
assert self.console.log.lines[0] == self.PY_COMPLETE
assert self.console.log.lines[-3:] == ["first", "second", "third"]
def test_asyncio_create_task(self):
self.pyscript_run(self.coroutine_script.format(func="create_task"))
self.wait_for_console("third")
assert self.console.log.lines == [self.PY_COMPLETE, "first", "second", "third"]
assert self.console.log.lines[0] == self.PY_COMPLETE
assert self.console.log.lines[-3:] == ["first", "second", "third"]
def test_asyncio_gather(self):
self.pyscript_run(
@@ -77,8 +79,10 @@ class TestAsync(PyScriptTest):
"""
)
self.wait_for_console("b func done")
assert self.console.log.lines == [
self.PY_COMPLETE,
assert self.console.log.lines[0] == self.PY_COMPLETE
# We are getting some deprecation warnings from pyodide, so we
# need to skip the first 2 lines
assert self.console.log.lines[3:] == [
"A 0",
"B 0",
"A 1",

View File

@@ -22,8 +22,8 @@ class TestRuntimeAccess(PyScriptTest):
"""
)
assert self.console.log.lines == [
self.PY_COMPLETE,
assert self.console.log.lines[0] == self.PY_COMPLETE
assert self.console.log.lines[-2:] == [
"x is 1",
"py_func() returns 2",
]
@@ -38,5 +38,5 @@ class TestRuntimeAccess(PyScriptTest):
interpreter.runPython('console.log("Interpreter Ran This")');
"""
)
assert self.console.log.lines == [self.PY_COMPLETE, "Interpreter Ran This"]
assert self.console.log.lines[0] == self.PY_COMPLETE
assert self.console.log.lines[-1] == "Interpreter Ran This"

View File

@@ -0,0 +1,184 @@
from .support import PyScriptTest
# Source code of a simple plugin that creates a Custom Element for testing purposes
CE_PLUGIN_CODE = """
from pyscript import Plugin, console
plugin = Plugin('py-upper')
console.log("py_upper Plugin loaded")
@plugin.register_custom_element('py-up')
class Upper:
def __init__(self, element):
self.element = element
def connect(self):
console.log("Upper plugin connected")
return self.element.originalInnerHTML.upper()
"""
# Source of a plugin hooks into the PyScript App lifecycle events
HOOKS_PLUGIN_CODE = """
from pyscript import Plugin, console
class TestLogger(Plugin):
def configure(self, config):
console.log('configure called')
def beforeLaunch(self, config):
console.log('beforeLaunch called')
def afterSetup(self, config):
console.log('afterSetup called')
def afterStartup(self, config):
console.log('afterStartup called')
def onUserError(self, config):
console.log('onUserError called')
plugin = TestLogger()
"""
# Source of a script that doesn't call define a `plugin` attribute
NO_PLUGIN_CODE = """
from pyscript import Plugin, console
class TestLogger(Plugin):
pass
"""
# Source code of a simple plugin that creates a Custom Element for testing purposes
CODE_CE_PLUGIN_BAD_RETURNS = """
from pyscript import Plugin, console
plugin = Plugin('py-broken')
@plugin.register_custom_element('py-up')
class Upper:
def __init__(self, element):
self.element = element
def connect(self):
# Just returning something... anything other than a string should be ignore
return Plugin
"""
HTML_TEMPLATE_WITH_TAG = """
<py-config>
plugins = [
"./{plugin_name}.py"
]
</py-config>
<{tagname}>
{html}
</{tagname}>
"""
HTML_TEMPLATE_NO_TAG = """
<py-config>
plugins = [
"./{plugin_name}.py"
]
</py-config>
"""
def prepare_test(
plugin_name, code, tagname="", html="", template=HTML_TEMPLATE_WITH_TAG
):
"""
Prepares the test by writing a new plugin file named `plugin_name`.py, with `code` as its
content and run `pyscript_run` on `template` formatted with the above inputs to create the
page HTML code.
For example:
>> @prepare_test('py-upper', CE_PLUGIN_CODE, tagname='py-up', html="Hello World")
>> def my_foo(...):
>> ...
will:
* write a new `py-upper.py` file to the FS
* the contents of `py-upper.py` is equal to CE_PLUGIN_CODE
* call self.pyscript_run with the following string:
'''
<py-config>
plugins = [
"./py-upper.py"
]
</py-config>
<py-up>
{html}
</py-up>
'''
* call `my_foo` just like a normal decorator would
"""
def dec(f):
def _inner(self, *args, **kws):
self.writefile(f"{plugin_name}.py", code)
page_html = template.format(
plugin_name=plugin_name, tagname=tagname, html=html
)
self.pyscript_run(page_html)
return f(self, *args, **kws)
return _inner
return dec
class TestPlugin(PyScriptTest):
@prepare_test("py-upper", CE_PLUGIN_CODE, tagname="py-up", html="Hello World")
def test_py_plugin_inline(self):
"""Test that a regular plugin that returns new HTML content from connected works"""
# GIVEN a plugin that returns the all caps version of the tag innerHTML and logs text
# during it's execution/hooks
# EXPECT the plugin logs to be present in the console logs
log_lines = self.console.log.lines
for log_line in ["py_upper Plugin loaded", "Upper plugin connected"]:
assert log_line in log_lines
# EXPECT the inner text of the Plugin CustomElement to be all caps
rendered_text = self.page.locator("py-up").inner_text()
assert rendered_text == "HELLO WORLD"
@prepare_test("hooks_logger", HOOKS_PLUGIN_CODE, template=HTML_TEMPLATE_NO_TAG)
def test_execution_hooks(self):
"""Test that a Plugin that hooks into the PyScript App events, gets called
for each one of them"""
# GIVEN a plugin that logs specific strings for each app execution event
hooks_available = ["afterSetup", "afterStartup"]
hooks_unavailable = ["configure", "beforeLaunch"]
# EXPECT it to log the correct logs for the events it intercepts
log_lines = self.console.log.lines
for method in hooks_available:
assert f"{method} called" in log_lines
# EXPECT it to NOT be called (hence not log anything) the events that happen
# before it's ready, hence is not called
for method in hooks_unavailable:
assert f"{method} called" not in log_lines
# TODO: It'd be actually better to check that the events get called in order
@prepare_test("no_plugin", NO_PLUGIN_CODE)
def test_no_plugin_attribute_error(self):
"""
Test a plugin that do not add the `plugin` attribute to its module
"""
# GIVEN a Plugin NO `plugin` attribute in it's module
error_msg = (
"[pyscript/main] Cannot find plugin on Python module no_plugin! Python plugins "
'modules must contain a "plugin" attribute. For more information check the '
"plugins documentation."
)
# EXPECT an error for the missing attribute
assert error_msg in self.console.error.lines

View File

@@ -12,10 +12,11 @@ class TestPyButton(PyScriptTest):
</py-button>
"""
)
assert self.console.log.lines == [self.PY_COMPLETE]
assert self.console.log.lines[0] == self.PY_COMPLETE
self.page.locator("text=my button").click()
self.page.locator("text=my button").click()
assert self.console.log.lines == [self.PY_COMPLETE, "clicked!", "clicked!"]
assert self.console.log.lines[-2:] == ["clicked!", "clicked!"]
def test_deprecated_element(self):
self.pyscript_run(

View File

@@ -228,8 +228,8 @@ class TestConfig(PyScriptTest):
</py-script>
"""
)
assert self.console.log.lines == [
self.PY_COMPLETE,
assert self.console.log.lines[0] == self.PY_COMPLETE
assert self.console.log.lines[-2:] == [
"hello from A",
"hello from B",
]
@@ -279,7 +279,5 @@ class TestConfig(PyScriptTest):
</py-script>
"""
)
assert self.console.log.lines == [
self.PY_COMPLETE,
"hello from A",
]
assert self.console.log.lines[0] == self.PY_COMPLETE
assert self.console.log.lines[-1] == "hello from A"

View File

@@ -13,13 +13,13 @@ class TestPyInputBox(PyScriptTest):
</py-inputbox>
"""
)
assert self.console.log.lines == [self.PY_COMPLETE]
assert self.console.log.lines[0] == self.PY_COMPLETE
input = self.page.locator("input")
input.type("Hello")
input.press("Enter")
assert self.console.log.lines == [self.PY_COMPLETE, "Hello"]
assert self.console.log.lines[-1] == "Hello"
def test_deprecated_element(self):
self.pyscript_run(

View File

@@ -65,7 +65,9 @@ class TestPyRepl(PyScriptTest):
)
self.page.wait_for_selector("#runButton")
self.page.keyboard.press("Shift+Enter")
assert self.console.log.lines == [self.PY_COMPLETE, "hello world"]
assert self.console.log.lines[0] == self.PY_COMPLETE
assert self.console.log.lines[-1] == "hello world"
def test_display(self):
self.pyscript_run(

View File

@@ -32,8 +32,7 @@ class TestPyTerminal(PyScriptTest):
"this goes to stderr",
"this goes to stdout",
]
assert self.console.log.lines == [
self.PY_COMPLETE,
assert self.console.log.lines[-3:] == [
"hello world",
"this goes to stderr",
"this goes to stdout",

View File

@@ -34,11 +34,9 @@ class TestSplashscreen(PyScriptTest):
# and now the splashscreen should have been removed
expect(div).to_be_hidden()
assert self.page.locator("py-locator").count() == 0
#
assert self.console.log.lines == [
self.PY_COMPLETE,
"hello pyscript",
]
assert self.console.log.lines[0] == self.PY_COMPLETE
assert "hello pyscript" in self.console.log.lines
def test_autoclose_false(self):
self.pyscript_run(
@@ -56,10 +54,8 @@ class TestSplashscreen(PyScriptTest):
expect(div).to_be_visible()
expect(div).to_contain_text("Python startup...")
expect(div).to_contain_text("Startup complete")
assert self.console.log.lines == [
self.PY_COMPLETE,
"hello pyscript",
]
assert self.console.log.lines[0] == self.PY_COMPLETE
assert "hello pyscript" in self.console.log.lines
def test_autoclose_loader_deprecated(self):
self.pyscript_run(
@@ -75,12 +71,10 @@ class TestSplashscreen(PyScriptTest):
warning = self.page.locator(".py-warning")
inner_text = warning.inner_text()
assert "The setting autoclose_loader is deprecated" in inner_text
#
div = self.page.locator("py-splashscreen > div")
expect(div).to_be_visible()
expect(div).to_contain_text("Python startup...")
expect(div).to_contain_text("Startup complete")
assert self.console.log.lines == [
self.PY_COMPLETE,
"hello pyscript",
]
assert self.console.log.lines[0] == self.PY_COMPLETE
assert "hello pyscript" in self.console.log.lines

View File

@@ -0,0 +1,4 @@
"""Mock module that emulates some of the pyodide js module features for the sake of tests"""
from unittest.mock import Mock
create_proxy = Mock()

View File

@@ -0,0 +1,4 @@
"""Mock module that emulates some of the pyodide js module features for the sake of tests"""
from unittest.mock import Mock
create_proxy = Mock()