mirror of
https://github.com/pyscript/pyscript.git
synced 2025-12-20 10:47:35 -05:00
Make the life-cycle more linear and kill some svelte stores (#830)
This is a follow-up of PR #806 and it's a big step forward towards solving issue #763. The basic idea is that at this stage I want to streamline the execution logic and make it as linear and easy to read/understand as possible. The diff is relatively big, but for the most part is just "shuffling code around". Svelte stores: the idea is to eventually kill of them, so that we can remove the dependency on svelte but also to avoid relying on so much global state, which makes things more complicated and error-prone (e.g., we have several issues related to using runtime when it's not ready yet). I killed addInitializer, addPostInitializer and the corresponding svelte stores tada. They are no longer needed since the relevant code is called directly from main.ts. I started to kill the usage of the runtimeLoaded svelte store: instead of relying on a global variable, I want to arrive at the point in which the runtime is passed as a parameter in all places where it's needed: pyscript.ts is now free of global state, but I couldn't kill it yet because it's used heavily by base.ts and pyrepl.ts. I will do it in another PR. Other misc changes: I added sanity checks (and corresponding tests!) which complain if you specify 0 or multiple runtimes. Currently we support having one and only one, so there is no point to pretend otherwise I modified the messages displayed by the loader, to be more informative from the user point of view.
This commit is contained in:
@@ -1,10 +1,5 @@
|
||||
import {
|
||||
addInitializer,
|
||||
addPostInitializer,
|
||||
addToScriptsQueue,
|
||||
loadedEnvironments,
|
||||
runtimeLoaded,
|
||||
type Environment,
|
||||
} from '../stores';
|
||||
|
||||
import { addClasses, htmlDecode } from '../utils';
|
||||
@@ -14,17 +9,6 @@ import { getLogger } from '../logger';
|
||||
|
||||
const logger = getLogger('py-script');
|
||||
|
||||
// Premise used to connect to the first available runtime (can be pyodide or others)
|
||||
let runtime: Runtime;
|
||||
let environments: Record<Environment['id'], Environment> = {};
|
||||
|
||||
runtimeLoaded.subscribe(value => {
|
||||
runtime = value;
|
||||
});
|
||||
loadedEnvironments.subscribe(value => {
|
||||
environments = value;
|
||||
});
|
||||
|
||||
export class PyScript extends BaseEvalElement {
|
||||
constructor() {
|
||||
super();
|
||||
@@ -211,7 +195,7 @@ const pyAttributeToEvent: Map<string, string> = new Map<string, string>([
|
||||
]);
|
||||
|
||||
/** Initialize all elements with py-* handlers attributes */
|
||||
async function initHandlers() {
|
||||
export async function initHandlers(runtime: Runtime) {
|
||||
logger.debug('Initializing py-* event handlers...');
|
||||
for (const pyAttribute of pyAttributeToEvent.keys()) {
|
||||
await createElementsWithEventListeners(runtime, pyAttribute);
|
||||
@@ -259,7 +243,7 @@ async function createElementsWithEventListeners(runtime: Runtime, pyAttribute: s
|
||||
}
|
||||
|
||||
/** Mount all elements with attribute py-mount into the Python namespace */
|
||||
async function mountElements() {
|
||||
export async function mountElements(runtime: Runtime) {
|
||||
const matches: NodeListOf<HTMLElement> = document.querySelectorAll('[py-mount]');
|
||||
logger.info(`py-mount: found ${matches.length} elements`);
|
||||
|
||||
@@ -270,5 +254,3 @@ async function mountElements() {
|
||||
}
|
||||
await runtime.run(source);
|
||||
}
|
||||
addInitializer(mountElements);
|
||||
addPostInitializer(initHandlers);
|
||||
|
||||
@@ -3,42 +3,73 @@ import './styles/pyscript_base.css';
|
||||
import { loadConfigFromElement } from './pyconfig';
|
||||
import type { AppConfig } from './pyconfig';
|
||||
import type { Runtime } from './runtime';
|
||||
import { PyScript } from './components/pyscript';
|
||||
import { PyScript, initHandlers, mountElements } from './components/pyscript';
|
||||
import { PyLoader } from './components/pyloader';
|
||||
import { PyodideRuntime } from './pyodide';
|
||||
import { getLogger } from './logger';
|
||||
import { globalLoader, runtimeLoaded, addInitializer } from './stores';
|
||||
import {
|
||||
runtimeLoaded,
|
||||
scriptsQueue,
|
||||
} from './stores';
|
||||
import { handleFetchError, showError, globalExport } from './utils'
|
||||
import { createCustomElements } from './components/elements';
|
||||
|
||||
|
||||
const logger = getLogger('pyscript/main');
|
||||
|
||||
// XXX this should be killed eventually
|
||||
let runtimeSpec: Runtime;
|
||||
runtimeLoaded.subscribe(value => {
|
||||
runtimeSpec = value;
|
||||
let scriptsQueue_: PyScript[];
|
||||
scriptsQueue.subscribe((value: PyScript[]) => {
|
||||
scriptsQueue_ = value;
|
||||
});
|
||||
|
||||
|
||||
|
||||
/* High-level overview of the lifecycle of a PyScript App:
|
||||
|
||||
1. pyscript.js is loaded by the browser. PyScriptApp().main() is called
|
||||
|
||||
2. loadConfig(): search for py-config and compute the config for the app
|
||||
|
||||
3. show the loader/splashscreen
|
||||
|
||||
4. loadRuntime(): start downloading the actual runtime (e.g. pyodide.js)
|
||||
|
||||
--- wait until (4) has finished ---
|
||||
|
||||
5. now the pyodide src is available. Initialize the engine
|
||||
|
||||
6. setup the environment, install packages
|
||||
|
||||
7. run user scripts
|
||||
|
||||
8. initialize the rest of web components such as py-button, py-repl, etc.
|
||||
|
||||
More concretely:
|
||||
|
||||
- Points 1-4 are implemented sequentially in PyScriptApp.main().
|
||||
|
||||
- PyScriptApp.loadRuntime adds a <script> tag to the document to initiate
|
||||
the download, and then adds an event listener for the 'load' event, which
|
||||
in turns calls PyScriptApp.afterRuntimeLoad().
|
||||
|
||||
- PyScriptApp.afterRuntimeLoad() implements all the points >= 5.
|
||||
*/
|
||||
|
||||
|
||||
class PyScriptApp {
|
||||
|
||||
config: AppConfig;
|
||||
loader: PyLoader;
|
||||
|
||||
// lifecycle (1)
|
||||
main() {
|
||||
this.loadConfig();
|
||||
this.initialize();
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
const xPyScript = customElements.define('py-script', PyScript);
|
||||
const xPyLoader = customElements.define('py-loader', PyLoader);
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
// add loader to the page body
|
||||
logger.info('add py-loader');
|
||||
const loader = <PyLoader>document.createElement('py-loader');
|
||||
document.body.append(loader);
|
||||
globalLoader.set(loader);
|
||||
customElements.define('py-script', PyScript);
|
||||
this.showLoader();
|
||||
this.loadRuntime();
|
||||
}
|
||||
|
||||
// lifecycle (2)
|
||||
loadConfig() {
|
||||
// find the <py-config> tag. If not found, we get null which means
|
||||
// "use the default config"
|
||||
@@ -61,46 +92,117 @@ class PyScriptApp {
|
||||
logger.info('config loaded:\n' + JSON.stringify(this.config, null, 2));
|
||||
}
|
||||
|
||||
initialize() {
|
||||
addInitializer(this.loadPackages);
|
||||
addInitializer(this.loadPaths);
|
||||
this.loadRuntimes();
|
||||
// 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);
|
||||
}
|
||||
|
||||
loadPackages = async () => {
|
||||
// lifecycle (4)
|
||||
loadRuntime() {
|
||||
logger.info('Initializing runtime');
|
||||
if (this.config.runtimes.length == 0) {
|
||||
showError("Fatal error: config.runtimes is empty");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.config.runtimes.length > 1) {
|
||||
showError("Multiple runtimes are not supported yet. " +
|
||||
"Only the first will be used");
|
||||
}
|
||||
const runtime_cfg = this.config.runtimes[0];
|
||||
const runtime: Runtime = new PyodideRuntime(this.config, runtime_cfg.src,
|
||||
runtime_cfg.name, runtime_cfg.lang);
|
||||
this.loader.log(`Downloading ${runtime_cfg.name}...`);
|
||||
const script = document.createElement('script'); // create a script DOM node
|
||||
script.src = runtime.src;
|
||||
script.addEventListener('load', () => {
|
||||
void this.afterRuntimeLoad(runtime);
|
||||
});
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
// lifecycle (5)
|
||||
// See the overview comment above for an explanation of how we jump from
|
||||
// point (4) to point (5).
|
||||
//
|
||||
// Invariant: this.config 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);
|
||||
|
||||
this.loader.log('Python startup...');
|
||||
await runtime.loadInterpreter();
|
||||
runtimeLoaded.set(runtime);
|
||||
this.loader.log('Python ready!');
|
||||
|
||||
// eslint-disable-next-line
|
||||
runtime.globals.set('pyscript_loader', this.loader);
|
||||
|
||||
this.loader.log('Setting up virtual environment...');
|
||||
await this.setupVirtualEnv(runtime);
|
||||
await mountElements(runtime);
|
||||
|
||||
this.loader.log('Executing <py-script> tags...');
|
||||
this.executeScripts(runtime);
|
||||
|
||||
this.loader.log('Initializing web components...');
|
||||
// lifecycle (8)
|
||||
createCustomElements();
|
||||
|
||||
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
|
||||
logger.info('PyScript page fully initialized');
|
||||
}
|
||||
|
||||
|
||||
// lifecycle (6)
|
||||
async setupVirtualEnv(runtime: Runtime): Promise<void> {
|
||||
// XXX: maybe the following calls could be parallelized, instead of
|
||||
// await()ing immediately. For now I'm using await to be 100%
|
||||
// compatible with the old behavior.
|
||||
logger.info("Packages to install: ", this.config.packages);
|
||||
await runtimeSpec.installPackage(this.config.packages);
|
||||
await runtime.installPackage(this.config.packages);
|
||||
await this.fetchPaths(runtime);
|
||||
}
|
||||
|
||||
loadPaths = async () => {
|
||||
async fetchPaths(runtime: Runtime) {
|
||||
// XXX this can be VASTLY improved: for each path we need to fetch a
|
||||
// URL and write to the virtual filesystem: pyodide.loadFromFile does
|
||||
// it in Python, which means we need to have the runtime
|
||||
// initialized. But we could easily do it in JS in parallel with the
|
||||
// download/startup of pyodide.
|
||||
const paths = this.config.paths;
|
||||
logger.info("Paths to load: ", paths)
|
||||
logger.info("Paths to fetch: ", paths)
|
||||
for (const singleFile of paths) {
|
||||
logger.info(` loading path: ${singleFile}`);
|
||||
logger.info(` fetching path: ${singleFile}`);
|
||||
try {
|
||||
await runtimeSpec.loadFromFile(singleFile);
|
||||
await runtime.loadFromFile(singleFile);
|
||||
} catch (e) {
|
||||
//Should we still export full error contents to console?
|
||||
handleFetchError(<Error>e, singleFile);
|
||||
}
|
||||
}
|
||||
logger.info("All paths loaded");
|
||||
logger.info("All paths fetched");
|
||||
}
|
||||
|
||||
loadRuntimes() {
|
||||
logger.info('Initializing runtimes');
|
||||
for (const runtime of this.config.runtimes) {
|
||||
const runtimeObj: Runtime = new PyodideRuntime(this.config, runtime.src,
|
||||
runtime.name, runtime.lang);
|
||||
const script = document.createElement('script'); // create a script DOM node
|
||||
script.src = runtimeObj.src; // set its src to the provided URL
|
||||
script.addEventListener('load', () => {
|
||||
void runtimeObj.initialize();
|
||||
});
|
||||
document.head.appendChild(script);
|
||||
// lifecycle (7)
|
||||
executeScripts(runtime: Runtime) {
|
||||
for (const script of scriptsQueue_) {
|
||||
void script.evaluate();
|
||||
}
|
||||
scriptsQueue.set([]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function pyscript_get_config() {
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
import type { AppConfig } from './pyconfig';
|
||||
import type { PyodideInterface } from 'pyodide';
|
||||
import type { PyLoader } from './components/pyloader';
|
||||
import {
|
||||
runtimeLoaded,
|
||||
loadedEnvironments,
|
||||
globalLoader,
|
||||
initializers,
|
||||
postInitializers,
|
||||
Initializer,
|
||||
scriptsQueue,
|
||||
} from './stores';
|
||||
import { createCustomElements } from './components/elements';
|
||||
import type { PyScript } from './components/pyscript';
|
||||
import { getLogger } from './logger';
|
||||
|
||||
const logger = getLogger('pyscript/runtime');
|
||||
@@ -20,26 +8,6 @@ export const version = "<<VERSION>>";
|
||||
export type RuntimeInterpreter = PyodideInterface | null;
|
||||
|
||||
|
||||
let loader: PyLoader | undefined;
|
||||
globalLoader.subscribe(value => {
|
||||
loader = value;
|
||||
});
|
||||
|
||||
let initializers_: Initializer[];
|
||||
initializers.subscribe((value: Initializer[]) => {
|
||||
initializers_ = value;
|
||||
});
|
||||
|
||||
let postInitializers_: Initializer[];
|
||||
postInitializers.subscribe((value: Initializer[]) => {
|
||||
postInitializers_ = value;
|
||||
});
|
||||
|
||||
let scriptsQueue_: PyScript[];
|
||||
scriptsQueue.subscribe((value: PyScript[]) => {
|
||||
scriptsQueue_ = value;
|
||||
});
|
||||
|
||||
|
||||
/*
|
||||
Runtime class is a super class that all different runtimes must respect
|
||||
@@ -113,60 +81,4 @@ export abstract class Runtime extends Object {
|
||||
* underlying interpreter.
|
||||
* */
|
||||
abstract loadFromFile(path: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* initializes the page which involves loading of runtime,
|
||||
* as well as evaluating all the code inside <py-script> tags
|
||||
* along with initializers and postInitializers
|
||||
* */
|
||||
async initialize(): Promise<void> {
|
||||
loader?.log('Loading runtime...');
|
||||
await this.loadInterpreter();
|
||||
const newEnv = {
|
||||
id: 'default',
|
||||
runtime: this,
|
||||
state: 'loading',
|
||||
};
|
||||
runtimeLoaded.set(this);
|
||||
|
||||
// Inject the loader into the runtime namespace
|
||||
// eslint-disable-next-line
|
||||
this.globals.set('pyscript_loader', loader);
|
||||
|
||||
loader?.log('Runtime created...');
|
||||
loadedEnvironments.update(environments => ({
|
||||
...environments,
|
||||
[newEnv['id']]: newEnv,
|
||||
}));
|
||||
|
||||
// now we call all initializers before we actually executed all page scripts
|
||||
loader?.log('Initializing components...');
|
||||
for (const initializer of initializers_) {
|
||||
await initializer();
|
||||
}
|
||||
|
||||
loader?.log('Initializing scripts...');
|
||||
for (const script of scriptsQueue_) {
|
||||
void script.evaluate();
|
||||
}
|
||||
scriptsQueue.set([]);
|
||||
|
||||
// now we call all post initializers AFTER we actually executed all page scripts
|
||||
loader?.log('Running post initializers...');
|
||||
|
||||
// Finally create the custom elements for pyscript such as pybutton
|
||||
createCustomElements();
|
||||
|
||||
if (this.config.autoclose_loader) {
|
||||
loader?.close();
|
||||
}
|
||||
|
||||
for (const initializer of postInitializers_) {
|
||||
await initializer();
|
||||
}
|
||||
// NOTE: this 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
|
||||
logger.info('PyScript page fully initialized');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,40 +5,20 @@ import type { Runtime } from './runtime';
|
||||
import type { AppConfig } from './pyconfig';
|
||||
import { getLogger } from './logger';
|
||||
|
||||
export type Initializer = () => Promise<void>;
|
||||
|
||||
export type Environment = {
|
||||
id: string;
|
||||
runtime: Runtime;
|
||||
state: string;
|
||||
};
|
||||
|
||||
/*
|
||||
A store for Runtime which can encompass any
|
||||
runtime, but currently only has Pyodide as its offering.
|
||||
*/
|
||||
export const runtimeLoaded = writable<Runtime>();
|
||||
|
||||
export const loadedEnvironments = writable<Record<Environment['id'], Environment>>({});
|
||||
|
||||
export const navBarOpen = writable(false);
|
||||
export const componentsNavOpen = writable(false);
|
||||
export const componentDetailsNavOpen = writable(false);
|
||||
export const mainDiv = writable(null);
|
||||
export const currentComponentDetails = writable([]);
|
||||
export const scriptsQueue = writable<PyScript[]>([]);
|
||||
export const initializers = writable<Initializer[]>([]);
|
||||
export const postInitializers = writable<Initializer[]>([]);
|
||||
export const globalLoader = writable<PyLoader | undefined>();
|
||||
|
||||
export const addToScriptsQueue = (script: PyScript) => {
|
||||
scriptsQueue.update(scriptsQueue => [...scriptsQueue, script]);
|
||||
};
|
||||
|
||||
export const addInitializer = (initializer: Initializer) => {
|
||||
initializers.update(initializers => [...initializers, initializer]);
|
||||
};
|
||||
|
||||
export const addPostInitializer = (initializer: Initializer) => {
|
||||
postInitializers.update(postInitializers => [...postInitializers, initializer]);
|
||||
};
|
||||
|
||||
@@ -187,7 +187,7 @@ class PyScriptTest:
|
||||
"""
|
||||
# this is printed by runtime.ts:Runtime.initialize
|
||||
self.wait_for_console(
|
||||
"[pyscript/runtime] PyScript page fully initialized",
|
||||
"[pyscript/main] PyScript page fully initialized",
|
||||
timeout=timeout,
|
||||
check_errors=check_errors,
|
||||
)
|
||||
@@ -195,7 +195,7 @@ class PyScriptTest:
|
||||
# events aren't being triggered in the tests.
|
||||
self.page.wait_for_timeout(100)
|
||||
|
||||
def pyscript_run(self, snippet, *, extra_head=""):
|
||||
def pyscript_run(self, snippet, *, extra_head="", wait_for_pyscript=True):
|
||||
"""
|
||||
Main entry point for pyscript tests.
|
||||
|
||||
@@ -224,6 +224,7 @@ class PyScriptTest:
|
||||
filename = f"{self.testname}.html"
|
||||
self.writefile(filename, doc)
|
||||
self.goto(filename)
|
||||
if wait_for_pyscript:
|
||||
self.wait_for_pyscript()
|
||||
|
||||
|
||||
|
||||
@@ -153,3 +153,47 @@ class TestConfig(PyScriptTest):
|
||||
"is going to be parsed, all the others will be ignored"
|
||||
)
|
||||
assert div.text_content() == expected
|
||||
|
||||
def test_no_runtimes(self):
|
||||
snippet = """
|
||||
<py-config type="json">
|
||||
{
|
||||
"runtimes": []
|
||||
}
|
||||
</py-config>
|
||||
"""
|
||||
self.pyscript_run(snippet, wait_for_pyscript=False)
|
||||
div = self.page.wait_for_selector(".py-error")
|
||||
assert div.text_content() == "Fatal error: config.runtimes is empty"
|
||||
|
||||
def test_multiple_runtimes(self):
|
||||
snippet = """
|
||||
<py-config type="json">
|
||||
{
|
||||
"runtimes": [
|
||||
{
|
||||
"src": "https://cdn.jsdelivr.net/pyodide/v0.21.2/full/pyodide.js",
|
||||
"name": "pyodide-0.21.2",
|
||||
"lang": "python"
|
||||
},
|
||||
{
|
||||
"src": "http://...",
|
||||
"name": "this will be ignored",
|
||||
"lang": "this as well"
|
||||
}
|
||||
]
|
||||
}
|
||||
</py-config>
|
||||
|
||||
<py-script>
|
||||
import js
|
||||
js.console.log("hello world");
|
||||
</py-script>
|
||||
"""
|
||||
self.pyscript_run(snippet)
|
||||
div = self.page.wait_for_selector(".py-error")
|
||||
expected = (
|
||||
"Multiple runtimes are not supported yet. Only the first will be used"
|
||||
)
|
||||
assert div.text_content() == expected
|
||||
assert self.console.log.lines[-1] == "hello world"
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('PyodideRuntime', () => {
|
||||
*/
|
||||
const pyodideSpec = await import('pyodide');
|
||||
global.loadPyodide = pyodideSpec.loadPyodide;
|
||||
await runtime.initialize();
|
||||
await runtime.loadInterpreter();
|
||||
});
|
||||
|
||||
it('should check if runtime is an instance of abstract Runtime', async () => {
|
||||
|
||||
Reference in New Issue
Block a user