Commit e24c610a authored by Roman Yurchak's avatar Roman Yurchak Committed by GitHub

Merge pull request #217 from mdboom/runpython-async

Add a package-loading version of runPython
parents 0946e42b 6aa6c946
...@@ -55,13 +55,13 @@ The package needs to be imported from Python before it can be used. ...@@ -55,13 +55,13 @@ The package needs to be imported from Python before it can be used.
*Parameters* *Parameters*
| name | type | description | | name | type | description |
|---------|-----------------|---------------------------------------| |-------------------|-----------------|---------------------------------------|
| *names* | {String, Array} | package name, or URL. Can be either a single element, or an array. | | *names* | {String, Array} | package name, or URL. Can be either a single element, or an array. |
| *messageCallback* | function | A callback, called with progress messages. (optional) |
*Returns* *Returns*
Loading is asynchronous, therefore, this returns a promise. Loading is asynchronous, therefore, this returns a `Promise`.
### pyodide.loadedPackage ### pyodide.loadedPackage
...@@ -137,6 +137,43 @@ Runs a string of code. The last part of the string may be an expression, in whic ...@@ -137,6 +137,43 @@ Runs a string of code. The last part of the string may be an expression, in whic
| *jsresult* | *any* | Result, converted to Javascript | | *jsresult* | *any* | Result, converted to Javascript |
### pyodide.runPythonAsync(code, messageCallback)
Runs Python code, possibly asynchronously loading any known packages that the code
chunk imports.
For example, given the following code chunk
```python
import numpy as np
x = np.array([1, 2, 3])
```
pyodide will first call `pyodide.loadPackage(['numpy'])`, and then run the code
chunk, returning the result. Since package fetching must happen asyncronously,
this function returns a `Promise` which resolves to the output. For example, to
use:
```javascript
pyodide.runPythonAsync(code, messageCallback)
.then((output) => handleOutput(output))
```
*Parameters*
| name | type | description |
|-------------------|----------|--------------------------------|
| *code* | String | Python code to evaluate |
| *messageCallback* | function | Callback given status messages |
| | | (optional) |
*Returns*
| name | type | description |
|------------|---------|------------------------------------------|
| *result* | Promise | Resolves to the result of the code chunk |
### pyodide.version() ### pyodide.version()
Returns the pyodide version. Returns the pyodide version.
......
...@@ -41,3 +41,4 @@ requirements: ...@@ -41,3 +41,4 @@ requirements:
test: test:
imports: imports:
- matplotlib - matplotlib
- mpl_toolkits
...@@ -34,6 +34,7 @@ def build_packages(packagesdir, outputdir, args): ...@@ -34,6 +34,7 @@ def build_packages(packagesdir, outputdir, args):
# We have to build the packages in the correct order (dependencies first), # We have to build the packages in the correct order (dependencies first),
# so first load in all of the package metadata and build a dependency map. # so first load in all of the package metadata and build a dependency map.
dependencies = {} dependencies = {}
import_name_to_package_name = {}
for pkgdir in packagesdir.iterdir(): for pkgdir in packagesdir.iterdir():
pkgpath = pkgdir / 'meta.yaml' pkgpath = pkgdir / 'meta.yaml'
if pkgdir.is_dir() and pkgpath.is_file(): if pkgdir.is_dir() and pkgpath.is_file():
...@@ -41,6 +42,9 @@ def build_packages(packagesdir, outputdir, args): ...@@ -41,6 +42,9 @@ def build_packages(packagesdir, outputdir, args):
name = pkg['package']['name'] name = pkg['package']['name']
reqs = pkg.get('requirements', {}).get('run', []) reqs = pkg.get('requirements', {}).get('run', [])
dependencies[name] = reqs dependencies[name] = reqs
imports = pkg.get('test', {}).get('imports', [name])
for imp in imports:
import_name_to_package_name[imp] = name
for pkgname in dependencies.keys(): for pkgname in dependencies.keys():
build_package(pkgname, dependencies, packagesdir, outputdir, args) build_package(pkgname, dependencies, packagesdir, outputdir, args)
...@@ -51,7 +55,10 @@ def build_packages(packagesdir, outputdir, args): ...@@ -51,7 +55,10 @@ def build_packages(packagesdir, outputdir, args):
# This is done last so the Makefile can use it as a completion token. # This is done last so the Makefile can use it as a completion token.
with open(outputdir / 'packages.json', 'w') as fd: with open(outputdir / 'packages.json', 'w') as fd:
json.dump({'dependencies': dependencies}, fd) json.dump({
'dependencies': dependencies,
'import_name_to_package_name': import_name_to_package_name,
}, fd)
def make_parser(parser): def make_parser(parser):
......
...@@ -75,7 +75,7 @@ var languagePluginLoader = new Promise((resolve, reject) => { ...@@ -75,7 +75,7 @@ var languagePluginLoader = new Promise((resolve, reject) => {
} }
// clang-format on // clang-format on
let _loadPackage = (names) => { let _loadPackage = (names, messageCallback) => {
// DFS to find all dependencies of the requested packages // DFS to find all dependencies of the requested packages
let packages = window.pyodide._module.packages.dependencies; let packages = window.pyodide._module.packages.dependencies;
let loadedPackages = window.pyodide.loadedPackages; let loadedPackages = window.pyodide.loadedPackages;
...@@ -140,13 +140,17 @@ var languagePluginLoader = new Promise((resolve, reject) => { ...@@ -140,13 +140,17 @@ var languagePluginLoader = new Promise((resolve, reject) => {
resolve('No new packages to load'); resolve('No new packages to load');
} }
const packageList = Array.from(Object.keys(toLoad)).join(', ');
if (messageCallback !== undefined) {
messageCallback(`Loading ${packageList}`);
}
window.pyodide._module.monitorRunDependencies = (n) => { window.pyodide._module.monitorRunDependencies = (n) => {
if (n === 0) { if (n === 0) {
for (let package in toLoad) { for (let package in toLoad) {
window.pyodide.loadedPackages[package] = toLoad[package]; window.pyodide.loadedPackages[package] = toLoad[package];
} }
delete window.pyodide._module.monitorRunDependencies; delete window.pyodide._module.monitorRunDependencies;
const packageList = Array.from(Object.keys(toLoad)).join(', ');
if (!isFirefox) { if (!isFirefox) {
preloadWasm().then(() => {resolve(`Loaded ${packageList}`)}); preloadWasm().then(() => {resolve(`Loaded ${packageList}`)});
} else { } else {
...@@ -181,10 +185,11 @@ var languagePluginLoader = new Promise((resolve, reject) => { ...@@ -181,10 +185,11 @@ var languagePluginLoader = new Promise((resolve, reject) => {
return promise; return promise;
}; };
let loadPackage = (names) => { let loadPackage = (names, messageCallback) => {
/* We want to make sure that only one loadPackage invocation runs at any /* We want to make sure that only one loadPackage invocation runs at any
* given time, so this creates a "chain" of promises. */ * given time, so this creates a "chain" of promises. */
loadPackagePromise = loadPackagePromise.then(() => _loadPackage(names)); loadPackagePromise =
loadPackagePromise.then(() => _loadPackage(names, messageCallback));
return loadPackagePromise; return loadPackagePromise;
}; };
...@@ -224,6 +229,7 @@ var languagePluginLoader = new Promise((resolve, reject) => { ...@@ -224,6 +229,7 @@ var languagePluginLoader = new Promise((resolve, reject) => {
'pyimport', 'pyimport',
'repr', 'repr',
'runPython', 'runPython',
'runPythonAsync',
'version', 'version',
]; ];
......
...@@ -2,8 +2,6 @@ ...@@ -2,8 +2,6 @@
A library of helper utilities for connecting Python to the browser environment. A library of helper utilities for connecting Python to the browser environment.
""" """
from js import XMLHttpRequest
import ast import ast
import io import io
...@@ -14,6 +12,8 @@ def open_url(url): ...@@ -14,6 +12,8 @@ def open_url(url):
""" """
Fetches a given *url* and returns a io.StringIO to access its contents. Fetches a given *url* and returns a io.StringIO to access its contents.
""" """
from js import XMLHttpRequest
req = XMLHttpRequest.new() req = XMLHttpRequest.new()
req.open('GET', url, False) req.open('GET', url, False)
req.send(None) req.send(None)
...@@ -39,4 +39,22 @@ def eval_code(code, ns): ...@@ -39,4 +39,22 @@ def eval_code(code, ns):
return None return None
__all__ = ['open_url', 'eval_code'] def find_imports(code):
"""
Finds the imports in a string of code and returns a list of their package
names.
"""
mod = ast.parse(code)
imports = set()
for node in ast.walk(mod):
if isinstance(node, ast.Import):
for name in node.names:
name = name.name
imports.add(name.split('.')[0])
elif isinstance(node, ast.ImportFrom):
name = node.module
imports.add(name.split('.')[0])
return list(imports)
__all__ = ['open_url', 'eval_code', 'find_imports']
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
extern PyObject* globals; extern PyObject* globals;
PyObject* eval_code; PyObject* eval_code;
PyObject* find_imports;
int int
_runPython(char* code) _runPython(char* code)
...@@ -32,18 +33,79 @@ _runPython(char* code) ...@@ -32,18 +33,79 @@ _runPython(char* code)
return id; return id;
} }
int
_findImports(char* code)
{
PyObject* py_code;
py_code = PyUnicode_FromString(code);
if (py_code == NULL) {
return pythonexc2js();
}
PyObject* ret = PyObject_CallFunctionObjArgs(find_imports, py_code, NULL);
if (ret == NULL) {
return pythonexc2js();
}
int id = python2js(ret);
Py_DECREF(ret);
return id;
}
EM_JS(int, runpython_init_js, (), { EM_JS(int, runpython_init_js, (), {
Module.runPython = function(code) Module._runPythonInternal = function(pycode)
{ {
var pycode = allocate(intArrayFromString(code), 'i8', ALLOC_NORMAL);
var idresult = Module.__runPython(pycode); var idresult = Module.__runPython(pycode);
jsresult = Module.hiwire_get_value(idresult); var jsresult = Module.hiwire_get_value(idresult);
Module.hiwire_decref(idresult); Module.hiwire_decref(idresult);
_free(pycode); _free(pycode);
return jsresult; return jsresult;
}; };
return 0; Module.runPython = function(code)
{
var pycode = allocate(intArrayFromString(code), 'i8', ALLOC_NORMAL);
return Module._runPythonInternal(pycode);
};
Module.runPythonAsync = function(code, messageCallback)
{
var pycode = allocate(intArrayFromString(code), 'i8', ALLOC_NORMAL);
var idimports = Module.__findImports(pycode);
var jsimports = Module.hiwire_get_value(idimports);
Module.hiwire_decref(idimports);
var internal = function(resolve, reject)
{
try {
resolve(Module._runPythonInternal(pycode));
} catch (e) {
reject(e);
}
};
if (jsimports.length) {
var packageNames =
window.pyodide._module.packages.import_name_to_package_name;
var packages = {};
for (var i = 0; i < jsimports.length; ++i) {
var name = jsimports[i];
// clang-format off
if (packageNames[name] !== undefined) {
// clang-format on
packages[packageNames[name]] = undefined;
}
}
if (Object.keys(packages).length) {
var runInternal = function() { return new Promise(internal); };
return Module.loadPackage(Object.keys(packages), messageCallback)
.then(runInternal);
}
}
return new Promise(internal);
};
}); });
int int
...@@ -64,6 +126,11 @@ runpython_init_py() ...@@ -64,6 +126,11 @@ runpython_init_py()
return 1; return 1;
} }
find_imports = PyDict_GetItemString(d, "find_imports");
if (find_imports == NULL) {
return 1;
}
Py_DECREF(m); Py_DECREF(m);
Py_DECREF(d); Py_DECREF(d);
return 0; return 0;
......
...@@ -105,6 +105,37 @@ class SeleniumWrapper: ...@@ -105,6 +105,37 @@ class SeleniumWrapper:
return self.run_js( return self.run_js(
'return pyodide.runPython({!r})'.format(code)) 'return pyodide.runPython({!r})'.format(code))
def run_async(self, code):
from selenium.common.exceptions import TimeoutException
if isinstance(code, str) and code.startswith('\n'):
# we have a multiline string, fix indentation
code = textwrap.dedent(code)
self.run_js(
"""
window.done = false;
pyodide.runPythonAsync({!r})
.then(function(output)
{{ window.output = output; window.error = false; }},
function(output)
{{ window.output = output; window.error = true; }})
.finally(() => window.done = true);
""".format(code)
)
try:
self.wait.until(PackageLoaded())
except TimeoutException as exc:
_display_driver_logs(self.browser, self.driver)
print(self.logs)
raise TimeoutException('runPythonAsync timed out')
return self.run_js(
"""
if (window.error) {
throw window.output;
}
return window.output;
"""
)
def run_js(self, code): def run_js(self, code):
if isinstance(code, str) and code.startswith('\n'): if isinstance(code, str) and code.startswith('\n'):
# we have a multiline string, fix indentation # we have a multiline string, fix indentation
......
...@@ -448,3 +448,73 @@ def test_recursive_dict(selenium_standalone): ...@@ -448,3 +448,73 @@ def test_recursive_dict(selenium_standalone):
""" """
) )
selenium_standalone.run_js("x = pyodide.pyimport('x')") selenium_standalone.run_js("x = pyodide.pyimport('x')")
def test_runpythonasync(selenium_standalone):
output = selenium_standalone.run_async(
"""
import numpy as np
np.zeros(5)
"""
)
assert list(output) == [0, 0, 0, 0, 0]
def test_runpythonasync_different_package_name(selenium_standalone):
output = selenium_standalone.run_async(
"""
import dateutil
dateutil.__version__
"""
)
assert isinstance(output, str)
def test_runpythonasync_no_imports(selenium_standalone):
output = selenium_standalone.run_async(
"""
42
"""
)
assert output == 42
def test_runpythonasync_missing_import(selenium_standalone):
try:
selenium_standalone.run_async(
"""
import foo
"""
)
except selenium_standalone.JavascriptException as e:
assert "ModuleNotFoundError" in str(e)
else:
assert False
def test_runpythonasync_exception(selenium_standalone):
try:
selenium_standalone.run_async(
"""
42 / 0
"""
)
except selenium_standalone.JavascriptException as e:
assert "ZeroDivisionError" in str(e)
else:
assert False
def test_runpythonasync_exception_after_import(selenium_standalone):
try:
selenium_standalone.run_async(
"""
import numpy as np
x = np.empty(5)
42 / 0
"""
)
except selenium_standalone.JavascriptException as e:
assert "ZeroDivisionError" in str(e)
else:
assert False
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment