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

@@ -37,4 +37,12 @@ module.exports = {
'@typescript-eslint/no-empty-function': 'error',
'@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 { pyExec, displayPyException } from '../pyexec';
import { _createAlertBanner } from '../exceptions';
@@ -10,61 +10,140 @@ import { InterpreterClient } from '../interpreter_client';
const logger = getLogger('py-script');
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 {
srcCode: string;
stdout_manager: Stdio | null;
stderr_manager: Stdio | null;
_fetchSourceFallback = () => htmlDecode(this.srcCode);
async connectedCallback() {
/**
* 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(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();
}
// 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;
this.innerHTML = '';
await init(this, this._fetchSourceFallback);
}
async getPySrc(): Promise<string> {
if (this.hasAttribute('src')) {
const url = this.getAttribute('src');
try {
const response = await robustFetch(url);
return await response.text();
} catch (err) {
const e = err as Error;
_createAlertBanner(e.message);
this.innerHTML = '';
throw e;
getPySrc(): Promise<string> {
return fetchSource(this, this._fetchSourceFallback);
}
}
// bootstrap the <script> tag fallback only if needed (once per definition)
if (!customElements.get('py-script')) {
// allow any HTMLScriptElement to behave like a PyScript custom-elelement
type PyScriptElement = HTMLScriptElement & PyScript;
// 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;

View File

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