Merge remote-tracking branch 'origin/main' into antocuni/py-terminal

This commit is contained in:
Antonio Cuni
2023-10-05 15:14:54 +02:00
97 changed files with 341 additions and 17422 deletions

View File

@@ -1,17 +1,17 @@
{
"name": "@pyscript/core",
"version": "0.2.5",
"version": "0.2.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@pyscript/core",
"version": "0.2.5",
"version": "0.2.7",
"license": "APACHE-2.0",
"dependencies": {
"@ungap/with-resolvers": "^0.1.0",
"basic-devtools": "^0.1.6",
"polyscript": "^0.4.8",
"polyscript": "^0.4.11",
"type-checked-collections": "^0.1.7"
},
"devDependencies": {
@@ -1782,9 +1782,9 @@
"integrity": "sha512-yyVAOFKTAElc7KdLt2+UKGExNYwYb/Y/WE9i+1ezCQsJE8gbKSjewfpRqK2nQgZ4d4hhAAGgDCOcIZVilqE5UA=="
},
"node_modules/polyscript": {
"version": "0.4.8",
"resolved": "https://registry.npmjs.org/polyscript/-/polyscript-0.4.8.tgz",
"integrity": "sha512-YlgjdMeEnv/i6WOqkh7gc52iSPY1l/psA+egu7z1GNrjwq6udw4WuQPz3rHRbaFhTUdYsVulLd8SBugjbVH6sQ==",
"version": "0.4.11",
"resolved": "https://registry.npmjs.org/polyscript/-/polyscript-0.4.11.tgz",
"integrity": "sha512-wNvCUJp003OR/Q9C0eZJ84MHYeJiMtPTt1pqtsRQ0odRV/M1b3qVQ23oD5DAjq1weXQv1EdfpILwFOpw6VnirA==",
"dependencies": {
"@ungap/structured-clone": "^1.2.0",
"@ungap/with-resolvers": "^0.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@pyscript/core",
"version": "0.2.5",
"version": "0.2.7",
"type": "module",
"description": "PyScript",
"module": "./index.js",
@@ -33,7 +33,7 @@
"dependencies": {
"@ungap/with-resolvers": "^0.1.0",
"basic-devtools": "^0.1.6",
"polyscript": "^0.4.8",
"polyscript": "^0.4.11",
"type-checked-collections": "^0.1.7"
},
"devDependencies": {

View File

@@ -1,62 +1,17 @@
import TYPES from "./types.js";
import hooks from "./hooks.js";
const DONE = "py:all-done";
const {
onAfterRun,
onAfterRunAsync,
codeAfterRunWorker,
codeAfterRunWorkerAsync,
} = hooks;
const waitForIt = [];
const codes = [];
const codeFor = (element) => {
const isAsync = element.hasAttribute("async");
const { promise, resolve } = Promise.withResolvers();
const type = `${DONE}:${waitForIt.push(promise)}`;
// resolve each promise once notified
addEventListener(type, resolve, { once: true });
if (element.hasAttribute("worker")) {
const code = `
from pyscript import window as _w
_w.dispatchEvent(_w.Event.new("${type}"))
`;
if (isAsync) codeAfterRunWorkerAsync.add(code);
else codeAfterRunWorker.add(code);
return code;
for (const [TYPE] of TYPES) {
const selectors = [`script[type="${TYPE}"]`, `${TYPE}-script`];
for (const element of document.querySelectorAll(selectors.join(","))) {
const { promise, resolve } = Promise.withResolvers();
waitForIt.push(promise);
element.addEventListener(`${TYPE}:done`, resolve, { once: true });
}
// dispatch only once the ready element is the same
const code = (_, el) => {
if (el === element) dispatchEvent(new Event(type));
};
if (isAsync) onAfterRunAsync.add(code);
else onAfterRun.add(code);
return code;
};
const selector = [];
for (const [TYPE] of TYPES)
selector.push(`script[type="${TYPE}"]`, `${TYPE}-script`);
// loop over all known scripts and elements
for (const element of document.querySelectorAll(selector.join(",")))
codes.push(codeFor(element));
}
// wait for all the things then cleanup
Promise.all(waitForIt).then(() => {
// cleanup unnecessary hooks
for (const code of codes) {
onAfterRun.delete(code);
onAfterRunAsync.delete(code);
codeAfterRunWorker.delete(code);
codeAfterRunWorkerAsync.delete(code);
}
dispatchEvent(new Event(DONE));
dispatchEvent(new Event("py:all-done"));
});

View File

@@ -50,7 +50,7 @@ const syntaxError = (type, url, { message }) => {
const configs = new Map();
for (const [TYPE] of TYPES) {
/** @type {Promise<any> | undefined} A Promise wrapping any plugins which should be loaded. */
/** @type {Promise<[...any]>} A Promise wrapping any plugins which should be loaded. */
let plugins;
/** @type {any} The PyScript configuration parsed from the JSON or TOML object*. May be any of the return types of JSON.parse() or toml-j0.4's parse() ( {number | string | boolean | null | object | Array} ) */
@@ -119,7 +119,7 @@ for (const [TYPE] of TYPES) {
}
// assign plugins as Promise.all only if needed
if (toBeAwaited.length) plugins = Promise.all(toBeAwaited);
plugins = Promise.all(toBeAwaited);
configs.set(TYPE, { config: parsed, plugins, error });
}

View File

@@ -100,6 +100,11 @@ const exportedConfig = {};
export { exportedConfig as config, hooks };
for (const [TYPE, interpreter] of TYPES) {
const dispatchDone = (element, isAsync, result) => {
if (isAsync) result.then(() => dispatch(element, TYPE, "done"));
else dispatch(element, TYPE, "done");
};
const { config, plugins, error } = configs.get(TYPE);
// create a unique identifier when/if needed
@@ -133,155 +138,162 @@ for (const [TYPE, interpreter] of TYPES) {
// define the module as both `<script type="py">` and `<py-script>`
// but only if the config didn't throw an error
if (!error) {
// possible early errors sent by polyscript
const errors = new Map();
// ensure plugins are bootstrapped already before custom type definition
// NOTE: we cannot top-level await in here as plugins import other utilities
// from core.js itself so that custom definition should not be blocking.
plugins.then(() => {
// possible early errors sent by polyscript
const errors = new Map();
define(TYPE, {
config,
interpreter,
env: `${TYPE}-script`,
version: config?.interpreter,
onerror(error, element) {
errors.set(element, error);
},
...workerHooks,
onWorkerReady(_, xworker) {
assign(xworker.sync, sync);
for (const callback of hooks.onWorkerReady)
callback(_, xworker);
},
onBeforeRun(wrap, element) {
currentElement = element;
bootstrapNodeAndPlugins(wrap, element, before, "onBeforeRun");
},
onBeforeRunAsync(wrap, element) {
currentElement = element;
bootstrapNodeAndPlugins(
wrap,
element,
before,
"onBeforeRunAsync",
);
},
onAfterRun(wrap, element) {
bootstrapNodeAndPlugins(wrap, element, after, "onAfterRun");
},
onAfterRunAsync(wrap, element) {
bootstrapNodeAndPlugins(
wrap,
element,
after,
"onAfterRunAsync",
);
},
async onInterpreterReady(wrap, element) {
if (shouldRegister) {
shouldRegister = false;
registerModule(wrap);
}
// ensure plugins are bootstrapped already
if (plugins) await plugins;
// allows plugins to do whatever they want with the element
// before regular stuff happens in here
for (const callback of hooks.onInterpreterReady)
callback(wrap, element);
// now that all possible plugins are configured,
// bail out if polyscript encountered an error
if (errors.has(element)) {
let { message } = errors.get(element);
errors.delete(element);
const clone = message === INVALID_CONTENT;
message = `(${ErrorCode.CONFLICTING_CODE}) ${message} for `;
message += element.cloneNode(clone).outerHTML;
wrap.io.stderr(message);
return;
}
if (isScript(element)) {
const {
attributes: { async: isAsync, target },
} = element;
const hasTarget = !!target?.value;
const show = hasTarget
? queryTarget(element, target.value)
: document.createElement("script-py");
if (!hasTarget) {
const { head, body } = document;
if (head.contains(element)) body.append(show);
else element.after(show);
}
if (!show.id) show.id = getID();
// allows the code to retrieve the target element via
// document.currentScript.target if needed
defineProperty(element, "target", { value: show });
// notify before the code runs
dispatch(element, TYPE);
wrap[`run${isAsync ? "Async" : ""}`](
await fetchSource(element, wrap.io, true),
define(TYPE, {
config,
interpreter,
env: `${TYPE}-script`,
version: config?.interpreter,
onerror(error, element) {
errors.set(element, error);
},
...workerHooks,
onWorkerReady(_, xworker) {
assign(xworker.sync, sync);
for (const callback of hooks.onWorkerReady)
callback(_, xworker);
},
onBeforeRun(wrap, element) {
currentElement = element;
bootstrapNodeAndPlugins(
wrap,
element,
before,
"onBeforeRun",
);
} else {
// resolve PyScriptElement to allow connectedCallback
element._wrap.resolve(wrap);
}
console.debug("[pyscript/main] PyScript Ready");
},
},
onBeforeRunAsync(wrap, element) {
currentElement = element;
bootstrapNodeAndPlugins(
wrap,
element,
before,
"onBeforeRunAsync",
);
},
onAfterRun(wrap, element) {
bootstrapNodeAndPlugins(wrap, element, after, "onAfterRun");
},
onAfterRunAsync(wrap, element) {
bootstrapNodeAndPlugins(
wrap,
element,
after,
"onAfterRunAsync",
);
},
async onInterpreterReady(wrap, element) {
if (shouldRegister) {
shouldRegister = false;
registerModule(wrap);
}
// allows plugins to do whatever they want with the element
// before regular stuff happens in here
for (const callback of hooks.onInterpreterReady)
callback(wrap, element);
// now that all possible plugins are configured,
// bail out if polyscript encountered an error
if (errors.has(element)) {
let { message } = errors.get(element);
errors.delete(element);
const clone = message === INVALID_CONTENT;
message = `(${ErrorCode.CONFLICTING_CODE}) ${message} for `;
message += element.cloneNode(clone).outerHTML;
wrap.io.stderr(message);
return;
}
if (isScript(element)) {
const {
attributes: { async: isAsync, target },
} = element;
const hasTarget = !!target?.value;
const show = hasTarget
? queryTarget(element, target.value)
: document.createElement("script-py");
if (!hasTarget) {
const { head, body } = document;
if (head.contains(element)) body.append(show);
else element.after(show);
}
if (!show.id) show.id = getID();
// allows the code to retrieve the target element via
// document.currentScript.target if needed
defineProperty(element, "target", { value: show });
// notify before the code runs
dispatch(element, TYPE, "ready");
dispatchDone(
element,
isAsync,
wrap[`run${isAsync ? "Async" : ""}`](
await fetchSource(element, wrap.io, true),
),
);
} else {
// resolve PyScriptElement to allow connectedCallback
element._wrap.resolve(wrap);
}
console.debug("[pyscript/main] PyScript Ready");
},
});
customElements.define(
`${TYPE}-script`,
class extends HTMLElement {
constructor() {
assign(super(), {
_wrap: Promise.withResolvers(),
srcCode: "",
executed: false,
});
}
get id() {
return super.id || (super.id = getID());
}
set id(value) {
super.id = value;
}
async connectedCallback() {
if (!this.executed) {
this.executed = true;
const isAsync = this.hasAttribute("async");
const { io, run, runAsync } = await this._wrap
.promise;
this.srcCode = await fetchSource(
this,
io,
!this.childElementCount,
);
this.replaceChildren();
this.style.display = "block";
dispatch(this, TYPE, "ready");
dispatchDone(
this,
isAsync,
(isAsync ? runAsync : run)(this.srcCode),
);
}
}
},
);
});
}
class PyScriptElement extends HTMLElement {
constructor() {
assign(super(), {
_wrap: Promise.withResolvers(),
srcCode: "",
executed: false,
});
}
get _pyodide() {
// TODO: deprecate this hidden attribute already
// currently used by integration tests
return this._wrap;
}
get id() {
return super.id || (super.id = getID());
}
set id(value) {
super.id = value;
}
async connectedCallback() {
if (!this.executed) {
this.executed = true;
const { io, run, runAsync } = await this._wrap.promise;
const runner = this.hasAttribute("async") ? runAsync : run;
this.srcCode = await fetchSource(
this,
io,
!this.childElementCount,
);
this.replaceChildren();
// notify before the code runs
dispatch(this, TYPE);
runner(this.srcCode);
this.style.display = "block";
}
}
}
// define py-script only if the config didn't throw an error
if (!error) customElements.define(`${TYPE}-script`, PyScriptElement);
// export the used config without allowing leaks through it
exportedConfig[TYPE] = structuredClone(config);
}
// TBD: I think manual worker cases are interesting in pyodide only
// so for the time being we should be fine with this export.
/**
* A `Worker` facade able to bootstrap on the worker thread only a PyScript module.
* @param {string} file the python file to run ina worker.
@@ -295,8 +307,8 @@ export function PyWorker(file, options) {
// and as `pyodide` is the only default interpreter that can deal with
// all the features we need to deliver pyscript out there.
const xworker = XWorker.call(new Hook(null, workerHooks), file, {
...options,
type: "pyodide",
...options,
});
assign(xworker.sync, sync);
return xworker;

View File

@@ -29,8 +29,15 @@
# pyscript.magic_js. This is the blessed way to access them from pyscript,
# as it works transparently in both the main thread and worker cases.
from pyscript.magic_js import RUNNING_IN_WORKER, window, document, sync
from pyscript.display import HTML, display
from pyscript.magic_js import (
RUNNING_IN_WORKER,
PyWorker,
current_target,
document,
sync,
window,
)
try:
from pyscript.event_handling import when
@@ -38,6 +45,5 @@ except:
from pyscript.util import NotSupported
when = NotSupported(
"pyscript.when",
"pyscript.when currently not available with this interpreter"
"pyscript.when", "pyscript.when currently not available with this interpreter"
)

View File

@@ -3,7 +3,7 @@ import html
import io
import re
from pyscript.magic_js import document, window, current_target
from pyscript.magic_js import current_target, document, window
_MIME_METHODS = {
"__repr__": "text/plain",
@@ -148,14 +148,30 @@ def _write(element, value, append=False):
def display(*values, target=None, append=True):
if target is None:
target = current_target()
elif not isinstance(target, str):
raise TypeError(f"target must be str or None, not {target.__class__.__name__}")
elif target == "":
raise ValueError("Cannot have an empty target")
elif target.startswith("#"):
# note: here target is str and not None!
# align with @when behavior
target = target[1:]
element = document.getElementById(target)
# If target cannot be found on the page, a ValueError is raised
if element is None:
raise ValueError(
f"Invalid selector with id={target}. Cannot be found in the page."
)
# if element is a <script type="py">, it has a 'target' attribute which
# points to the visual element holding the displayed values. In that case,
# use that.
if element.tagName == 'SCRIPT' and hasattr(element, 'target'):
if element.tagName == "SCRIPT" and hasattr(element, "target"):
element = element.target
for v in values:
if not append:
element.replaceChildren()
_write(element, v, append=append)

View File

@@ -1,5 +1,5 @@
from pyscript.util import NotSupported
import js as globalThis
from pyscript.util import NotSupported
RUNNING_IN_WORKER = not hasattr(globalThis, "document")
@@ -7,8 +7,9 @@ if RUNNING_IN_WORKER:
import polyscript
PyWorker = NotSupported(
'pyscript.PyWorker',
'pyscript.PyWorker works only when running in the main thread')
"pyscript.PyWorker",
"pyscript.PyWorker works only when running in the main thread",
)
window = polyscript.xworker.window
document = window.document
sync = polyscript.xworker.sync
@@ -21,11 +22,12 @@ if RUNNING_IN_WORKER:
else:
import _pyscript
from _pyscript import PyWorker
window = globalThis
document = globalThis.document
sync = NotSupported(
'pyscript.sync',
'pyscript.sync works only when running in a worker')
"pyscript.sync", "pyscript.sync works only when running in a worker"
)
# in MAIN the current element target exist, just use it
def current_target():

View File

@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PyScript Next</title>
<script>
addEventListener("py:all-done", ({ type }) => console.log(type));
</script>
<link rel="stylesheet" href="../dist/core.css">
<script type="module" src="../dist/core.js"></script>
</head>
<body>
<script type="py" worker async>
from pyscript import display
display('hello 1')
import js
import time
js.console.log('sleeping...')
time.sleep(2)
js.console.log('...done')
</script>
<p>hello 2</p>
<script type="py" worker async>
from pyscript import display
display('hello 3')
</script>
</body>
</html>

View File

@@ -1,8 +1,9 @@
import random
from datetime import datetime as dt
from pyscript import display
from pyweb import pydom
from pyweb.base import when
from datetime import datetime as dt
@when("click", "#just-a-button")

View File

@@ -1,7 +1,7 @@
import pytest
from pyscript import document, when
from unittest import mock
import pytest
from pyscript import document, when
from pyweb import pydom

View File

@@ -1,8 +1,9 @@
###### magic monkey patching ######
import sys
import builtins
from pyscript import sync
import sys
from pyodide.code import eval_code
from pyscript import sync
sys.stdout = sync
builtins.input = sync.readline

View File

@@ -1,6 +1,5 @@
from pyscript import display, sync
import a
from pyscript import display, sync
display("Hello World", target="test", append=True)

View File

@@ -54,7 +54,7 @@ def pytest_configure(config):
--no-fake-server, but because of how pytest works, they are available only
if this is the "root conftest" for the test session.
This means that if you are in the pyscriptjs directory:
This means that if you are in the pyscript.core directory:
$ py.test # does NOT work
$ py.test tests/integration/ # works
@@ -70,10 +70,9 @@ def pytest_configure(config):
"""
if not hasattr(config.option, "dev"):
msg = """
Running a bare "pytest" command from the pyscriptjs directory
Running a bare "pytest" command from the pyscript.core directory
is not supported. Please use one of the following commands:
- pytest tests/integration
- pytest tests/py-unit
- pytest tests/*
- cd tests/integration; pytest
"""

View File

@@ -2,10 +2,20 @@ import re
import pytest
from .support import PyScriptTest, skip_worker, only_main
from .support import PyScriptTest, only_main, skip_worker
class TestBasic(PyScriptTest):
def test_pyscript_exports(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import RUNNING_IN_WORKER, PyWorker, window, document, sync, current_target
</script>
"""
)
assert self.console.error.lines == []
def test_script_py_hello(self):
self.pyscript_run(
"""
@@ -96,10 +106,6 @@ class TestBasic(PyScriptTest):
assert "hello pyscript" in self.console.log.lines
self.check_py_errors("Exception: this is an error")
#
# check that we sent the traceback to the console
tb_lines = self.console.error.lines[-1].splitlines()
assert tb_lines[0] == "PythonError: Traceback (most recent call last):"
#
# check that we show the traceback in the page. Note that here we
# display the "raw" python traceback, without the "[pyexec] Python
# exception:" line (which is useful in the console, but not for the
@@ -128,10 +134,6 @@ class TestBasic(PyScriptTest):
self.check_py_errors("Exception: this is an error inside handler")
## error in console
tb_lines = self.console.error.lines[-1].splitlines()
assert tb_lines[0] == "PythonError: Traceback (most recent call last):"
## error in DOM
tb_lines = self.page.locator(".py-error").inner_text().splitlines()
assert tb_lines[0] == "Traceback (most recent call last):"

View File

@@ -1,22 +1,23 @@
################################################################################
import base64
import html
import io
import os
import re
import html
import numpy as np
import pytest
from PIL import Image
from .support import (
PageErrors,
PyScriptTest,
filter_inner_text,
filter_page_content,
wait_for_render,
skip_worker,
only_main,
skip_worker,
wait_for_render,
)
DISPLAY_OUTPUT_ID_PATTERN = r'script-py[id^="py-"]'
@@ -72,6 +73,67 @@ class TestDisplay(PyScriptTest):
mydiv = self.page.locator("#mydiv")
assert mydiv.inner_text() == "hello world"
def test_target_parameter_with_sharp(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
display('hello world', target="#mydiv")
</script>
<div id="mydiv"></div>
"""
)
mydiv = self.page.locator("#mydiv")
assert mydiv.inner_text() == "hello world"
def test_non_existing_id_target_raises_value_error(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
display('hello world', target="non-existing")
</script>
"""
)
error_msg = (
f"Invalid selector with id=non-existing. Cannot be found in the page."
)
self.check_py_errors(f"ValueError: {error_msg}")
def test_empty_string_target_raises_value_error(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
display('hello world', target="")
</script>
"""
)
self.check_py_errors(f"ValueError: Cannot have an empty target")
def test_non_string_target_values_raise_typerror(self):
self.pyscript_run(
"""
<script type="py">
from pyscript import display
display("hello False", target=False)
</script>
"""
)
error_msg = f"target must be str or None, not bool"
self.check_py_errors(f"TypeError: {error_msg}")
self.pyscript_run(
"""
<script type="py">
from pyscript import display
display("hello False", target=123)
</script>
"""
)
error_msg = f"target must be str or None, not int"
self.check_py_errors(f"TypeError: {error_msg}")
@skip_worker("NEXT: display(target=...) does not work")
def test_tag_target_attribute(self):
self.pyscript_run(

View File

@@ -1,4 +1,5 @@
import pytest
from .support import PyScriptTest, filter_inner_text, only_main

View File

@@ -15,7 +15,6 @@ from .support import ROOT, PyScriptTest, wait_for_render, with_execution_thread
reason="SKIPPING EXAMPLES: these should be moved elsewhere and updated"
)
@with_execution_thread(None)
@pytest.mark.usefixtures("chdir")
class TestExamples(PyScriptTest):
"""
Each example requires the same three tests:
@@ -26,11 +25,6 @@ class TestExamples(PyScriptTest):
- Testing that the page contains appropriate content after rendering
"""
@pytest.fixture()
def chdir(self):
# make sure that the http server serves from the right directory
ROOT.join("pyscriptjs").chdir()
def test_hello_world(self):
self.goto("examples/hello_world.html")
self.wait_for_pyscript()