Refactor py-config and the general initialization logic of the page (#806)

This PR is the first step to improve and rationalize the life-cycle of a pyscript app along the lines of what I described in #763 .
It is not a complete solution, more PRs will follow.
Highlights:

- py-config is no longer a web component: the old code relied on PyConfig.connectedCallback to do some logic, but then if no <py-config> tag was present, we had to introduce a dummy one with the sole goal of activating the callback. Now the logic is much more linear.

- the new pyconfig.ts only contains the code which is needed to parse the config; I also moved some relevant code from utils.ts because it didn't really belong to it

- the old PyConfig class did much more than dealing with the config: in particular, it contained the code to initialize the env and the runtime. Now this logic has been moved directly into main.ts, inside the new PyScriptApp class. I plan to refactor the initialization code in further PRs

- the current code relies too much on global state and global variables, they are everywhere. This PR is a first step to solve the problem by introducing a PyScriptApp class, which will hold all the mutable state of the page. Currently only config is stored there, but eventually I will migrate more state to it, until we will have only one global singleton, globalApp

- thanks to what I described above, I could kill the appConfig svelte store: one less store to kill :).
This commit is contained in:
Antonio Cuni
2022-10-04 14:26:12 +02:00
committed by GitHub
parent 4011a51013
commit c75f885cb4
12 changed files with 526 additions and 463 deletions

View File

@@ -1,130 +0,0 @@
import { BaseEvalElement } from './base';
import { appConfig, addInitializer, runtimeLoaded } from '../stores';
import type { AppConfig, Runtime } from '../runtime';
import { version } from '../runtime';
import { PyodideRuntime } from '../pyodide';
import { getLogger } from '../logger';
import { readTextFromPath, handleFetchError, mergeConfig, validateConfig, defaultConfig } from '../utils'
// Subscriber used to connect to the first available runtime (can be pyodide or others)
let runtimeSpec: Runtime;
runtimeLoaded.subscribe(value => {
runtimeSpec = value;
});
let appConfig_: AppConfig;
appConfig.subscribe(value => {
appConfig_ = value;
});
const logger = getLogger('py-config');
/**
* Configures general metadata about the PyScript application such
* as a list of runtimes, name, version, closing the loader
* automatically, etc.
*
* Also initializes the different runtimes passed. If no runtime is passed,
* the default runtime based on Pyodide is used.
*/
export class PyConfig extends BaseEvalElement {
widths: Array<string>;
label: string;
mount_name: string;
details: HTMLElement;
operation: HTMLElement;
values: AppConfig;
constructor() {
super();
}
extractFromSrc(configType: string) {
if (this.hasAttribute('src'))
{
logger.info('config set from src attribute');
return validateConfig(readTextFromPath(this.getAttribute('src')), configType);
}
return {};
}
extractFromInline(configType: string) {
if (this.innerHTML!=='')
{
this.code = this.innerHTML;
this.innerHTML = '';
logger.info('config set from inline');
return validateConfig(this.code, configType);
}
return {};
}
injectMetadata() {
this.values.pyscript = {
"version": version,
"time": new Date().toISOString()
};
}
connectedCallback() {
const configType: string = this.hasAttribute("type") ? this.getAttribute("type") : "toml";
let srcConfig = this.extractFromSrc(configType);
const inlineConfig = this.extractFromInline(configType);
// first make config from src whole if it is partial
srcConfig = mergeConfig(srcConfig, defaultConfig);
// then merge inline config and config from src
this.values = mergeConfig(inlineConfig, srcConfig);
this.injectMetadata();
appConfig.set(this.values);
logger.info('config set:', this.values);
addInitializer(this.loadPackages);
addInitializer(this.loadPaths);
this.loadRuntimes();
}
log(msg: string) {
const newLog = document.createElement('p');
newLog.innerText = msg;
this.details.appendChild(newLog);
}
close() {
this.remove();
}
loadPackages = async () => {
const env = appConfig_.packages;
logger.info("Loading env: ", env);
await runtimeSpec.installPackage(env);
}
loadPaths = async () => {
const paths = appConfig_.paths;
logger.info("Paths to load: ", paths)
for (const singleFile of paths) {
logger.info(` loading path: ${singleFile}`);
try {
await runtimeSpec.loadFromFile(singleFile);
} catch (e) {
//Should we still export full error contents to console?
handleFetchError(<Error>e, singleFile);
}
}
logger.info("All paths loaded");
}
loadRuntimes() {
logger.info('Initializing runtimes');
for (const runtime of this.values.runtimes) {
const runtimeObj: Runtime = new PyodideRuntime(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);
}
}
}

