diff --git a/docs/changelog.md b/docs/changelog.md index c56f5b5d6ea..cfdc4bd5e9b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,8 +6,21 @@ Features -------- + +### <py-terminal> - Added a `docked` field and attribute for the `` custom element, enabled by default when the terminal is in `auto` mode, and able to dock the terminal at the bottom of the page with auto scroll on new code execution. +### <py-script> +- 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)) + +### <py-repl> +- The `output` attribute of `py-repl` tags now specifies the id of the DOM element that `sys.stdout`, `sys.stderr`, and the results of a REPL execution are written to. It no longer affects the location of calls to `display()` +- Added a `stderr` attribute of `py-repl` tags to route `sys.stderr` to a DOM element with the given ID. ([#1106](https://github.com/pyscript/pyscript/pull/1106)) +- Resored the `output-mode` attribute of `py-repl` tags. If `output-mode` == 'append', the DOM element where output is printed is _not_ cleared before writing new results. + +### Plugins +- Plugins may now implement the `beforePyReplExec()` and `afterPyReplExec()` hooks, which are called immediately before and after code in a `py-repl` tag is executed. ([#1106](https://github.com/pyscript/pyscript/pull/1106)) Bug fixes --------- @@ -24,12 +37,10 @@ Enhancements 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 --------- @@ -39,6 +50,7 @@ Bug fixes Enhancements ------------ + - When adding a `py-` attribute to an element but didn't added an `id` attribute, PyScript will now generate a random ID for the element instead of throwing an error which caused the splash screen to not shutdown. ([#1122](https://github.com/pyscript/pyscript/pull/1122)) - You can now disable the splashscreen by setting `enabled = false` in your `py-config` under the `[splashscreen]` configuration section. ([#1138](https://github.com/pyscript/pyscript/pull/1138)) diff --git a/docs/reference/elements/py-repl.md b/docs/reference/elements/py-repl.md index 3005db20634..244211e54d7 100644 --- a/docs/reference/elements/py-repl.md +++ b/docs/reference/elements/py-repl.md @@ -7,24 +7,42 @@ The `` element provides a REPL(Read Eval Print Loop) to evaluate multi- | attribute | type | default | description | |-------------------|---------|---------|---------------------------------------| | **auto-generate** | boolean | | Auto-generates REPL after evaluation | -| **output** | string | | The element to write output into | +| **output-mode** | string | "" | Determines whether the output element is cleared prior to writing output | +| **output** | string | | The id of the element to write `stdout` and `stderr` to | +| **stderr** | string | | The id of the element to write `stderr` to | -### Examples -#### `` element set to auto-generate +### `auto-generate` +If a \ tag has the `auto-generate` attribute, upon execution, another \ tag will be created and added to the DOM as a sibling of the current tag. + +### `output-mode` +By default, the element which displays the output from a REPL is cleared (`innerHTML` set to "") prior to each new execution of the REPL. If `output-mode` == "append", that element is not cleared, and the output is appended instead. + +### `output` +The ID of an element in the DOM that `stdout` (e.g. `print()`), `stderr`, and the results of executing the repl are written to. Defaults to an automatically-generated \ as the next sibling of the REPL itself. + +### `stderr` +The ID of an element in the DOM that `stderr` will be written to. Defaults to None, though writes to `stderr` will still appear in the location specified by `output`. + +## Examples + +### `` element set to auto-generate ```html ``` -#### `` element with output +### `` element with output + +The following will write "Hello! World!" to the div with id `replOutput`. ```html
- hello = "Hello world!" + print("Hello!") + hello = "World!" hello ``` -Note that if we `print` any element in the repl, the output will be printed in the [`py-terminal`](../plugins/py-terminal.md) if is enabled. +Note that if we `print` from the REPL (or otherwise write to `sys.stdout`), the output will be printed in the [`py-terminal`](../plugins/py-terminal.md) if is enabled. diff --git a/pyscriptjs/src/components/elements.ts b/pyscriptjs/src/components/elements.ts index 7524cbd8033..9c24841b7ce 100644 --- a/pyscriptjs/src/components/elements.ts +++ b/pyscriptjs/src/components/elements.ts @@ -1,10 +1,11 @@ import { InterpreterClient } from '../interpreter_client'; +import type { PyScriptApp } from '../main'; import { make_PyRepl } from './pyrepl'; import { make_PyWidget } from './pywidget'; -function createCustomElements(interpreter: InterpreterClient) { +function createCustomElements(interpreter: InterpreterClient, app: PyScriptApp) { const PyWidget = make_PyWidget(interpreter); - const PyRepl = make_PyRepl(interpreter); + const PyRepl = make_PyRepl(interpreter, app); /* eslint-disable @typescript-eslint/no-unused-vars */ const xPyRepl = customElements.define('py-repl', PyRepl); diff --git a/pyscriptjs/src/components/pyrepl.ts b/pyscriptjs/src/components/pyrepl.ts index 48840ed1510..e560fade7ae 100644 --- a/pyscriptjs/src/components/pyrepl.ts +++ b/pyscriptjs/src/components/pyrepl.ts @@ -7,14 +7,16 @@ import { defaultKeymap } from '@codemirror/commands'; import { oneDarkTheme } from '@codemirror/theme-one-dark'; import { getAttribute, ensureUniqueId, htmlDecode } from '../utils'; -import { pyExec, pyDisplay } from '../pyexec'; +import { pyExec } from '../pyexec'; import { getLogger } from '../logger'; import { InterpreterClient } from '../interpreter_client'; +import type { PyScriptApp } from '../main'; +import { Stdio } from '../stdio'; const logger = getLogger('py-repl'); const RUNBUTTON = ``; -export function make_PyRepl(interpreter: InterpreterClient) { +export function make_PyRepl(interpreter: InterpreterClient, app: PyScriptApp) { /* High level structure of py-repl DOM, and the corresponding JS names. this @@ -31,6 +33,8 @@ export function make_PyRepl(interpreter: InterpreterClient) { shadow: ShadowRoot; outDiv: HTMLElement; editor: EditorView; + stdout_manager: Stdio | null; + stderr_manager: Stdio | null; constructor() { super(); @@ -152,27 +156,19 @@ export function make_PyRepl(interpreter: InterpreterClient) { */ async execute(): Promise { const pySrc = this.getPySrc(); - - // determine the output element - const outEl = this.getOutputElement(); - if (outEl === undefined) { - // this happens if we specified output="..." but we couldn't - // find the ID. We already displayed an error message inside - // getOutputElement, stop the execution. - return; - } - - // clear the old output before executing the new code - outEl.innerHTML = ''; + const outEl = this.outDiv; // execute the python code + app.plugins.beforePyReplExec({ interpreter: interpreter, src: pySrc, outEl: outEl, pyReplTag: this }); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const pyResult = (await pyExec(interpreter, pySrc, outEl)).result; - - // display the value of the last evaluated expression (REPL-style) - if (pyResult !== undefined) { - pyDisplay(interpreter, pyResult, { target: outEl.id }); - } + app.plugins.afterPyReplExec({ + interpreter: interpreter, + src: pySrc, + outEl: outEl, + pyReplTag: this, + result: pyResult, // eslint-disable-line @typescript-eslint/no-unsafe-assignment + }); this.autogenerateMaybe(); } @@ -181,21 +177,6 @@ export function make_PyRepl(interpreter: InterpreterClient) { return this.editor.state.doc.toString(); } - getOutputElement(): HTMLElement { - const outputID = getAttribute(this, 'output'); - if (outputID !== null) { - const el = document.getElementById(outputID); - if (el === null) { - const err = `py-repl ERROR: cannot find the output element #${outputID} in the DOM`; - this.outDiv.innerText = err; - return undefined; - } - return el; - } else { - return this.outDiv; - } - } - // XXX the autogenerate logic is very messy. We should redo it, and it // should be the default. autogenerateMaybe(): void { @@ -206,27 +187,21 @@ export function make_PyRepl(interpreter: InterpreterClient) { const nextExecId = parseInt(lastExecId) + 1; const newPyRepl = document.createElement('py-repl'); - newPyRepl.setAttribute('root', this.getAttribute('root')); - newPyRepl.id = this.getAttribute('root') + '-' + nextExecId.toString(); - if (this.hasAttribute('auto-generate')) { - newPyRepl.setAttribute('auto-generate', ''); - this.removeAttribute('auto-generate'); - } - - const outputMode = getAttribute(this, 'output-mode'); - if (outputMode) { - newPyRepl.setAttribute('output-mode', outputMode); - } - - const addReplAttribute = (attribute: string) => { + //Attributes to be copied from old REPL to auto-generated REPL + for (const attribute of ['root', 'output-mode', 'output', 'stderr']) { const attr = getAttribute(this, attribute); if (attr) { newPyRepl.setAttribute(attribute, attr); } - }; + } - addReplAttribute('output'); + newPyRepl.id = this.getAttribute('root') + '-' + nextExecId.toString(); + + if (this.hasAttribute('auto-generate')) { + newPyRepl.setAttribute('auto-generate', ''); + this.removeAttribute('auto-generate'); + } newPyRepl.setAttribute('exec-id', nextExecId.toString()); if (this.parentElement) { diff --git a/pyscriptjs/src/main.ts b/pyscriptjs/src/main.ts index cd442cb9b63..072c3352332 100644 --- a/pyscriptjs/src/main.ts +++ b/pyscriptjs/src/main.ts @@ -189,8 +189,9 @@ export class PyScriptApp { this.logStatus('Initializing web components...'); // lifecycle (8) - createCustomElements(interpreter); + //Takes a runtime and a reference to the PyScriptApp (to access plugins) + createCustomElements(interpreter, this); await initHandlers(interpreter); // NOTE: interpreter message is used by integration tests to know that diff --git a/pyscriptjs/src/plugin.ts b/pyscriptjs/src/plugin.ts index e8dc737fc4f..fad343e05c9 100644 --- a/pyscriptjs/src/plugin.ts +++ b/pyscriptjs/src/plugin.ts @@ -55,7 +55,7 @@ export class Plugin { /** The source of a > tag has been fetched, and we're about * to evaluate that source using the provided interpreter. * - * @param options.interpreter The Interpreter object that will be used to evaluated the Python source code + * @param options.interpreter The Interpreter object that will be used to evaluate the Python source code * @param options.src {string} The Python source code to be evaluated * @param options.pyScriptTag The HTML tag that originated the evaluation */ @@ -66,7 +66,7 @@ export class Plugin { /** The Python in a has just been evaluated, but control * has not been ceded back to the JavaScript event loop yet * - * @param options.interpreter The Interpreter object that will be used to evaluated the Python source code + * @param options.interpreter The Interpreter object that will be used to evaluate the Python source code * @param options.src {string} The Python source code to be evaluated * @param options.pyScriptTag The HTML tag that originated the evaluation * @param options.result The returned result of evaluating the Python (if any) @@ -80,6 +80,36 @@ export class Plugin { /* empty */ } + /** The source of the tag has been fetched and its output-element determined; + * we're about to evaluate the source using the provided interpreter + * + * @param options.interpreter The interpreter object that will be used to evaluated the Python source code + * @param options.src {string} The Python source code to be evaluated + * @param options.outEl The element that the result of the REPL evaluation will be output to. + * @param options.pyReplTag The HTML tag the originated the evaluation + */ + beforePyReplExec(options: { interpreter: InterpreterClient; src: string; outEl: HTMLElement; pyReplTag: any }) { + /* empty */ + } + + /** + * + * @param options.interpreter The interpreter object that will be used to evaluated the Python source code + * @param options.src {string} The Python source code to be evaluated + * @param options.outEl The element that the result of the REPL evaluation will be output to. + * @param options.pyReplTag The HTML tag the originated the evaluation + * @param options.result The result of evaluating the Python (if any) + */ + afterPyReplExec(options: { + interpreter: InterpreterClient; + src: string; + outEl: HTMLElement; + pyReplTag: HTMLElement; + result: any; + }) { + /* empty */ + } + /** 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. @@ -158,6 +188,18 @@ export class PluginManager { for (const p of this._pythonPlugins) p.afterPyScriptExec?.callKwargs(options); } + beforePyReplExec(options: { interpreter: InterpreterClient; src: string; outEl: HTMLElement; pyReplTag: any }) { + for (const p of this._plugins) p.beforePyReplExec(options); + + for (const p of this._pythonPlugins) p.beforePyReplExec?.callKwargs(options); + } + + afterPyReplExec(options: { interpreter: InterpreterClient; src: string; outEl; pyReplTag; result }) { + for (const p of this._plugins) p.afterPyReplExec(options); + + for (const p of this._pythonPlugins) p.afterPyReplExec?.callKwargs(options); + } + onUserError(error: UserError) { for (const p of this._plugins) p.onUserError?.(error); diff --git a/pyscriptjs/src/plugins/stdiodirector.ts b/pyscriptjs/src/plugins/stdiodirector.ts index 2cb379ac419..9f7c463ccd7 100644 --- a/pyscriptjs/src/plugins/stdiodirector.ts +++ b/pyscriptjs/src/plugins/stdiodirector.ts @@ -1,7 +1,10 @@ import { Plugin } from '../plugin'; import { TargetedStdio, StdioMultiplexer } from '../stdio'; +import type { InterpreterClient } from '../interpreter_client'; +import { createSingularWarning } from '../utils'; import { make_PyScript } from '../components/pyscript'; -import { InterpreterClient } from '../interpreter_client'; +import { pyDisplay } from '../pyexec'; +import { make_PyRepl } from '../components/pyrepl'; type PyScriptTag = InstanceType>; @@ -58,4 +61,71 @@ export class StdioDirector extends Plugin { options.pyScriptTag.stderr_manager = null; } } + + beforePyReplExec(options: { + interpreter: InterpreterClient; + src: string; + outEl: HTMLElement; + pyReplTag: InstanceType>; + }): void { + //Handle 'output-mode' attribute (removed in PR #881/f9194cc8, restored here) + //If output-mode == 'append', don't clear target tag before writing + if (options.pyReplTag.getAttribute('output-mode') != 'append') { + options.outEl.innerHTML = ''; + } + + // Handle 'output' attribute; defaults to writing stdout to the existing outEl + // If 'output' attribute is used, the DOM element with the specified ID receives + // -both- sys.stdout and sys.stderr + let output_targeted_io: TargetedStdio; + if (options.pyReplTag.hasAttribute('output')) { + output_targeted_io = new TargetedStdio(options.pyReplTag, 'output', true, true); + } else { + output_targeted_io = new TargetedStdio(options.pyReplTag.outDiv, 'id', true, true); + } + options.pyReplTag.stdout_manager = output_targeted_io; + this._stdioMultiplexer.addListener(output_targeted_io); + + //Handle 'stderr' attribute; + if (options.pyReplTag.hasAttribute('stderr')) { + const stderr_targeted_io = new TargetedStdio(options.pyReplTag, 'stderr', false, true); + options.pyReplTag.stderr_manager = stderr_targeted_io; + this._stdioMultiplexer.addListener(stderr_targeted_io); + } + } + + afterPyReplExec(options: { + interpreter: InterpreterClient; + src: string; + outEl: HTMLElement; + pyReplTag: InstanceType>; + result: any; // eslint-disable-line @typescript-eslint/no-explicit-any + }): void { + // display the value of the last-evaluated expression in the REPL + if (options.result !== undefined) { + const outputId: string | undefined = options.pyReplTag.getAttribute('output'); + if (outputId) { + // 'output' attribute also used as location to send + // result of REPL + if (document.getElementById(outputId)) { + pyDisplay(options.interpreter, options.result, { target: outputId }); + } else { + //no matching element on page + createSingularWarning(`output = "${outputId}" does not match the id of any element on the page.`); + } + } else { + // 'otuput atribuite not provided + pyDisplay(options.interpreter, options.result, { target: options.outEl.id }); + } + } + + if (options.pyReplTag.stdout_manager != null) { + this._stdioMultiplexer.removeListener(options.pyReplTag.stdout_manager); + options.pyReplTag.stdout_manager = null; + } + if (options.pyReplTag.stderr_manager != null) { + this._stdioMultiplexer.removeListener(options.pyReplTag.stderr_manager); + options.pyReplTag.stderr_manager = null; + } + } } diff --git a/pyscriptjs/tests/integration/test_plugins.py b/pyscriptjs/tests/integration/test_plugins.py index e51eaac04df..a1d32bac593 100644 --- a/pyscriptjs/tests/integration/test_plugins.py +++ b/pyscriptjs/tests/integration/test_plugins.py @@ -54,7 +54,7 @@ def onUserError(self, config): # Source of script that defines a plugin with only beforePyScriptExec and # afterPyScriptExec methods -EXEC_HOOKS_PLUGIN_CODE = """ +PYSCRIPT_HOOKS_PLUGIN_CODE = """ from pyscript import Plugin from js import console @@ -75,6 +75,31 @@ def afterPyScriptExec(self, interpreter, src, pyScriptTag, result): plugin = ExecTestLogger() """ +# Source of script that defines a plugin with only beforePyScriptExec and +# afterPyScriptExec methods +PYREPL_HOOKS_PLUGIN_CODE = """ +from pyscript import Plugin +from js import console + +console.warn("This is in pyrepl hooks file") + +class PyReplTestLogger(Plugin): + + def beforePyReplExec(self, interpreter, outEl, src, pyReplTag): + console.log(f'beforePyReplExec called') + console.log(f'before_src:{src}') + console.log(f'before_id:{pyReplTag.id}') + + def afterPyReplExec(self, interpreter, src, outEl, pyReplTag, result): + console.log(f'afterPyReplExec called') + console.log(f'after_src:{src}') + console.log(f'after_id:{pyReplTag.id}') + console.log(f'result:{result}') + + +plugin = PyReplTestLogger() +""" + # Source of a script that doesn't call define a `plugin` attribute NO_PLUGIN_CODE = """ from pyscript import Plugin @@ -195,6 +220,8 @@ def test_execution_hooks(self): "beforeLaunch", "beforePyScriptExec", "afterPyScriptExec", + "beforePyReplExec", + "afterPyReplExec", ] # EXPECT it to log the correct logs for the events it intercepts @@ -211,7 +238,7 @@ def test_execution_hooks(self): @prepare_test( "exec_test_logger", - EXEC_HOOKS_PLUGIN_CODE, + PYSCRIPT_HOOKS_PLUGIN_CODE, template=HTML_TEMPLATE_NO_TAG + "\nx=2; x", ) def test_pyscript_exec_hooks(self): @@ -231,6 +258,28 @@ def test_pyscript_exec_hooks(self): assert "after_id:pyid" in log_lines assert "result:2" in log_lines + @prepare_test( + "pyrepl_test_logger", + PYREPL_HOOKS_PLUGIN_CODE, + template=HTML_TEMPLATE_NO_TAG + "\nx=2; x", + ) + def test_pyrepl_exec_hooks(self): + py_repl = self.page.locator("py-repl") + py_repl.locator("button").click() + + log_lines: list[str] = self.console.log.lines + + assert "beforePyReplExec called" in log_lines + assert "afterPyReplExec 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): """ diff --git a/pyscriptjs/tests/integration/test_py_repl.py b/pyscriptjs/tests/integration/test_py_repl.py index 5d42fbd9f80..64221e8df4b 100644 --- a/pyscriptjs/tests/integration/test_py_repl.py +++ b/pyscriptjs/tests/integration/test_py_repl.py @@ -109,6 +109,27 @@ def test_show_last_expression(self): out_div = py_repl.locator("div.py-repl-output") assert out_div.all_inner_texts()[0] == "42" + def test_show_last_expression_with_output(self): + """ + Test that we display() the value of the last expression, as you would + expect by a REPL + """ + self.pyscript_run( + """ +
+ + 42 + + """ + ) + py_repl = self.page.locator("py-repl") + py_repl.locator("button").click() + out_div = py_repl.locator("div.py-repl-output") + assert out_div.all_inner_texts()[0] == "" + + out_div = self.page.locator("#repl-target") + assert out_div.all_inner_texts()[0] == "42" + def test_run_clears_previous_output(self): """ Check that we clear the previous output of the cell before executing it @@ -219,26 +240,6 @@ def test_hide_previous_error_after_successful_run(self): self.page.keyboard.press("Shift+Enter") assert out_div.all_inner_texts()[0] == "hello" - def test_output_attribute(self): - self.pyscript_run( - """ - - display('hello world') - -
-
- """ - ) - py_repl = self.page.locator("py-repl") - py_repl.locator("button").click() - # - # check that we did NOT write to py-repl-output - out_div = py_repl.locator("div.py-repl-output") - assert out_div.inner_text() == "" - # check that we are using mydiv instead - mydiv = self.page.locator("#mydiv") - assert mydiv.all_inner_texts()[0] == "hello world" - def test_output_attribute_does_not_exist(self): """ If we try to use an attribute which doesn't exist, we display an error @@ -253,11 +254,15 @@ def test_output_attribute_does_not_exist(self): ) py_repl = self.page.locator("py-repl") py_repl.locator("button").click() - # - out_div = py_repl.locator("div.py-repl-output") - msg = "py-repl ERROR: cannot find the output element #I-dont-exist in the DOM" - assert out_div.all_inner_texts()[0] == msg - assert "I will not be executed" not in self.console.log.text + + banner = self.page.query_selector_all(".py-warning") + assert len(banner) == 1 + + banner_content = banner[0].inner_text() + expected = ( + 'output = "I-dont-exist" does not match the id of any element on the page.' + ) + assert banner_content == expected def test_auto_generate(self): self.pyscript_run( @@ -315,3 +320,249 @@ def test_multiple_repls_mixed_display_order(self): assert self.page.inner_text("#py-internal-1-1-repl-output") == "second children" assert self.page.inner_text("#py-internal-0-1-repl-output") == "first children" + + def test_repl_output_attribute(self): + # Test that output attribute sends stdout to the element + # with the given ID, but not display() + self.pyscript_run( + """ +
+ + print('print from py-repl') + display('display from py-repl') + + + """ + ) + + py_repl = self.page.locator("py-repl") + py_repl.locator("button").click() + + target = self.page.locator("#repl-target") + assert "print from py-repl" in target.text_content() + + out_div = py_repl.locator("div.py-repl-output") + assert out_div.all_inner_texts()[0] == "display from py-repl" + + self.assert_no_banners() + + def test_repl_output_display_async(self): + # py-repls running async code are not expected to + # send display to element element + self.pyscript_run( + """ +
+ + import asyncio + import js + + async def print_it(): + await asyncio.sleep(1) + print('print from py-repl') + + + async def display_it(): + display('display from py-repl') + await asyncio.sleep(2) + + async def done(): + await asyncio.sleep(3) + js.console.log("DONE") + + + + asyncio.ensure_future(print_it()); + asyncio.ensure_future(display_it()); + asyncio.ensure_future(done()); + + """ + ) + + py_repl = self.page.locator("py-repl") + py_repl.locator("button").click() + + self.wait_for_console("DONE") + + assert self.page.locator("#repl-target").text_content() == "" + self.assert_no_banners() + + def test_repl_stdio_dynamic_tags(self): + self.pyscript_run( + """ +
+
+ + import js + + print("first.") + + # Using string, since no clean way to write to the + # code contents of the CodeMirror in a PyRepl + newTag = 'print("second.")' + js.document.body.innerHTML += newTag + + """ + ) + + py_repl = self.page.locator("py-repl") + py_repl.locator("button").click() + + assert self.page.locator("#first").text_content() == "first." + + second_repl = self.page.locator("py-repl#second-repl") + second_repl.locator("button").click() + assert self.page.locator("#second").text_content() == "second." + + def test_repl_output_id_errors(self): + self.pyscript_run( + """ + + print("bad.") + print("bad.") + + + + print("bad.") + + """ + ) + py_repls = self.page.query_selector_all("py-repl") + for repl in py_repls: + repl.query_selector_all("button")[0].click() + + 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_repl_stderr_id_errors(self): + self.pyscript_run( + """ + + import sys + print("bad.", file=sys.stderr) + print("bad.", file=sys.stderr) + + + + print("bad.", file=sys.stderr) + + """ + ) + py_repls = self.page.query_selector_all("py-repl") + for repl in py_repls: + repl.query_selector_all("button")[0].click() + + 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_repl_output_stderr(self): + # Test that stderr works, and routes to the same location as stdout + # Also, repls with the stderr attribute route to an additional location + self.pyscript_run( + """ +
+
+ + import sys + print("one.", file=sys.stderr) + print("two.") + + """ + ) + + py_repl = self.page.locator("py-repl") + py_repl.locator("button").click() + + 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_repl_output_attribute_change(self): + # If the user changes the 'output' attribute of a tag mid-execution, + # Output should no longer go to the selected div and a warning should appear + self.pyscript_run( + """ +
+
+ + + print("one.") + + # Change the 'output' attribute of this tag + import js + this_tag = js.document.getElementById("repl-tag") + + this_tag.setAttribute("output", "second") + print("two.") + + this_tag.setAttribute("output", "third") + print("three.") +
+ """ + ) + + py_repl = self.page.locator("py-repl") + py_repl.locator("button").click() + + 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_repl_output_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( + """ +
+
+ + + 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_repl = self.page.locator("py-repl") + py_repl.locator("button").click() + + # 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() diff --git a/pyscriptjs/tests/py-unit/conftest.py b/pyscriptjs/tests/py-unit/conftest.py index d4eb3a78fc2..7e236c4c4b2 100644 --- a/pyscriptjs/tests/py-unit/conftest.py +++ b/pyscriptjs/tests/py-unit/conftest.py @@ -6,6 +6,10 @@ pyscriptjs = Path(__file__).parents[2] +import pytest # noqa + +pyscriptjs = Path(__file__).parents[2] + # add pyscript folder to path python_source = pyscriptjs / "src" / "python" sys.path.append(str(python_source))