Fix #1326 - Allow <script type="py"> tag to work as <py-script> (#1396)

The goal of this MR is to unobtrusively allow the usage of `<script type="py">`, `<script type="pyscript">` or `<script type="py-script">` tags instead of `<py-script>` for all those case where the layout in custom elements get parsed and breaks users' expectations (including our SVG based tests).
This commit is contained in:
Andrea Giammarchi
2023-04-27 15:21:31 +02:00
committed by GitHub
parent 074ca0ef8f
commit a1281d1331
5 changed files with 141 additions and 51 deletions

View File

@@ -10,6 +10,9 @@ Features
- The `py-mount` attribute on HTML elements has been deprecated, and will be removed in a future release. - The `py-mount` attribute on HTML elements has been deprecated, and will be removed in a future release.
### &lt;script type="py"&gt;
- Added the ability to optionally use `<script type="py">`, `<script type="pyscript">` or `<script type="py-script">` instead of a `<py-script>` custom element, in order to tackle cases where the content of the `<py-script>` tag, inevitably parsed by browsers, could accidentally contain *HTML* able to break the surrounding page layout. ([#1396](https://github.com/pyscript/pyscript/pull/1396))
### &lt;py-terminal&gt; ### &lt;py-terminal&gt;
- Added a `docked` field and attribute for the `<py-terminal>` 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. - Added a `docked` field and attribute for the `<py-terminal>` 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.

View File

@@ -1,6 +1,6 @@
# &lt;py-script&gt; # &lt;py-script&gt;
The `<py-script>` element lets you execute multi-line Python scripts both inline and via a src attribute. The `<py-script>` element, also available as `<script type="py-script">`, lets you execute multi-line Python scripts both inline and via a src attribute.
## Attributes ## Attributes
@@ -12,13 +12,13 @@ The `<py-script>` element lets you execute multi-line Python scripts both inline
### output ### 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\>`). 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. This output is in addition to the output being written to the developer console and the `<py-terminal>` if it is being used.
### stderr ### 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\>`). 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. This output is in addition to the output being written to the developer console and the `<py-terminal>` if it is being used.

View File

@@ -37,4 +37,12 @@ module.exports = {
'@typescript-eslint/no-empty-function': 'error', '@typescript-eslint/no-empty-function': 'error',
'@typescript-eslint/restrict-template-expressions': ['error', { allowBoolean: true }], '@typescript-eslint/restrict-template-expressions': ['error', { allowBoolean: true }],
}, },
overrides: [
{
files: ['src/components/pyscript.ts'],
rules: {
'@typescript-eslint/unbound-method': 'off',
},
},
],
}; };

View File