View File

@@ -1,31 +1,104 @@
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 { PyEnv } from './components/pyenv';
import { PyLoader } from './components/pyloader';
import { PyConfig } from './components/pyconfig';
import { PyodideRuntime } from './pyodide';
import { getLogger } from './logger';
import { globalLoader } from './stores';
import { globalLoader, runtimeLoaded, addInitializer } from './stores';
import { handleFetchError, globalExport } from './utils'
const logger = getLogger('pyscript/main');
/* eslint-disable @typescript-eslint/no-unused-vars */
const xPyScript = customElements.define('py-script', PyScript);
const xPyLoader = customElements.define('py-loader', PyLoader);
const xPyConfig = customElements.define('py-config', PyConfig);
const xPyEnv = customElements.define('py-env', PyEnv);
/* eslint-disable @typescript-eslint/no-unused-vars */
// XXX this should be killed eventually
let runtimeSpec: Runtime;
runtimeLoaded.subscribe(value => {
runtimeSpec = value;
});
class PyScriptApp {
config: AppConfig;
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);
const xPyEnv = customElements.define('py-env', PyEnv);
/* 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);
}
loadConfig() {
// find the <py-config> tag. If not found, we get null which means
// "use the default config"
// XXX: we should actively complain if there are multiple <py-config>
// and show a big error. PRs welcome :)
logger.info('searching for <py-config>');
const el = document.querySelector('py-config');
this.config = loadConfigFromElement(el);
logger.info('config loaded:\n' + JSON.stringify(this.config, null, 2));
}
initialize() {
addInitializer(this.loadPackages);
addInitializer(this.loadPaths);
this.loadRuntimes();
}
loadPackages = async () => {
logger.info("Packages to install: ", this.config.packages);
await runtimeSpec.installPackage(this.config.packages);
}
loadPaths = async () => {
const paths = this.config.paths;
logger.info("Paths to load: ", paths)
for (const singleFile of paths) {
logger.info(` loading path: ${singleFile}`);
try {
await runtimeSpec.loadFromFile(singleFile);
} catch (e) {
//Should we still export full error contents to console?
handleFetchError(<Error>e, singleFile);
}
}
logger.info("All paths loaded");
}
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);
}
}
// As first thing, loop for application configs
logger.info('checking for py-config');
const config: PyConfig = document.querySelector('py-config');
if (!config) {
const loader = document.createElement('py-config');
document.body.append(loader);
}
// 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);
function pyscript_get_config() {
return globalApp.config;
}
globalExport('pyscript_get_config', pyscript_get_config);
// main entry point of execution
const globalApp = new PyScriptApp();
globalApp.main();

232
pyscriptjs/src/pyconfig.ts Normal file
View File

@@ -0,0 +1,232 @@
import toml from '../src/toml'
import { getLogger } from './logger';
import { version } from './runtime';
import { readTextFromPath, showError } from './utils'
const logger = getLogger('py-config');
export interface AppConfig extends Record<string, any> {
name?: string;
description?: string;
version?: string;
schema_version?: number;
type?: string;
author_name?: string;
author_email?: string;
license?: string;
autoclose_loader?: boolean;
runtimes?: Array<RuntimeConfig>;
packages?: Array<string>;
paths?: Array<string>;
plugins?: Array<string>;
pyscript?: PyScriptMetadata;
}
export type RuntimeConfig = {
src?: string;
name?: string;
lang?: string;
};
export type PyScriptMetadata = {
version?: string;
time?: string;
}
const allKeys = {
"string": ["name", "description", "version", "type", "author_name", "author_email", "license"],
"number": ["schema_version"],
"boolean": ["autoclose_loader"],
"array": ["runtimes", "packages", "paths", "plugins"]
};
export const defaultConfig: AppConfig = {
"schema_version": 1,
"type": "app",
"autoclose_loader": true,
"runtimes": [{
"src": "https://cdn.jsdelivr.net/pyodide/v0.21.2/full/pyodide.js",
"name": "pyodide-0.21.2",
"lang": "python"
}],
"packages":[],
"paths":[],
"plugins": []
}
export function loadConfigFromElement(el: Element): AppConfig {
let srcConfig;
let inlineConfig;
if (el === null) {
srcConfig = {};
inlineConfig = {};
}
else {
const configType: string = el.hasAttribute("type") ? el.getAttribute("type") : "toml";
srcConfig = extractFromSrc(el, configType);
inlineConfig = extractFromInline(el, configType);
}
srcConfig = mergeConfig(srcConfig, defaultConfig);
const result = mergeConfig(inlineConfig, srcConfig);
result.pyscript = {
"version": version,
"time": new Date().toISOString()
};
return result;
}
function extractFromSrc(el: Element, configType: string) {
if (el.hasAttribute('src'))
{
const src = el.getAttribute('src');
logger.info('loading ', src)
return validateConfig(readTextFromPath(src), configType);
}
return {};
}
function extractFromInline(el: Element, configType: string) {
if (el.innerHTML !== '')
{
logger.info('loading <py-config> content');
return validateConfig(el.innerHTML, configType);
}
return {};
}
function fillUserData(inputConfig: AppConfig, resultConfig: AppConfig): AppConfig
{
for (const key in inputConfig)
{
// fill in all extra keys ignored by the validator
if (!(key in defaultConfig))
{
resultConfig[key] = inputConfig[key];
}
}
return resultConfig;
}
function mergeConfig(inlineConfig: AppConfig, externalConfig: AppConfig): AppConfig {
if (Object.keys(inlineConfig).length === 0 && Object.keys(externalConfig).length === 0)
{
return defaultConfig;
}
else if (Object.keys(inlineConfig).length === 0)
{
return externalConfig;
}
else if(Object.keys(externalConfig).length === 0)
{
return inlineConfig;
}
else
{
let merged: AppConfig = {};
for (const keyType in allKeys)
{
const keys = allKeys[keyType];
keys.forEach(function(item: string){
if (keyType === "boolean")
{
merged[item] = (typeof inlineConfig[item] !== "undefined") ? inlineConfig[item] : externalConfig[item];
}
else
{
merged[item] = inlineConfig[item] || externalConfig[item];
}
});
}
// fill extra keys from external first
// they will be overridden by inline if extra keys also clash
merged = fillUserData(externalConfig, merged);
merged = fillUserData(inlineConfig, merged);
return merged;
}
}
function parseConfig(configText: string, configType = "toml") {
let config: object;
if (configType === "toml") {
try {
// TOML parser is soft and can parse even JSON strings, this additional check prevents it.
if (configText.trim()[0] === "{")
{
const errMessage = `config supplied: ${configText} is an invalid TOML and cannot be parsed`;
showError(`<p>${errMessage}</p>`);
throw Error(errMessage);
}
config = toml.parse(configText);
}
catch (err) {
const errMessage: string = err.toString();
showError(`<p>config supplied: ${configText} is an invalid TOML and cannot be parsed: ${errMessage}</p>`);
throw err;
}
}
else if (configType === "json") {
try {
config = JSON.parse(configText);
}
catch (err) {
const errMessage: string = err.toString();
showError(`<p>config supplied: ${configText} is an invalid JSON and cannot be parsed: ${errMessage}</p>`);
throw err;
}
}
else {
showError(`<p>type of config supplied is: ${configType}, supported values are ["toml", "json"].</p>`);
}
return config;
}
function validateConfig(configText: string, configType = "toml") {
const config = parseConfig(configText, configType);
const finalConfig: AppConfig = {}
for (const keyType in allKeys)
{
const keys = allKeys[keyType];
keys.forEach(function(item: string){
if (validateParamInConfig(item, keyType, config))
{
if (item === "runtimes")
{
finalConfig[item] = [];
const runtimes = config[item];
runtimes.forEach(function(eachRuntime: object){
const runtimeConfig: object = {};
for (const eachRuntimeParam in eachRuntime)
{
if (validateParamInConfig(eachRuntimeParam, "string", eachRuntime))
{
runtimeConfig[eachRuntimeParam] = eachRuntime[eachRuntimeParam];
}
}
finalConfig[item].push(runtimeConfig);
});
}
else
{
finalConfig[item] = config[item];
}
}
});
}
return fillUserData(config, finalConfig);
}
function validateParamInConfig(paramName: string, paramType: string, config: object): boolean {
if (paramName in config)
{
return paramType === "array" ? Array.isArray(config[paramName]) : typeof config[paramName] === paramType;
}
return false;
}

View File

@@ -1,4 +1,4 @@
import { Runtime, RuntimeConfig } from './runtime';
import { Runtime } from './runtime';
import { getLastPath } from './utils';
import { getLogger } from './logger';
import type { PyodideInterface } from 'pyodide';
@@ -16,12 +16,13 @@ export class PyodideRuntime extends Runtime {
globals: any;
constructor(
config,
src = 'https://cdn.jsdelivr.net/pyodide/v0.21.2/full/pyodide.js',
name = 'pyodide-default',
lang = 'python',
) {
logger.info('Runtime config:', { name, lang, src });
super();
super(config);
this.src = src;
this.name = name;
this.lang = lang;

View File

@@ -1,3 +1,4 @@
import type { AppConfig } from './pyconfig';
import type { PyodideInterface } from 'pyodide';
import type { PyLoader } from './components/pyloader';
import {
@@ -8,7 +9,6 @@ import {
postInitializers,
Initializer,
scriptsQueue,
appConfig,
} from './stores';
import { createCustomElements } from './components/elements';
import type { PyScript } from './components/pyscript';
@@ -19,33 +19,6 @@ const logger = getLogger('pyscript/runtime');
export const version = "<<VERSION>>";
export type RuntimeInterpreter = PyodideInterface | null;
export interface AppConfig extends Record<string, any> {
name?: string;
description?: string;
version?: string;
schema_version?: number;
type?: string;
author_name?: string;
author_email?: string;
license?: string;
autoclose_loader?: boolean;
runtimes?: Array<RuntimeConfig>;
packages?: Array<string>;
paths?: Array<string>;
plugins?: Array<string>;
pyscript?: PyScriptMetadata;
}
export type PyScriptMetadata = {
version?: string;
time?: string;
}
export type RuntimeConfig = {
src?: string;
name?: string;
lang?: string;
};
let loader: PyLoader | undefined;
globalLoader.subscribe(value => {
@@ -67,15 +40,6 @@ scriptsQueue.subscribe((value: PyScript[]) => {
scriptsQueue_ = value;
});
let appConfig_: AppConfig = {
autoclose_loader: true
};
appConfig.subscribe((value: AppConfig) => {
if (value) {
appConfig_ = value;
}
});
/*
Runtime class is a super class that all different runtimes must respect
@@ -95,6 +59,7 @@ For an example implementation, refer to the `PyodideRuntime` class
in `pyodide.ts`
*/
export abstract class Runtime extends Object {
config: AppConfig;
abstract src: string;
abstract name?: string;
abstract lang?: string;
@@ -104,6 +69,11 @@ export abstract class Runtime extends Object {
* */
abstract globals: any;
constructor(config: AppConfig) {
super();
this.config = config;
}
/**
* loads the interpreter for the runtime and saves an instance of it
* in the `this.interpreter` property along with calling of other
@@ -187,7 +157,7 @@ export abstract class Runtime extends Object {
// Finally create the custom elements for pyscript such as pybutton
createCustomElements();
if (appConfig_ && appConfig_.autoclose_loader) {
if (this.config.autoclose_loader) {
loader?.close();
}

View File

@@ -1,7 +1,8 @@
import { writable } from 'svelte/store';
import type { PyLoader } from './components/pyloader';
import type { PyScript } from './components/pyscript';
import type { Runtime, AppConfig } from './runtime';
import type { Runtime } from './runtime';
import type { AppConfig } from './pyconfig';
import { getLogger } from './logger';
export type Initializer = () => Promise<void>;
@@ -29,7 +30,6 @@ export const scriptsQueue = writable<PyScript[]>([]);
export const initializers = writable<Initializer[]>([]);
export const postInitializers = writable<Initializer[]>([]);
export const globalLoader = writable<PyLoader | undefined>();
export const appConfig = writable<AppConfig>();
export const addToScriptsQueue = (script: PyScript) => {
scriptsQueue.update(scriptsQueue => [...scriptsQueue, script]);

View File

@@ -1,27 +1,3 @@
import toml from '../src/toml'
import type { AppConfig } from "./runtime";
const allKeys = {
"string": ["name", "description", "version", "type", "author_name", "author_email", "license"],
"number": ["schema_version"],
"boolean": ["autoclose_loader"],
"array": ["runtimes", "packages", "paths", "plugins"]
};
const defaultConfig: AppConfig = {
"schema_version": 1,
"type": "app",
"autoclose_loader": true,
"runtimes": [{
"src": "https://cdn.jsdelivr.net/pyodide/v0.21.2/full/pyodide.js",
"name": "pyodide-0.21.2",
"lang": "python"
}],
"packages":[],
"paths":[],
"plugins": []
}
function addClasses(element: HTMLElement, classes: Array<string>) {
for (const entry of classes) {
element.classList.add(entry);
@@ -121,139 +97,15 @@ function inJest(): boolean {
return typeof process === 'object' && process.env.JEST_WORKER_ID !== undefined;
}
function fillUserData(inputConfig: AppConfig, resultConfig: AppConfig): AppConfig
{
for (const key in inputConfig)
{
// fill in all extra keys ignored by the validator
if (!(key in defaultConfig))
{
resultConfig[key] = inputConfig[key];
}
}
return resultConfig;
function globalExport(name: string, obj: any) {
// attach the given object to the global object, so that it is globally
// visible everywhere. Should be used very sparingly!
// `window` in the browser, `global` in node
const _global = (window || global) as any;
_global[name] = obj;
}
function mergeConfig(inlineConfig: AppConfig, externalConfig: AppConfig): AppConfig {
if (Object.keys(inlineConfig).length === 0 && Object.keys(externalConfig).length === 0)
{
return defaultConfig;
}
else if (Object.keys(inlineConfig).length === 0)
{
return externalConfig;
}
else if(Object.keys(externalConfig).length === 0)
{
return inlineConfig;
}
else
{
let merged: AppConfig = {};
for (const keyType in allKeys)
{
const keys = allKeys[keyType];
keys.forEach(function(item: string){
if (keyType === "boolean")
{
merged[item] = (typeof inlineConfig[item] !== "undefined") ? inlineConfig[item] : externalConfig[item];
}
else
{
merged[item] = inlineConfig[item] || externalConfig[item];
}
});
}
// fill extra keys from external first
// they will be overridden by inline if extra keys also clash
merged = fillUserData(externalConfig, merged);
merged = fillUserData(inlineConfig, merged);
return merged;
}
}
function parseConfig(configText: string, configType = "toml") {
let config: object;
if (configType === "toml") {
try {
// TOML parser is soft and can parse even JSON strings, this additional check prevents it.
if (configText.trim()[0] === "{")
{
const errMessage = `config supplied: ${configText} is an invalid TOML and cannot be parsed`;
showError(`<p>${errMessage}</p>`);
throw Error(errMessage);
}
config = toml.parse(configText);
}
catch (err) {
const errMessage: string = err.toString();
showError(`<p>config supplied: ${configText} is an invalid TOML and cannot be parsed: ${errMessage}</p>`);
throw err;
}
}
else if (configType === "json") {
try {
config = JSON.parse(configText);
}
catch (err) {
const errMessage: string = err.toString();
showError(`<p>config supplied: ${configText} is an invalid JSON and cannot be parsed: ${errMessage}</p>`);
throw err;
}
}
else {
showError(`<p>type of config supplied is: ${configType}, supported values are ["toml", "json"].</p>`);
}
return config;
}
function validateConfig(configText: string, configType = "toml") {
const config = parseConfig(configText, configType);
const finalConfig: AppConfig = {}
for (const keyType in allKeys)
{
const keys = allKeys[keyType];
keys.forEach(function(item: string){
if (validateParamInConfig(item, keyType, config))
{
if (item === "runtimes")
{
finalConfig[item] = [];
const runtimes = config[item];
runtimes.forEach(function(eachRuntime: object){
const runtimeConfig: object = {};
for (const eachRuntimeParam in eachRuntime)
{
if (validateParamInConfig(eachRuntimeParam, "string", eachRuntime))
{
runtimeConfig[eachRuntimeParam] = eachRuntime[eachRuntimeParam];
}
}
finalConfig[item].push(runtimeConfig);
});
}
else
{
finalConfig[item] = config[item];
}
}
});
}
return fillUserData(config, finalConfig);
}
function validateParamInConfig(paramName: string, paramType: string, config: object): boolean {
if (paramName in config)
{
return paramType === "array" ? Array.isArray(config[paramName]) : typeof config[paramName] === paramType;
}
return false;
}
export { defaultConfig, addClasses, removeClasses, getLastPath, ltrim, htmlDecode, guidGenerator, showError, handleFetchError, readTextFromPath, inJest, mergeConfig, validateConfig };
export { addClasses, removeClasses, getLastPath, ltrim, htmlDecode, guidGenerator, showError, handleFetchError, readTextFromPath, inJest, globalExport, };