mirror of
https://github.com/pyscript/pyscript.git
synced 2026-03-11 22:00:24 -04:00
Restore output attribute of py-script tags, add py-script exec lifecycle hooks (#1063)
* Add beforePyScriptExec, afterPyScriptExec lifecycle hooks * Add stdiodirector plugin for `output`, `stderr` attributes of py-script tag * Add docs on `output` and `stderr` attributes of py-script tag * Tests * Removed output deprecation warning for `output` attribute * Add createSingularWarning(), with createDeprecationWarning as alias
This commit is contained in:
@@ -3,6 +3,12 @@
|
||||
2023.01.1
|
||||
=========
|
||||
|
||||
Features
|
||||
--------
|
||||
|
||||
- Restored the `output` attribute of <py-script> tags to route `sys.stdout` to a DOM element with the given ID. ([#1063](https://github.com/pyscript/pyscript/pull/1063))
|
||||
- Added a `stderr` attribute of <py-script> tags to route `sys.stderr` to a DOM element with the given ID. ([#1063](https://github.com/pyscript/pyscript/pull/1063))
|
||||
|
||||
Bug fixes
|
||||
---------
|
||||
|
||||
|
||||
@@ -4,9 +4,24 @@ The `<py-script>` element lets you execute multi-line Python scripts both inline
|
||||
|
||||
## Attributes
|
||||
|
||||
| attribute | type | default | description |
|
||||
|-----------|------|---------|------------------------------|
|
||||
| **src** | url | | Url of a python source file. |
|
||||
| attribute | type | default | description |
|
||||
|-----------|--------|---------|------------------------------|
|
||||
| **src** | url | | Url of a python source file. |
|
||||
| **output**| string | | The id of a DOM element to route `sys.stdout` and `stderr` to, in addition to sending it to the `<py-terminal>`|
|
||||
| **stderr**| string | | The id of a DOM element to route just `sys.stderr` to, in addition to sending it to the `<py-terminal>`|
|
||||
|
||||
### output
|
||||
|
||||
If the `output` attribute is provided, any output to [sys.stdout](https://docs.python.org/3/library/sys.html#sys.stdout) or [sys.stderr](https://docs.python.org/3/library/sys.html#sys.stderr) is written to the DOM element with the ID matching the attribute. If no DOM element is found with a matching ID, a warning is shown. The msg is output to the `innerHTML` of the HTML Element, with newlines (`\n'`) converted to breaks (`<br\>`).
|
||||
|
||||
This output is in addition to the output being written to the developer console and the `<py-terminal>` if it is being used.
|
||||
|
||||
### stderr
|
||||
|
||||
If the `stderr` attribute is provided, any output to [sys.stderr](https://docs.python.org/3/library/sys.html#sys.stderr) is written to the DOM element with the ID matching the attribute. If no DOM element is found with a matching ID, a warning is shown. The msg is output to the `innerHTML` of the HTML Element, with newlines (`\n'`) converted to breaks (`<br\>`).
|
||||
|
||||
This output is in addition to the output being written to the developer console and the `<py-terminal>` if it is being used.
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
import { htmlDecode, ensureUniqueId, showWarning, createDeprecationWarning } from '../utils';
|
||||
import { htmlDecode, ensureUniqueId, createDeprecationWarning } from '../utils';
|
||||
import type { Runtime } from '../runtime';
|
||||
import { getLogger } from '../logger';
|
||||
import { pyExec } from '../pyexec';
|
||||
import { _createAlertBanner } from '../exceptions';
|
||||
import { robustFetch } from '../fetch';
|
||||
import { PyScriptApp } from '../main';
|
||||
import { Stdio } from '../stdio';
|
||||
|
||||
const logger = getLogger('py-script');
|
||||
|
||||
export function make_PyScript(runtime: Runtime) {
|
||||
export function make_PyScript(runtime: Runtime, app: PyScriptApp) {
|
||||
class PyScript extends HTMLElement {
|
||||
srcCode: string;
|
||||
srcCode: string
|
||||
stdout_manager: Stdio | null
|
||||
stderr_manager: Stdio | null
|
||||
|
||||
async connectedCallback() {
|
||||
if (this.hasAttribute('output')) {
|
||||
const deprecationMessage =
|
||||
"The 'output' attribute is deprecated and ignored. You should use " +
|
||||
"'display()' to output the content to a specific element. " +
|
||||
'For example display(myElement, target="divID").';
|
||||
showWarning(deprecationMessage);
|
||||
}
|
||||
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
|
||||
@@ -26,7 +23,9 @@ export function make_PyScript(runtime: Runtime) {
|
||||
this.srcCode = this.innerHTML;
|
||||
const pySrc = await this.getPySrc();
|
||||
this.innerHTML = '';
|
||||
pyExec(runtime, pySrc, this);
|
||||
app.plugins.beforePyScriptExec(runtime, pySrc, this);
|
||||
const result = pyExec(runtime, pySrc, this);
|
||||
app.plugins.afterPyScriptExec(runtime, pySrc, this, result);
|
||||
}
|
||||
|
||||
async getPySrc(): Promise<string> {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { type Stdio, StdioMultiplexer, DEFAULT_STDIO } from './stdio';
|
||||
import { PyTerminalPlugin } from './plugins/pyterminal';
|
||||
import { SplashscreenPlugin } from './plugins/splashscreen';
|
||||
import { ImportmapPlugin } from './plugins/importmap';
|
||||
import { StdioDirector as StdioDirector } from './plugins/stdiodirector';
|
||||
// eslint-disable-next-line
|
||||
// @ts-ignore
|
||||
import pyscript from './python/pyscript.py';
|
||||
@@ -70,6 +71,8 @@ export class PyScriptApp {
|
||||
|
||||
this._stdioMultiplexer = new StdioMultiplexer();
|
||||
this._stdioMultiplexer.addListener(DEFAULT_STDIO);
|
||||
|
||||
this.plugins.add(new StdioDirector(this._stdioMultiplexer))
|
||||
}
|
||||
|
||||
// Error handling logic: if during the execution we encounter an error
|
||||
@@ -309,7 +312,8 @@ modules must contain a "plugin" attribute. For more information check the plugin
|
||||
|
||||
// lifecycle (7)
|
||||
executeScripts(runtime: Runtime) {
|
||||
this.PyScript = make_PyScript(runtime);
|
||||
// make_PyScript takes a runtime and a PyScriptApp as arguments
|
||||
this.PyScript = make_PyScript(runtime, this);
|
||||
customElements.define('py-script', this.PyScript);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,25 @@ export class Plugin {
|
||||
*/
|
||||
afterSetup(runtime: Runtime) {}
|
||||
|
||||
/** The source of a <py-script>> tag has been fetched, and we're about
|
||||
* to evaluate that source using the provided runtime.
|
||||
*
|
||||
* @param runtime The Runtime object that will be used to evaluated the Python source code
|
||||
* @param src {string} The Python source code to be evaluated
|
||||
* @param PyScriptTag The <py-script> HTML tag that originated the evaluation
|
||||
*/
|
||||
beforePyScriptExec(runtime, src, PyScriptTag) {}
|
||||
|
||||
/** The Python in a <py-script> has just been evaluated, but control
|
||||
* has not been ceded back to the JavaScript event loop yet
|
||||
*
|
||||
* @param runtime The Runtime object that will be used to evaluated the Python source code
|
||||
* @param src {string} The Python source code to be evaluated
|
||||
* @param PyScriptTag The <py-script> HTML tag that originated the evaluation
|
||||
* @param result The returned result of evaluating the Python (if any)
|
||||
*/
|
||||
afterPyScriptExec(runtime, src, PyScriptTag, result) {}
|
||||
|
||||
/** Startup complete. The interpreter is initialized and ready, user
|
||||
* scripts have been executed: the main initialization logic ends here and
|
||||
* the page is ready to accept user interactions.
|
||||
@@ -89,6 +108,18 @@ export class PluginManager {
|
||||
for (const p of this._pythonPlugins) p.afterStartup?.(runtime);
|
||||
}
|
||||
|
||||
beforePyScriptExec(runtime, src, pyscriptTag) {
|
||||
for (const p of this._plugins) p.beforePyScriptExec(runtime, src, pyscriptTag);
|
||||
|
||||
for (const p of this._pythonPlugins) p.beforePyScriptExec?.(runtime, src, pyscriptTag);
|
||||
}
|
||||
|
||||
afterPyScriptExec(runtime: Runtime, src, pyscriptTag, result) {
|
||||
for (const p of this._plugins) p.afterPyScriptExec(runtime, src, pyscriptTag, result);
|
||||
|
||||
for (const p of this._pythonPlugins) p.afterPyScriptExec?.(runtime, src, pyscriptTag, result);
|
||||
}
|
||||
|
||||
onUserError(error: UserError) {
|
||||
for (const p of this._plugins) p.onUserError(error);
|
||||
|
||||
|
||||
53
pyscriptjs/src/plugins/stdiodirector.ts
Normal file
53
pyscriptjs/src/plugins/stdiodirector.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Plugin } from "../plugin";
|
||||
import { TargetedStdio, StdioMultiplexer } from "../stdio";
|
||||
|
||||
|
||||
/**
|
||||
* The StdioDirector plugin captures the output to Python's sys.stdio and
|
||||
* sys.stderr and writes it to a specific element in the DOM. It does this by
|
||||
* creating a new TargetedStdio manager and adding it to the global stdioMultiplexer's
|
||||
* list of listeners prior to executing the Python in a specific tag. Following
|
||||
* execution of the Python in that tag, it removes the TargetedStdio as a listener
|
||||
*
|
||||
*/
|
||||
export class StdioDirector extends Plugin {
|
||||
_stdioMultiplexer: StdioMultiplexer;
|
||||
|
||||
constructor(stdio: StdioMultiplexer) {
|
||||
super()
|
||||
this._stdioMultiplexer = stdio
|
||||
}
|
||||
|
||||
/** Prior to a <py-script> tag being evaluated, if that tag itself has
|
||||
* an 'output' attribute, a new TargetedStdio object is created and added
|
||||
* to the stdioMultiplexer to route sys.stdout and sys.stdout to the DOM object
|
||||
* with that ID for the duration of the evaluation.
|
||||
*
|
||||
*/
|
||||
beforePyScriptExec(runtime: any, src: any, PyScriptTag): void {
|
||||
if (PyScriptTag.hasAttribute("output")){
|
||||
const targeted_io = new TargetedStdio(PyScriptTag, "output", true, true)
|
||||
PyScriptTag.stdout_manager = targeted_io
|
||||
this._stdioMultiplexer.addListener(targeted_io)
|
||||
}
|
||||
if (PyScriptTag.hasAttribute("stderr")){
|
||||
const targeted_io = new TargetedStdio(PyScriptTag, "stderr", false, true)
|
||||
PyScriptTag.stderr_manager = targeted_io
|
||||
this._stdioMultiplexer.addListener(targeted_io)
|
||||
}
|
||||
}
|
||||
|
||||
/** After a <py-script> tag is evaluated, if that tag has a 'stdout_manager'
|
||||
* (presumably TargetedStdio, or some other future IO handler), it is removed.
|
||||
*/
|
||||
afterPyScriptExec(runtime: any, src: any, PyScriptTag: any, result: any): void {
|
||||
if (PyScriptTag.stdout_manager != null){
|
||||
this._stdioMultiplexer.removeListener(PyScriptTag.stdout_manager)
|
||||
PyScriptTag.stdout_manager = null
|
||||
}
|
||||
if (PyScriptTag.stderr_manager != null){
|
||||
this._stdioMultiplexer.removeListener(PyScriptTag.stderr_manager)
|
||||
PyScriptTag.stderr_manager = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createSingularWarning, escape } from "./utils";
|
||||
|
||||
export interface Stdio {
|
||||
stdout_writeline: (msg: string) => void;
|
||||
stderr_writeline: (msg: string) => void;
|
||||
@@ -36,6 +38,63 @@ export class CaptureStdio implements Stdio {
|
||||
}
|
||||
}
|
||||
|
||||
/** Stdio provider for sending output to DOM element
|
||||
* specified by ID. Used with "output" keyword.
|
||||
*
|
||||
*/
|
||||
export class TargetedStdio implements Stdio{
|
||||
|
||||
source_element: HTMLElement;
|
||||
source_attribute: string;
|
||||
capture_stdout: boolean;
|
||||
capture_stderr: boolean;
|
||||
|
||||
constructor(source_element: HTMLElement, source_attribute: string, capture_stdout = true, capture_stderr = true) {
|
||||
this.source_element = source_element;
|
||||
this.source_attribute = source_attribute;
|
||||
this.capture_stdout = capture_stdout;
|
||||
this.capture_stderr = capture_stderr;
|
||||
}
|
||||
|
||||
/** Writes the given msg to an element with a given ID. The ID is the value an attribute
|
||||
* of the source_element specified by source_attribute.
|
||||
* Both the element to be targeted and the ID of the element to write to
|
||||
* are determined at write-time, not when the TargetdStdio object is
|
||||
* created. This way, if either the 'output' attribute of the HTML tag
|
||||
* or the ID of the target element changes during execution of the Python
|
||||
* code, the output is still routed (or not) as expected
|
||||
*
|
||||
* @param msg The output to be written
|
||||
*/
|
||||
writeline_by_attribute(msg:string){
|
||||
const target_id = this.source_element.getAttribute(this.source_attribute)
|
||||
const target = document.getElementById(target_id)
|
||||
if (target === null) { // No matching ID
|
||||
createSingularWarning(`${this.source_attribute} = "${target_id}" does not match the id of any element on the page.`)
|
||||
}
|
||||
else {
|
||||
msg = escape(msg).replace("\n", "<br>")
|
||||
if (!msg.endsWith("<br/>") && !msg.endsWith("<br>")){
|
||||
msg = msg + "<br>"
|
||||
}
|
||||
target.innerHTML += msg
|
||||
}
|
||||
}
|
||||
|
||||
stdout_writeline (msg: string) {
|
||||
if (this.capture_stdout){
|
||||
this.writeline_by_attribute(msg)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
stderr_writeline (msg: string) {
|
||||
if (this.capture_stderr){
|
||||
this.writeline_by_attribute(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Redirect stdio streams to multiple listeners
|
||||
*/
|
||||
export class StdioMultiplexer implements Stdio {
|
||||
@@ -49,6 +108,13 @@ export class StdioMultiplexer implements Stdio {
|
||||
this._listeners.push(obj);
|
||||
}
|
||||
|
||||
removeListener(obj: Stdio) {
|
||||
const index = this._listeners.indexOf(obj)
|
||||
if (index > -1){
|
||||
this._listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
stdout_writeline(msg: string) {
|
||||
for (const obj of this._listeners) obj.stdout_writeline(msg);
|
||||
}
|
||||
|
||||
@@ -111,10 +111,22 @@ export function joinPaths(parts: string[], separator = '/') {
|
||||
}
|
||||
|
||||
export function createDeprecationWarning(msg: string, elementName: string): void {
|
||||
createSingularWarning(msg, elementName);
|
||||
}
|
||||
|
||||
/** Adds a warning banner with content {msg} at the top of the page if
|
||||
* and only if no banner containing the {sentinelText} already exists.
|
||||
* If sentinelText is null, the full text of {msg} is used instead
|
||||
*
|
||||
* @param msg {string} The full text content of the warning banner to be displayed
|
||||
* @param sentinelText {string} [null] The text to match against existing warning banners.
|
||||
* If null, the full text of 'msg' is used instead.
|
||||
*/
|
||||
export function createSingularWarning(msg: string, sentinelText: string | null = null): void {
|
||||
const banners = document.getElementsByClassName('alert-banner py-warning');
|
||||
let bannerCount = 0;
|
||||
for (const banner of banners) {
|
||||
if (banner.innerHTML.includes(elementName)) {
|
||||
if (banner.innerHTML.includes(sentinelText ? sentinelText : msg)) {
|
||||
bannerCount++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,18 +39,6 @@ class TestOutput(PyScriptTest):
|
||||
lines = [line for line in lines if line != ""] # remove empty lines
|
||||
assert lines == ["hello 1", "hello 2", "hello 3"]
|
||||
|
||||
def test_output_attribute_shows_deprecated_warning(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-script output="myDiv">
|
||||
display('hello world')
|
||||
</py-script>
|
||||
<div id="mydiv"></div>
|
||||
"""
|
||||
)
|
||||
warning_banner = self.page.locator(".alert-banner")
|
||||
assert "The 'output' attribute is deprecated" in warning_banner.inner_text()
|
||||
|
||||
def test_target_attribute(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
|
||||
@@ -92,7 +92,7 @@ class TestAsync(PyScriptTest):
|
||||
"b func done",
|
||||
]
|
||||
|
||||
def test_multiple_async_multiple_display_targetted(self):
|
||||
def test_multiple_async_multiple_display_targeted(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-script id='pyA'>
|
||||
@@ -124,7 +124,7 @@ class TestAsync(PyScriptTest):
|
||||
inner_text = self.page.inner_text("html")
|
||||
assert "A0\nA1\nB0\nB1" in inner_text
|
||||
|
||||
def test_async_display_untargetted(self):
|
||||
def test_async_display_untargeted(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-script id='pyA'>
|
||||
|
||||
@@ -37,6 +37,14 @@ class TestLogger(Plugin):
|
||||
def afterStartup(self, config):
|
||||
console.log('afterStartup called')
|
||||
|
||||
def beforePyScriptExec(self, runtime, src, pyscript_tag):
|
||||
console.log(f'beforePyScriptExec called')
|
||||
console.log(f'before_src:{src}')
|
||||
|
||||
def afterPyScriptExec(self, runtime, src, pyscript_tag, result):
|
||||
console.log(f'afterPyScriptExec called')
|
||||
console.log(f'after_src:{src}')
|
||||
|
||||
def onUserError(self, config):
|
||||
console.log('onUserError called')
|
||||
|
||||
@@ -44,6 +52,29 @@ class TestLogger(Plugin):
|
||||
plugin = TestLogger()
|
||||
"""
|
||||
|
||||
# Source of script that defines a plugin with only beforePyScriptExec and
|
||||
# afterPyScriptExec methods
|
||||
EXEC_HOOKS_PLUGIN_CODE = """
|
||||
from pyscript import Plugin
|
||||
from js import console
|
||||
|
||||
class ExecTestLogger(Plugin):
|
||||
|
||||
def beforePyScriptExec(self, runtime, src, pyscript_tag):
|
||||
console.log(f'beforePyScriptExec called')
|
||||
console.log(f'before_src:{src}')
|
||||
console.log(f'before_id:{pyscript_tag.id}')
|
||||
|
||||
def afterPyScriptExec(self, runtime, src, pyscript_tag, result):
|
||||
console.log(f'afterPyScriptExec called')
|
||||
console.log(f'after_src:{src}')
|
||||
console.log(f'after_id:{pyscript_tag.id}')
|
||||
console.log(f'result:{result}')
|
||||
|
||||
|
||||
plugin = ExecTestLogger()
|
||||
"""
|
||||
|
||||
# Source of a script that doesn't call define a `plugin` attribute
|
||||
NO_PLUGIN_CODE = """
|
||||
from pyscript import Plugin
|
||||
@@ -159,7 +190,12 @@ class TestPlugin(PyScriptTest):
|
||||
for each one of them"""
|
||||
# GIVEN a plugin that logs specific strings for each app execution event
|
||||
hooks_available = ["afterSetup", "afterStartup"]
|
||||
hooks_unavailable = ["configure", "beforeLaunch"]
|
||||
hooks_unavailable = [
|
||||
"configure",
|
||||
"beforeLaunch",
|
||||
"beforePyScriptExec",
|
||||
"afterPyScriptExec",
|
||||
]
|
||||
|
||||
# EXPECT it to log the correct logs for the events it intercepts
|
||||
log_lines = self.console.log.lines
|
||||
@@ -173,6 +209,28 @@ class TestPlugin(PyScriptTest):
|
||||
|
||||
# TODO: It'd be actually better to check that the events get called in order
|
||||
|
||||
@prepare_test(
|
||||
"exec_test_logger",
|
||||
EXEC_HOOKS_PLUGIN_CODE,
|
||||
template=HTML_TEMPLATE_NO_TAG + "\n<py-script id='pyid'>x=2; x</py-script>",
|
||||
)
|
||||
def test_pyscript_exec_hooks(self):
|
||||
"""Test that the beforePyScriptExec and afterPyScriptExec hooks work as intended"""
|
||||
assert self.page.locator("py-script") is not None
|
||||
|
||||
log_lines: list[str] = self.console.log.lines
|
||||
|
||||
assert "beforePyScriptExec called" in log_lines
|
||||
assert "afterPyScriptExec called" in log_lines
|
||||
|
||||
# These could be made better with a utility function that found log lines
|
||||
# that match a filter function, or start with something
|
||||
assert "before_src:x=2; x" in log_lines
|
||||
assert "before_id:pyid" in log_lines
|
||||
assert "after_src:x=2; x" in log_lines
|
||||
assert "after_id:pyid" in log_lines
|
||||
assert "result:2" in log_lines
|
||||
|
||||
@prepare_test("no_plugin", NO_PLUGIN_CODE)
|
||||
def test_no_plugin_attribute_error(self):
|
||||
"""
|
||||
|
||||
363
pyscriptjs/tests/integration/test_stdio_handling.py
Normal file
363
pyscriptjs/tests/integration/test_stdio_handling.py
Normal file
@@ -0,0 +1,363 @@
|
||||
from .support import PyScriptTest
|
||||
|
||||
|
||||
class TestOutputHandling(PyScriptTest):
|
||||
# Source of a script to test the TargetedStdio functionality
|
||||
|
||||
def test_targeted_stdio(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-config>
|
||||
terminal = true
|
||||
</py-config>
|
||||
<py-terminal></py-terminal>
|
||||
<div id="container">
|
||||
<div id="first"></div>
|
||||
<div id="second"></div>
|
||||
<div id="third"></div>
|
||||
</div>
|
||||
<py-script output="first">print("first 1.")</py-script>
|
||||
<py-script output="second">print("second.")</py-script>
|
||||
<py-script output="third">print("third.")</py-script>
|
||||
<py-script output="first">print("first 2.")</py-script>
|
||||
<py-script>print("no output.")</py-script>
|
||||
"""
|
||||
)
|
||||
|
||||
# Check that page has desired parent/child structure, and that
|
||||
# Output divs are correctly located
|
||||
assert (container := self.page.locator("#container")).count() > 0
|
||||
assert (first_div := container.locator("#first")).count() > 0
|
||||
assert (second_div := container.locator("#second")).count() > 0
|
||||
assert (third_div := container.locator("#third")).count() > 0
|
||||
|
||||
# Check that output ends up in proper div
|
||||
assert first_div.text_content() == "first 1.first 2."
|
||||
assert second_div.text_content() == "second."
|
||||
assert third_div.text_content() == "third."
|
||||
|
||||
# Check that tag with no otuput attribute doesn't end up in container at all
|
||||
assert container.get_by_text("no output.").count() == 0
|
||||
|
||||
# Check that all output ends up in py-terminal
|
||||
assert (
|
||||
self.page.locator("py-terminal").text_content()
|
||||
== "first 1.second.third.first 2.no output."
|
||||
)
|
||||
|
||||
# Check that all output ends up in the dev console, in order
|
||||
last_index = -1
|
||||
for line in ["first 1.", "second.", "third.", "first 2.", "no output."]:
|
||||
assert (line_index := self.console.log.lines.index(line)) > -1
|
||||
assert line_index > last_index
|
||||
last_index = line_index
|
||||
|
||||
self.assert_no_banners()
|
||||
|
||||
def test_stdio_escape(self):
|
||||
# Test that text that looks like HTML tags is properly escaped in stdio
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<div id="first"></div>
|
||||
<py-script output="first">
|
||||
print("<p>Hello</p>")
|
||||
print('<img src="https://example.net">')
|
||||
</py-script>
|
||||
"""
|
||||
)
|
||||
|
||||
text = self.page.locator("#first").text_content()
|
||||
|
||||
assert "<p>Hello</p>" in text
|
||||
assert '<img src="https://example.net">' in text
|
||||
|
||||
self.assert_no_banners()
|
||||
|
||||
def test_targeted_stdio_linebreaks(self):
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<div id="first"></div>
|
||||
<py-script output="first">
|
||||
print("one.")
|
||||
print("two.")
|
||||
print("three.")
|
||||
</py-script>
|
||||
|
||||
<div id="second"></div>
|
||||
<py-script output="second">
|
||||
print("one.\\ntwo.\\nthree.")
|
||||
</py-script>
|
||||
"""
|
||||
)
|
||||
|
||||
# check line breaks at end of each input
|
||||
assert self.page.locator("#first").inner_html() == "one.<br>two.<br>three.<br>"
|
||||
|
||||
# new lines are converted to line breaks
|
||||
assert self.page.locator("#second").inner_html() == "one.<br>two.<br>three.<br>"
|
||||
|
||||
self.assert_no_banners()
|
||||
|
||||
def test_targeted_stdio_async(self):
|
||||
# Test the behavior of stdio capture in async contexts
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-script>
|
||||
import asyncio
|
||||
import js
|
||||
|
||||
async def coro(value, delay):
|
||||
print(value)
|
||||
await asyncio.sleep(delay)
|
||||
js.console.log(f"DONE {value}")
|
||||
</py-script>
|
||||
|
||||
<div id="first"></div>
|
||||
<py-script>
|
||||
asyncio.ensure_future(coro("first", 1))
|
||||
</py-script>
|
||||
|
||||
<div id="second"></div>
|
||||
<py-script output="second">
|
||||
asyncio.ensure_future(coro("second", 1))
|
||||
</py-script>
|
||||
|
||||
<div id="third"></div>
|
||||
<py-script output="third">
|
||||
asyncio.ensure_future(coro("third", 0))
|
||||
</py-script>
|
||||
|
||||
<py-script output="third">
|
||||
asyncio.ensure_future(coro("DONE", 3))
|
||||
</py-script>
|
||||
"""
|
||||
)
|
||||
|
||||
self.wait_for_console("DONE DONE")
|
||||
|
||||
# py-script tags without output parameter should not send
|
||||
# stdout to element
|
||||
assert self.page.locator("#first").text_content() == ""
|
||||
|
||||
# py-script tags with output parameter not expected to send
|
||||
# std to element in coroutine
|
||||
assert self.page.locator("#second").text_content() == ""
|
||||
assert self.page.locator("#third").text_content() == ""
|
||||
|
||||
self.assert_no_banners()
|
||||
|
||||
def test_targeted_stdio_interleaved(self):
|
||||
# Test that synchronous writes to stdout are placed correctly, even
|
||||
# While interleaved with scheduling coroutines in the same tag
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<div id="good"></div>
|
||||
<div id="bad"></div>
|
||||
<py-script output="good">
|
||||
import asyncio
|
||||
import js
|
||||
|
||||
async def coro_bad(value, delay):
|
||||
print(value)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
print("one.")
|
||||
asyncio.ensure_future(coro_bad("badone.", 0.1))
|
||||
print("two.")
|
||||
asyncio.ensure_future(coro_bad("badtwo.", 0.2))
|
||||
print("three.")
|
||||
asyncio.ensure_future(coro_bad("badthree.", 0))
|
||||
asyncio.ensure_future(coro_bad("DONE", 1))
|
||||
</py-script>
|
||||
"""
|
||||
)
|
||||
|
||||
# Three prints should appear from synchronous writes
|
||||
assert self.page.locator("#good").text_content() == "one.two.three."
|
||||
|
||||
# Check that all output ends up in the dev console, in order
|
||||
last_index = -1
|
||||
for line in ["one.", "two.", "three.", "badthree.", "badone.", "badtwo."]:
|
||||
assert (line_index := self.console.log.lines.index(line)) > -1
|
||||
assert line_index > last_index
|
||||
|
||||
self.assert_no_banners()
|
||||
|
||||
def test_targeted_stdio_dynamic_tags(self):
|
||||
# Test that creating py-script tags via Python still leaves
|
||||
# stdio targets working
|
||||
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<div id="first"></div>
|
||||
<div id="second"></div>
|
||||
<py-script output="first">
|
||||
print("first.")
|
||||
|
||||
import js
|
||||
tag = js.document.createElement("py-script")
|
||||
tag.innerText = "print('second.')"
|
||||
tag.setAttribute("output", "second")
|
||||
js.document.body.appendChild(tag)
|
||||
|
||||
print("first.")
|
||||
</py-script>
|
||||
"""
|
||||
)
|
||||
|
||||
# Ensure second tag was added to page
|
||||
assert (second_div := self.page.locator("#second")).count() > 0
|
||||
|
||||
# Ensure output when to correct locations
|
||||
assert self.page.locator("#first").text_content() == "first.first."
|
||||
assert second_div.text_content() == "second."
|
||||
|
||||
self.assert_no_banners()
|
||||
|
||||
def test_stdio_stdout_id_errors(self):
|
||||
# Test that using an ID not present on the page as the Output
|
||||
# Attribute creates exactly 1 warning banner per missing id
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-script output="not-on-page">
|
||||
print("bad.")
|
||||
</py-script>
|
||||
|
||||
<div id="on-page"></div>
|
||||
<py-script>
|
||||
print("good.")
|
||||
</py-script>
|
||||
|
||||
<py-script output="not-on-page">
|
||||
print("bad.")
|
||||
</py-script>
|
||||
"""
|
||||
)
|
||||
|
||||
banner = self.page.query_selector_all(".py-warning")
|
||||
assert len(banner) == 1
|
||||
banner_content = banner[0].inner_text()
|
||||
expected = (
|
||||
'output = "not-on-page" does not match the id of any element on the page.'
|
||||
)
|
||||
|
||||
assert banner_content == expected
|
||||
|
||||
def test_stdio_stderr_id_errors(self):
|
||||
# Test that using an ID not present on the page as the stderr
|
||||
# attribute creates exactly 1 warning banner per missing id
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-script stderr="not-on-page">
|
||||
import sys
|
||||
print("bad.", file=sys.stderr)
|
||||
</py-script>
|
||||
|
||||
<div id="on-page"></div>
|
||||
<py-script>
|
||||
print("good.", file=sys.stderr)
|
||||
</py-script>
|
||||
|
||||
<py-script stderr="not-on-page">
|
||||
print("bad.", file=sys.stderr)
|
||||
</py-script>
|
||||
"""
|
||||
)
|
||||
|
||||
banner = self.page.query_selector_all(".py-warning")
|
||||
assert len(banner) == 1
|
||||
banner_content = banner[0].inner_text()
|
||||
expected = (
|
||||
'stderr = "not-on-page" does not match the id of any element on the page.'
|
||||
)
|
||||
|
||||
assert banner_content == expected
|
||||
|
||||
def test_stdio_stderr(self):
|
||||
# Test that stderr works, and routes to the same location as stdout
|
||||
# Also, script tags with the stderr attribute route to an additional location
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<div id="stdout-div"></div>
|
||||
<div id="stderr-div"></div>
|
||||
<py-script output="stdout-div" stderr="stderr-div">
|
||||
import sys
|
||||
print("one.", file=sys.stderr)
|
||||
print("two.")
|
||||
</py-script>
|
||||
"""
|
||||
)
|
||||
|
||||
assert self.page.locator("#stdout-div").text_content() == "one.two."
|
||||
assert self.page.locator("#stderr-div").text_content() == "one."
|
||||
self.assert_no_banners()
|
||||
|
||||
def test_stdio_output_attribute_change(self):
|
||||
# If the user changes the 'output' attribute of a <py-script> tag mid-execution,
|
||||
# Output should no longer go to the selected div and a warning should appear
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<div id="first"></div>
|
||||
<div id="second"></div>
|
||||
<!-- There is no tag with id "third" -->
|
||||
<py-script id="pyscript-tag" output="first">
|
||||
print("one.")
|
||||
|
||||
# Change the 'output' attribute of this tag
|
||||
import js
|
||||
this_tag = js.document.getElementById("pyscript-tag")
|
||||
|
||||
this_tag.setAttribute("output", "second")
|
||||
print("two.")
|
||||
|
||||
this_tag.setAttribute("output", "third")
|
||||
print("three.")
|
||||
</py-script>
|
||||
"""
|
||||
)
|
||||
|
||||
assert self.page.locator("#first").text_content() == "one."
|
||||
assert self.page.locator("#second").text_content() == "two."
|
||||
expected_alert_banner_msg = (
|
||||
'output = "third" does not match the id of any element on the page.'
|
||||
)
|
||||
|
||||
alert_banner = self.page.locator(".alert-banner")
|
||||
assert expected_alert_banner_msg in alert_banner.inner_text()
|
||||
|
||||
def test_stdio_target_element_id_change(self):
|
||||
# If the user changes the ID of the targeted DOM element mid-execution,
|
||||
# Output should no longer go to the selected element and a warning should appear
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<div id="first"></div>
|
||||
<div id="second"></div>
|
||||
<!-- There is no tag with id "third" -->
|
||||
<py-script id="pyscript-tag" output="first">
|
||||
print("one.")
|
||||
|
||||
# Change the ID of the targeted DIV to something else
|
||||
import js
|
||||
target_tag = js.document.getElementById("first")
|
||||
|
||||
# should fail and show banner
|
||||
target_tag.setAttribute("id", "second")
|
||||
print("two.")
|
||||
|
||||
# But changing both the 'output' attribute and the id of the target
|
||||
# should work
|
||||
target_tag.setAttribute("id", "third")
|
||||
js.document.getElementById("pyscript-tag").setAttribute("output", "third")
|
||||
print("three.")
|
||||
</py-script>
|
||||
"""
|
||||
)
|
||||
|
||||
# Note the ID of the div has changed by the time of this assert
|
||||
assert self.page.locator("#third").text_content() == "one.three."
|
||||
|
||||
expected_alert_banner_msg = (
|
||||
'output = "first" does not match the id of any element on the page.'
|
||||
)
|
||||
alert_banner = self.page.locator(".alert-banner")
|
||||
assert expected_alert_banner_msg in alert_banner.inner_text()
|
||||
28
pyscriptjs/tests/integration/test_warnings_and_banners.py
Normal file
28
pyscriptjs/tests/integration/test_warnings_and_banners.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from .support import PyScriptTest
|
||||
|
||||
|
||||
class TestWarningsAndBanners(PyScriptTest):
|
||||
# Test the behavior of generated warning banners
|
||||
|
||||
def test_create_singular_warning(self):
|
||||
# Use a script tag with an invalid output attribute to generate a warning, but only one
|
||||
self.pyscript_run(
|
||||
"""
|
||||
<py-script output="foo">
|
||||
print("one.")
|
||||
print("two.")
|
||||
</py-script>
|
||||
<py-script output="foo">
|
||||
print("three.")
|
||||
</py-script>
|
||||
"""
|
||||
)
|
||||
|
||||
loc = self.page.locator(".alert-banner")
|
||||
|
||||
# Only one banner should appear
|
||||
assert loc.count() == 1
|
||||
assert (
|
||||
loc.text_content()
|
||||
== 'output = "foo" does not match the id of any element on the page.'
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type Stdio, CaptureStdio, StdioMultiplexer } from '../../src/stdio';
|
||||
import { expect } from '@jest/globals';
|
||||
import { type Stdio, CaptureStdio, StdioMultiplexer, TargetedStdio } from '../../src/stdio';
|
||||
|
||||
describe('CaptureStdio', () => {
|
||||
it('captured streams are initialized to empty string', () => {
|
||||
@@ -65,3 +66,75 @@ describe('StdioMultiplexer', () => {
|
||||
expect(b.captured_stderr).toBe("err 2\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe('TargetedStdio', () => {
|
||||
let capture: CaptureStdio;
|
||||
let targeted: TargetedStdio;
|
||||
let error_targeted: TargetedStdio;
|
||||
let multi: StdioMultiplexer;
|
||||
|
||||
beforeEach(() => {
|
||||
//DOM element to capture stdout and stderr
|
||||
let target_div = document.getElementById("output-id");
|
||||
|
||||
if (target_div=== null){
|
||||
target_div = document.createElement('div');
|
||||
target_div.id = "output-id";
|
||||
document.body.appendChild(target_div);
|
||||
}
|
||||
else {
|
||||
target_div.innerHTML = "";
|
||||
}
|
||||
|
||||
//DOM element to capture stderr
|
||||
let error_div = document.getElementById("error-id");
|
||||
|
||||
if (error_div=== null){
|
||||
error_div = document.createElement('div');
|
||||
error_div.id = "error-id";
|
||||
document.body.appendChild(error_div);
|
||||
}
|
||||
else {
|
||||
error_div.innerHTML = "";
|
||||
}
|
||||
|
||||
const tag = document.createElement('div');
|
||||
tag.setAttribute("output", "output-id");
|
||||
tag.setAttribute("stderr", "error-id");
|
||||
|
||||
capture = new CaptureStdio();
|
||||
targeted = new TargetedStdio(tag, 'output', true, true);
|
||||
error_targeted = new TargetedStdio(tag, 'stderr', false, true);
|
||||
|
||||
multi = new StdioMultiplexer();
|
||||
multi.addListener(capture);
|
||||
multi.addListener(targeted);
|
||||
multi.addListener(error_targeted);
|
||||
});
|
||||
|
||||
it('targeted id is set by constructor', () =>{
|
||||
expect(targeted.source_attribute).toBe("output");
|
||||
});
|
||||
|
||||
it('targeted stdio/stderr also goes to multiplexer', () =>{
|
||||
multi.stdout_writeline("out 1");
|
||||
multi.stderr_writeline("out 2");
|
||||
expect(capture.captured_stdout).toBe("out 1\n");
|
||||
expect(capture.captured_stderr).toBe("out 2\n");
|
||||
expect(document.getElementById("output-id")?.innerHTML).toBe("out 1<br>out 2<br>");
|
||||
expect(document.getElementById("error-id")?.innerHTML).toBe("out 2<br>");
|
||||
});
|
||||
|
||||
it('Add and remove targeted listener', () => {
|
||||
multi.stdout_writeline("out 1");
|
||||
multi.removeListener(targeted);
|
||||
multi.stdout_writeline("out 2");
|
||||
multi.addListener(targeted);
|
||||
multi.stdout_writeline("out 3");
|
||||
|
||||
//all three should be captured by multiplexer
|
||||
expect(capture.captured_stdout).toBe("out 1\nout 2\nout 3\n");
|
||||
//out 2 should not be present in the DOM element
|
||||
expect(document.getElementById("output-id")?.innerHTML).toBe("out 1<br>out 3<br>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, expect, describe, it } from "@jest/globals"
|
||||
import { ensureUniqueId, joinPaths} from "../../src/utils"
|
||||
import { ensureUniqueId, joinPaths, createSingularWarning} from "../../src/utils"
|
||||
|
||||
describe("Utils", () => {
|
||||
|
||||
@@ -51,4 +51,23 @@ describe("JoinPaths", () => {
|
||||
const joinedPath = joinPaths(paths);
|
||||
expect(joinedPath).toStrictEqual('hhh/ll/pp/kkk');
|
||||
})
|
||||
|
||||
describe("createSingularBanner", () => {
|
||||
it("should create one and new banner containing the sentinel text, and not duplicate it", () => {
|
||||
//One warning banner with the desired text should be created
|
||||
createSingularWarning("A unique error message", "unique")
|
||||
expect(document.getElementsByClassName("alert-banner")?.length).toEqual(1)
|
||||
expect(document.getElementsByClassName("alert-banner")[0].textContent).toEqual(expect.stringContaining("A unique error message"))
|
||||
|
||||
//Should still only be one banner, since the second uses the existing sentinel value "unique"
|
||||
createSingularWarning("This banner should not appear", "unique")
|
||||
expect(document.getElementsByClassName("alert-banner")?.length).toEqual(1)
|
||||
expect(document.getElementsByClassName("alert-banner")[0].textContent).toEqual(expect.stringContaining("A unique error message"))
|
||||
|
||||
//If the sentinel value is not provided, the entire msg is used as the sentinel
|
||||
createSingularWarning("A unique error message", null)
|
||||
expect(document.getElementsByClassName("alert-banner")?.length).toEqual(1)
|
||||
expect(document.getElementsByClassName("alert-banner")[0].textContent).toEqual(expect.stringContaining("A unique error message"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user