@@ -1,4 +1,4 @@
import { htmlDecode, ensureUniqueId, createDeprecationWarning } from '../utils'; import { ltrim, htmlDecode, ensureUniqueId, createDeprecationWarning } from '../utils';
import { getLogger } from '../logger'; import { getLogger } from '../logger';
import { pyExec, displayPyException } from '../pyexec'; import { pyExec, displayPyException } from '../pyexec';
import { _createAlertBanner } from '../exceptions'; import { _createAlertBanner } from '../exceptions';
@@ -10,61 +10,140 @@ import { InterpreterClient } from '../interpreter_client';
const logger = getLogger('py-script'); const logger = getLogger('py-script');
export function make_PyScript(interpreter: InterpreterClient, app: PyScriptApp) { export function make_PyScript(interpreter: InterpreterClient, app: PyScriptApp) {
/**
* A common <py-script> VS <script type="py"> initializator.
*/
const init = async (pyScriptTag: PyScript, fallback: () => string) => {
/**
* Since connectedCallback is async, multiple py-script tags can be executed in
* an order which is not particularly sequential. The locking mechanism here ensures
* a sequential execution of multiple py-script tags present in one page.
*
* Concurrent access to the multiple py-script tags is thus avoided.
*/
app.incrementPendingTags();
let releaseLock: () => void;
try {
releaseLock = await app.tagExecutionLock();
ensureUniqueId(pyScriptTag);
const src = await fetchSource(pyScriptTag, fallback);
await app.plugins.beforePyScriptExec({ interpreter, src, pyScriptTag });
const { result } = await pyExec(interpreter, src, pyScriptTag);
await app.plugins.afterPyScriptExec({ interpreter, src, pyScriptTag, result });
} finally {
releaseLock();
app.decrementPendingTags();
}
};
/**
* Given a generic DOM Element, tries to fetch the 'src' attribute, if present.
* It either throws an error if the 'src' can't be fetched or it returns a fallback
* content as source.
*/
const fetchSource = async (tag: Element, fallback: () => string): Promise<string> => {
if (tag.hasAttribute('src')) {
try {
const response = await robustFetch(tag.getAttribute('src'));
return await response.text();
} catch (err) {
const e = err as Error;
_createAlertBanner(e.message);
throw e;
}
}
return fallback();
};
class PyScript extends HTMLElement { class PyScript extends HTMLElement {
srcCode: string; srcCode: string;
stdout_manager: Stdio | null; stdout_manager: Stdio | null;
stderr_manager: Stdio | null; stderr_manager: Stdio | null;
_fetchSourceFallback = () => htmlDecode(this.srcCode);
async connectedCallback() { async connectedCallback() {
/** // Save innerHTML information in srcCode so we can access it later
* Since connectedCallback is async, multiple py-script tags can be executed in // once we clean innerHTML (which is required since we don't want
* an order which is not particularly sequential. The locking mechanism here ensures // source code to be rendered on the screen)
* a sequential execution of multiple py-script tags present in one page. this.srcCode = this.innerHTML;
* this.innerHTML = '';
* Concurrent access to the multiple py-script tags is thus avoided. await init(this, this._fetchSourceFallback);
*/
app.incrementPendingTags();
let releaseLock: () => void;
try {
releaseLock = await app.tagExecutionLock();
ensureUniqueId(this);
// Save innerHTML information in srcCode so we can access it later
// once we clean innerHTML (which is required since we don't want
// source code to be rendered on the screen)
this.srcCode = this.innerHTML;
const pySrc = await this.getPySrc();
this.innerHTML = '';
await app.plugins.beforePyScriptExec({ interpreter: interpreter, src: pySrc, pyScriptTag: this });
const result = (await pyExec(interpreter, pySrc, this)).result;
await app.plugins.afterPyScriptExec({
interpreter: interpreter,
src: pySrc,
pyScriptTag: this,
result: result,
});
} finally {
releaseLock();
app.decrementPendingTags();
}
} }
async getPySrc(): Promise<string> { getPySrc(): Promise<string> {
if (this.hasAttribute('src')) { return fetchSource(this, this._fetchSourceFallback);
const url = this.getAttribute('src'); }
try { }
const response = await robustFetch(url);
return await response.text(); // bootstrap the <script> tag fallback only if needed (once per definition)
} catch (err) { if (!customElements.get('py-script')) {
const e = err as Error; // allow any HTMLScriptElement to behave like a PyScript custom-elelement
_createAlertBanner(e.message); type PyScriptElement = HTMLScriptElement & PyScript;
this.innerHTML = '';
throw e; // the <script> tags to look for, acting like a <py-script> one
// both py, pyscript, and py-script, are valid types to help reducing typo cases
const pyScriptCSS = 'script[type="py"],script[type="pyscript"],script[type="py-script"]';
// bootstrap with the same connectedCallback logic any <script>
const bootstrap = (script: PyScriptElement) => {
const pyScriptTag = document.createElement('py-script-tag') as PyScript;
// move attributes to the live resulting pyScriptTag reference
for (const name of ['output', 'stderr']) {
const value = script.getAttribute(name);
if (value) {
pyScriptTag.setAttribute(name, value);
} }
} else {
return htmlDecode(this.srcCode);
} }
}
// insert pyScriptTag companion right after the original script
script.after(pyScriptTag);
// remove the first empty line to preserve line numbers/counting
init(pyScriptTag, () => ltrim(script.textContent.replace(/^[\r\n]+/, ''))).catch(() =>
pyScriptTag.remove(),
);
};
// callback used to bootstrap already known <script> tags
const callback: MutationCallback = records => {
for (const { addedNodes } of records) {
for (const node of addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
if ((node as PyScriptElement).matches(pyScriptCSS)) {
bootstrap(node as PyScriptElement);
}
for (const child of (node as PyScriptElement).querySelectorAll(pyScriptCSS)) {
bootstrap(child as PyScriptElement);
}
}
}
}
};
// globally shared MutationObserver for <script> special cases
const pyScriptMO = new MutationObserver(callback);
// simplifies observing any root node (document/shadowRoot)
const observe = (root: Document | ShadowRoot) => {
pyScriptMO.observe(root, { childList: true, subtree: true });
return root;
};
// patch attachShadow once to bootstrap <script> special cases in there too
const { attachShadow } = Element.prototype;
Object.assign(Element.prototype, {
attachShadow(init: ShadowRootInit) {
return observe(attachShadow.call(this as Element, init));
},
});
// bootstrap all already live py <script> tags
callback([{ addedNodes: document.querySelectorAll(pyScriptCSS) } as unknown] as MutationRecord[], null);
// once all tags have been initialized, observe new possible tags added later on
// this is to save a few ticks within the callback as each <script> already adds a companion node
observe(document);
} }
return PyScript; return PyScript;

View File

@@ -9,10 +9,10 @@ class TestBasic(PyScriptTest):
def test_pyscript_hello(self): def test_pyscript_hello(self):
self.pyscript_run( self.pyscript_run(
""" """
<py-script> <script type="py">
import js import js
js.console.log('hello pyscript') js.console.log('hello pyscript')
</py-script> </script>
""" """
) )
assert self.console.log.lines == ["hello pyscript"] assert self.console.log.lines == ["hello pyscript"]