mirror of
https://github.com/pyscript/pyscript.git
synced 2025-12-19 18:27:29 -05:00
More plugins: splashscreen and importmap (#938)
This PR move codes from main.ts into two new plugins: - splashscreen (formerly known as py-loader) - importmap The old setting config.autoclose_loader is still supported but deprecated; the new setting is config.splashscreen.autoclose. Moreover, it does a small refactoring around UserError: now UserErrors are correctly caught even if they are raised from within afterRuntimeLoad.
This commit is contained in:
@@ -21,8 +21,6 @@
|
||||
files = ["./utils.py"]
|
||||
</py-config>
|
||||
<py-script output="outputDiv">
|
||||
# demonstrates how use the global PyScript pyscript_loader
|
||||
# to send operation log messages to it
|
||||
import utils
|
||||
display(utils.now())
|
||||
</py-script>
|
||||
@@ -43,8 +41,6 @@ async def foo():
|
||||
else:
|
||||
out3.clear()
|
||||
|
||||
# close the global PyScript pyscript_loader
|
||||
pyscript_loader.close()
|
||||
pyscript.run_until_complete(foo())
|
||||
</py-script>
|
||||
</body>
|
||||
|
||||
1
pyscriptjs/__mocks__/cssMock.js
Normal file
1
pyscriptjs/__mocks__/cssMock.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = "";
|
||||
@@ -18,5 +18,6 @@ module.exports = {
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^[./a-zA-Z0-9$_-]+\\.py$': '<rootDir>/__mocks__/fileMock.js',
|
||||
'\\.(css)$': '<rootDir>/__mocks__/cssMock.js',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { getLogger } from '../logger';
|
||||
|
||||
const logger = getLogger('py-loader');
|
||||
|
||||
export class PyLoader extends HTMLElement {
|
||||
widths: string[];
|
||||
label: string;
|
||||
mount_name: string;
|
||||
details: HTMLElement;
|
||||
operation: HTMLElement;
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.innerHTML = `<div id="pyscript_loading_splash" class="py-overlay">
|
||||
<div class="py-pop-up">
|
||||
<div class="smooth spinner"></div>
|
||||
<div id="pyscript-loading-label" class="label">
|
||||
<div id="pyscript-operation-details">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
this.mount_name = this.id.split('-').join('_');
|
||||
this.operation = document.getElementById('pyscript-operation');
|
||||
this.details = document.getElementById('pyscript-operation-details');
|
||||
}
|
||||
|
||||
log(msg: string) {
|
||||
// loader messages are showed both in the HTML and in the console
|
||||
logger.info(msg);
|
||||
const newLog = document.createElement('p');
|
||||
newLog.innerText = msg;
|
||||
this.details.appendChild(newLog);
|
||||
}
|
||||
|
||||
close() {
|
||||
logger.info('Closing');
|
||||
this.remove();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
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";
|
||||
|
||||
export class UserError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = "UserError"
|
||||
messageType: MessageType;
|
||||
|
||||
constructor(message: string, t: MessageType = "text") {
|
||||
super(message);
|
||||
this.name = "UserError";
|
||||
this.messageType = t;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +22,7 @@ export class FetchError extends Error {
|
||||
export function _createAlertBanner(
|
||||
message: string,
|
||||
level: "error" | "warning" = "error",
|
||||
messageType: "text" | "html" = "text",
|
||||
messageType: MessageType = "text",
|
||||
logMessage = true) {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
switch (`log-${level}-${logMessage}`) {
|
||||
@@ -53,27 +58,3 @@ export function _createAlertBanner(
|
||||
|
||||
document.body.prepend(banner);
|
||||
}
|
||||
|
||||
/*
|
||||
* This function is used to handle UserError, if we see an error of this
|
||||
* type, we will automatically create a banner on the page that will tell
|
||||
* the user what went wrong. Note that the error will still stop execution,
|
||||
* any other errors we will simply throw them and no banner will be shown.
|
||||
*/
|
||||
export function withUserErrorHandler(fn) {
|
||||
try {
|
||||
return fn();
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof UserError) {
|
||||
/*
|
||||
* Display a page-wide error message to show that something has gone wrong with
|
||||
* PyScript or Pyodide during loading. Probably not be used for issues that occur within
|
||||
* Python scripts, since stderr can be routed to somewhere in the DOM
|
||||
*/
|
||||
_createAlertBanner(error.message);
|
||||
}
|
||||
else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,20 +5,16 @@ import type { AppConfig } from './pyconfig';
|
||||
import type { Runtime } from './runtime';
|
||||
import { PluginManager } from './plugin';
|
||||
import { make_PyScript, initHandlers, mountElements } from './components/pyscript';
|
||||
import { PyLoader } from './components/pyloader';
|
||||
import { PyodideRuntime } from './pyodide';
|
||||
import { getLogger } from './logger';
|
||||
import { handleFetchError, showWarning, globalExport } from './utils';
|
||||
import { calculatePaths } from './plugins/fetch';
|
||||
import { createCustomElements } from './components/elements';
|
||||
import { UserError, withUserErrorHandler } from "./exceptions"
|
||||
import { UserError, _createAlertBanner } from "./exceptions"
|
||||
import { type Stdio, StdioMultiplexer, DEFAULT_STDIO } from './stdio';
|
||||
import { PyTerminalPlugin } from './plugins/pyterminal';
|
||||
|
||||
type ImportType = { [key: string]: unknown };
|
||||
type ImportMapType = {
|
||||
imports: ImportType | null;
|
||||
};
|
||||
import { SplashscreenPlugin } from './plugins/splashscreen';
|
||||
import { ImportmapPlugin } from './plugins/importmap';
|
||||
|
||||
const logger = getLogger('pyscript/main');
|
||||
|
||||
@@ -28,7 +24,7 @@ const logger = getLogger('pyscript/main');
|
||||
|
||||
2. loadConfig(): search for py-config and compute the config for the app
|
||||
|
||||
3. show the loader/splashscreen
|
||||
3. (it used to be "show the splashscreen", but now it's a plugin)
|
||||
|
||||
4. loadRuntime(): start downloading the actual runtime (e.g. pyodide.js)
|
||||
|
||||
@@ -60,7 +56,6 @@ More concretely:
|
||||
|
||||
export class PyScriptApp {
|
||||
config: AppConfig;
|
||||
loader: PyLoader;
|
||||
runtime: Runtime;
|
||||
PyScript: any; // XXX would be nice to have a more precise type for the class itself
|
||||
plugins: PluginManager;
|
||||
@@ -69,20 +64,47 @@ export class PyScriptApp {
|
||||
constructor() {
|
||||
// initialize the builtin plugins
|
||||
this.plugins = new PluginManager();
|
||||
this.plugins.add(new PyTerminalPlugin(this));
|
||||
this.plugins.add(
|
||||
new SplashscreenPlugin(),
|
||||
new PyTerminalPlugin(this),
|
||||
new ImportmapPlugin(),
|
||||
);
|
||||
|
||||
this._stdioMultiplexer = new StdioMultiplexer();
|
||||
this._stdioMultiplexer.addListener(DEFAULT_STDIO);
|
||||
}
|
||||
|
||||
// lifecycle (1)
|
||||
// Error handling logic: if during the execution we encounter an error
|
||||
// which is ultimate responsibility of the user (e.g.: syntax error in the
|
||||
// config, file not found in fetch, etc.), we can throw UserError(). It is
|
||||
// responsibility of main() to catch it and show it to the user in a
|
||||
// proper way (e.g. by using a banner at the top of the page).
|
||||
main() {
|
||||
try {
|
||||
this._realMain();
|
||||
}
|
||||
catch(error) {
|
||||
this._handleUserErrorMaybe(error);
|
||||
}
|
||||
}
|
||||
|
||||
_handleUserErrorMaybe(error) {
|
||||
if (error instanceof UserError) {
|
||||
_createAlertBanner(error.message, "error", error.messageType);
|
||||
this.plugins.onUserError(error);
|
||||
}
|
||||
else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ lifecycle ============
|
||||
|
||||
// lifecycle (1)
|
||||
_realMain() {
|
||||
this.loadConfig();
|
||||
this.plugins.configure(this.config);
|
||||
|
||||
this.showLoader(); // this should be a plugin
|
||||
this.plugins.beforeLaunch(this.config);
|
||||
|
||||
this.loadRuntime();
|
||||
}
|
||||
|
||||
@@ -97,10 +119,6 @@ export class PyScriptApp {
|
||||
let el: Element | null = null;
|
||||
if (elements.length > 0) el = elements[0];
|
||||
if (elements.length >= 2) {
|
||||
// XXX: ideally, I would like to have a way to raise "fatal
|
||||
// errors" and stop the computation, but currently our life cycle
|
||||
// is too messy to implement it reliably. We might want to revisit
|
||||
// this once it's in a better shape.
|
||||
showWarning(
|
||||
'Multiple <py-config> tags detected. Only the first is ' +
|
||||
'going to be parsed, all the others will be ignored',
|
||||
@@ -110,15 +128,6 @@ export class PyScriptApp {
|
||||
logger.info('config loaded:\n' + JSON.stringify(this.config, null, 2));
|
||||
}
|
||||
|
||||
// lifecycle (3)
|
||||
showLoader() {
|
||||
// add loader to the page body
|
||||
logger.info('add py-loader');
|
||||
customElements.define('py-loader', PyLoader);
|
||||
this.loader = <PyLoader>document.createElement('py-loader');
|
||||
document.body.append(this.loader);
|
||||
}
|
||||
|
||||
// lifecycle (4)
|
||||
loadRuntime() {
|
||||
logger.info('Initializing runtime');
|
||||
@@ -135,11 +144,20 @@ export class PyScriptApp {
|
||||
runtime_cfg.src,
|
||||
runtime_cfg.name,
|
||||
runtime_cfg.lang);
|
||||
this.loader.log(`Downloading ${runtime_cfg.name}...`);
|
||||
this.logStatus(`Downloading ${runtime_cfg.name}...`);
|
||||
|
||||
// download pyodide by using a <script> tag. Once it's ready, the
|
||||
// "load" event will be fired and the exeuction logic will continue.
|
||||
// Note that the load event is fired asynchronously and thus any
|
||||
// exception which is throw inside the event handler is *NOT* caught
|
||||
// by the try/catch inside main(): that's why we need to .catch() it
|
||||
// explicitly and call _handleUserErrorMaybe also there.
|
||||
const script = document.createElement('script'); // create a script DOM node
|
||||
script.src = this.runtime.src;
|
||||
script.addEventListener('load', () => {
|
||||
void this.afterRuntimeLoad(this.runtime);
|
||||
this.afterRuntimeLoad(this.runtime).catch((error) => {
|
||||
this._handleUserErrorMaybe(error);
|
||||
});
|
||||
});
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
@@ -148,40 +166,35 @@ export class PyScriptApp {
|
||||
// See the overview comment above for an explanation of how we jump from
|
||||
// point (4) to point (5).
|
||||
//
|
||||
// Invariant: this.config and this.loader are set and available.
|
||||
// Invariant: this.config is set and available.
|
||||
async afterRuntimeLoad(runtime: Runtime): Promise<void> {
|
||||
console.assert(this.config !== undefined);
|
||||
console.assert(this.loader !== undefined);
|
||||
|
||||
this.loader.log('Python startup...');
|
||||
this.logStatus('Python startup...');
|
||||
await runtime.loadInterpreter();
|
||||
this.loader.log('Python ready!');
|
||||
this.logStatus('Python ready!');
|
||||
|
||||
// eslint-disable-next-line
|
||||
runtime.globals.set('pyscript_loader', this.loader);
|
||||
|
||||
this.loader.log('Setting up virtual environment...');
|
||||
this.logStatus('Setting up virtual environment...');
|
||||
await this.setupVirtualEnv(runtime);
|
||||
await mountElements(runtime);
|
||||
|
||||
// lifecycle (6.5)
|
||||
this.plugins.afterSetup(runtime);
|
||||
|
||||
this.loader.log('Executing <py-script> tags...');
|
||||
this.logStatus('Executing <py-script> tags...');
|
||||
this.executeScripts(runtime);
|
||||
|
||||
this.loader.log('Initializing web components...');
|
||||
this.logStatus('Initializing web components...');
|
||||
// lifecycle (8)
|
||||
createCustomElements(runtime);
|
||||
|
||||
if (runtime.config.autoclose_loader) {
|
||||
this.loader.close();
|
||||
}
|
||||
await initHandlers(runtime);
|
||||
|
||||
// NOTE: runtime message is used by integration tests to know that
|
||||
// pyscript initialization has complete. If you change it, you need to
|
||||
// change it also in tests/integration/support.py
|
||||
this.logStatus("Startup complete");
|
||||
this.plugins.afterStartup(runtime);
|
||||
logger.info('PyScript page fully initialized');
|
||||
}
|
||||
|
||||
@@ -208,8 +221,6 @@ export class PyScriptApp {
|
||||
try {
|
||||
await runtime.loadFromFile(paths[i], fetchPaths[i]);
|
||||
} catch (e) {
|
||||
// Remove the loader so users can see the banner better
|
||||
this.loader.remove()
|
||||
// The 'TypeError' here happens when running pytest
|
||||
// I'm not particularly happy with this solution.
|
||||
if (e.name === "FetchError" || e.name === "TypeError") {
|
||||
@@ -224,57 +235,20 @@ export class PyScriptApp {
|
||||
|
||||
// lifecycle (7)
|
||||
executeScripts(runtime: Runtime) {
|
||||
void this.register_importmap(runtime);
|
||||
this.PyScript = make_PyScript(runtime);
|
||||
customElements.define('py-script', this.PyScript);
|
||||
}
|
||||
|
||||
// ================= registraton API ====================
|
||||
|
||||
registerStdioListener(stdio: Stdio) {
|
||||
this._stdioMultiplexer.addListener(stdio);
|
||||
logStatus(msg: string) {
|
||||
logger.info(msg);
|
||||
const ev = new CustomEvent("py-status-message", { detail: msg });
|
||||
document.dispatchEvent(ev);
|
||||
}
|
||||
|
||||
async register_importmap(runtime: Runtime) {
|
||||
// make importmap ES modules available from python using 'import'.
|
||||
//
|
||||
// XXX: this code can probably be improved because errors are silently
|
||||
// ignored. Moreover at the time of writing we don't really have a test
|
||||
// for it and this functionality is used only by the d3 example. We
|
||||
// might want to rethink the whole approach at some point. E.g., maybe
|
||||
// we should move it to py-config?
|
||||
//
|
||||
// Moreover, it's also wrong because it's async and currently we don't
|
||||
// await the module to be fully registered before executing the code
|
||||
// inside py-script. It's also unclear whether we want to wait or not
|
||||
// (or maybe only wait only if we do an actual 'import'?)
|
||||
for (const node of document.querySelectorAll("script[type='importmap']")) {
|
||||
const importmap: ImportMapType = (() => {
|
||||
try {
|
||||
return JSON.parse(node.textContent) as ImportMapType;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
if (importmap?.imports == null) continue;
|
||||
|
||||
for (const [name, url] of Object.entries(importmap.imports)) {
|
||||
if (typeof name != 'string' || typeof url != 'string') continue;
|
||||
|
||||
let exports: object;
|
||||
try {
|
||||
// XXX: pyodide doesn't like Module(), failing with
|
||||
// "can't read 'name' of undefined" at import time
|
||||
exports = { ...(await import(url)) } as object;
|
||||
} catch {
|
||||
logger.warn(`failed to fetch '${url}' for '${name}'`);
|
||||
continue;
|
||||
}
|
||||
|
||||
runtime.registerJsModule(name, exports);
|
||||
}
|
||||
}
|
||||
registerStdioListener(stdio: Stdio) {
|
||||
this._stdioMultiplexer.addListener(stdio);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +259,6 @@ globalExport('pyscript_get_config', pyscript_get_config);
|
||||
|
||||
// main entry point of execution
|
||||
const globalApp = new PyScriptApp();
|
||||
withUserErrorHandler(globalApp.main.bind(globalApp));
|
||||
globalApp.main();
|
||||
|
||||
export const runtime = globalApp.runtime;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AppConfig } from './pyconfig';
|
||||
import type { Runtime } from './runtime';
|
||||
import type { UserError } from './exceptions';
|
||||
|
||||
export class Plugin {
|
||||
|
||||
@@ -38,6 +39,19 @@ export class Plugin {
|
||||
*/
|
||||
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) {
|
||||
}
|
||||
|
||||
/** Called when an UserError is raised
|
||||
*/
|
||||
onUserError(error: UserError) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,8 +62,9 @@ export class PluginManager {
|
||||
this._plugins = [];
|
||||
}
|
||||
|
||||
add(p: Plugin) {
|
||||
this._plugins.push(p);
|
||||
add(...plugins: Plugin[]) {
|
||||
for (const p of plugins)
|
||||
this._plugins.push(p);
|
||||
}
|
||||
|
||||
configure(config: AppConfig) {
|
||||
@@ -66,4 +81,14 @@ export class PluginManager {
|
||||
for (const p of this._plugins)
|
||||
p.afterSetup(runtime);
|
||||
}
|
||||
|
||||
afterStartup(runtime: Runtime) {
|
||||
for (const p of this._plugins)
|
||||
p.afterStartup(runtime);
|
||||
}
|
||||
|
||||
onUserError(error: UserError) {
|
||||
for (const p of this._plugins)
|
||||
p.onUserError(error);
|
||||
}
|
||||
}
|
||||
|
||||
56
pyscriptjs/src/plugins/importmap.ts
Normal file
56
pyscriptjs/src/plugins/importmap.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Runtime } from '../runtime';
|
||||
import { showWarning } from '../utils';
|
||||
import { Plugin } from '../plugin';
|
||||
import { getLogger } from '../logger';
|
||||
|
||||
const logger = getLogger('plugins/importmap');
|
||||
|
||||
type ImportType = { [key: string]: unknown };
|
||||
type ImportMapType = {
|
||||
imports: ImportType | null;
|
||||
};
|
||||
|
||||
export class ImportmapPlugin extends Plugin {
|
||||
|
||||
async afterSetup(runtime: Runtime) {
|
||||
// make importmap ES modules available from python using 'import'.
|
||||
//
|
||||
// XXX: this code can probably be improved because errors are silently
|
||||
// ignored.
|
||||
//
|
||||
// Moreover, it's also wrong because it's async and currently we don't
|
||||
// await the module to be fully registered before executing the code
|
||||
// inside py-script. It's also unclear whether we want to wait or not
|
||||
// (or maybe only wait only if we do an actual 'import'?)
|
||||
for (const node of document.querySelectorAll("script[type='importmap']")) {
|
||||
const importmap: ImportMapType = (() => {
|
||||
try {
|
||||
return JSON.parse(node.textContent) as ImportMapType;
|
||||
}
|
||||
catch(error) {
|
||||
showWarning("Failed to parse import map: " + error.message);
|
||||
}
|
||||
})();
|
||||
|
||||
if (importmap?.imports == null) continue;
|
||||
|
||||
for (const [name, url] of Object.entries(importmap.imports)) {
|
||||
if (typeof name != 'string' || typeof url != 'string') continue;
|
||||
|
||||
let exports: object;
|
||||
try {
|
||||
// XXX: pyodide doesn't like Module(), failing with
|
||||
// "can't read 'name' of undefined" at import time
|
||||
exports = { ...(await import(url)) } as object;
|
||||
} catch {
|
||||
logger.warn(`failed to fetch '${url}' for '${name}'`);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info("Registering JS module", name);
|
||||
runtime.registerJsModule(name, exports);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PyScriptApp } from '../main';
|
||||
import type { AppConfig } from '../pyconfig';
|
||||
import type { Runtime } from '../runtime';
|
||||
import { Plugin } from '../plugin';
|
||||
import { UserError } from "../exceptions"
|
||||
import { getLogger } from '../logger';
|
||||
@@ -46,7 +47,7 @@ export class PyTerminalPlugin extends Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
afterSetup() {
|
||||
afterSetup(runtime: Runtime) {
|
||||
// the Python interpreter has been initialized and we are ready to
|
||||
// execute user code:
|
||||
//
|
||||
|
||||
103
pyscriptjs/src/plugins/splashscreen.ts
Normal file
103
pyscriptjs/src/plugins/splashscreen.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { PyScriptApp } from '../main';
|
||||
import type { AppConfig } from '../pyconfig';
|
||||
import type { UserError } from '../exceptions';
|
||||
import type { Runtime } from '../runtime';
|
||||
import { showWarning } from '../utils';
|
||||
import { Plugin } from '../plugin';
|
||||
import { getLogger } from '../logger';
|
||||
|
||||
const logger = getLogger('py-splashscreen');
|
||||
|
||||
const AUTOCLOSE_LOADER_DEPRECATED = `
|
||||
The setting autoclose_loader is deprecated. Please use the
|
||||
following instead:<br>
|
||||
<pre>
|
||||
<py-config>
|
||||
[splashscreen]
|
||||
autoclose = false
|
||||
</py-config>
|
||||
</pre>`;
|
||||
|
||||
export class SplashscreenPlugin extends Plugin {
|
||||
elem: PySplashscreen;
|
||||
autoclose: boolean;
|
||||
|
||||
configure(config: AppConfig) {
|
||||
// the officially supported setting is config.splashscreen.autoclose,
|
||||
// but we still also support the old config.autoclose_loader (with a
|
||||
// deprecation warning)
|
||||
this.autoclose = true;
|
||||
|
||||
if ("autoclose_loader" in config) {
|
||||
this.autoclose = config.autoclose_loader;
|
||||
showWarning(AUTOCLOSE_LOADER_DEPRECATED);
|
||||
}
|
||||
|
||||
if (config.splashscreen) {
|
||||
this.autoclose = config.splashscreen.autoclose ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
beforeLaunch(config: AppConfig) {
|
||||
// add the splashscreen to the DOM
|
||||
logger.info('add py-splashscreen');
|
||||
customElements.define('py-splashscreen', PySplashscreen);
|
||||
this.elem = <PySplashscreen>document.createElement('py-splashscreen');
|
||||
document.body.append(this.elem);
|
||||
|
||||
document.addEventListener("py-status-message", (e: CustomEvent) => {
|
||||
const msg = e.detail;
|
||||
this.elem.log(msg);
|
||||
});
|
||||
}
|
||||
|
||||
afterStartup(runtime: Runtime) {
|
||||
if (this.autoclose) {
|
||||
this.elem.close();
|
||||
}
|
||||
}
|
||||
|
||||
onUserError(error: UserError) {
|
||||
if (this.elem !== undefined) {
|
||||
// Remove the splashscreen so users can see the banner better
|
||||
this.elem.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PySplashscreen extends HTMLElement {
|
||||
widths: string[];
|
||||
label: string;
|
||||
mount_name: string;
|
||||
details: HTMLElement;
|
||||
operation: HTMLElement;
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.innerHTML = `<div id="pyscript_loading_splash" class="py-overlay">
|
||||
<div class="py-pop-up">
|
||||
<div class="smooth spinner"></div>
|
||||
<div id="pyscript-loading-label" class="label">
|
||||
<div id="pyscript-operation-details">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
this.mount_name = this.id.split('-').join('_');
|
||||
this.operation = document.getElementById('pyscript-operation');
|
||||
this.details = document.getElementById('pyscript-operation-details');
|
||||
}
|
||||
|
||||
log(msg: string) {
|
||||
const newLog = document.createElement('p');
|
||||
newLog.innerText = msg;
|
||||
this.details.appendChild(newLog);
|
||||
}
|
||||
|
||||
close() {
|
||||
logger.info('Closing');
|
||||
this.remove();
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ export interface AppConfig extends Record<string, any> {
|
||||
author_name?: string;
|
||||
author_email?: string;
|
||||
license?: string;
|
||||
autoclose_loader?: boolean;
|
||||
runtimes?: RuntimeConfig[];
|
||||
packages?: string[];
|
||||
fetch?: FetchConfig[];
|
||||
@@ -44,14 +43,12 @@ export type PyScriptMetadata = {
|
||||
const allKeys = {
|
||||
string: ['name', 'description', 'version', 'type', 'author_name', 'author_email', 'license'],
|
||||
number: ['schema_version'],
|
||||
boolean: ['autoclose_loader'],
|
||||
array: ['runtimes', 'packages', 'fetch', 'plugins'],
|
||||
};
|
||||
|
||||
export const defaultConfig: AppConfig = {
|
||||
schema_version: 1,
|
||||
type: 'app',
|
||||
autoclose_loader: true,
|
||||
runtimes: [
|
||||
{
|
||||
src: 'https://cdn.jsdelivr.net/pyodide/v0.21.3/full/pyodide.js',
|
||||
@@ -163,7 +160,7 @@ function parseConfig(configText: string, configType = 'toml') {
|
||||
throw new UserError(`The config supplied: ${configText} is an invalid JSON and cannot be parsed: ${errMessage}`);
|
||||
}
|
||||
} else {
|
||||
throw new UserError(`<p>The type of config supplied'${configType}' is not supported, supported values are ["toml", "json"].</p>`);
|
||||
throw new UserError(`The type of config supplied '${configType}' is not supported, supported values are ["toml", "json"]`);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -46,10 +46,8 @@ export function showWarning(msg: string, messageType: "text" | "html" = "text"):
|
||||
}
|
||||
|
||||
export function handleFetchError(e: Error, singleFile: string) {
|
||||
//Should we still export full error contents to console?
|
||||
// XXX: What happens if I make a typo? i.e. a web server is being used but a file
|
||||
// that doesn't exist is being accessed. We should cover this case as well.
|
||||
console.warn(`Caught an error in fetchPaths:\r\n ${e.toString()}`);
|
||||
// XXX: inspecting the error message to understand what happened is very
|
||||
// fragile. We need a better solution.
|
||||
let errorContent: string;
|
||||
if (e.message.includes('Failed to fetch')) {
|
||||
errorContent = `<p>PyScript: Access to local files
|
||||
@@ -66,11 +64,7 @@ export function handleFetchError(e: Error, singleFile: string) {
|
||||
} else {
|
||||
errorContent = `<p>PyScript encountered an error while loading from file: ${e.message} </p>`;
|
||||
}
|
||||
// We need to create the banner because `handleFetchError` is called before we
|
||||
// use withUserErrorHandler in main.js we are also disabling the log message
|
||||
// because it will be logged by the uncaught exception in promise.
|
||||
_createAlertBanner(errorContent, "error", "html", false);
|
||||
throw new UserError(errorContent);
|
||||
throw new UserError(errorContent, "html");
|
||||
}
|
||||
|
||||
export function readTextFromPath(path: string) {
|
||||
|
||||
@@ -77,6 +77,7 @@ class PyScriptTest:
|
||||
# fixture, the server automatically starts in its own thread.
|
||||
self.http_server = request.getfixturevalue("http_server")
|
||||
self.router = None
|
||||
self.is_fake_server = False
|
||||
else:
|
||||
# use the internal playwright routing
|
||||
self.http_server = "http://fake_server"
|
||||
@@ -87,6 +88,7 @@ class PyScriptTest:
|
||||
usepdb=request.config.option.usepdb,
|
||||
)
|
||||
self.router.install(page)
|
||||
self.is_fake_server = True
|
||||
#
|
||||
self.init_page(page)
|
||||
#
|
||||
|
||||
65
pyscriptjs/tests/integration/test_importmap.py
Normal file
65
pyscriptjs/tests/integration/test_importmap.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from .support import PyScriptTest
|
||||
|
||||
|
||||
class TestImportmap(PyScriptTest):
|
||||
def test_importmap(self):
|
||||
src = """
|
||||
export function say_hello(who) {
|
||||
console.log("hello from", who);
|
||||
}
|
||||
"""
|
||||
self.writefile("mymod.js", src)
|
||||
#
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"mymod": "/mymod.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="module">
|
||||
import { say_hello } from "mymod";
|
||||
say_hello("JS");
|
||||
</script>
|
||||
|
||||
<py-script>
|
||||
import mymod
|
||||
mymod.say_hello("Python")
|
||||
</py-script>
|
||||
"""
|
||||
)
|
||||
assert self.console.log.lines == [
|
||||
"hello from JS",
|
||||
self.PY_COMPLETE,
|
||||
"hello from Python",
|
||||
]
|
||||
|
||||
def test_invalid_json(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<script type="importmap">
|
||||
this is not valid JSON
|
||||
</script>
|
||||
|
||||
<py-script>
|
||||
print("hello world")
|
||||
</py-script>
|
||||
""",
|
||||
wait_for_pyscript=False,
|
||||
)
|
||||
# this error is raised by the browser itself, when *it* tries to parse
|
||||
# the import map
|
||||
self.check_js_errors("Failed to parse import map")
|
||||
|
||||
self.wait_for_pyscript()
|
||||
assert self.console.log.lines == [
|
||||
self.PY_COMPLETE,
|
||||
"hello world",
|
||||
]
|
||||
# this warning is shown by pyscript, when *we* try to parse the import
|
||||
# map
|
||||
banner = self.page.locator(".py-warning")
|
||||
assert "Failed to parse import map" in banner.inner_text()
|
||||
@@ -5,7 +5,7 @@ import tempfile
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from .support import JsErrors, PyScriptTest
|
||||
from .support import PyScriptTest
|
||||
|
||||
URL = "https://github.com/pyodide/pyodide/releases/download/0.20.0/pyodide-build-0.20.0.tar.bz2"
|
||||
TAR_NAME = "pyodide-build-0.20.0.tar.bz2"
|
||||
@@ -245,30 +245,20 @@ class TestConfig(PyScriptTest):
|
||||
wait_for_pyscript=False,
|
||||
)
|
||||
|
||||
# This is expected if running pytest with --dev flag
|
||||
localErrorContent = """PyScript: Access to local files
|
||||
if self.is_fake_server:
|
||||
expected = """PyScript: Access to local files
|
||||
(using "Paths:" in <py-config>)
|
||||
is not available when directly opening a HTML file;
|
||||
you must use a webserver to serve the additional files."""
|
||||
|
||||
# This is expected if running a live server
|
||||
serverErrorContent = (
|
||||
"Loading from file <u>./f.py</u> failed with error 404 (File not Found). "
|
||||
"Are your filename and path are correct?"
|
||||
)
|
||||
else:
|
||||
expected = (
|
||||
"Loading from file <u>./f.py</u> failed with error 404 (File not Found). "
|
||||
"Are your filename and path are correct?"
|
||||
)
|
||||
|
||||
inner_html = self.page.locator(".py-error").inner_html()
|
||||
assert localErrorContent in inner_html or serverErrorContent in inner_html
|
||||
assert "Failed to load resource" in self.console.error.lines[0]
|
||||
assert "Caught an error in fetchPaths" in self.console.warning.lines[0]
|
||||
with pytest.raises(JsErrors) as exc:
|
||||
self.check_js_errors()
|
||||
|
||||
received_error_msg = str(exc.value)
|
||||
assert (
|
||||
localErrorContent in received_error_msg
|
||||
or serverErrorContent in received_error_msg
|
||||
)
|
||||
assert expected in inner_html
|
||||
assert expected in self.console.error.lines[-1]
|
||||
|
||||
def test_paths_from_packages(self):
|
||||
self.writefile("utils/__init__.py", "")
|
||||
|
||||
86
pyscriptjs/tests/integration/test_splashscreen.py
Normal file
86
pyscriptjs/tests/integration/test_splashscreen.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from .support import PyScriptTest
|
||||
|
||||
|
||||
class TestSplashscreen(PyScriptTest):
|
||||
def test_autoshow_and_autoclose(self):
|
||||
"""
|
||||
By default, we show the splashscreen and we close it when the loading is
|
||||
complete.
|
||||
|
||||
XXX: this test is a bit fragile: now it works reliably because the
|
||||
startup is so slow that when we do expect(div).to_be_visible(), the
|
||||
splashscreen is still there. But in theory, if the startup become very
|
||||
fast, it could happen that by the time we arrive in python lang, it
|
||||
has already been removed.
|
||||
"""
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-script>
|
||||
print('hello pyscript')
|
||||
</py-script>
|
||||
""",
|
||||
wait_for_pyscript=False,
|
||||
)
|
||||
div = self.page.locator("py-splashscreen > div")
|
||||
expect(div).to_be_visible()
|
||||
expect(div).to_contain_text("Python startup...")
|
||||
assert "Python startup..." in self.console.info.text
|
||||
#
|
||||
# now we wait for the startup to complete
|
||||
self.wait_for_pyscript()
|
||||
#
|
||||
# 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",
|
||||
]
|
||||
|
||||
def test_autoclose_false(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-config>
|
||||
[splashscreen]
|
||||
autoclose = false
|
||||
</py-config>
|
||||
<py-script>
|
||||
print('hello pyscript')
|
||||
</py-script>
|
||||
""",
|
||||
)
|
||||
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",
|
||||
]
|
||||
|
||||
def test_autoclose_loader_deprecated(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-config>
|
||||
autoclose_loader = false
|
||||
</py-config>
|
||||
<py-script>
|
||||
print('hello pyscript')
|
||||
</py-script>
|
||||
""",
|
||||
)
|
||||
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",
|
||||
]
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, it, jest } from "@jest/globals"
|
||||
import { _createAlertBanner, withUserErrorHandler, UserError } from "../../src/exceptions"
|
||||
import { _createAlertBanner, UserError } from "../../src/exceptions"
|
||||
|
||||
describe("Test _createAlertBanner", () => {
|
||||
|
||||
@@ -80,41 +80,8 @@ describe("Test _createAlertBanner", () => {
|
||||
_createAlertBanner("Test warning", "warning", "text", false);
|
||||
expect(warnLogSpy).not.toHaveBeenCalledWith("Test warning");
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe("Test withUserErrorHandler", () => {
|
||||
|
||||
afterEach(() => {
|
||||
// Ensure we always have a clean body
|
||||
document.body.innerHTML = `<div>Hello World</div>`;
|
||||
})
|
||||
|
||||
it("userError doesn't stop execution", async () => {
|
||||
function exception() {
|
||||
throw new UserError("Computer says no");
|
||||
}
|
||||
|
||||
function func() {
|
||||
withUserErrorHandler(exception);
|
||||
return "Hello, world";
|
||||
}
|
||||
|
||||
const returnValue = func();
|
||||
const banners = document.getElementsByClassName("alert-banner");
|
||||
expect(banners.length).toBe(1);
|
||||
expect(banners[0].innerHTML).toBe("Computer says no");
|
||||
expect(returnValue).toBe("Hello, world");
|
||||
})
|
||||
|
||||
it("any other exception should stop execution and raise", async () => {
|
||||
function exception() {
|
||||
throw new Error("Explosions!");
|
||||
}
|
||||
|
||||
expect(() => withUserErrorHandler(exception)).toThrow(new Error("Explosions!"))
|
||||
})
|
||||
|
||||
it('_createAlertbanner messageType text writes message to content', async () => {
|
||||
let banner = document.getElementsByClassName("alert-banner");
|
||||
expect(banner.length).toBe(0);
|
||||
|
||||
69
pyscriptjs/tests/unit/main.test.ts
Normal file
69
pyscriptjs/tests/unit/main.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { jest } from "@jest/globals"
|
||||
import { UserError } from "../../src/exceptions"
|
||||
import { PyScriptApp } from "../../src/main"
|
||||
|
||||
describe("Test withUserErrorHandler", () => {
|
||||
|
||||
class MyApp extends PyScriptApp {
|
||||
myRealMain: any;
|
||||
|
||||
constructor(myRealMain) {
|
||||
super();
|
||||
this.myRealMain = myRealMain;
|
||||
}
|
||||
|
||||
_realMain() {
|
||||
this.myRealMain();
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Ensure we always have a clean body
|
||||
document.body.innerHTML = `<div>Hello World</div>`;
|
||||
});
|
||||
|
||||
it("userError doesn't stop execution", () => {
|
||||
function myRealMain() {
|
||||
throw new UserError("Computer says no");
|
||||
}
|
||||
|
||||
const app = new MyApp(myRealMain);
|
||||
app.main();
|
||||
const banners = document.getElementsByClassName("alert-banner");
|
||||
expect(banners.length).toBe(1);
|
||||
expect(banners[0].innerHTML).toBe("Computer says no");
|
||||
});
|
||||
|
||||
it("userError escapes by default", () => {
|
||||
function myRealMain() {
|
||||
throw new UserError("hello <br>");
|
||||
}
|
||||
|
||||
const app = new MyApp(myRealMain);
|
||||
app.main();
|
||||
const banners = document.getElementsByClassName("alert-banner");
|
||||
expect(banners.length).toBe(1);
|
||||
expect(banners[0].innerHTML).toBe("hello <br>");
|
||||
});
|
||||
|
||||
it("userError messageType=html don't escape", () => {
|
||||
function myRealMain() {
|
||||
throw new UserError("hello <br>", "html");
|
||||
}
|
||||
|
||||
const app = new MyApp(myRealMain);
|
||||
app.main();
|
||||
const banners = document.getElementsByClassName("alert-banner");
|
||||
expect(banners.length).toBe(1);
|
||||
expect(banners[0].innerHTML).toBe("hello <br>");
|
||||
});
|
||||
|
||||
it("any other exception should stop execution and raise", () => {
|
||||
function myRealMain() {
|
||||
throw new Error("Explosions!");
|
||||
}
|
||||
|
||||
const app = new MyApp(myRealMain);
|
||||
expect(() => app.main()).toThrow(new Error("Explosions!"))
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { jest } from '@jest/globals';
|
||||
import { PyLoader } from "../../src/components/pyloader"
|
||||
import { getLogger } from "../../src/logger"
|
||||
|
||||
customElements.define('py-loader', PyLoader);
|
||||
|
||||
describe('PyLoader', () => {
|
||||
let instance: PyLoader;
|
||||
const logger = getLogger("py-loader")
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
instance = new PyLoader();
|
||||
logger.info = jest.fn()
|
||||
})
|
||||
|
||||
it('PyLoader instantiates correctly', async () => {
|
||||
expect (instance).toBeInstanceOf(PyLoader);
|
||||
})
|
||||
|
||||
it('connectedCallback adds splash screen', async () => {
|
||||
// innerHTML should be empty
|
||||
expect(instance.innerHTML).toBe("")
|
||||
instance.connectedCallback();
|
||||
|
||||
// This is just checking that we have some ids or class names
|
||||
expect(instance.innerHTML).toContain('pyscript_loading_splash')
|
||||
expect(instance.innerHTML).toContain("spinner")
|
||||
|
||||
expect(instance.mount_name).toBe("")
|
||||
})
|
||||
|
||||
it('confirm calling log will log to console and page', () => {
|
||||
const element = document.createElement('div')
|
||||
element.setAttribute("id", "pyscript-operation-details")
|
||||
|
||||
instance.details = element
|
||||
instance.log("Hello, world!")
|
||||
|
||||
const printedLog = element.getElementsByTagName('p')
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith("Hello, world!")
|
||||
expect(printedLog[0].innerText).toBe("Hello, world!")
|
||||
})
|
||||
|
||||
it('confirm that calling close removes element', async () => {
|
||||
instance.remove = jest.fn()
|
||||
instance.close()
|
||||
expect(logger.info).toHaveBeenCalledWith("Closing")
|
||||
expect(instance.remove).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user