mirror of
https://github.com/pyscript/pyscript.git
synced 2025-12-19 18:27:29 -05:00
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:
@@ -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' }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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) || '';
|
||||
|
||||
@@ -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('_');
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 === '') {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
31
pyscriptjs/src/plugins/python/py_markdown.py
Normal file
31
pyscriptjs/src/plugins/python/py_markdown.py
Normal 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"]
|
||||
)
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ' +
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
184
pyscriptjs/tests/integration/test_plugins.py
Normal file
184
pyscriptjs/tests/integration/test_plugins.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
0
pyscriptjs/tests/py-unit/pyodide/__init__.py
Normal file
0
pyscriptjs/tests/py-unit/pyodide/__init__.py
Normal file
4
pyscriptjs/tests/py-unit/pyodide/ffi.py
Normal file
4
pyscriptjs/tests/py-unit/pyodide/ffi.py
Normal 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()
|
||||
4
pyscriptjs/tests/py-unit/pyodide/pyodide.py
Normal file
4
pyscriptjs/tests/py-unit/pyodide/pyodide.py
Normal 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()
|
||||
Reference in New Issue
Block a user