mirror of
https://github.com/pyscript/pyscript.git
synced 2025-12-21 19:25:35 -05:00
* split interpreter class * add new files * add newlines * disable eslint for run * remove usage of interpreter from unit test * delete fakeinterpreter class * fix unit tests * add comments * remove interpreter.ts and pyodide.ts files * suggested changes
262 lines
10 KiB
TypeScript
262 lines
10 KiB
TypeScript
import type { AppConfig } from './pyconfig';
|
|
import { getLogger } from './logger';
|
|
import { Stdio } from './stdio';
|
|
import { InstallError, ErrorCode } from './exceptions';
|
|
import { robustFetch } from './fetch';
|
|
import type { loadPyodide as loadPyodideDeclaration, PyodideInterface, PyProxy } from 'pyodide';
|
|
|
|
declare const loadPyodide: typeof loadPyodideDeclaration;
|
|
const logger = getLogger('pyscript/pyodide');
|
|
|
|
export type InterpreterInterface = PyodideInterface | null;
|
|
|
|
interface Micropip extends PyProxy {
|
|
install: (packageName: string | string[]) => Promise<void>;
|
|
destroy: () => void;
|
|
}
|
|
|
|
/*
|
|
RemoteInterpreter class is responsible to process requests from the
|
|
`InterpreterClient` class -- these can be requests for installation of
|
|
a package, executing code, etc.
|
|
|
|
Currently, the only interpreter available is Pyodide as indicated by the
|
|
`InterpreterInterface` type above. This serves as a Union of types of
|
|
different interpreters which will be added in near future.
|
|
|
|
Methods available handle loading of the interpreter, initialization,
|
|
running code, loading and installation of packages, loading from files etc.
|
|
|
|
The class will be turned `abstract` in future, to support more runtimes
|
|
such as MicroPython.
|
|
*/
|
|
export class RemoteInterpreter extends Object {
|
|
src: string;
|
|
interface: InterpreterInterface;
|
|
globals: PyProxy;
|
|
// TODO: Remove this once `runtimes` is removed!
|
|
interpreter: InterpreterInterface;
|
|
|
|
constructor(
|
|
src = 'https://cdn.jsdelivr.net/pyodide/v0.22.1/full/pyodide.js'
|
|
) {
|
|
super();
|
|
this.src = src;
|
|
}
|
|
|
|
/**
|
|
* loads the interface for the interpreter and saves an instance of it
|
|
* in the `this.interface` property along with calling of other
|
|
* additional convenience functions.
|
|
* */
|
|
|
|
/**
|
|
* Although `loadPyodide` is used below,
|
|
* notice that it is not imported i.e.
|
|
* import { loadPyodide } from 'pyodide';
|
|
* is not used at the top of this file.
|
|
*
|
|
* This is because, if it's used, loadPyodide
|
|
* behaves mischievously i.e. it tries to load
|
|
* `pyodide.asm.js` and `pyodide_py.tar` but
|
|
* with paths that are wrong such as:
|
|
*
|
|
* http://127.0.0.1:8080/build/pyodide_py.tar
|
|
* which results in a 404 since `build` doesn't
|
|
* contain these files and is clearly the wrong
|
|
* path.
|
|
*/
|
|
async loadInterpreter(config: AppConfig, stdio: Stdio): Promise<void> {
|
|
this.interface = await loadPyodide({
|
|
stdout: (msg: string) => {
|
|
stdio.stdout_writeline(msg);
|
|
},
|
|
stderr: (msg: string) => {
|
|
stdio.stderr_writeline(msg);
|
|
},
|
|
fullStdLib: false,
|
|
});
|
|
|
|
// TODO: Remove this once `runtimes` is removed!
|
|
this.interpreter = this.interface;
|
|
|
|
this.globals = this.interface.globals;
|
|
|
|
if (config.packages) {
|
|
logger.info('Found packages in configuration to install. Loading micropip...');
|
|
await this.loadPackage('micropip');
|
|
}
|
|
logger.info('pyodide loaded and initialized');
|
|
await this.run('print("Python initialization complete")')
|
|
}
|
|
|
|
/* eslint-disable */
|
|
async run(code: string): Promise<{result: any}> {
|
|
/**
|
|
* eslint wants `await` keyword to be used i.e.
|
|
* { result: await this.interface.runPython(code) }
|
|
* However, `await` is not a no-op (no-operation) i.e.
|
|
* `await 42` is NOT the same as `42` i.e. if the awaited
|
|
* thing is not a promise, it is wrapped inside a promise and
|
|
* that promise is awaited. Thus, it changes the execution order.
|
|
* See https://stackoverflow.com/questions/55262996/does-awaiting-a-non-promise-have-any-detectable-effect
|
|
* Thus, `eslint` is disabled for this block / snippet.
|
|
*/
|
|
|
|
/**
|
|
* The output of `runPython` is wrapped inside an object
|
|
* since an object is not thennable and avoids return of
|
|
* a coroutine directly. This is so we do not `await` the results
|
|
* of the underlying python execution, even if it's an
|
|
* awaitable object (Future, Task, etc.)
|
|
*/
|
|
return { result: this.interface.runPython(code) };
|
|
}
|
|
/* eslint-enable */
|
|
|
|
/**
|
|
* delegates the registration of JS modules to
|
|
* the underlying interface.
|
|
* */
|
|
registerJsModule(name: string, module: object): void {
|
|
this.interface.registerJsModule(name, module);
|
|
}
|
|
|
|
/**
|
|
* delegates the loading of packages to
|
|
* the underlying interface.
|
|
* */
|
|
async loadPackage(names: string | string[]): Promise<void> {
|
|
logger.info(`pyodide.loadPackage: ${names.toString()}`);
|
|
// the new way in v0.22.1 is to pass it as a dict of options i.e.
|
|
// { messageCallback: logger.info.bind(logger), errorCallback: logger.info.bind(logger) }
|
|
// but one of our tests tries to use a locally downloaded older version of pyodide
|
|
// for which the signature of `loadPackage` accepts the above params as args i.e.
|
|
// the call uses `logger.info.bind(logger), logger.info.bind(logger)`.
|
|
const pyodide_version = (await this.run("import sys; sys.modules['pyodide'].__version__")).result.toString();
|
|
if (pyodide_version.startsWith("0.22")) {
|
|
await this.interface.loadPackage(names, { messageCallback: logger.info.bind(logger), errorCallback: logger.info.bind(logger) });
|
|
}
|
|
else {
|
|
await this.interface.loadPackage(names, logger.info.bind(logger), logger.info.bind(logger));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* delegates the installation of packages
|
|
* (using a package manager, which can be specific to
|
|
* the interface) to the underlying interface.
|
|
*
|
|
* For Pyodide, we use `micropip`
|
|
* */
|
|
async installPackage(package_name: string | string[]): Promise<void> {
|
|
if (package_name.length > 0) {
|
|
logger.info(`micropip install ${package_name.toString()}`);
|
|
|
|
const micropip = this.interface.pyimport('micropip') as Micropip;
|
|
try {
|
|
await micropip.install(package_name);
|
|
micropip.destroy();
|
|
} catch (e) {
|
|
let exceptionMessage = `Unable to install package(s) '` + package_name + `'.`;
|
|
|
|
// If we can't fetch `package_name` micropip.install throws a huge
|
|
// Python traceback in `e.message` this logic is to handle the
|
|
// error and throw a more sensible error message instead of the
|
|
// huge traceback.
|
|
if (e.message.includes("Can't find a pure Python 3 wheel")) {
|
|
exceptionMessage +=
|
|
` Reason: Can't find a pure Python 3 Wheel for package(s) '` +
|
|
package_name +
|
|
`'. See: https://pyodide.org/en/stable/usage/faq.html#micropip-can-t-find-a-pure-python-wheel ` +
|
|
`for more information.`;
|
|
} else if (e.message.includes("Can't fetch metadata")) {
|
|
exceptionMessage +=
|
|
' Unable to find package in PyPI. ' +
|
|
'Please make sure you have entered a correct package name.';
|
|
} else {
|
|
exceptionMessage +=
|
|
` Reason: ${e.message as string}. Please open an issue at ` +
|
|
`https://github.com/pyscript/pyscript/issues/new if you require help or ` +
|
|
`you think it's a bug.`;
|
|
}
|
|
|
|
logger.error(e);
|
|
|
|
throw new InstallError(ErrorCode.MICROPIP_INSTALL_ERROR, exceptionMessage);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param path : the path in the filesystem
|
|
* @param fetch_path : the path to be fetched
|
|
*
|
|
* Given a file available at `fetch_path` URL (eg: `http://dummy.com/hi.py`),
|
|
* the function downloads the file and saves it to the `path` (eg: `a/b/c/foo.py`)
|
|
* on the FS.
|
|
*
|
|
* Example usage:
|
|
* await loadFromFile(`a/b/c/foo.py`, `http://dummy.com/hi.py`)
|
|
*
|
|
* Nested paths are iteratively analysed and each part is created
|
|
* if it doesn't exist.
|
|
*
|
|
* The analysis returns if the part exists and if it's parent directory exists
|
|
* Due to the manner in which we proceed, the parent will ALWAYS exist.
|
|
*
|
|
* The iteration proceeds in the following manner for `a/b/c/foo.py`:
|
|
*
|
|
* - `a` doesn't exist but it's parent i.e. `root` exists --> create `a`
|
|
* - `a/b` doesn't exist but it's parent i.e. `a` exists --> create `a/b`
|
|
* - `a/b/c` doesn't exist but it's parent i.e. `a/b` exists --> create `a/b/c`
|
|
*
|
|
* Finally, write content of `http://dummy.com/hi.py` to `a/b/c/foo.py`
|
|
*
|
|
* NOTE: The `path` parameter expects to have the `filename` in it i.e.
|
|
* `a/b/c/foo.py` is valid while `a/b/c` (i.e. only the folders) are incorrect.
|
|
*/
|
|
async loadFromFile(path: string, fetch_path: string): Promise<void> {
|
|
const pathArr = path.split('/');
|
|
const filename = pathArr.pop();
|
|
for (let i = 0; i < pathArr.length; i++) {
|
|
// iteratively calculates parts of the path i.e. `a`, `a/b`, `a/b/c` for `a/b/c/foo.py`
|
|
const eachPath = pathArr.slice(0, i + 1).join('/');
|
|
|
|
// analyses `eachPath` and returns if it exists along with if its parent directory exists or not
|
|
const { exists, parentExists } = this.interface.FS.analyzePath(eachPath);
|
|
|
|
// due to the iterative manner in which we proceed, the parent directory should ALWAYS exist
|
|
if (!parentExists) {
|
|
throw new Error(`'INTERNAL ERROR! cannot create ${path}, this should never happen'`);
|
|
}
|
|
|
|
// creates `eachPath` if it doesn't exist
|
|
if (!exists) {
|
|
this.interface.FS.mkdir(eachPath);
|
|
}
|
|
}
|
|
|
|
// `robustFetch` checks for failures in getting a response
|
|
const response = await robustFetch(fetch_path);
|
|
const buffer = await response.arrayBuffer();
|
|
const data = new Uint8Array(buffer);
|
|
|
|
pathArr.push(filename);
|
|
// opens a file descriptor for the file at `path`
|
|
const stream = this.interface.FS.open(pathArr.join('/'), 'w');
|
|
this.interface.FS.write(stream, data, 0, data.length, 0);
|
|
this.interface.FS.close(stream);
|
|
}
|
|
|
|
/**
|
|
* delegates clearing importlib's module path
|
|
* caches to the underlying interface
|
|
*/
|
|
invalidate_module_path_cache(): void {
|
|
const importlib = this.interface.pyimport('importlib');
|
|
importlib.invalidate_caches();
|
|
}
|
|
}
|