wrap runPython in async (#1212)

This commit is contained in:
Madhur Tandon
2023-02-21 20:35:19 +00:00
committed by GitHub
parent 11c79a5344
commit e2c2459290
14 changed files with 158 additions and 42 deletions

View File

@@ -150,7 +150,7 @@ export function make_PyRepl(interpreter: Interpreter) {
/** Execute the python code written in the editor, and automatically
* display() the last evaluated expression
*/
execute(): void {
async execute(): Promise<void> {
const pySrc = this.getPySrc();
// determine the output element
@@ -166,7 +166,7 @@ export function make_PyRepl(interpreter: Interpreter) {
outEl.innerHTML = '';
// execute the python code
const pyResult = pyExec(interpreter, pySrc, outEl);
const pyResult = (await pyExec(interpreter, pySrc, outEl)).result;
// display the value of the last evaluated expression (REPL-style)
if (pyResult !== undefined) {

View File

@@ -16,17 +16,30 @@ export function make_PyScript(interpreter: Interpreter, app: PyScriptApp) {
stderr_manager: Stdio | null;
async connectedCallback() {
ensureUniqueId(this);
// Save innerHTML information in srcCode so we can access it later
// once we clean innerHTML (which is required since we don't want
// source code to be rendered on the screen)
this.srcCode = this.innerHTML;
const pySrc = await this.getPySrc();
this.innerHTML = '';
/**
* Since connectedCallback is async, multiple py-script tags can be executed in
* an order which is not particularly sequential. The locking mechanism here ensures
* a sequential execution of multiple py-script tags present in one page.
*
* Concurrent access to the multiple py-script tags is thus avoided.
*/
let releaseLock: any;
try {
releaseLock = await app.tagExecutionLock();
ensureUniqueId(this);
// Save innerHTML information in srcCode so we can access it later
// once we clean innerHTML (which is required since we don't want
// source code to be rendered on the screen)
this.srcCode = this.innerHTML;
const pySrc = await this.getPySrc();
this.innerHTML = '';
app.plugins.beforePyScriptExec({interpreter: interpreter, src: pySrc, pyScriptTag: this});
const result = pyExec(interpreter, pySrc, this);
app.plugins.afterPyScriptExec({interpreter: interpreter, src: pySrc, pyScriptTag: this, result: result});
app.plugins.beforePyScriptExec({interpreter: interpreter, src: pySrc, pyScriptTag: this});
const result = (await pyExec(interpreter, pySrc, this)).result;
app.plugins.afterPyScriptExec({interpreter: interpreter, src: pySrc, pyScriptTag: this, result: result});
} finally {
releaseLock()
}
}
async getPySrc(): Promise<string> {
@@ -183,12 +196,14 @@ function createElementsWithEventListeners(interpreter: Interpreter, pyAttribute:
}
} else {
el.addEventListener(event, () => {
try {
interpreter.run(handlerCode)
}
catch (err) {
displayPyException(err, el.parentElement);
}
void (async () => {
try {
await interpreter.run(handlerCode);
}
catch (err) {
displayPyException(err, el.parentElement);
}
})();
});
}
// TODO: Should we actually map handlers in JS instead of Python?
@@ -208,7 +223,7 @@ function createElementsWithEventListeners(interpreter: Interpreter, pyAttribute:
}
/** Mount all elements with attribute py-mount into the Python namespace */
export function mountElements(interpreter: Interpreter) {
export async function mountElements(interpreter: Interpreter) {
const matches: NodeListOf<HTMLElement> = document.querySelectorAll('[py-mount]');
logger.info(`py-mount: found ${matches.length} elements`);
@@ -217,5 +232,5 @@ export function mountElements(interpreter: Interpreter) {
const mountName = el.getAttribute('py-mount') || el.id.split('-').join('_');
source += `\n${mountName} = Element("${el.id}")`;
}
interpreter.run(source);
await interpreter.run(source);
}

View File

@@ -26,8 +26,8 @@ function createWidget(interpreter: Interpreter, name: string, code: string, klas
this.shadow.appendChild(this.wrapper);
}
connectedCallback() {
interpreter.runButDontRaise(this.code);
async connectedCallback() {
await interpreter.runButDontRaise(this.code);
this.proxyClass = interpreter.globals.get(this.klass);
this.proxy = this.proxyClass(this);
this.proxy.connect();

View File

@@ -51,7 +51,7 @@ export abstract class Interpreter extends Object {
* (asynchronously) which can call its own API behind the scenes.
* Python exceptions are turned into JS exceptions.
* */
abstract run(code: string): unknown;
abstract run(code: string): Promise<{result: any}>;
/**
* Same as run, but Python exceptions are not propagated: instead, they
@@ -60,10 +60,10 @@ export abstract class Interpreter extends Object {
* This is a bad API and should be killed/refactored/changed eventually,
* but for now we have code which relies on it.
* */
runButDontRaise(code: string): unknown {
async runButDontRaise(code: string): Promise<unknown> {
let result: unknown;
try {
result = this.run(code);
result = (await this.run(code)).result;
} catch (error: unknown) {
logger.error('Error:', error);
}

View File

@@ -8,7 +8,7 @@ import { PluginManager, define_custom_element } from './plugin';
import { make_PyScript, initHandlers, mountElements } from './components/pyscript';
import { PyodideInterpreter } from './pyodide';
import { getLogger } from './logger';
import { showWarning, globalExport } from './utils';
import { showWarning, globalExport, createLock } from './utils';
import { calculatePaths } from './plugins/fetch';
import { createCustomElements } from './components/elements';
import { UserError, ErrorCode, _createAlertBanner } from './exceptions';
@@ -21,7 +21,6 @@ import { StdioDirector as StdioDirector } from './plugins/stdiodirector';
// @ts-ignore
import pyscript from './python/pyscript.py';
import { robustFetch } from './fetch';
import type { Plugin } from './plugin';
const logger = getLogger('pyscript/main');
@@ -65,6 +64,7 @@ export class PyScriptApp {
PyScript: ReturnType<typeof make_PyScript>;
plugins: PluginManager;
_stdioMultiplexer: StdioMultiplexer;
tagExecutionLock: any; // this is used to ensure that py-script tags are executed sequentially
constructor() {
// initialize the builtin plugins
@@ -75,6 +75,7 @@ export class PyScriptApp {
this._stdioMultiplexer.addListener(DEFAULT_STDIO);
this.plugins.add(new StdioDirector(this._stdioMultiplexer));
this.tagExecutionLock = createLock();
}
// Error handling logic: if during the execution we encounter an error
@@ -180,7 +181,7 @@ export class PyScriptApp {
this.logStatus('Setting up virtual environment...');
await this.setupVirtualEnv(interpreter);
mountElements(interpreter);
await mountElements(interpreter);
// lifecycle (6.5)
this.plugins.afterSetup(interpreter);

View File

@@ -5,10 +5,9 @@ import type { Interpreter } from './interpreter';
const logger = getLogger('pyexec');
export function pyExec(interpreter: Interpreter, pysrc: string, outElem: HTMLElement) {
export async function pyExec(interpreter: Interpreter, pysrc: string, outElem: HTMLElement) {
//This is pyscript.py
const pyscript_py = interpreter.interface.pyimport('pyscript');
ensureUniqueId(outElem);
pyscript_py.set_current_display_target(outElem.id);
try {
@@ -23,13 +22,14 @@ export function pyExec(interpreter: Interpreter, pysrc: string, outElem: HTMLEle
'\nSee https://docs.pyscript.net/latest/guides/asyncio.html for more information.',
);
}
return interpreter.run(pysrc);
return (await interpreter.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);
return {result: undefined};
}
} finally {
pyscript_py.set_current_display_target(undefined);

View File

@@ -78,12 +78,32 @@ export class PyodideInterpreter extends Interpreter {
await this.loadPackage('micropip');
}
logger.info('pyodide loaded and initialized');
this.run('print("Python initialization complete")')
await this.run('print("Python initialization complete")')
}
run(code: string): unknown {
return this.interface.runPython(code);
/* 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 */
registerJsModule(name: string, module: object): void {
this.interface.registerJsModule(name, module);
@@ -96,7 +116,8 @@ export class PyodideInterpreter extends Interpreter {
// 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)`.
if (this.run("import sys; sys.modules['pyodide'].__version__").toString().startsWith("0.22")) {
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 {

View File

@@ -203,12 +203,10 @@ get_current_display_target._id = None
def display(*values, target=None, append=True):
default_target = get_current_display_target()
if default_target is None and target is None:
raise Exception(
"Implicit target not allowed here. Please use display(..., target=...)"
)
if target is not None:
for v in values:
Element(target).write(v, append=append)

View File

@@ -112,3 +112,26 @@ export function createSingularWarning(msg: string, sentinelText: string | null =
_createAlertBanner(msg, 'warning');
}
}
/**
* @returns A new asynchronous lock
* @private
*/
export function createLock() {
// This is a promise that is resolved when the lock is open, not resolved when lock is held.
let _lock = Promise.resolve();
/**
* Acquire the async lock
* @returns A zero argument function that releases the lock.
* @private
*/
async function acquireLock() {
const old_lock = _lock;
let releaseLock: () => void;
_lock = new Promise((resolve) => (releaseLock = resolve));
await old_lock;
return releaseLock;
}
return acquireLock;
}

View File

@@ -311,6 +311,7 @@ class TestBasic(PyScriptTest):
== 'print("hello world!")\n'
)
@pytest.mark.skip(reason="pys-onClick is broken, we should kill it, see #1213")
def test_pys_onClick_shows_deprecation_warning(self):
self.pyscript_run(
"""

View File

@@ -149,3 +149,58 @@ class TestAsync(PyScriptTest):
self.console.error.lines[-1]
== "Implicit target not allowed here. Please use display(..., target=...)"
)
def test_sync_and_async_order(self):
"""
The order of execution is defined as follows:
1. first, we execute all the py-script tag in order
2. then, we start all the tasks which were scheduled with create_task
Note that tasks are started *AFTER* all py-script tags have been
executed. That's why the console.log() inside mytask1 and mytask2 are
executed after e.g. js.console.log("6").
"""
src = """
<py-script>
import js
js.console.log("1")
</py-script>
<py-script>
import asyncio
import js
async def mytask1():
js.console.log("7")
await asyncio.sleep(0)
js.console.log("9")
js.console.log("2")
asyncio.create_task(mytask1())
js.console.log("3")
</py-script>
<py-script>
import js
js.console.log("4")
</py-script>
<py-script>
import asyncio
import js
async def mytask2():
js.console.log("8")
await asyncio.sleep(0)
js.console.log("10")
js.console.log("DONE")
js.console.log("5")
asyncio.create_task(mytask2())
js.console.log("6")
</py-script>
"""
self.pyscript_run(src, wait_for_pyscript=False)
self.wait_for_console("DONE")
lines = self.console.log.lines[-11:]
assert lines == ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "DONE"]

View File

@@ -1,3 +1,4 @@
import pytest
from playwright.sync_api import expect
from .support import PyScriptTest
@@ -79,6 +80,7 @@ class TestSplashscreen(PyScriptTest):
assert self.console.log.lines[0] == self.PY_COMPLETE
assert "hello pyscript" in self.console.log.lines
@pytest.mark.skip(reason="pys-onClick is broken, we should kill it, see #1213")
def test_splashscreen_closes_on_error_with_pys_onClick(self):
self.pyscript_run(
"""

View File

@@ -4,7 +4,7 @@ from .support import PyScriptTest
class TestOutputHandling(PyScriptTest):
# Source of a script to test the TargetedStdio functionality
def test_targeted_stdio(self):
def test_targeted_stdio_solo(self):
self.pyscript_run(
"""
<py-config>

View File

@@ -51,19 +51,19 @@ describe('PyodideInterpreter', () => {
});
it('should check if interpreter can run python code asynchronously', async () => {
expect(interpreter.run('2+3')).toBe(5);
expect((await interpreter.run('2+3')).result).toBe(5);
});
it('should capture stdout', async () => {
stdio.reset();
interpreter.run("print('hello')");
await interpreter.run("print('hello')");
expect(stdio.captured_stdout).toBe('hello\n');
});
it('should check if interpreter is able to load a package', async () => {
await interpreter.loadPackage('numpy');
interpreter.run('import numpy as np');
interpreter.run('x = np.ones((10,))');
await interpreter.run('import numpy as np');
await interpreter.run('x = np.ones((10,))');
expect(interpreter.globals.get('x').toJs()).toBeInstanceOf(Float64Array);
});
});