mirror of
https://github.com/pyscript/pyscript.git
synced 2026-02-21 11:01:26 -05:00
Refactor how py-script are executed, kill scriptQueue store, introduce pyExec (#881)
Yet another refactoring to untangle the old mess. Highlights: base.ts, pyscript.ts and pyrepl.ts were a tangled mess of code, in which each of them interacted with the others in non-obvious ways. Now PyScript is no longer a subclass of BaseEvalElement and it is much simpler. I removed code for handling the attributes std-out and std-err because they are no longer needed with the new display() logic. The logic for executing python code is now in pyexec.ts: so we are decoupling the process of "finding" the python code (handled by the py-script web component) and the logic to actually execute it. This has many advantages, including the fact that it will be more easily usable by other components (e.g. pyrepl). Also, note that it's called pyexec and not pyeval: in the vast majority of cases in Python you have statements to execute, and almost never expressions to evaluate. I killed the last remaining global store, scriptQueue tada. As a bonus effect, now we automatically do the correct thing when a <py-script> tag is dynamically added to the DOM (I added a test for it). I did not remove svelte from packages.json, because I don't fully understand the implications: there are various options which mention svelte in rollup.js and tsconfig.json, so it's probably better to kill it in its own PR. pyexec.ts is also responsible of handling the default target for display() and correct handling/visualization of exceptions. I fixed/improved/added display/output tests in the process. I also found a problem though, see issue #878, so I improved the test and marked it as xfail. I removed BaseEvalElement as the superclass of most components. Now the only class which inherits from it is PyRepl. In a follow-up PR, I plan to merge them into a single class and do more cleanup. During the refactoring, I killed guidGenerator: now instead of generating random py-* IDs which are very hard to read for humans, we generated py-internal-X IDs, where X is 0, 1, 2, 3, etc. This makes writing tests and debugging much easier. I improved a lot our test machinery: it turns out that PR #829 broke the ability to use/view sourcemaps inside the playwright browser (at least on my machine). For some reason chromium is unable to find sourcemaps if you use playwrights internal routing. So I reintroduced the http_server fixture which was removed by that PR, and added a pytest option --no-fake-server to use it instead, useful for debugging. By default we are still using the fakeserver though (which is faster and parallelizable). Similarly, I added --dev which implies --headed and also automatically open chrome dev tools.
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import { getAttribute, guidGenerator, addClasses, removeClasses } from '../utils';
|
||||
// XXX this should be eventually killed.
|
||||
// The only remaining class which inherit from BaseEvalElement is PyRepl: we
|
||||
// should merge the two classes together, do a refactoing of how PyRepl to use
|
||||
// the new pyExec and in general clean up the unnecessary code.
|
||||
|
||||
import { ensureUniqueId, addClasses, removeClasses, getAttribute } from '../utils';
|
||||
import type { Runtime } from '../runtime';
|
||||
import { getLogger } from '../logger';
|
||||
import { pyExecDontHandleErrors } from '../pyexec';
|
||||
|
||||
const logger = getLogger('pyscript/base');
|
||||
|
||||
@@ -28,11 +34,6 @@ export class BaseEvalElement extends HTMLElement {
|
||||
this.setOutputMode("append");
|
||||
}
|
||||
|
||||
addToOutput(s: string) {
|
||||
this.outputElement.innerHTML += '<div>' + s + '</div>';
|
||||
this.outputElement.hidden = false;
|
||||
}
|
||||
|
||||
setOutputMode(defaultMode = "append") {
|
||||
const mode = getAttribute(this,'output-mode') || defaultMode;
|
||||
|
||||
@@ -61,7 +62,7 @@ export class BaseEvalElement extends HTMLElement {
|
||||
}
|
||||
|
||||
checkId() {
|
||||
if (!this.id) this.id = 'py-' + guidGenerator();
|
||||
ensureUniqueId(this);
|
||||
}
|
||||
|
||||
getSourceFromElement(): string {
|
||||
@@ -74,39 +75,6 @@ export class BaseEvalElement extends HTMLElement {
|
||||
return this.code;
|
||||
}
|
||||
|
||||
protected async _register_esm(runtime: Runtime): Promise<void> {
|
||||
const imports: { [key: string]: unknown } = {};
|
||||
const nodes = document.querySelectorAll("script[type='importmap']");
|
||||
const importmaps: any[] = [];
|
||||
nodes.forEach( node =>
|
||||
{
|
||||
let importmap;
|
||||
try {
|
||||
importmap = JSON.parse(node.textContent);
|
||||
if (importmap?.imports == null) return;
|
||||
importmaps.push(importmap);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
)
|
||||
for (const importmap of importmaps){
|
||||
for (const [name, url] of Object.entries(importmap.imports)) {
|
||||
if (typeof name != 'string' || typeof url != 'string') continue;
|
||||
|
||||
try {
|
||||
// XXX: pyodide doesn't like Module(), failing with
|
||||
// "can't read 'name' of undefined" at import time
|
||||
imports[name] = { ...(await import(url)) };
|
||||
} catch {
|
||||
logger.error(`failed to fetch '${url}' for '${name}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runtime.registerJsModule('esm', imports);
|
||||
}
|
||||
|
||||
async evaluate(runtime: Runtime): Promise<void> {
|
||||
this.preEvaluate();
|
||||
|
||||
@@ -114,14 +82,9 @@ export class BaseEvalElement extends HTMLElement {
|
||||
try {
|
||||
source = this.source ? await this.getSourceFromFile(this.source)
|
||||
: this.getSourceFromElement();
|
||||
this._register_esm(runtime);
|
||||
|
||||
try {
|
||||
<string>await runtime.run(`set_current_display_target(target_id="${this.id}")`);
|
||||
<string>await runtime.run(source);
|
||||
} finally {
|
||||
<string>await runtime.run(`set_current_display_target(target_id=None)`);
|
||||
}
|
||||
// XXX we should use pyExec and let it display the errors
|
||||
await pyExecDontHandleErrors(runtime, source, this);
|
||||
|
||||
removeClasses(this.errorElement, ['py-error']);
|
||||
this.postEvaluate();
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { BaseEvalElement } from './base';
|
||||
import { getAttribute, addClasses, htmlDecode } from '../utils';
|
||||
import { getAttribute, addClasses, htmlDecode, ensureUniqueId } from '../utils';
|
||||
import { getLogger } from '../logger'
|
||||
import type { Runtime } from '../runtime';
|
||||
|
||||
const logger = getLogger('py-button');
|
||||
|
||||
export function make_PyButton(runtime: Runtime) {
|
||||
class PyButton extends BaseEvalElement {
|
||||
class PyButton extends HTMLElement {
|
||||
widths: string[] = [];
|
||||
label: string | undefined = undefined;
|
||||
class: string[];
|
||||
defaultClass: string[];
|
||||
mount_name: string | undefined = undefined;
|
||||
code: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@@ -41,7 +42,7 @@ export function make_PyButton(runtime: Runtime) {
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.checkId();
|
||||
ensureUniqueId(this);
|
||||
this.code = htmlDecode(this.innerHTML) || "";
|
||||
this.mount_name = this.id.split('-').join('_');
|
||||
this.innerHTML = '';
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { BaseEvalElement } from './base';
|
||||
import { getAttribute, addClasses, htmlDecode } from '../utils';
|
||||
import { getAttribute, addClasses, htmlDecode, ensureUniqueId } from '../utils';
|
||||
import { getLogger } from '../logger'
|
||||
import type { Runtime } from '../runtime';
|
||||
|
||||
const logger = getLogger('py-inputbox');
|
||||
|
||||
export function make_PyInputBox(runtime: Runtime) {
|
||||
class PyInputBox extends BaseEvalElement {
|
||||
class PyInputBox extends HTMLElement {
|
||||
widths: string[] = [];
|
||||
label: string | undefined = undefined;
|
||||
mount_name: string | undefined = undefined;
|
||||
code: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@@ -20,7 +21,7 @@ export function make_PyInputBox(runtime: Runtime) {
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
this.checkId();
|
||||
ensureUniqueId(this);
|
||||
this.code = htmlDecode(this.innerHTML);
|
||||
this.mount_name = this.id.split('-').join('_');
|
||||
this.innerHTML = '';
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { BaseEvalElement } from './base';
|
||||
import { getLogger } from '../logger';
|
||||
|
||||
const logger = getLogger('py-loader');
|
||||
|
||||
export class PyLoader extends BaseEvalElement {
|
||||
export class PyLoader extends HTMLElement {
|
||||
widths: string[];
|
||||
label: string;
|
||||
mount_name: string;
|
||||
|
||||
@@ -132,36 +132,16 @@ export function make_PyRepl(runtime: Runtime) {
|
||||
this.outputElement = el
|
||||
}
|
||||
} else {
|
||||
const stdOut = getAttribute(this, "std-out");
|
||||
if (stdOut) {
|
||||
const el = document.getElementById(stdOut);
|
||||
if(el){
|
||||
this.outputElement = el
|
||||
}
|
||||
} else {
|
||||
// In this case neither output or std-out have been provided so we need
|
||||
// to create a new output div to output to
|
||||
this.outputElement = document.createElement('div');
|
||||
this.outputElement.classList.add('output');
|
||||
this.outputElement.hidden = true;
|
||||
const stdOut = getAttribute(this, "exec-id") || "";
|
||||
this.outputElement.id = this.id + '-' + stdOut;
|
||||
// to create a new output div to output to
|
||||
this.outputElement = document.createElement('div');
|
||||
this.outputElement.classList.add('output');
|
||||
this.outputElement.hidden = true;
|
||||
this.outputElement.id = this.id + '-' + this.getAttribute('exec-id');
|
||||
|
||||
// add the output div id if there's not output pre-defined
|
||||
mainDiv.appendChild(this.outputElement);
|
||||
}
|
||||
// add the output div id if there's not output pre-defined
|
||||
mainDiv.appendChild(this.outputElement);
|
||||
|
||||
const stdErr = getAttribute(this, "std-err");
|
||||
if( stdErr ){
|
||||
const el = document.getElementById(stdErr);
|
||||
if(el){
|
||||
this.errorElement = el;
|
||||
}else{
|
||||
this.errorElement = this.outputElement
|
||||
}
|
||||
}else{
|
||||
this.errorElement = this.outputElement
|
||||
}
|
||||
this.errorElement = this.outputElement;
|
||||
}
|
||||
|
||||
this.appendChild(mainDiv);
|
||||
@@ -169,11 +149,6 @@ export function make_PyRepl(runtime: Runtime) {
|
||||
logger.debug(`element ${this.id} successfully connected`);
|
||||
}
|
||||
|
||||
addToOutput(s: string): void {
|
||||
this.outputElement.innerHTML += '<div>' + s + '</div>';
|
||||
this.outputElement.hidden = false;
|
||||
}
|
||||
|
||||
preEvaluate(): void {
|
||||
this.setOutputMode("replace");
|
||||
if(!this.appendOutput) {
|
||||
@@ -213,8 +188,6 @@ export function make_PyRepl(runtime: Runtime) {
|
||||
};
|
||||
|
||||
addReplAttribute('output');
|
||||
addReplAttribute('std-out');
|
||||
addReplAttribute('std-err');
|
||||
|
||||
newPyRepl.setAttribute('exec-id', nextExecId.toString());
|
||||
if( this.parentElement ){
|
||||
|
||||
@@ -1,117 +1,37 @@
|
||||
import {
|
||||
addToScriptsQueue,
|
||||
} from '../stores';
|
||||
|
||||
import { getAttribute, addClasses, htmlDecode } from '../utils';
|
||||
import { BaseEvalElement } from './base';
|
||||
import { htmlDecode, ensureUniqueId } from '../utils';
|
||||
import type { Runtime } from '../runtime';
|
||||
import { getLogger } from '../logger';
|
||||
import { pyExec } from '../pyexec';
|
||||
|
||||
const logger = getLogger('py-script');
|
||||
|
||||
export class PyScript extends BaseEvalElement {
|
||||
constructor() {
|
||||
super();
|
||||
export function make_PyScript(runtime: Runtime) {
|
||||
|
||||
// add an extra div where we can attach the codemirror editor
|
||||
this.shadow.appendChild(this.wrapper);
|
||||
}
|
||||
class PyScript extends HTMLElement {
|
||||
|
||||
connectedCallback() {
|
||||
this.checkId();
|
||||
this.code = htmlDecode(this.innerHTML);
|
||||
this.innerHTML = '';
|
||||
|
||||
const mainDiv = document.createElement('div');
|
||||
addClasses(mainDiv, ['output']);
|
||||
// add Editor to main PyScript div
|
||||
|
||||
const output = getAttribute( this, "output");
|
||||
if (output) {
|
||||
const el = document.getElementById(output);
|
||||
if( el ){
|
||||
this.errorElement = el;
|
||||
this.outputElement = el;
|
||||
}
|
||||
|
||||
// in this case, the default output-mode is append, if hasn't been specified
|
||||
if (!this.hasAttribute('output-mode')) {
|
||||
this.setAttribute('output-mode', 'append');
|
||||
}
|
||||
} else {
|
||||
const stdOut = getAttribute( this, "std-out");
|
||||
if (stdOut) {
|
||||
const el = document.getElementById( stdOut );
|
||||
if( el){
|
||||
this.outputElement = el
|
||||
}
|
||||
} else {
|
||||
// In this case neither output or std-out have been provided so we need
|
||||
// to create a new output div to output to
|
||||
|
||||
// Let's check if we have an id first and create one if not
|
||||
this.outputElement = document.createElement('div');
|
||||
const exec_id = this.getAttribute('exec-id');
|
||||
this.outputElement.id = this.id + (exec_id ? '-' + exec_id : '');
|
||||
|
||||
// add the output div id if there's not output pre-defined
|
||||
mainDiv.appendChild(this.outputElement);
|
||||
}
|
||||
|
||||
const stdErr = getAttribute( this, "std-err");
|
||||
if ( stdErr ) {
|
||||
const el = document.getElementById( stdErr );
|
||||
if( el ){
|
||||
this.errorElement = el;
|
||||
}else{
|
||||
this.errorElement = this.outputElement;
|
||||
}
|
||||
} else {
|
||||
this.errorElement = this.outputElement;
|
||||
}
|
||||
async connectedCallback() {
|
||||
ensureUniqueId(this);
|
||||
const pySrc = await this.getPySrc();
|
||||
this.innerHTML = '';
|
||||
await pyExec(runtime, pySrc, this);
|
||||
}
|
||||
|
||||
this.appendChild(mainDiv);
|
||||
addToScriptsQueue(this);
|
||||
|
||||
if (this.hasAttribute('src')) {
|
||||
this.source = this.getAttribute('src');
|
||||
}
|
||||
}
|
||||
|
||||
protected async _register_esm(runtime: Runtime): Promise<void> {
|
||||
for (const node of document.querySelectorAll("script[type='importmap']")) {
|
||||
const importmap = (() => {
|
||||
try {
|
||||
return JSON.parse(node.textContent);
|
||||
} 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)) };
|
||||
} catch {
|
||||
logger.warn(`failed to fetch '${url}' for '${name}'`);
|
||||
continue;
|
||||
}
|
||||
|
||||
runtime.registerJsModule(name, exports);
|
||||
async getPySrc(): Promise<string> {
|
||||
if (this.hasAttribute('src')) {
|
||||
// XXX: what happens if the fetch() fails?
|
||||
// We should handle the case correctly, but in my defense
|
||||
// this case was broken also before the refactoring. FIXME!
|
||||
const url = this.getAttribute('src');
|
||||
const response = await fetch(url);
|
||||
return await response.text();
|
||||
}
|
||||
else {
|
||||
return htmlDecode(this.innerHTML);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSourceFromElement(): string {
|
||||
return htmlDecode(this.code);
|
||||
}
|
||||
return PyScript;
|
||||
}
|
||||
|
||||
/** Defines all possible py-on* and their corresponding event types */
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BaseEvalElement } from './base';
|
||||
import { addClasses, htmlDecode } from '../utils';
|
||||
import { addClasses, htmlDecode, ensureUniqueId } from '../utils';
|
||||
|
||||
export class PyTitle extends BaseEvalElement {
|
||||
export class PyTitle extends HTMLElement {
|
||||
widths: string[];
|
||||
label: string;
|
||||
mount_name: string;
|
||||
|
||||
@@ -3,23 +3,16 @@ import './styles/pyscript_base.css';
|
||||
import { loadConfigFromElement } from './pyconfig';
|
||||
import type { AppConfig } from './pyconfig';
|
||||
import type { Runtime } from './runtime';
|
||||
import { PyScript, initHandlers, mountElements } from './components/pyscript';
|
||||
import { make_PyScript, initHandlers, mountElements } from './components/pyscript';
|
||||
import { PyLoader } from './components/pyloader';
|
||||
import { PyodideRuntime } from './pyodide';
|
||||
import { getLogger } from './logger';
|
||||
import { scriptsQueue } from './stores';
|
||||
import { handleFetchError, showError, globalExport } from './utils'
|
||||
import { createCustomElements } from './components/elements';
|
||||
|
||||
|
||||
const logger = getLogger('pyscript/main');
|
||||
|
||||
let scriptsQueue_: PyScript[];
|
||||
scriptsQueue.subscribe((value: PyScript[]) => {
|
||||
scriptsQueue_ = value;
|
||||
});
|
||||
|
||||
|
||||
|
||||
/* High-level overview of the lifecycle of a PyScript App:
|
||||
|
||||
@@ -37,7 +30,8 @@ scriptsQueue.subscribe((value: PyScript[]) => {
|
||||
|
||||
6. setup the environment, install packages
|
||||
|
||||
7. run user scripts
|
||||
7. connect the py-script web component. This causes the execution of all the
|
||||
user scripts
|
||||
|
||||
8. initialize the rest of web components such as py-button, py-repl, etc.
|
||||
|
||||
@@ -58,11 +52,11 @@ class PyScriptApp {
|
||||
config: AppConfig;
|
||||
loader: PyLoader;
|
||||
runtime: Runtime;
|
||||
PyScript: any; // XXX would be nice to have a more precise type for the class itself
|
||||
|
||||
// lifecycle (1)
|
||||
main() {
|
||||
this.loadConfig();
|
||||
customElements.define('py-script', PyScript);
|
||||
this.showLoader();
|
||||
this.loadRuntime();
|
||||
}
|
||||
@@ -129,7 +123,6 @@ class PyScriptApp {
|
||||
//
|
||||
// Invariant: this.config and this.loader are set and available.
|
||||
async afterRuntimeLoad(runtime: Runtime): Promise<void> {
|
||||
// XXX what is the JS/TS standard way of doing asserts?
|
||||
console.assert(this.config !== undefined);
|
||||
console.assert(this.loader !== undefined);
|
||||
|
||||
@@ -195,11 +188,53 @@ class PyScriptApp {
|
||||
|
||||
// lifecycle (7)
|
||||
executeScripts(runtime: Runtime) {
|
||||
for (const script of scriptsQueue_) {
|
||||
void script.evaluate(runtime);
|
||||
}
|
||||
scriptsQueue.set([]);
|
||||
this.register_importmap(runtime);
|
||||
this.PyScript = make_PyScript(runtime);
|
||||
customElements.define('py-script', this.PyScript);
|
||||
}
|
||||
|
||||
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 = (() => {
|
||||
try {
|
||||
return JSON.parse(node.textContent);
|
||||
} 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)) };
|
||||
} catch {
|
||||
logger.warn(`failed to fetch '${url}' for '${name}'`);
|
||||
continue;
|
||||
}
|
||||
|
||||
runtime.registerJsModule(name, exports);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function pyscript_get_config() {
|
||||
|
||||
65
pyscriptjs/src/pyexec.ts
Normal file
65
pyscriptjs/src/pyexec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { getLogger } from './logger';
|
||||
import { ensureUniqueId, addClasses } from './utils';
|
||||
import type { Runtime } from './runtime';
|
||||
|
||||
const logger = getLogger('pyexec');
|
||||
|
||||
export async function pyExec(runtime: Runtime, pysrc: string, outElem: HTMLElement)
|
||||
{
|
||||
// this is the python function defined in pyscript.py
|
||||
const set_current_display_target = runtime.globals.get('set_current_display_target');
|
||||
ensureUniqueId(outElem);
|
||||
set_current_display_target(outElem.id);
|
||||
try {
|
||||
try {
|
||||
await runtime.run(pysrc);
|
||||
}
|
||||
catch (err) {
|
||||
// XXX: currently we display exceptions in the same position as
|
||||
// the output. But we probably need a better way to do that,
|
||||
// e.g. allowing plugins to intercept exceptions and display them
|
||||
// in a configurable way.
|
||||
displayPyException(err, outElem);
|
||||
}
|
||||
}
|
||||
finally {
|
||||
set_current_display_target(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function displayPyException(err: any, errElem: HTMLElement) {
|
||||
//addClasses(errElem, ['py-error'])
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = "py-error";
|
||||
|
||||
if (err.name === "PythonError") {
|
||||
// err.message contains the python-level traceback (i.e. a string
|
||||
// starting with: "Traceback (most recent call last) ..."
|
||||
logger.error("Python exception:\n" + err.message);
|
||||
pre.innerText = err.message;
|
||||
}
|
||||
else {
|
||||
// this is very likely a normal JS exception. The best we can do is to
|
||||
// display it as is.
|
||||
logger.error("Non-python exception:\n" + err);
|
||||
pre.innerText = err;
|
||||
}
|
||||
errElem.appendChild(pre);
|
||||
}
|
||||
|
||||
|
||||
// XXX this is used by base.ts but should be removed once we complete the refactoring
|
||||
export async function pyExecDontHandleErrors(runtime: Runtime, pysrc: string, out: HTMLElement)
|
||||
{
|
||||
// this is the python function defined in pyscript.py
|
||||
const set_current_display_target = runtime.globals.get('set_current_display_target');
|
||||
ensureUniqueId(out);
|
||||
set_current_display_target(out.id);
|
||||
try {
|
||||
await runtime.run(pysrc);
|
||||
}
|
||||
finally {
|
||||
set_current_display_target(undefined);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { PyScript } from './components/pyscript';
|
||||
|
||||
export const scriptsQueue = writable<PyScript[]>([]);
|
||||
|
||||
export const addToScriptsQueue = (script: PyScript) => {
|
||||
scriptsQueue.update(scriptsQueue => [...scriptsQueue, script]);
|
||||
};
|
||||
@@ -38,11 +38,10 @@ export function ltrim(code: string): string {
|
||||
return k != 0 ? lines.map(line => line.substring(k)).join('\n') : code;
|
||||
}
|
||||
|
||||
export function guidGenerator(): string {
|
||||
const S4 = function (): string {
|
||||
return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
|
||||
};
|
||||
return S4() + S4() + '-' + S4() + '-' + S4() + '-' + S4() + '-' + S4() + S4() + S4();
|
||||
let _uniqueIdCounter = 0;
|
||||
export function ensureUniqueId(el: HTMLElement) {
|
||||
if (el.id === "")
|
||||
el.id = "py-internal-" + _uniqueIdCounter++;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
"""All data required for testing examples"""
|
||||
import threading
|
||||
from http.server import HTTPServer as SuperHTTPServer
|
||||
from http.server import SimpleHTTPRequestHandler
|
||||
|
||||
import pytest
|
||||
|
||||
from .support import Logger
|
||||
@@ -7,3 +11,72 @@ from .support import Logger
|
||||
@pytest.fixture(scope="session")
|
||||
def logger():
|
||||
return Logger()
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
"--no-fake-server",
|
||||
action="store_true",
|
||||
help="Use a real HTTP server instead of http://fakeserver",
|
||||
)
|
||||
parser.addoption(
|
||||
"--dev",
|
||||
action="store_true",
|
||||
help="Automatically open a devtools panel. Implies --headed",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser_type_launch_args(request):
|
||||
"""
|
||||
Override the browser_type_launch_args defined by pytest-playwright to
|
||||
support --devtools.
|
||||
|
||||
NOTE: this has been tested with pytest-playwright==0.3.0. It might break
|
||||
with newer versions of it.
|
||||
"""
|
||||
if request.config.option.dev:
|
||||
request.config.option.headed = True
|
||||
# this calls the "original" fixture defined by pytest_playwright.py
|
||||
launch_options = request.getfixturevalue("browser_type_launch_args")
|
||||
if request.config.option.dev:
|
||||
launch_options["devtools"] = True
|
||||
return launch_options
|
||||
|
||||
|
||||
class HTTPServer(SuperHTTPServer):
|
||||
"""
|
||||
Class for wrapper to run SimpleHTTPServer on Thread.
|
||||
Ctrl +Only Thread remains dead when terminated with C.
|
||||
Keyboard Interrupt passes.
|
||||
"""
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
self.server_close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def http_server(logger):
|
||||
class MyHTTPRequestHandler(SimpleHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
logger.log("http_server", fmt % args, color="blue")
|
||||
|
||||
host, port = "127.0.0.1", 8080
|
||||
base_url = f"http://{host}:{port}"
|
||||
|
||||
# serve_Run forever under thread
|
||||
server = HTTPServer((host, port), MyHTTPRequestHandler)
|
||||
|
||||
thread = threading.Thread(None, server.run)
|
||||
thread.start()
|
||||
|
||||
yield base_url # Transition to test here
|
||||
|
||||
# End thread
|
||||
server.shutdown()
|
||||
thread.join()
|
||||
|
||||
@@ -70,11 +70,19 @@ class PyScriptTest:
|
||||
tmpdir.join("build").mksymlinkto(BUILD)
|
||||
self.tmpdir.chdir()
|
||||
self.logger = logger
|
||||
self.fake_server = "http://fake_server"
|
||||
self.router = SmartRouter(
|
||||
"fake_server", logger=logger, usepdb=request.config.option.usepdb
|
||||
)
|
||||
self.router.install(page)
|
||||
|
||||
if request.config.option.no_fake_server:
|
||||
# use a real HTTP server. Note that as soon as we request the
|
||||
# fixture, the server automatically starts in its own thread.
|
||||
self.http_server = request.getfixturevalue("http_server")
|
||||
else:
|
||||
# use the internal playwright routing
|
||||
self.http_server = "http://fake_server"
|
||||
self.router = SmartRouter(
|
||||
"fake_server", logger=logger, usepdb=request.config.option.usepdb
|
||||
)
|
||||
self.router.install(page)
|
||||
#
|
||||
self.init_page(page)
|
||||
#
|
||||
# this extra print is useful when using pytest -s, else we start printing
|
||||
@@ -157,7 +165,7 @@ class PyScriptTest:
|
||||
def goto(self, path):
|
||||
self.logger.reset()
|
||||
self.logger.log("page.goto", path, color="yellow")
|
||||
url = f"{self.fake_server}/{path}"
|
||||
url = f"{self.http_server}/{path}"
|
||||
self.page.goto(url, timeout=0)
|
||||
|
||||
def wait_for_console(self, text, *, timeout=None, check_errors=True):
|
||||
@@ -224,8 +232,8 @@ class PyScriptTest:
|
||||
doc = f"""
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="{self.fake_server}/build/pyscript.css" />
|
||||
<script defer src="{self.fake_server}/build/pyscript.js"></script>
|
||||
<link rel="stylesheet" href="{self.http_server}/build/pyscript.css" />
|
||||
<script defer src="{self.http_server}/build/pyscript.js"></script>
|
||||
{extra_head}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -91,7 +91,7 @@ class TestSupport(PyScriptTest):
|
||||
# stack trace
|
||||
msg = str(exc.value)
|
||||
assert "Error: this is an error" in msg
|
||||
assert f"at {self.fake_server}/mytest.html" in msg
|
||||
assert f"at {self.http_server}/mytest.html" in msg
|
||||
#
|
||||
# after a call to check_errors, the errors are cleared
|
||||
self.check_errors()
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import re
|
||||
|
||||
from .support import PyScriptTest
|
||||
|
||||
|
||||
@@ -8,16 +6,39 @@ class TestBasic(PyScriptTest):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-script>
|
||||
display('hello pyscript')
|
||||
print('hello pyscript')
|
||||
</py-script>
|
||||
"""
|
||||
)
|
||||
assert self.console.log.lines == [
|
||||
self.PY_COMPLETE,
|
||||
"hello pyscript",
|
||||
]
|
||||
|
||||
def test_python_exception(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-script>
|
||||
print('hello pyscript')
|
||||
raise Exception('this is an error')
|
||||
</py-script>
|
||||
"""
|
||||
)
|
||||
# this is a very ugly way of checking the content of the DOM. If we
|
||||
# find ourselves to write a lot of code in this style, we will
|
||||
# probably want to write a nicer API for it.
|
||||
inner_html = self.page.locator("py-script").inner_html()
|
||||
pattern = r'<div id="py-.*">hello pyscript</div>'
|
||||
assert re.search(pattern, inner_html)
|
||||
assert self.console.log.lines == [self.PY_COMPLETE, "hello pyscript"]
|
||||
# check that we sent the traceback to the console
|
||||
tb_lines = self.console.error.lines[-1].splitlines()
|
||||
assert tb_lines[0] == "[pyexec] Python exception:"
|
||||
assert tb_lines[1] == "Traceback (most recent call last):"
|
||||
assert tb_lines[-1] == "Exception: this is an error"
|
||||
#
|
||||
# check that we show the traceback in the page. Note that here we
|
||||
# display the "raw" python traceback, without the "[pyexec] Python
|
||||
# exception:" line (which is useful in the console, but not for the
|
||||
# user)
|
||||
pre = self.page.locator("py-script > pre")
|
||||
tb_lines = pre.inner_text().splitlines()
|
||||
assert tb_lines[0] == "Traceback (most recent call last):"
|
||||
assert tb_lines[-1] == "Exception: this is an error"
|
||||
|
||||
def test_execution_in_order(self):
|
||||
"""
|
||||
@@ -118,3 +139,35 @@ class TestBasic(PyScriptTest):
|
||||
"Loaded asciitree", # printed by pyodide
|
||||
"hello asciitree", # printed by us
|
||||
]
|
||||
|
||||
def test_dynamically_add_py_script_tag(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<script>
|
||||
function addPyScriptTag() {
|
||||
let tag = document.createElement('py-script');
|
||||
tag.innerHTML = "print('hello world')";
|
||||
document.body.appendChild(tag);
|
||||
}
|
||||
</script>
|
||||
<button onclick="addPyScriptTag()">Click me</button>
|
||||
"""
|
||||
)
|
||||
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",
|
||||
]
|
||||
|
||||
def test_py_script_src_attribute(self):
|
||||
self.writefile("foo.py", "print('hello from foo')")
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-script src="foo.py"></py-script>
|
||||
"""
|
||||
)
|
||||
assert self.console.log.lines == [
|
||||
self.PY_COMPLETE,
|
||||
"hello from foo",
|
||||
]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .support import PyScriptTest
|
||||
|
||||
|
||||
class TestOutuput(PyScriptTest):
|
||||
class TestOutput(PyScriptTest):
|
||||
def test_simple_display(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
@@ -16,26 +18,36 @@ class TestOutuput(PyScriptTest):
|
||||
pattern = r'<div id="py-.*">hello world</div>'
|
||||
assert re.search(pattern, inner_html)
|
||||
|
||||
@pytest.mark.xfail(reason="issue #878")
|
||||
def test_consecutive_display(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-script>
|
||||
display('hello 1')
|
||||
</py-script>
|
||||
<p>hello 2</p>
|
||||
<py-script>
|
||||
display('hello 2')
|
||||
display('hello 3')
|
||||
</py-script>
|
||||
"""
|
||||
"""
|
||||
)
|
||||
# need to improve this to get the first/second input
|
||||
# instead of just searching for it in the page
|
||||
inner_html = self.page.content()
|
||||
first_pattern = r'<div id="py-.*?-2">hello 1</div>'
|
||||
assert re.search(first_pattern, inner_html)
|
||||
second_pattern = r'<div id="py-.*?-3">hello 2</div>'
|
||||
assert re.search(second_pattern, inner_html)
|
||||
inner_text = self.page.inner_text("body")
|
||||
lines = inner_text.splitlines()
|
||||
lines = [line for line in lines if line != ""] # remove empty lines
|
||||
assert lines == ["hello 1", "hello 2", "hello 3"]
|
||||
|
||||
assert first_pattern is not second_pattern
|
||||
@pytest.mark.xfail(reason="fix me")
|
||||
def test_output_attribute(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-script output="mydiv">
|
||||
display('hello world')
|
||||
</py-script>
|
||||
<div id="mydiv"></div>
|
||||
"""
|
||||
)
|
||||
mydiv = self.page.locator("#mydiv")
|
||||
assert mydiv.inner_text() == "hello world"
|
||||
|
||||
def test_multiple_display_calls_same_tag(self):
|
||||
self.pyscript_run(
|
||||
@@ -46,11 +58,27 @@ class TestOutuput(PyScriptTest):
|
||||
</py-script>
|
||||
"""
|
||||
)
|
||||
inner_html = self.page.content()
|
||||
pattern = r'<div id="py-.*?-2">hello</div>'
|
||||
assert re.search(pattern, inner_html)
|
||||
pattern = r'<div id="py-.*?-3">world</div>'
|
||||
assert re.search(pattern, inner_html)
|
||||
tag = self.page.locator("py-script")
|
||||
lines = tag.inner_text().splitlines()
|
||||
assert lines == ["hello", "world"]
|
||||
|
||||
def test_implicit_target_from_a_different_tag(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-script id="py1">
|
||||
def say_hello():
|
||||
display('hello')
|
||||
</py-script>
|
||||
|
||||
<py-script id="py2">
|
||||
say_hello()
|
||||
</py-script>
|
||||
"""
|
||||
)
|
||||
py1 = self.page.locator("#py1")
|
||||
py2 = self.page.locator("#py2")
|
||||
assert py1.inner_text() == ""
|
||||
assert py2.inner_text() == "hello"
|
||||
|
||||
def test_no_implicit_target(self):
|
||||
self.pyscript_run(
|
||||
@@ -89,10 +117,10 @@ class TestOutuput(PyScriptTest):
|
||||
<py-script id="second-pyscript-tag">
|
||||
display_hello()
|
||||
</py-script>
|
||||
"""
|
||||
"""
|
||||
)
|
||||
text = self.page.locator("id=second-pyscript-tag-2").inner_text()
|
||||
assert "hello" in text
|
||||
text = self.page.locator("id=second-pyscript-tag").inner_text()
|
||||
assert text == "hello"
|
||||
|
||||
def test_explicit_target_on_button_tag(self):
|
||||
self.pyscript_run(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import re
|
||||
|
||||
from .support import PyScriptTest
|
||||
|
||||
|
||||
@@ -37,22 +35,6 @@ class TestAsync(PyScriptTest):
|
||||
"async tadone",
|
||||
]
|
||||
|
||||
def test_multiple_async_display(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-script id="py1">
|
||||
def say_hello():
|
||||
display('hello')
|
||||
</py-script>
|
||||
<py-script id="py2">
|
||||
say_hello()
|
||||
</py-script>
|
||||
"""
|
||||
)
|
||||
inner_html = self.page.content()
|
||||
pattern = r'<div id="py2-2">hello</div>'
|
||||
assert re.search(pattern, inner_html)
|
||||
|
||||
def test_multiple_async_multiple_display(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
|
||||
@@ -15,17 +15,6 @@ describe('BaseEvalElement', () => {
|
||||
expect(instance).toBeInstanceOf(BaseEvalElement);
|
||||
});
|
||||
|
||||
it('addToOutput sets outputElements property correctly', async () => {
|
||||
instance.outputElement = document.createElement('body');
|
||||
instance.addToOutput('Hello, world!');
|
||||
|
||||
expect(instance.outputElement.innerHTML).toBe('<div>Hello, world!</div>');
|
||||
expect(instance.outputElement.hidden).toBe(false);
|
||||
|
||||
instance.addToOutput('Have a good day!');
|
||||
expect(instance.outputElement.innerHTML).toBe('<div>Hello, world!</div><div>Have a good day!</div>');
|
||||
});
|
||||
|
||||
it('setOutputMode updates appendOutput property correctly', async () => {
|
||||
// Confirm that the default mode is 'append'
|
||||
expect(instance.appendOutput).toBe(true);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { jest } from '@jest/globals';
|
||||
import type { Runtime } from "../../src/runtime"
|
||||
import { FakeRuntime } from "./fakeruntime"
|
||||
import { make_PyButton } from '../../src/components/pybutton';
|
||||
import { ensureUniqueId } from '../../src/utils';
|
||||
|
||||
const runtime: Runtime = new FakeRuntime();
|
||||
const PyButton = make_PyButton(runtime);
|
||||
@@ -25,8 +26,8 @@ describe('PyButton', () => {
|
||||
// id should be similar to py-4850c8c3-d70d-d9e0-03c1-3cfeb0bcec0d-container
|
||||
expect(instanceId).toMatch(/py-(\w+-){1,5}container/);
|
||||
|
||||
// calling checkId directly should return the same id
|
||||
instance.checkId();
|
||||
// ensureUniqueId doesn't change the ID
|
||||
ensureUniqueId(instance);
|
||||
expect(instance.id).toEqual(instanceId);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { jest } from "@jest/globals"
|
||||
import type { Runtime } from "../../src/runtime"
|
||||
import { FakeRuntime } from "./fakeruntime"
|
||||
import { make_PyInputBox } from "../../src/components/pyinputbox"
|
||||
import { ensureUniqueId } from '../../src/utils';
|
||||
|
||||
const runtime: Runtime = new FakeRuntime();
|
||||
const PyInputBox = make_PyInputBox(runtime);
|
||||
@@ -29,8 +30,8 @@ describe("PyInputBox", () => {
|
||||
// id should be similar to py-4850c8c3-d70d-d9e0-03c1-3cfeb0bcec0d-container
|
||||
expect(instanceId).toMatch(/py-(\w+-){1,5}container/);
|
||||
|
||||
// calling checkId directly should return the same id
|
||||
instance.checkId();
|
||||
// ensureUniqueId doesn't change the ID
|
||||
ensureUniqueId(instance);
|
||||
expect(instance.id).toEqual(instanceId);
|
||||
});
|
||||
|
||||
|
||||
@@ -52,17 +52,4 @@ describe('PyRepl', () => {
|
||||
// Confirm that our innerHTML is set as well
|
||||
expect(editorNode).toContain("Hello")
|
||||
})
|
||||
|
||||
it("confirm that addToOutput updates output element", async () => {
|
||||
expect(instance.outputElement).toBe(undefined)
|
||||
|
||||
// This is just to avoid throwing the test since outputElement is undefined
|
||||
instance.outputElement = document.createElement("div")
|
||||
|
||||
instance.addToOutput("Hello, World!")
|
||||
|
||||
expect(instance.outputElement.innerHTML).toBe("<div>Hello, World!</div>")
|
||||
expect(instance.outputElement.hidden).toBe(false)
|
||||
})
|
||||
|
||||
});
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { jest } from "@jest/globals"
|
||||
|
||||
import { PyScript } from "../../src/components/pyscript"
|
||||
|
||||
customElements.define('py-script', PyScript)
|
||||
|
||||
describe('PyScript', () => {
|
||||
let instance: PyScript;
|
||||
|
||||
beforeEach(() => {
|
||||
instance = new PyScript();
|
||||
})
|
||||
|
||||
it('PyScript instantiates correctly', async () => {
|
||||
expect(instance).toBeInstanceOf(PyScript)
|
||||
})
|
||||
|
||||
it('connectedCallback gets or sets a new id', async () => {
|
||||
expect(instance.id).toBe('');
|
||||
|
||||
instance.connectedCallback();
|
||||
const instanceId = instance.id;
|
||||
// id should be similar to py-4850c8c3-d70d-d9e0-03c1-3cfeb0bcec0d-container
|
||||
expect(instanceId).toMatch(/py-(\w+-){1,5}/);
|
||||
|
||||
// calling checkId directly should return the same id
|
||||
instance.checkId();
|
||||
expect(instance.id).toEqual(instanceId);
|
||||
});
|
||||
|
||||
it('connectedCallback creates output div', async () => {
|
||||
instance.connectedCallback();
|
||||
|
||||
expect(instance.innerHTML).toContain('<div class="output">')
|
||||
})
|
||||
|
||||
it('confirm that outputElement has std-out id element', async () => {
|
||||
expect(instance.outputElement).toBe(undefined);
|
||||
|
||||
instance.setAttribute('id', 'std-out')
|
||||
instance.connectedCallback();
|
||||
|
||||
expect(instance.outputElement.getAttribute('id')).toBe("std-out")
|
||||
})
|
||||
|
||||
it('confirm that std-err id element sets errorElement', async () => {
|
||||
expect(instance.outputElement).toBe(undefined);
|
||||
|
||||
instance.setAttribute('id', 'std-err')
|
||||
instance.connectedCallback();
|
||||
|
||||
// We should have an errorElement
|
||||
expect(instance.errorElement.getAttribute('id')).toBe("std-err")
|
||||
})
|
||||
|
||||
it('test output attribute path', async () => {
|
||||
expect(instance.outputElement).toBe(undefined);
|
||||
expect(instance.errorElement).toBe(undefined)
|
||||
|
||||
const createdOutput = document.createElement("output")
|
||||
|
||||
instance.setAttribute('output', 'output')
|
||||
instance.connectedCallback();
|
||||
|
||||
expect(instance.innerHTML).toBe('<div class="output"></div>')
|
||||
})
|
||||
|
||||
it('getSourceFromElement returns decoded html', async () => {
|
||||
instance.innerHTML = "<p>Hello</p>"
|
||||
|
||||
instance.connectedCallback();
|
||||
const source = instance.getSourceFromElement();
|
||||
|
||||
expect(source).toBe("<p>Hello</p>")
|
||||
})
|
||||
})
|
||||
@@ -25,16 +25,13 @@ describe("PyTitle", () => {
|
||||
})
|
||||
|
||||
it("label renders correctly on the page and updates id", async () => {
|
||||
instance.innerHTML = "Hello, world!"
|
||||
// We need this to test mount_name works properly since connectedCallback
|
||||
// doesn't automatically call checkId (should it?)
|
||||
instance.checkId();
|
||||
instance.innerHTML = "Hello, world!";
|
||||
instance.id = "my-fancy-title";
|
||||
|
||||
instance.connectedCallback();
|
||||
|
||||
expect(instance.label).toBe("Hello, world!")
|
||||
// mount_name should be similar to: py_be025f4c_2150_7f2a_1a85_af92970c2a0e
|
||||
expect(instance.mount_name).toMatch(/py_(\w+_){1,5}/);
|
||||
expect(instance.mount_name).toMatch("my_fancy_title");
|
||||
expect(instance.innerHTML).toContain("<h1>Hello, world!</h1>")
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user