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

@@ -41,7 +41,7 @@ jobs:
- name: build
run: npm run build
- name: Generate index.html in snapshot
working-directory: .
run: sed 's#_PATH_#https://pyscript.net/releases/${{ github.ref_name }}/#' ./public/index.html > ./pyscript.core/dist/index.html

View File

@@ -39,13 +39,13 @@ jobs:
${{ runner.os }}-build-${{ env.cache-name }}-
${{ runner.os }}-build-
${{ runner.os }}-
- name: Install Dependencies
run: npm install
- name: Build Pyscript.core
run: npm run build
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
@@ -58,4 +58,4 @@ jobs:
- name: Copy to Snapshot
run: >
aws s3 sync ./dist/ s3://pyscript.net/snapshots/${{ inputs.snapshot_version }}/
aws s3 sync ./dist/ s3://pyscript.net/snapshots/${{ inputs.snapshot_version }}/

View File

@@ -1,20 +1,13 @@
name: "Publish Unstable"
on:
push: # Only run on merges into main that modify files under pyscriptjs/ and examples/
push: # Only run on merges into main that modify files under pyscript.core/ and examples/
branches:
- main
paths:
- pyscript.core/**
- examples/**
pull_request: # Run on any PR that modifies files under pyscriptjs/ and examples/
branches:
- main
paths:
- pyscriptjs/**
- examples/**
workflow_dispatch:
jobs:
@@ -53,7 +46,7 @@ jobs:
- name: Build
run: npm run build
- name: Generate index.html in snapshot
working-directory: .
run: sed 's#_PATH_#https://pyscript.net/unstable/#' ./public/index.html > ./pyscript.core/dist/index.html

View File

@@ -26,7 +26,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 3
fetch-depth: 3
# display a git log: when you run CI on PRs, github automatically
# merges the PR into main and run the CI on that commit. The idea

1
.gitignore vendored
View File

@@ -51,7 +51,6 @@ coverage.xml
*.py,cover
.hypothesis/
.pytest_cache/
pyscriptjs/examples
# Translations
*.mo

View File

@@ -1,7 +1,7 @@
# This is the configuration for pre-commit, a local framework for managing pre-commit hooks
# Check out the docs at: https://pre-commit.com/
ci:
skip: [eslint]
#skip: [eslint]
autoupdate_schedule: monthly
default_stages: [commit]
@@ -24,13 +24,6 @@ repos:
exclude: pyscript\.core/dist|\.min\.js$
- id: trailing-whitespace
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.0.257
hooks:
- id: ruff
exclude: pyscript\.core/src/stdlib/pyscript/__init__\.py|pyscript\.core/test|pyscript\.core/dist|pyscript\.core/src/stdlib/pyscript\.py
args: [--fix]
- repo: https://github.com/psf/black
rev: 23.1.0
hooks:
@@ -52,14 +45,9 @@ repos:
exclude: pyscript\.core/test|pyscript\.core/dist|pyscript\.core/types|pyscript.core/src/stdlib/pyscript.js|pyscript\.sw/
args: [--tab-width, "4"]
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.36.0
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: eslint
files: pyscriptjs/src/.*\.[jt]sx?$ # *.js, *.jsx, *.ts and *.tsx
types: [file]
additional_dependencies:
- eslint@8.25.0
- typescript@5.0.4
- "@typescript-eslint/eslint-plugin@5.58.0"
- "@typescript-eslint/parser@5.58.0"
- id: isort
name: isort (python)
args: [--profile, black]

View File

@@ -2,7 +2,6 @@ tag := latest
git_hash ?= $(shell git log -1 --pretty=format:%h)
base_dir ?= $(shell git rev-parse --show-toplevel)
src_dir ?= $(base_dir)/pyscriptjs/src
examples ?= ../$(base_dir)/examples
app_dir ?= $(shell git rev-parse --show-prefix)
@@ -101,14 +100,6 @@ test-examples:
mkdir -p test_results
$(PYTEST_EXE) -vv $(ARGS) pyscript.core/tests/integration/ --log-cli-level=warning --junitxml=test_results/integration.xml -k 'zz_examples'
test-py:
@echo "Tests from $(src_dir)"
mkdir -p test_results
$(PYTEST_EXE) -vv $(ARGS) tests/py-unit/ --log-cli-level=warning --junitxml=test_results/py-unit.xml
test-ts:
npm run test
fmt: fmt-py fmt-ts
@echo "Format completed"

View File

@@ -11,8 +11,7 @@
rel="stylesheet"
href="https://pyscript.net/latest/pyscript.css"
/>
<script defer src="../../pyscriptjs/build/pyscript.js"></script>
<!-- <script defer src="https://pyscript.net/latest/pyscript.js"></script> -->
<script defer src="https://pyscript.net/latest/pyscript.js"></script>
</head>
<body>

View File

@@ -47,7 +47,8 @@ now = datetime.now()
display(now.strftime(&quot;%m/%d/%Y, %H:%M:%S&quot;))
&lt;/py-script&gt;
&lt;/body&gt;
&lt;/html&gt;</pre>
&lt;/html&gt;</pre
>
</main>
</body>
</html>
</html>

View File

@@ -7,32 +7,7 @@ dynamic = ["version"]
[tool.codespell]
ignore-words-list = "afterall"
skip = "pyscriptjs/node_modules/*,*.js,*.json"
[tool.ruff]
builtins = [
"Element",
"pyscript",
]
ignore = [
"S101",
"S113",
]
line-length = 100
select = [
"B",
"C9",
"E",
"F",
"I",
"S",
"UP",
"W",
]
target-version = "py310"
[tool.ruff.mccabe]
max-complexity = 10
skip = "*.js,*.json"
[tool.setuptools]
include-package-data = false

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()

View File

@@ -1,48 +0,0 @@
module.exports = {
parser: '@typescript-eslint/parser',
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
],
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module',
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
env: {
es6: true,
browser: true,
},
plugins: ['@typescript-eslint'],
ignorePatterns: ['node_modules', 'src/interpreter_worker/*'],
rules: {
// ts-ignore is already an explicit override, no need to have a second lint
'@typescript-eslint/ban-ts-comment': 'off',
// any related lints
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
// other rules
'no-prototype-builtins': 'error',
'@typescript-eslint/no-unused-vars': ['error', { args: 'none' }],
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/restrict-plus-operands': 'error',
'@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,6 +0,0 @@
build
node_modules
# Ignore all HTML files
*.html

View File

@@ -1,9 +0,0 @@
module.exports = {
arrowParens: 'avoid',
bracketSameLine: true,
singleQuote: true,
printWidth: 120,
semi: true,
tabWidth: 4,
trailingComma: 'all',
};

View File

@@ -1,137 +0,0 @@
tag := latest
git_hash ?= $(shell git log -1 --pretty=format:%h)
base_dir ?= $(shell git rev-parse --show-toplevel)
src_dir ?= $(base_dir)/pyscriptjs/src
examples ?= ../$(base_dir)/examples
app_dir ?= $(shell git rev-parse --show-prefix)
CONDA_EXE := conda
CONDA_ENV ?= $(base_dir)/pyscriptjs/env
env := $(CONDA_ENV)
conda_run := $(CONDA_EXE) run -p $(env)
PYTEST_EXE := $(CONDA_ENV)/bin/pytest
GOOD_NODE_VER := 14
GOOD_NPM_VER := 6
NODE_VER := $(shell node -v | cut -d. -f1 | sed 's/^v\(.*\)/\1/')
NPM_VER := $(shell npm -v | cut -d. -f1)
ifeq ($(shell uname -s), Darwin)
SED_I_ARG := -i ''
else
SED_I_ARG := -i
endif
GOOD_NODE := $(shell if [ $(NODE_VER) -ge $(GOOD_NODE_VER) ]; then echo true; else echo false; fi)
GOOD_NPM := $(shell if [ $(NPM_VER) -ge $(GOOD_NPM_VER) ]; then echo true; else echo false; fi)
.PHONY: check-node
check-node:
@echo Build requires Node $(GOOD_NODE_VER).x or higher: $(NODE_VER) detected && $(GOOD_NODE)
.PHONY: check-npm
check-npm:
@echo Build requires npm $(GOOD_NPM_VER).x or higher: $(NPM_VER) detected && $(GOOD_NPM)
setup:
make check-node
make check-npm
npm install
$(CONDA_EXE) env $(shell [ -d $(env) ] && echo update || echo create) -p $(env) --file environment.yml
$(conda_run) playwright install
$(CONDA_EXE) install -c anaconda pytest -y
clean:
find . -name \*.py[cod] -delete
rm -rf .pytest_cache .coverage coverage.xml
clean-all: clean
rm -rf $(env) *.egg-info
shell:
@export CONDA_ENV_PROMPT='<{name}>'
@echo 'conda activate $(env)'
dev:
npm run dev
build:
npm run build
build-fast:
node esbuild.mjs
# use the following rule to do all the checks done by precommit: in
# particular, use this if you want to run eslint.
precommit-check:
pre-commit run --all-files
examples:
mkdir -p ./examples
cp -r ../examples/* ./examples
chmod -R 755 examples
find ./examples/toga -type f -name '*.html' -exec sed $(SED_I_ARG) s+https://pyscript.net/latest/+../../build/+g {} \;
find ./examples/webgl -type f -name '*.html' -exec sed $(SED_I_ARG) s+https://pyscript.net/latest/+../../../build/+g {} \;
find ./examples -type f -name '*.html' -exec sed $(SED_I_ARG) s+https://pyscript.net/latest/+../build/+g {} \;
npm run build
rm -rf ./examples/build
mkdir -p ./examples/build
cp -R ./build/* ./examples/build
@echo "To serve examples run: $(conda_run) python -m http.server 8080 --directory examples"
# run prerequisites and serve pyscript examples at http://localhost:8000/examples/
run-examples: setup build examples
make examples
npm install
make dev
test:
make test-ts
make test-py
make test-integration-parallel
make test-examples
# run all integration tests *including examples* sequentially
test-integration:
mkdir -p test_results
$(PYTEST_EXE) -vv $(ARGS) tests/integration/ --log-cli-level=warning --junitxml=test_results/integration.xml
# run all integration tests *except examples* in parallel (examples use too much memory)
test-integration-parallel:
mkdir -p test_results
$(PYTEST_EXE) --numprocesses auto -vv $(ARGS) tests/integration/ --log-cli-level=warning --junitxml=test_results/integration.xml -k 'not zz_examples'
# run integration tests on only examples sequentially (to avoid running out of memory)
test-examples:
make examples
mkdir -p test_results
$(PYTEST_EXE) -vv $(ARGS) tests/integration/ --log-cli-level=warning --junitxml=test_results/integration.xml -k 'zz_examples'
test-py:
@echo "Tests from $(src_dir)"
mkdir -p test_results
$(PYTEST_EXE) -vv $(ARGS) tests/py-unit/ --log-cli-level=warning --junitxml=test_results/py-unit.xml
test-ts:
npm run test
fmt: fmt-py fmt-ts
@echo "Format completed"
fmt-check: fmt-ts-check fmt-py-check
@echo "Format check completed"
fmt-ts:
npm run format
fmt-ts-check:
npm run format:check
fmt-py:
$(conda_run) black --skip-string-normalization .
$(conda_run) isort --profile black .
fmt-py-check:
$(conda_run) black -l 88 --check .
.PHONY: $(MAKECMDGOALS)

View File

@@ -1,3 +0,0 @@
# PyScript Demonstrator
[A simple webapp to demonstrate the capabilities of PyScript.](https://github.com/pyscript/pyscript/blob/main/docs/development/setting-up-environment.md#pyscript-demonstrator)

View File

@@ -1,14 +0,0 @@
/**
* this file mocks the `src/python/pyscript.py` file
* since importing of `.py` files isn't usually supported
* inside JS/TS files.
*
* It sets the value of whatever is imported from
* `src/python/pyscript.py` the contents of that file
*
* This is needed since the imported object is further
* passed to a function which only accepts a string.
*/
const fs = require('fs');
module.exports = fs.readFileSync('./src/python/pyscript.py', 'utf8');

View File

@@ -1 +0,0 @@
module.exports = '';

View File

@@ -1,16 +0,0 @@
/**
* this file mocks python files that are not explicitly
* matched by a regex in jest.config.js, since importing of
* `.py` files isn't usually supported inside JS/TS files.
*
* This is needed since the imported object is further
* passed to a function which only accepts a string.
*
* The mocked contents of the `.py` file will be "", e.g.
* nothing.
*/
console.warn(`.py files that are not explicitly mocked in \
jest.config.js and /__mocks__/ are mocked as empty strings`);
module.exports = '';

View File

@@ -1,53 +0,0 @@
// This logic split out because it is shared by:
// 1. esbuild.mjs
// 2. Jest setup.ts
import path, { join } from 'path';
import { opendir, readFile } from 'fs/promises';
/**
* List out everything in a directory, but skip __pycache__ directory. Used to
* list out the directory paths and the [file path, file contents] pairs in the
* Python package. All paths are relative to the directory we are listing. The
* directories are sorted topologically so that a parent directory always
* appears before its children.
*
* This is consumed in main.ts which calls mkdir for each directory and then
* writeFile to create each file.
*
* @param {string} dir The path to the directory we want to list out
* @returns {dirs: string[], files: [string, string][]}
*/
export async function directoryManifest(dir) {
const result = { dirs: [], files: [] };
await _directoryManifestHelper(dir, '.', result);
return result;
}
/**
* Recursive helper function for directoryManifest
*/
async function _directoryManifestHelper(root, dir, result) {
const dirObj = await opendir(join(root, dir));
for await (const d of dirObj) {
const entry = join(dir, d.name);
if (d.isDirectory()) {
if (d.name === '__pycache__') {
continue;
}
result.dirs.push(entry);
await _directoryManifestHelper(root, entry, result);
} else if (d.isFile()) {
result.files.push([normalizePath(entry), await readFile(join(root, entry), { encoding: 'utf-8' })]);
}
}
}
/**
* Normalize paths under different operating systems to
* the correct path that will be used for src on browser.
* @param {string} originalPath
*/
function normalizePath(originalPath) {
return path.normalize(originalPath).replace(/\\/g, '/');
}

View File

@@ -1,138 +0,0 @@
import { build } from 'esbuild';
import { spawn } from 'child_process';
import { join } from 'path';
import { watchFile } from 'fs';
import { cp, lstat, readdir } from 'fs/promises';
import { directoryManifest } from './directoryManifest.mjs';
import { fileURLToPath } from 'url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const production = !process.env.NODE_WATCH || process.env.NODE_ENV === 'production';
const copy_targets = [
{ src: 'public/index.html', dest: 'build' },
{ src: 'src/plugins/python/*', dest: 'build/plugins/python' },
];
if (!production) {
copy_targets.push({ src: 'build/*', dest: 'examples/build' });
}
/**
* An esbuild plugin that injects the Pyscript Python package.
*
* It uses onResolve to attach our custom namespace to the import and then uses
* onLoad to inject the file contents.
*/
function bundlePyscriptPythonPlugin() {
const namespace = 'bundlePyscriptPythonPlugin';
return {
name: namespace,
setup(build) {
// Resolve the pyscript_package to our custom namespace
// The path doesn't really matter, but we need a separate namespace
// or else the file system resolver will raise an error.
build.onResolve({ filter: /^pyscript_python_package.esbuild_injected.json$/ }, args => {
return { path: 'dummy', namespace };
});
// Inject our manifest as JSON contents, and use the JSON loader.
// Also tell esbuild to watch the files & directories we've listed
// for updates.
build.onLoad({ filter: /^dummy$/, namespace }, async args => {
const manifest = await directoryManifest('./src/python');
return {
contents: JSON.stringify(manifest),
loader: 'json',
watchFiles: manifest.files.map(([k, v]) => k),
watchDirs: manifest.dirs,
};
});
},
};
}
const pyScriptConfig = {
entryPoints: ['src/main.ts'],
loader: { '.py': 'text' },
bundle: true,
format: 'iife',
globalName: 'pyscript',
plugins: [bundlePyscriptPythonPlugin()],
};
const interpreterWorkerConfig = {
entryPoints: ['src/interpreter_worker/worker.ts'],
loader: { '.py': 'text' },
bundle: true,
format: 'iife',
plugins: [bundlePyscriptPythonPlugin()],
};
const copyPath = (source, dest, ...rest) => cp(join(__dirname, source), join(__dirname, dest), ...rest);
const esbuild = async () => {
const timer = `\x1b[1mpyscript\x1b[0m \x1b[2m(${production ? 'prod' : 'dev'})\x1b[0m built in`;
console.time(timer);
await Promise.all([
build({
...pyScriptConfig,
sourcemap: true,
minify: false,
outfile: 'build/pyscript.js',
}),
build({
...pyScriptConfig,
sourcemap: true,
minify: true,
outfile: 'build/pyscript.min.js',
}),
// XXX I suppose we should also build a minified version
// TODO (HC): Simplify config a bit
build({
...interpreterWorkerConfig,
sourcemap: false,
minify: false,
outfile: 'build/interpreter_worker.js',
}),
]);
const copy = [];
for (const { src, dest } of copy_targets) {
if (src.endsWith('/*')) {
copy.push(copyPath(src.slice(0, -2), dest, { recursive: true }));
} else {
copy.push(copyPath(src, dest + src.slice(src.lastIndexOf('/'))));
}
}
await Promise.all(copy);
console.timeEnd(timer);
};
esbuild().then(() => {
if (!production) {
(async function watchPath(path) {
for (const file of await readdir(path)) {
const whole = join(path, file);
if (/\.(js|ts|css|py)$/.test(file)) {
watchFile(whole, async () => {
await esbuild();
});
} else if ((await lstat(whole)).isDirectory()) {
watchPath(whole);
}
}
})('src');
const server = spawn('python', ['-m', 'http.server', '--directory', './examples', '8080'], {
stdio: 'inherit',
detached: false,
});
process.on('exit', () => {
server.kill();
});
}
});

View File

@@ -1,28 +0,0 @@
'use strict';
const { TextEncoder, TextDecoder } = require('util');
const { MessageChannel } = require('node:worker_threads');
const { default: $JSDOMEnvironment, TestEnvironment } = require('jest-environment-jsdom');
Object.defineProperty(exports, '__esModule', {
value: true,
});
class JSDOMEnvironment extends $JSDOMEnvironment {
constructor(...args) {
const { global } = super(...args);
if (!global.TextEncoder) {
global.TextEncoder = TextEncoder;
}
if (!global.TextDecoder) {
global.TextDecoder = TextDecoder;
}
if (!global.MessageChannel) {
global.MessageChannel = MessageChannel;
}
}
}
exports.default = JSDOMEnvironment;
exports.TestEnvironment = TestEnvironment === $JSDOMEnvironment ? JSDOMEnvironment : TestEnvironment;

View File

@@ -1,25 +0,0 @@
//jest.config.js
module.exports = {
preset: 'ts-jest',
setupFilesAfterEnv: ['./tests/unit/setup.ts'],
testEnvironment: './jest-environment-jsdom.js',
extensionsToTreatAsEsm: ['.ts'],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: 'tsconfig.json',
useESM: true,
},
],
},
verbose: true,
testEnvironmentOptions: {
url: 'http://localhost',
},
moduleNameMapper: {
'^.*?pyscript.py$': '<rootDir>/__mocks__/_pyscript.js',
'^[./a-zA-Z0-9$_-]+\\.py$': '<rootDir>/__mocks__/fileMock.js',
'\\.(css)$': '<rootDir>/__mocks__/cssMock.js',
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +0,0 @@
{
"name": "pyscript",
"version": "0.0.1",
"scripts": {
"build": "npm run tsc && node esbuild.mjs",
"dev": "NODE_WATCH=1 node esbuild.mjs",
"tsc": "tsc --noEmit",
"format:check": "prettier --check './src/**/*.{mjs,js,html,ts}'",
"format": "prettier --write './src/**/*.{mjs,js,html,ts}'",
"lint": "eslint './src/**/*.{mjs,js,html,ts}'",
"lint:fix": "eslint --fix './src/**/*.{mjs,js,html,ts}'",
"xprelint": "npm run format",
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --coverage",
"test:watch": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --watch"
},
"devDependencies": {
"@codemirror/commands": "^6.2.2",
"@codemirror/lang-python": "^6.1.2",
"@codemirror/language": "^6.6.0",
"@codemirror/state": "^6.2.0",
"@codemirror/theme-one-dark": "^6.1.1",
"@codemirror/view": "^6.9.3",
"@hoodmane/toml-j0.4": "^1.1.2",
"@jest/globals": "29.1.2",
"@types/codemirror": "^5.60.5",
"@types/jest": "29.1.2",
"@types/node": "18.8.3",
"@typescript-eslint/eslint-plugin": "5.58.0",
"@typescript-eslint/parser": "5.58.0",
"codemirror": "6.0.1",
"cross-env": "7.0.3",
"esbuild": "0.17.12",
"eslint": "8.25.0",
"jest": "29.1.2",
"jest-environment-jsdom": "29.1.2",
"prettier": "2.7.1",
"pyodide": "0.23.2",
"synclink": "0.2.4",
"ts-jest": "29.0.3",
"typescript": "5.0.4",
"xterm": "^5.1.0"
},
"dependencies": {
"basic-devtools": "^0.1.6",
"not-so-weak": "^1.0.0"
}
}

View File

@@ -1,54 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://unpkg.com/mvp.css@1.12/mvp.css" />
<link rel="stylesheet" href="pyscript.css" />
<script defer src="pyscript.min.js"></script>
<title>PyScript</title>
</head>
<body>
<main>
<h1>&lt;py-script&gt;</h1>
<ul>
<li><a href="pyscript.js">pyscript.js</a></li>
<li><a href="pyscript.min.js">pyscript.min.js</a></li>
<li><a href="pyscript.css">pyscript.css</a></li>
<li><a href="pyscript.min.js.map">pyscript.min.js.map</a></li>
<li><a href="pyscript.js.map">pyscript.js.map</a></li>
</ul>
<div id="out"></div>
<py-script std-out="out">
import sys
print(sys.version)
</py-script>
<h2>Example</h2>
<pre style="padding: 1em; border: 1px solid #000000">
&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;en&quot;&gt;
&lt;head&gt;
&lt;meta charset=&quot;utf-8&quot; /&gt;
&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width,initial-scale=1&quot; /&gt;
&lt;title&gt;PyScript Hello World&lt;/title&gt;
&lt;link rel=&quot;stylesheet&quot; href=&quot;https://pyscript.net/latest/pyscript.css&quot; /&gt;
&lt;script defer src=&quot;https://pyscript.net/latest/pyscript.js&quot;&gt;&lt;/script&gt;
&lt;/head&gt;
&lt;body&gt;
Hello world! &lt;br&gt;
This is the current date and time, as computed by Python:
&lt;py-script&gt;
from datetime import datetime
now = datetime.now()
now.strftime(&quot;%m/%d/%Y, %H:%M:%S&quot;)
&lt;/py-script&gt;
&lt;/body&gt;
&lt;/html&gt;</pre
>
</main>
</body>
</html>

View File

@@ -1,11 +0,0 @@
import { InterpreterClient } from '../interpreter_client';
import type { PyScriptApp } from '../main';
import { make_PyRepl } from './pyrepl';
function createCustomElements(interpreter: InterpreterClient, app: PyScriptApp) {
const PyRepl = make_PyRepl(interpreter, app);
customElements.define('py-repl', PyRepl);
}
export { createCustomElements };

View File

@@ -1,237 +0,0 @@
import { $, $$ } from 'basic-devtools';
import { basicSetup, EditorView } from 'codemirror';
import { python } from '@codemirror/lang-python';
import { indentUnit } from '@codemirror/language';
import { Compartment } from '@codemirror/state';
import { keymap, Command } from '@codemirror/view';
import { defaultKeymap } from '@codemirror/commands';
import { oneDarkTheme } from '@codemirror/theme-one-dark';
import { ensureUniqueId, htmlDecode } from '../utils';
import { pyExec } from '../pyexec';
import { getLogger } from '../logger';
import { InterpreterClient } from '../interpreter_client';
import type { PyScriptApp } from '../main';
import { Stdio } from '../stdio';
import { robustFetch } from '../fetch';
import { _createAlertBanner } from '../exceptions';
const logger = getLogger('py-repl');
const RUNBUTTON = `<svg style="height:20px;width:20px;vertical-align:-.125em;transform-origin:center;overflow:visible;color:green" viewBox="0 0 384 512" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg"><g transform="translate(192 256)" transform-origin="96 0"><g transform="translate(0,0) scale(1,1)"><path d="M361 215C375.3 223.8 384 239.3 384 256C384 272.7 375.3 288.2 361 296.1L73.03 472.1C58.21 482 39.66 482.4 24.52 473.9C9.377 465.4 0 449.4 0 432V80C0 62.64 9.377 46.63 24.52 38.13C39.66 29.64 58.21 29.99 73.03 39.04L361 215z" fill="currentColor" transform="translate(-192 -256)"></path></g></g></svg>`;
export function make_PyRepl(interpreter: InterpreterClient, app: PyScriptApp) {
/* High level structure of py-repl DOM, and the corresponding JS names.
this <py-repl>
boxDiv <div class='py-repl-box'>
editorDiv <div class="py-repl-editor"></div>
outDiv <div class="py-repl-output"></div>
</div>
</py-repl>
*/
class PyRepl extends HTMLElement {
outDiv: HTMLElement;
editor: EditorView;
stdout_manager: Stdio | null;
stderr_manager: Stdio | null;
static observedAttributes = ['src'];
connectedCallback() {
ensureUniqueId(this);
if (!this.hasAttribute('exec-id')) {
this.setAttribute('exec-id', '0');
}
if (!this.hasAttribute('root')) {
this.setAttribute('root', this.id);
}
const pySrc = htmlDecode(this.innerHTML).trim();
this.innerHTML = '';
const boxDiv = this.makeBoxDiv();
const shadowRoot = $('.py-repl-editor > div', boxDiv).attachShadow({ mode: 'open' });
// avoid inheriting styles from the outer component
shadowRoot.innerHTML = `<style> :host { all: initial; }</style>`;
this.appendChild(boxDiv);
this.editor = this.makeEditor(pySrc, shadowRoot);
this.editor.focus();
logger.debug(`element ${this.id} successfully connected`);
}
get src() {
return this.getAttribute('src');
}
set src(value) {
this.setAttribute('src', value);
}
attributeChangedCallback(name: string, oldVal: string, newVal: string) {
if (name === 'src' && newVal !== oldVal) {
void this.loadReplSrc();
}
}
/**
* Fetch url from src attribute of py-repl tags and
* preload the code from fetch response into the Corresponding py-repl tag,
* but please note that they will not be pre-run unless you click the runbotton.
*/
async loadReplSrc() {
try {
const response = await robustFetch(this.src);
if (!response.ok) {
return;
}
const cmcontentElement = $('div.cm-content', this.editor.dom);
const { lastElementChild } = cmcontentElement;
cmcontentElement.replaceChildren(lastElementChild);
lastElementChild.textContent = await response.text();
logger.info(`loading code from ${this.src} to repl...success`);
} catch (err) {
const e = err as Error;
_createAlertBanner(e.message);
}
}
/** Create and configure the codemirror editor
*/
makeEditor(pySrc: string, parent: ShadowRoot): EditorView {
const languageConf = new Compartment();
const extensions = [
indentUnit.of(' '),
basicSetup,
languageConf.of(python()),
keymap.of([
...defaultKeymap,
{ key: 'Ctrl-Enter', run: this.execute.bind(this) as Command, preventDefault: true },
{ key: 'Shift-Enter', run: this.execute.bind(this) as Command, preventDefault: true },
]),
];
if (this.getAttribute('theme') === 'dark') {
extensions.push(oneDarkTheme);
}
return new EditorView({
doc: pySrc,
extensions,
parent,
});
}
// ******** main entry point for py-repl DOM building **********
//
// The following functions are written in a top-down, depth-first
// order (so that the order of code roughly matches the order of
// execution)
makeBoxDiv(): HTMLElement {
const boxDiv = document.createElement('div');
boxDiv.className = 'py-repl-box';
const editorDiv = this.makeEditorDiv();
this.outDiv = this.makeOutDiv();
boxDiv.appendChild(editorDiv);
boxDiv.appendChild(this.outDiv);
return boxDiv;
}
makeEditorDiv(): HTMLElement {
const editorDiv = document.createElement('div');
editorDiv.className = 'py-repl-editor';
editorDiv.setAttribute('aria-label', 'Python Script Area');
const runButton = this.makeRunButton();
const editorShadowContainer = document.createElement('div');
// avoid outer elements intercepting key events (reveal as example)
editorShadowContainer.addEventListener('keydown', event => {
event.stopPropagation();
});
editorDiv.append(editorShadowContainer, runButton);
return editorDiv;
}
makeRunButton(): HTMLElement {
const runButton = document.createElement('button');
runButton.className = 'absolute py-repl-run-button';
runButton.innerHTML = RUNBUTTON;
runButton.setAttribute('aria-label', 'Python Script Run Button');
runButton.addEventListener('click', this.execute.bind(this) as (e: MouseEvent) => void);
return runButton;
}
makeOutDiv(): HTMLElement {
const outDiv = document.createElement('div');
outDiv.className = 'py-repl-output';
outDiv.id = this.id + '-repl-output';
return outDiv;
}
// ********************* execution logic *********************
/** Execute the python code written in the editor, and automatically
* display() the last evaluated expression
*/
async execute(): Promise<void> {
const pySrc = this.getPySrc();
const outEl = this.outDiv;
// execute the python code
await app.plugins.beforePyReplExec({ interpreter: interpreter, src: pySrc, outEl: outEl, pyReplTag: this });
const { result } = await pyExec(interpreter, pySrc, outEl);
await app.plugins.afterPyReplExec({
interpreter: interpreter,
src: pySrc,
outEl: outEl,
pyReplTag: this,
result,
});
this.autogenerateMaybe();
}
getPySrc(): string {
return this.editor.state.doc.toString();
}
// XXX the autogenerate logic is very messy. We should redo it, and it
// should be the default.
autogenerateMaybe(): void {
if (this.hasAttribute('auto-generate')) {
const allPyRepls = $$(`py-repl[root='${this.getAttribute('root')}'][exec-id]`, document);
const lastRepl = allPyRepls[allPyRepls.length - 1];
const lastExecId = lastRepl.getAttribute('exec-id');
const nextExecId = parseInt(lastExecId) + 1;
const newPyRepl = document.createElement('py-repl');
//Attributes to be copied from old REPL to auto-generated REPL
for (const attribute of ['root', 'output-mode', 'output', 'stderr']) {
const attr = this.getAttribute(attribute);
if (attr) {
newPyRepl.setAttribute(attribute, attr);
}
}
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) {
this.parentElement.appendChild(newPyRepl);
}
}
}
}
return PyRepl;
}

View File

@@ -1,261 +0,0 @@
import { $$, $x } from 'basic-devtools';
import { shadowRoots } from '../shadow_roots';
import { ltrim, htmlDecode, ensureUniqueId, createDeprecationWarning } from '../utils';
import { getLogger } from '../logger';
import { pyExec, displayPyException } from '../pyexec';
import { _createAlertBanner } from '../exceptions';
import { robustFetch } from '../fetch';
import { PyScriptApp } from '../main';
import { Stdio } from '../stdio';
import { InterpreterClient } from '../interpreter_client';
const logger = getLogger('py-script');
// used to flag already initialized nodes
const knownPyScriptTags: WeakSet<HTMLElement> = new WeakSet();
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() {
// prevent multiple initialization of the same node if re-appended
if (knownPyScriptTags.has(this)) return;
knownPyScriptTags.add(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;
this.innerHTML = '';
await init(this, this._fetchSourceFallback);
}
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) => {
// prevent multiple initialization of the same node if re-appended
if (knownPyScriptTags.has(script)) return;
knownPyScriptTags.add(script);
const pyScriptTag = document.createElement('py-script-tag') as PyScript;
// move attributes to the live resulting pyScriptTag reference
for (const name of ['output', 'src', 'stderr']) {
const value = script.getAttribute(name);
if (value) {
pyScriptTag.setAttribute(name, value);
}
}
// 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(),
);
};
// loop over all py scripts and botstrap these
const bootstrapScripts = (root: Document | Element) => {
for (const node of $$(pyScriptCSS, root)) {
bootstrap(node as PyScriptElement);
}
};
// globally shared MutationObserver for <script> special cases
const pyScriptMO = new MutationObserver(records => {
for (const { type, target, attributeName, addedNodes } of records) {
if (type === 'attributes') {
// consider only py-* attributes
if (attributeName.startsWith('py-')) {
// if the attribute is currently present
if ((target as Element).hasAttribute(attributeName)) {
// handle the element
addPyScriptEventListener(
getInterpreter(target as Element),
target as Element,
attributeName.slice(3),
);
} else {
// remove the listener because the element should not answer
// to this specific event anymore
// Note: this is *NOT* a misused-promise, this is how async events work.
// eslint-disable-next-line @typescript-eslint/no-misused-promises
target.removeEventListener(attributeName.slice(3), pyScriptListener);
}
}
// skip further loop on empty addedNodes
continue;
}
for (const node of addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
if ((node as PyScriptElement).matches(pyScriptCSS)) {
bootstrap(node as PyScriptElement);
} else {
addAllPyScriptEventListeners(node as Element);
bootstrapScripts(node as Element);
}
}
}
}
});
// simplifies observing any root node (document/shadowRoot)
const observe = (root: Document | ShadowRoot) => {
pyScriptMO.observe(root, { childList: true, subtree: true, attributes: 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) {
const shadowRoot = observe(attachShadow.call(this as Element, init));
shadowRoots.add(shadowRoot);
return shadowRoot;
},
});
// bootstrap all already live py <script> tags
bootstrapScripts(document);
// 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;
}
/** A weak relation between an element and current interpreter */
const elementInterpreter: WeakMap<Element, InterpreterClient> = new WeakMap();
/** Return the interpreter, if any, or vallback to the last known one */
const getInterpreter = (el: Element) => elementInterpreter.get(el) || lastInterpreter;
/** Retain last used interpreter to bootstrap PyScript to augment via MO runtime nodes */
let lastInterpreter: InterpreterClient;
/** Find all py-* attributes in a context node and its descendant + add listeners */
const addAllPyScriptEventListeners = (root: Document | Element) => {
// note the XPath needs to start with a `.` to reference the starting root element
const attributes = $x('.//@*[starts-with(name(), "py-")]', root) as Attr[];
for (const { name, ownerElement: el } of attributes) {
addPyScriptEventListener(getInterpreter(el), el, name.slice(3));
}
};
/** Initialize all elements with py-* handlers attributes */
export function initHandlers(interpreter: InterpreterClient) {
logger.debug('Initializing py-* event handlers...');
lastInterpreter = interpreter;
addAllPyScriptEventListeners(document);
}
/** An always same listeners to reduce RAM and enable future runtime changes via MO */
const pyScriptListener = async ({ type, currentTarget: el }) => {
try {
const interpreter = getInterpreter(el);
await interpreter.run(el.getAttribute(`py-${type as string}`));
} catch (e) {
const err = e as Error;
displayPyException(err, el.parentElement);
}
};
/** Weakly relate an element with an interpreter and then add the listener's type */
function addPyScriptEventListener(interpreter: InterpreterClient, el: Element, type: string) {
// If the element doesn't have an id, let's add one automatically!
if (el.id.length === 0) {
ensureUniqueId(el as HTMLElement);
}
elementInterpreter.set(el, interpreter);
// Note: this is *NOT* a misused-promise, this is how async events work.
// eslint-disable-next-line @typescript-eslint/no-misused-promises
el.addEventListener(type, pyScriptListener);
}
/** Mount all elements with attribute py-mount into the Python namespace */
export async function mountElements(interpreter: InterpreterClient) {
const matches = $$('[py-mount]', document);
logger.info(`py-mount: found ${matches.length} elements`);
if (matches.length > 0) {
//last non-deprecated version: 2023.03.1
const deprecationMessage =
'The "py-mount" attribute is deprecated. Please add references to HTML Elements manually in your script.';
createDeprecationWarning(deprecationMessage, 'py-mount');
}
let source = '';
for (const el of matches) {
const mountName = el.getAttribute('py-mount') || el.id.split('-').join('_');
source += `\n${mountName} = Element("${el.id}")`;
}
await interpreter.run(source);
}

View File

@@ -1,87 +0,0 @@
const CLOSEBUTTON = `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill="currentColor" width="12px"><path d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/></svg>`;
type MessageType = 'text' | 'html';
/**
* These error codes are used to identify the type of error that occurred.
* @see https://docs.pyscript.net/latest/reference/exceptions.html?highlight=errors
*/
export enum ErrorCode {
GENERIC = 'PY0000', // Use this only for development then change to a more specific error code
FETCH_ERROR = 'PY0001',
FETCH_NAME_ERROR = 'PY0002',
// Currently these are created depending on error code received from fetching
FETCH_UNAUTHORIZED_ERROR = 'PY0401',
FETCH_FORBIDDEN_ERROR = 'PY0403',
FETCH_NOT_FOUND_ERROR = 'PY0404',
FETCH_SERVER_ERROR = 'PY0500',
FETCH_UNAVAILABLE_ERROR = 'PY0503',
BAD_CONFIG = 'PY1000',
MICROPIP_INSTALL_ERROR = 'PY1001',
BAD_PLUGIN_FILE_EXTENSION = 'PY2000',
NO_DEFAULT_EXPORT = 'PY2001',
TOP_LEVEL_AWAIT = 'PY9000',
}
export class UserError extends Error {
/**
* `isinstance` doesn't work correctly across multiple realms.
* Hence, `$$isUserError` flag / marker is used to identify a `UserError`.
*/
$$isUserError: boolean;
constructor(public errorCode: ErrorCode, message: string, public messageType: MessageType = 'text') {
super(`(${errorCode}): ${message}`);
this.name = 'UserError';
this.$$isUserError = true;
}
}
export class FetchError extends UserError {
constructor(errorCode: ErrorCode, message: string) {
super(errorCode, message);
this.name = 'FetchError';
}
}
export class InstallError extends UserError {
constructor(errorCode: ErrorCode, message: string) {
super(errorCode, message);
this.name = 'InstallError';
}
}
export function _createAlertBanner(
message: string,
level: 'error' | 'warning' = 'error',
messageType: MessageType = 'text',
logMessage = true,
) {
switch (`log-${level}-${logMessage}`) {
case 'log-error-true':
console.error(message);
break;
case 'log-warning-true':
console.warn(message);
break;
}
const content = messageType === 'html' ? 'innerHTML' : 'textContent';
const banner = Object.assign(document.createElement('div'), {
className: `alert-banner py-${level}`,
[content]: message,
});
if (level === 'warning') {
const closeButton = Object.assign(document.createElement('button'), {
id: 'alert-close-button',
innerHTML: CLOSEBUTTON,
});
banner.appendChild(closeButton).addEventListener('click', () => {
banner.remove();
});
}
document.body.prepend(banner);
}

View File

@@ -1,57 +0,0 @@
import { FetchError, ErrorCode } from './exceptions';
/**
* This is a fetch wrapper that handles any non 200 responses and throws a
* FetchError with the right ErrorCode. This is useful because our FetchError
* will automatically create an alert banner.
*
* @param url - URL to fetch
* @param options - options to pass to fetch
* @returns Response
*/
export async function robustFetch(url: string, options?: RequestInit): Promise<Response> {
let response: Response;
// Note: We need to wrap fetch into a try/catch block because fetch
// throws a TypeError if the URL is invalid such as http://blah.blah
try {
response = await fetch(url, options);
} catch (err) {
const error = err as Error;
let errMsg: string;
if (url.startsWith('http')) {
errMsg =
`Fetching from URL ${url} failed with error ` +
`'${error.message}'. Are your filename and path correct?`;
} else {
errMsg = `PyScript: Access to local files
(using [[fetch]] configurations in &lt;py-config&gt;)
is not available when directly opening a HTML file;
you must use a webserver to serve the additional files.
See <a style="text-decoration: underline;" href="https://github.com/pyscript/pyscript/issues/257#issuecomment-1119595062">this reference</a>
on starting a simple webserver with Python.
`;
}
throw new FetchError(ErrorCode.FETCH_ERROR, errMsg);
}
// Note that response.ok is true for 200-299 responses
if (!response.ok) {
const errorMsg = `Fetching from URL ${url} failed with error ${response.status} (${response.statusText}). Are your filename and path correct?`;
switch (response.status) {
case 404:
throw new FetchError(ErrorCode.FETCH_NOT_FOUND_ERROR, errorMsg);
case 401:
throw new FetchError(ErrorCode.FETCH_UNAUTHORIZED_ERROR, errorMsg);
case 403:
throw new FetchError(ErrorCode.FETCH_FORBIDDEN_ERROR, errorMsg);
case 500:
throw new FetchError(ErrorCode.FETCH_SERVER_ERROR, errorMsg);
case 503:
throw new FetchError(ErrorCode.FETCH_UNAVAILABLE_ERROR, errorMsg);
default:
throw new FetchError(ErrorCode.FETCH_ERROR, errorMsg);
}
}
return response;
}

View File

@@ -1,86 +0,0 @@
import type { AppConfig } from './pyconfig';
import { RemoteInterpreter } from './remote_interpreter';
import type { PyProxyDict, PyProxy } from 'pyodide';
import { getLogger } from './logger';
import type { Stdio } from './stdio';
import * as Synclink from 'synclink';
const logger = getLogger('pyscript/interpreter');
/*
InterpreterClient class is responsible to request code execution
(among other things) from a `RemoteInterpreter`
*/
export class InterpreterClient extends Object {
_remote: Synclink.Remote<RemoteInterpreter>;
config: AppConfig;
/**
* global symbols table for the underlying interface.
* */
globals: Synclink.Remote<PyProxyDict>;
stdio: Stdio;
constructor(config: AppConfig, stdio: Stdio, remote: Synclink.Remote<RemoteInterpreter>) {
super();
this.config = config;
this._remote = remote;
this.stdio = stdio;
}
/**
* initializes the remote interpreter, which further loads the underlying
* interface.
*/
async initializeRemote(): Promise<void> {
await this._remote.loadInterpreter(this.config, Synclink.proxy(this.stdio));
this.globals = this._remote.globals;
}
/**
* Run user Python code. See also the _run_pyscript docstring.
*
* The result is wrapped in an object to avoid accidentally awaiting a
* Python Task or Future returned as the result of the computation.
*
* @param code the code to run
* @param id The id for the default display target (or undefined if no
* default display target).
* @returns Either:
* 1. An Object of the form {result: the_result} if the result is
* serializable (or transferable), or
* 2. a Synclink Proxy wrapping an object of this if the result is not
* serializable.
*/
async run(code: string, id?: string): Promise<{ result: any }> {
return this._remote.pyscript_internal.run_pyscript(code, id);
}
/**
* Same as run, but Python exceptions are not propagated: instead, they
* are logged to the console.
*
* This is a bad API and should be killed/refactored/changed eventually,
* but for now we have code which relies on it.
* */
async runButDontRaise(code: string): Promise<unknown> {
let result: unknown;
try {
result = (await this.run(code)).result;
} catch (error: unknown) {
logger.error('Error:', error);
}
return result;
}
async pyimport(mod_name: string): Promise<Synclink.Remote<PyProxy>> {
return this._remote.pyimport(mod_name);
}
async mkdir(path: string) {
await this._remote.FS.mkdir(path);
}
async writeFile(path: string, content: string) {
await this._remote.FS.writeFile(path, content, { encoding: 'utf8' });
}
}

View File

@@ -1,26 +0,0 @@
// XXX: what about code duplications?
// With the current build configuration, the code for logger,
// remote_interpreter and everything which is included from there is
// bundled/fetched/executed twice, once in pyscript.js and once in
// worker_interpreter.js.
import { getLogger } from '../logger';
import { RemoteInterpreter } from '../remote_interpreter';
import * as Synclink from 'synclink';
const logger = getLogger('worker');
logger.info('Interpreter worker starting...');
async function worker_initialize(cfg) {
const remote_interpreter = new RemoteInterpreter(cfg.src);
// this is the equivalent of await import(interpreterURL)
logger.info(`Downloading ${cfg.name}...`); // XXX we should use logStatus
importScripts(cfg.src);
logger.info('worker_initialize() complete');
return Synclink.proxy(remote_interpreter);
}
Synclink.expose(worker_initialize);
export type { worker_initialize };

View File

@@ -1,64 +0,0 @@
/* Very simple logger interface.
Each module is expected to create its own logger by doing e.g.:
const logger = getLogger('my-prefix');
and then use it instead of console:
logger.info('hello', 'world');
logger.warn('...');
logger.error('...');
The logger automatically adds the prefix "[my-prefix]" to all logs.
E.g., the above call would print:
[my-prefix] hello world
logger.log is intentionally omitted. The idea is that PyScript should not
write anything to console.log, to leave it free for the user.
Currently, the logger does not to anything more than that. In the future,
we might want to add additional features such as the ability to
enable/disable logs on a global or per-module basis.
*/
interface Logger {
debug(message: string, ...args: unknown[]): void;
info(message: string, ...args: unknown[]): void;
warn(message: string, ...args: unknown[]): void;
error(message: string | Error, ...args: unknown[]): void;
}
const _cache = new Map<string, Logger>();
function getLogger(prefix: string): Logger {
let logger = _cache.get(prefix);
if (logger === undefined) {
logger = _makeLogger(prefix);
_cache.set(prefix, logger);
}
return logger;
}
function _makeLogger(prefix: string): Logger {
prefix = `[${prefix}] `;
function make(level: 'info' | 'debug' | 'warn' | 'error') {
const out_fn = console[level].bind(console) as typeof console.log;
function fn(fmt: string, ...args: unknown[]) {
out_fn(prefix + fmt, ...args);
}
return fn;
}
// 'log' is intentionally omitted
const debug = make('debug');
const info = make('info');
const warn = make('warn');
const error = make('error');
return { debug, info, warn, error };
}
export { getLogger };

View File

@@ -1,458 +0,0 @@
import { $$ } from 'basic-devtools';
import './styles/pyscript_base.css';
import { loadConfigFromElement } from './pyconfig';
import type { AppConfig, InterpreterConfig } from './pyconfig';
import { InterpreterClient } from './interpreter_client';
import { PluginManager, Plugin, PythonPlugin } from './plugin';
import { make_PyScript, initHandlers, mountElements } from './components/pyscript';
import { getLogger } from './logger';
import { showWarning, createLock } from './utils';
import { calculateFetchPaths } from './plugins/calculateFetchPaths';
import { createCustomElements } from './components/elements';
import { UserError, ErrorCode, _createAlertBanner } from './exceptions';
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';
import { RemoteInterpreter } from './remote_interpreter';
import { robustFetch } from './fetch';
import * as Synclink from 'synclink';
const logger = getLogger('pyscript/main');
/**
* Monkey patching the error transfer handler to preserve the `$$isUserError`
* marker so as to detect `UserError` subclasses in the error handling code.
*/
const throwHandler = Synclink.transferHandlers.get('throw') as Synclink.TransferHandler<
{ value: unknown },
{ value: { $$isUserError: boolean } }
>;
const old_error_transfer_handler = throwHandler.serialize.bind(throwHandler) as typeof throwHandler.serialize;
function new_error_transfer_handler({ value }: { value: { $$isUserError: boolean } }) {
const result = old_error_transfer_handler({ value });
result[0].value.$$isUserError = value.$$isUserError;
return result;
}
throwHandler.serialize = new_error_transfer_handler;
/* High-level overview of the lifecycle of a PyScript App:
1. pyscript.js is loaded by the browser. PyScriptApp().main() is called
2. loadConfig(): search for py-config and compute the config for the app
3. (it used to be "show the splashscreen", but now it's a plugin)
4. loadInterpreter(): start downloading the actual interpreter (e.g. pyodide.js)
--- wait until (4) has finished ---
5. now the pyodide src is available. Initialize the engine
6. setup the environment, install packages
6.5: call the Plugin.afterSetup() hook
7. connect the py-script web component. This causes the execution of all the
user scripts
8. initialize the rest of web components such as py-button, py-repl, etc.
*/
export let interpreter;
// TODO: This is for backwards compatibility, it should be removed
// when we finish the deprecation cycle of `runtime`
export let runtime;
export class PyScriptApp {
config: AppConfig;
interpreter: InterpreterClient;
readyPromise: Promise<void>;
PyScript: ReturnType<typeof make_PyScript>;
plugins: PluginManager;
_stdioMultiplexer: StdioMultiplexer;
tagExecutionLock: () => Promise<() => void>; // this is used to ensure that py-script tags are executed sequentially
_numPendingTags: number;
scriptTagsPromise: Promise<void>;
resolvedScriptTags: () => void;
constructor() {
// initialize the builtin plugins
this.plugins = new PluginManager();
this.plugins.add(new SplashscreenPlugin(), new PyTerminalPlugin(this), new ImportmapPlugin());
this._stdioMultiplexer = new StdioMultiplexer();
this._stdioMultiplexer.addListener(DEFAULT_STDIO);
this.plugins.add(new StdioDirector(this._stdioMultiplexer));
this.tagExecutionLock = createLock();
this._numPendingTags = 0;
this.scriptTagsPromise = new Promise(res => (this.resolvedScriptTags = res));
}
// Error handling logic: if during the execution we encounter an error
// which is ultimate responsibility of the user (e.g.: syntax error in the
// config, file not found in fetch, etc.), we can throw UserError(). It is
// responsibility of main() to catch it and show it to the user in a
// proper way (e.g. by using a banner at the top of the page).
async main() {
try {
await this._realMain();
} catch (error) {
await this._handleUserErrorMaybe(error);
}
}
incrementPendingTags() {
this._numPendingTags += 1;
}
decrementPendingTags() {
if (this._numPendingTags <= 0) {
throw new Error('INTERNAL ERROR: assertion _numPendingTags > 0 failed');
}
this._numPendingTags -= 1;
if (this._numPendingTags === 0) {
this.resolvedScriptTags();
}
}
async _handleUserErrorMaybe(error: any) {
const e = error as UserError;
if (e && e.$$isUserError) {
_createAlertBanner(e.message, 'error', e.messageType);
await this.plugins.onUserError(e);
} else {
throw error;
}
}
// ============ lifecycle ============
// lifecycle (1)
async _realMain() {
this.loadConfig();
await this.plugins.configure(this.config);
this.plugins.beforeLaunch(this.config);
await this.loadInterpreter();
interpreter = this.interpreter;
// TODO: This is for backwards compatibility, it should be removed
// when we finish the deprecation cycle of `runtime`
runtime = this.interpreter;
}
// lifecycle (2)
loadConfig() {
// find the <py-config> tag. If not found, we get null which means
// "use the default config"
// XXX: we should actively complain if there are multiple <py-config>
// and show a big error. PRs welcome :)
logger.info('searching for <py-config>');
const elements = $$('py-config', document);
let el: Element | null = null;
if (elements.length > 0) el = elements[0];
if (elements.length >= 2) {
showWarning(
'Multiple <py-config> tags detected. Only the first is ' +
'going to be parsed, all the others will be ignored',
);
}
this.config = loadConfigFromElement(el);
if (this.config.execution_thread === 'worker' && crossOriginIsolated === false) {
throw new UserError(
ErrorCode.BAD_CONFIG,
`When execution_thread is "worker", the site must be cross origin isolated, but crossOriginIsolated is false.
To be cross origin isolated, the server must use https and also serve with the following headers: ${JSON.stringify(
{
'Cross-Origin-Embedder-Policy': 'require-corp',
'Cross-Origin-Opener-Policy': 'same-origin',
},
)}.
The problem may be that one or both of these are missing.
`,
);
}
logger.info('config loaded:\n' + JSON.stringify(this.config, null, 2));
}
_get_base_url(): string {
// Note that this requires that pyscript is loaded via a <script>
// tag. If we want to allow loading via an ES6 module in the future,
// we need to think about some other strategy
const elem = document.currentScript as HTMLScriptElement;
const slash = elem.src.lastIndexOf('/');
return elem.src.slice(0, slash);
}
async _startInterpreter_main(interpreter_cfg: InterpreterConfig) {
logger.info('Starting the interpreter in the main thread');
// this is basically equivalent to worker_initialize()
const remote_interpreter = new RemoteInterpreter(interpreter_cfg.src);
const { port1, port2 } = new Synclink.FakeMessageChannel() as unknown as MessageChannel;
port1.start();
port2.start();
Synclink.expose(remote_interpreter, port2);
const wrapped_remote_interpreter = Synclink.wrap(port1);
this.logStatus(`Downloading ${interpreter_cfg.name}...`);
/* Dynamically download and import pyodide: the import() puts a
loadPyodide() function into globalThis, which is later called by
RemoteInterpreter.
This is suboptimal: ideally, we would like to import() a module
which exports loadPyodide(), but this plays badly with workers
because at the moment of writing (2023-03-24) Firefox does not
support ES modules in workers:
https://caniuse.com/mdn-api_worker_worker_ecmascript_modules
*/
const interpreterURL = interpreter_cfg.src;
await import(interpreterURL);
return wrapped_remote_interpreter;
}
async _startInterpreter_worker(interpreter_cfg: InterpreterConfig) {
logger.warn('execution_thread = "worker" is still VERY experimental, use it at your own risk');
logger.info('Starting the interpreter in a web worker');
const base_url = this._get_base_url();
const worker = new Worker(base_url + '/interpreter_worker.js');
const worker_initialize: any = Synclink.wrap(worker);
const wrapped_remote_interpreter = await worker_initialize(interpreter_cfg);
return wrapped_remote_interpreter;
}
// lifecycle (4)
async loadInterpreter() {
logger.info('Initializing interpreter');
if (this.config.interpreters.length == 0) {
throw new UserError(ErrorCode.BAD_CONFIG, 'Fatal error: config.interpreter is empty');
}
if (this.config.interpreters.length > 1) {
showWarning('Multiple interpreters are not supported yet.<br />Only the first will be used', 'html');
}
const cfg = this.config.interpreters[0];
let wrapped_remote_interpreter;
if (this.config.execution_thread == 'worker') {
wrapped_remote_interpreter = await this._startInterpreter_worker(cfg);
} else {
wrapped_remote_interpreter = await this._startInterpreter_main(cfg);
}
this.interpreter = new InterpreterClient(
this.config,
this._stdioMultiplexer,
wrapped_remote_interpreter as Synclink.Remote<RemoteInterpreter>,
);
await this.afterInterpreterLoad(this.interpreter);
}
// lifecycle (5)
// See the overview comment above for an explanation of how we jump from
// point (4) to point (5).
//
// Invariant: this.config is set and available.
async afterInterpreterLoad(interpreter: InterpreterClient): Promise<void> {
console.assert(this.config !== undefined);
this.logStatus('Python startup...');
await this.interpreter.initializeRemote();
this.logStatus('Python ready!');
this.logStatus('Setting up virtual environment...');
await this.setupVirtualEnv(interpreter);
await mountElements(interpreter);
// lifecycle (6.5)
await this.plugins.afterSetup(interpreter);
//Refresh module cache in case plugins have modified the filesystem
await interpreter._remote.invalidate_module_path_cache();
this.logStatus('Executing <py-script> tags...');
await this.executeScripts(interpreter);
this.logStatus('Initializing web components...');
// lifecycle (8)
//Takes a runtime and a reference to the PyScriptApp (to access plugins)
createCustomElements(interpreter, this);
initHandlers(interpreter);
// NOTE: interpreter message is used by integration tests to know that
// pyscript initialization has complete. If you change it, you need to
// change it also in tests/integration/support.py
this.logStatus('Startup complete');
await this.plugins.afterStartup(interpreter);
logger.info('PyScript page fully initialized');
}
// lifecycle (6)
async setupVirtualEnv(interpreter: InterpreterClient): Promise<void> {
// XXX: maybe the following calls could be parallelized, instead of
// await()ing immediately. For now I'm using await to be 100%
// compatible with the old behavior.
await Promise.all([this.installPackages(), this.fetchPaths(interpreter)]);
//This may be unnecessary - only useful if plugins try to import files fetch'd in fetchPaths()
await interpreter._remote.invalidate_module_path_cache();
// Finally load plugins
await this.fetchUserPlugins(interpreter);
}
async installPackages() {
if (!this.config.packages) {
return;
}
logger.info('Packages to install: ', this.config.packages);
await this.interpreter._remote.installPackage(this.config.packages);
}
async fetchPaths(interpreter: InterpreterClient) {
// TODO: start fetching before interpreter initialization
const paths = calculateFetchPaths(this.config.fetch);
logger.info('Fetching urls:', paths.map(({ url }) => url).join(', '));
await Promise.all(
paths.map(async ({ path, url }) => {
await interpreter._remote.loadFileFromURL(path, url);
logger.info(` Fetched ${url} ==> ${path}`);
}),
);
logger.info('Fetched all paths');
}
/**
* Fetch user plugins and adds them to `this.plugins` so they can
* be loaded by the PluginManager. Currently, we are just looking
* for .py and .js files and calling the appropriate methods.
*
* @param interpreter - the interpreter that will be used to execute the plugins that need it.
*/
async fetchUserPlugins(interpreter: InterpreterClient) {
const plugins = this.config.plugins;
logger.info('Plugins to fetch: ', plugins);
for (const singleFile of plugins) {
logger.info(` fetching plugins: ${singleFile}`);
if (singleFile.endsWith('.py')) {
await this.fetchPythonPlugin(interpreter, singleFile);
} else if (singleFile.endsWith('.js')) {
await this.fetchJSPlugin(singleFile);
} else {
throw new UserError(
ErrorCode.BAD_PLUGIN_FILE_EXTENSION,
`Unable to load plugin from '${singleFile}'. ` +
`Plugins need to contain a file extension and be ` +
`either a python or javascript file.`,
);
}
logger.info('All plugins fetched');
}
}
/**
* Fetch a javascript plugin from a filePath, it gets a blob from the
* fetch and creates a file from it, then we create a URL from the file
* so we can import it as a module.
*
* This allow us to instantiate the imported plugin with the default
* export in the module (the plugin class) and add it to the plugins
* list with `new importedPlugin()`.
*
* @param filePath - URL of the javascript file to fetch.
*/
async fetchJSPlugin(filePath: string) {
const pluginBlob = await (await robustFetch(filePath)).blob();
const blobFile = new File([pluginBlob], 'plugin.js', { type: 'text/javascript' });
const fileUrl = URL.createObjectURL(blobFile);
const module = (await import(fileUrl)) as { default: { new (): Plugin } };
// Note: We have to put module.default in a variable
// because we have seen weird behaviour when doing
// new module.default() directly.
const importedPlugin = module.default;
// If the imported plugin doesn't have a default export
// it will be undefined, so we throw a user error, so
// an alter banner will be created.
if (importedPlugin === undefined) {
throw new UserError(
ErrorCode.NO_DEFAULT_EXPORT,
`Unable to load plugin from '${filePath}'. ` + `Plugins need to contain a default export.`,
);
} else {
this.plugins.add(new importedPlugin());
}
}
/**
* Fetch python plugins from a filePath, saves it on the FS and import
* it as a module, executing any plugin define the module scope.
*
* @param interpreter - the interpreter that will execute the plugins
* @param filePath - path to the python file to fetch
*/
async fetchPythonPlugin(interpreter: InterpreterClient, filePath: string) {
const pathArr = filePath.split('/');
const filename = pathArr.pop();
// TODO: Would be probably be better to store plugins somewhere like /plugins/python/ or similar
await interpreter._remote.loadFileFromURL(filename, filePath);
//refresh module cache before trying to import module files into interpreter
await interpreter._remote.invalidate_module_path_cache();
const modulename = filePath.replace(/^.*[\\/]/, '').replace('.py', '');
console.log(`importing ${modulename}`);
// TODO: This is very specific to Pyodide API and will not work for other interpreters,
// when we add support for other interpreters we will need to move this to the
// interpreter API level and allow each one to implement it in its own way
const module = await interpreter.pyimport(modulename);
if (typeof (await module.plugin) !== 'undefined') {
const py_plugin = (await module.plugin) as PythonPlugin;
py_plugin.init(this);
this.plugins.addPythonPlugin(py_plugin);
} else {
logger.error(`Cannot find plugin on Python module ${modulename}! Python plugins \
modules must contain a "plugin" attribute. For more information check the plugins documentation.`);
}
}
// lifecycle (7)
async executeScripts(interpreter: InterpreterClient) {
// make_PyScript takes an interpreter and a PyScriptApp as arguments
this.PyScript = make_PyScript(interpreter, this);
customElements.define('py-script', this.PyScript);
this.incrementPendingTags();
this.decrementPendingTags();
await this.scriptTagsPromise;
await this.interpreter._remote.pyscript_internal.schedule_deferred_tasks();
}
// ================= registraton API ====================
logStatus(msg: string) {
logger.info(msg);
const ev = new CustomEvent('py-status-message', { detail: msg });
document.dispatchEvent(ev);
}
registerStdioListener(stdio: Stdio) {
this._stdioMultiplexer.addListener(stdio);
}
}
globalThis.pyscript_get_config = () => globalApp.config;
// main entry point of execution
const globalApp = new PyScriptApp();
// This top level execution causes trouble in jest
if (typeof jest === 'undefined') {
globalApp.readyPromise = globalApp.main();
}
export { version } from './version';

View File

@@ -1,385 +0,0 @@
import type { PyScriptApp } from './main';
import type { AppConfig } from './pyconfig';
import { UserError, ErrorCode } from './exceptions';
import { getLogger } from './logger';
import { make_PyScript } from './components/pyscript';
import { InterpreterClient } from './interpreter_client';
import { make_PyRepl } from './components/pyrepl';
const logger = getLogger('plugin');
type PyScriptTag = InstanceType<ReturnType<typeof make_PyScript>>;
type PyReplTag = InstanceType<ReturnType<typeof make_PyRepl>>;
export class Plugin {
/** Validate the configuration of the plugin and handle default values.
*
* Individual plugins are expected to check that the config keys/sections
* which are relevant to them contains valid values, and to raise an error
* if they contains unknown keys.
*
* This is also a good place where set default values for those keys which
* are not specified by the user.
*
* This hook should **NOT** contain expensive operations, else it delays
* the download of the python interpreter which is initiated later.
*/
configure(_config: AppConfig) {
/* empty */
}
/** The preliminary initialization phase is complete and we are about to
* download and launch the Python interpreter.
*
* We can assume that the page is already shown to the user and that the
* DOM content has been loaded. This is a good place where to add tags to
* the DOM, if needed.
*
* This hook should **NOT** contain expensive operations, else it delays
* the download of the python interpreter which is initiated later.
*/
beforeLaunch(_config: AppConfig) {
/* empty */
}
/** The Python interpreter has been launched, the virtualenv has been
* installed and we are ready to execute user code.
*
* The <py-script> tags will be executed after this hook.
*/
afterSetup(_interpreter: InterpreterClient) {
/* empty */
}
/** The source of a <py-script>> 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 evaluate the Python source code
* @param options.src {string} The Python source code to be evaluated
* @param options.pyScriptTag The <py-script> HTML tag that originated the evaluation
*/
beforePyScriptExec(_options: { interpreter: InterpreterClient; src: string; pyScriptTag: PyScriptTag }) {
/* empty */
}
/** The Python in a <py-script> 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 evaluate the Python source code
* @param options.src {string} The Python source code to be evaluated
* @param options.pyScriptTag The <py-script> HTML tag that originated the evaluation
* @param options.result The returned result of evaluating the Python (if any)
*/
afterPyScriptExec(_options: {
interpreter: InterpreterClient;
src: string;
pyScriptTag: PyScriptTag;
result: any;
}) {
/* empty */
}
/** The source of the <py-repl> 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 <py-repl> HTML tag the originated the evaluation
*/
beforePyReplExec(options: {
interpreter: InterpreterClient;
src: string;
outEl: HTMLElement;
pyReplTag: PyReplTag;
}) {
/* 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 <py-repl> 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: PyReplTag;
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.
*/
afterStartup(_interpreter: InterpreterClient) {
/* empty */
}
/** Called when an UserError is raised
*/
onUserError(_error: UserError) {
/* empty */
}
}
export type PythonPlugin = {
init(app: PyScriptApp): void;
configure?: (config: AppConfig) => Promise<void>;
afterSetup?: (interpreter: InterpreterClient) => Promise<void>;
afterStartup?: (interpreter: InterpreterClient) => Promise<void>;
beforePyScriptExec?: (interpreter: InterpreterClient, src: string, pyScriptTag: PyScriptTag) => Promise<void>;
afterPyScriptExec?: (
interpreter: InterpreterClient,
src: string,
pyScriptTag: PyScriptTag,
result: any,
) => Promise<void>;
beforePyReplExec?: (
interpreter: InterpreterClient,
src: string,
outEl: HTMLElement,
pyReplTag: PyReplTag,
) => Promise<void>;
afterPyReplExec?: (
interpreter: InterpreterClient,
src: string,
outEl: HTMLElement,
pyReplTag: PyReplTag,
result: any,
) => Promise<void>;
onUserError?: (error: UserError) => Promise<void>;
};
export class PluginManager {
_plugins: Plugin[];
_pythonPlugins: PythonPlugin[];
constructor() {
this._plugins = [];
this._pythonPlugins = [];
}
add(...plugins: Plugin[]) {
this._plugins.push(...plugins);
}
addPythonPlugin(plugin: PythonPlugin) {
this._pythonPlugins.push(plugin);
}
async configure(config: AppConfig) {
const fn = p => p.configure?.(config);
await Promise.all(this._plugins.map(fn));
await Promise.all(this._pythonPlugins.map(fn));
}
beforeLaunch(config: AppConfig) {
for (const p of this._plugins) {
try {
p?.beforeLaunch?.(config);
} catch (e) {
logger.error(`Error while calling beforeLaunch hook of plugin ${p.constructor.name}`, e);
}
}
}
async afterSetup(interpreter: InterpreterClient) {
const promises = [];
for (const p of this._plugins) {
try {
promises.push(p.afterSetup?.(interpreter));
} catch (e) {
logger.error(`Error while calling afterSetup hook of plugin ${p.constructor.name}`, e);
}
}
await Promise.all(promises);
for (const p of this._pythonPlugins) await p.afterSetup?.(interpreter);
}
async afterStartup(interpreter: InterpreterClient) {
const fn = p => p.afterStartup?.(interpreter);
await Promise.all(this._plugins.map(fn));
await Promise.all(this._pythonPlugins.map(fn));
}
async beforePyScriptExec(options: { interpreter: InterpreterClient; src: string; pyScriptTag: PyScriptTag }) {
await Promise.all(this._plugins.map(p => p.beforePyScriptExec?.(options)));
await Promise.all(
this._pythonPlugins.map(p => p.beforePyScriptExec?.(options.interpreter, options.src, options.pyScriptTag)),
);
}
async afterPyScriptExec(options: {
interpreter: InterpreterClient;
src: string;
pyScriptTag: PyScriptTag;
result: any;
}) {
await Promise.all(this._plugins.map(p => p.afterPyScriptExec?.(options)));
await Promise.all(
this._pythonPlugins.map(
p => p.afterPyScriptExec?.(options.interpreter, options.src, options.pyScriptTag, options.result),
),
);
}
async beforePyReplExec(options: {
interpreter: InterpreterClient;
src: string;
outEl: HTMLElement;
pyReplTag: PyReplTag;
}) {
await Promise.all(this._plugins.map(p => p.beforePyReplExec?.(options)));
await Promise.all(
this._pythonPlugins.map(
p => p.beforePyReplExec?.(options.interpreter, options.src, options.outEl, options.pyReplTag),
),
);
}
async afterPyReplExec(options: {
interpreter: InterpreterClient;
src: string;
outEl: HTMLElement;
pyReplTag: PyReplTag;
result: any;
}) {
await Promise.all(this._plugins.map(p => p.afterPyReplExec?.(options)));
await Promise.all(
this._pythonPlugins.map(
p =>
p.afterPyReplExec?.(
options.interpreter,
options.src,
options.outEl,
options.pyReplTag,
options.result,
),
),
);
}
async onUserError(error: UserError) {
const fn = p => p.onUserError?.(error);
await Promise.all(this._plugins.map(fn));
await Promise.all(this._pythonPlugins.map(fn));
}
}
type PyElementInstance = { connect(): void };
type PyElementClass = (htmlElement: HTMLElement) => PyElementInstance;
/**
* Defines a new CustomElement (via customElement.defines) with `tag`,
* where the new CustomElement is a proxy that delegates the logic to
* pyPluginClass.
*
* @param tag - tag that will be used to define the new CustomElement (i.e: "py-script")
* @param pyPluginClass - class that will be used to create instance to be
* used as CustomElement logic handler. Any DOM event
* received by the newly created CustomElement will be
* delegated to that instance.
*/
export function define_custom_element(tag: string, pyElementClass: PyElementClass): any {
logger.info(`creating plugin: ${tag}`);
class ProxyCustomElement extends HTMLElement {
wrapper: HTMLElement;
pyElementInstance: PyElementInstance;
originalInnerHTML: string;
constructor() {
logger.debug(`creating ${tag} plugin instance`);
super();
this.wrapper = document.createElement('slot');
this.attachShadow({ mode: 'open' }).appendChild(this.wrapper);
this.originalInnerHTML = this.innerHTML;
this.pyElementInstance = pyElementClass(this);
}
connectedCallback() {
const innerHTML = this.pyElementInstance.connect();
if (typeof innerHTML === 'string') this.innerHTML = innerHTML;
}
}
customElements.define(tag, ProxyCustomElement);
}
// Members of py-config in plug that we want to validate must be one of these types
type BaseConfigObject = string | boolean | number | undefined;
/**
* Validate that parameter the user provided to py-config conforms to the specified validation function;
* if not, throw an error explaining the bad value. If no value is provided, set the parameter
* to the provided default value
* This is the most generic validation function; other validation functions for common situations follow
* @param options.config - The (extended) AppConfig object from py-config
* @param {string} options.name - The name of the key in py-config to be checked
* @param {(b:BaseConfigObject) => boolean} options.validator - the validation function used to test the user-supplied value
* @param {BaseConfigObject} options.defaultValue - The default value for this parameter, if none is provided
* @param {string} [options.hintMessage] - The message to show in a user error if the supplied value isn't valid
*/
export function validateConfigParameter(options: {
config: AppConfig;
name: string;
validator: (b: BaseConfigObject) => boolean;
defaultValue: BaseConfigObject;
hintMessage?: string;
}) {
//Validate that the default value is acceptable, at runtime
if (!options.validator(options.defaultValue)) {
throw Error(
`Default value ${JSON.stringify(options.defaultValue)} for ${options.name} is not a valid argument, ` +
`according to the provided validator function. ${options.hintMessage ? options.hintMessage : ''}`,
);
}
const value = options.config[options.name] as BaseConfigObject;
if (value !== undefined && !options.validator(value)) {
//Use default hint message if none is provided:
const hintOutput = `Invalid value ${JSON.stringify(value)} for config.${options.name}. ${
options.hintMessage ? options.hintMessage : ''
}`;
throw new UserError(ErrorCode.BAD_CONFIG, hintOutput);
}
if (value === undefined) {
options.config[options.name] = options.defaultValue;
}
}
/**
* Validate that parameter the user provided to py-config is one of the acceptable values in
* the given Array; if not, throw an error explaining the bad value. If no value is provided,
* set the parameter to the provided default value
* @param options.config - The (extended) AppConfig object from py-config
* @param {string} options.name - The name of the key in py-config to be checked
* @param {Array<BaseConfigObject>} options.possibleValues: The acceptable values for this parameter
* @param {BaseConfigObject} options.defaultValue: The default value for this parameter, if none is provided
*/
export function validateConfigParameterFromArray(options: {
config: AppConfig;
name: string;
possibleValues: Array<BaseConfigObject>;
defaultValue: BaseConfigObject;
}) {
const validator = (b: BaseConfigObject) => options.possibleValues.includes(b);
const hint = `The only accepted values are: [${options.possibleValues
.map(item => JSON.stringify(item))
.join(', ')}]`;
validateConfigParameter({
config: options.config,
name: options.name,
validator: validator,
defaultValue: options.defaultValue,
hintMessage: hint,
});
}

View File

@@ -1,26 +0,0 @@
import { joinPaths } from '../utils';
import { FetchConfig } from '../pyconfig';
import { UserError, ErrorCode } from '../exceptions';
export function calculateFetchPaths(fetch_cfg: FetchConfig[]): { url: string; path: string }[] {
for (const { files, to_file, from = '' } of fetch_cfg) {
if (files !== undefined && to_file !== undefined) {
throw new UserError(ErrorCode.BAD_CONFIG, `Cannot use 'to_file' and 'files' parameters together!`);
}
if (files === undefined && to_file === undefined && from.endsWith('/')) {
throw new UserError(
ErrorCode.BAD_CONFIG,
`Couldn't determine the filename from the path ${from}, please supply 'to_file' parameter.`,
);
}
}
return fetch_cfg.flatMap(function ({ from = '', to_folder = '.', to_file, files }) {
if (files !== undefined) {
return files.map(file => ({ url: joinPaths([from, file]), path: joinPaths([to_folder, file]) }));
}
const filename = to_file || from.slice(1 + from.lastIndexOf('/'));
const to_path = joinPaths([to_folder, filename]);
return [{ url: from, path: to_path }];
});
}

View File

@@ -1,56 +0,0 @@
import { $$ } from 'basic-devtools';
import { showWarning } from '../utils';
import { Plugin } from '../plugin';
import { getLogger } from '../logger';
import { InterpreterClient } from '../interpreter_client';
const logger = getLogger('plugins/importmap');
type ImportType = { [key: string]: unknown };
type ImportMapType = {
imports: ImportType | null;
};
export class ImportmapPlugin extends Plugin {
async afterSetup(interpreter: InterpreterClient) {
// make importmap ES modules available from python using 'import'.
//
// XXX: this code can probably be improved because errors are silently
// ignored.
//
// Moreover, it's also wrong because it's async and currently we don't
// await the module to be fully registered before executing the code
// inside py-script. It's also unclear whether we want to wait or not
// (or maybe only wait only if we do an actual 'import'?)
for (const node of $$("script[type='importmap']", document)) {
const importmap: ImportMapType = (() => {
try {
return JSON.parse(node.textContent) as ImportMapType;
} catch (e) {
const error = e as Error;
showWarning('Failed to parse import map: ' + error.message);
}
})();
if (importmap?.imports == null) continue;
for (const [name, url] of Object.entries(importmap.imports)) {
if (typeof name != 'string' || typeof url != 'string') continue;
let exports: object;
try {
// XXX: pyodide doesn't like Module(), failing with
// "can't read 'name' of undefined" at import time
exports = { ...(await import(url)) } as object;
} catch {
logger.warn(`failed to fetch '${url}' for '${name}'`);
continue;
}
logger.info('Registering JS module', name);
await interpreter._remote.registerJsModule(name, exports);
}
}
}
}

View File

@@ -1,275 +0,0 @@
import { $ } from 'basic-devtools';
import type { PyScriptApp } from '../main';
import type { AppConfig } from '../pyconfig';
import { Plugin, validateConfigParameterFromArray } from '../plugin';
import { getLogger } from '../logger';
import { type Stdio } from '../stdio';
import { InterpreterClient } from '../interpreter_client';
import { Terminal as TerminalType } from 'xterm';
const knownPyTerminalTags: WeakSet<HTMLElement> = new WeakSet();
type AppConfigStyle = AppConfig & {
terminal?: boolean | 'auto';
docked?: boolean | 'docked';
xterm?: boolean | 'xterm';
};
const logger = getLogger('py-terminal');
export class PyTerminalPlugin extends Plugin {
app: PyScriptApp;
constructor(app: PyScriptApp) {
super();
this.app = app;
}
configure(config: AppConfigStyle) {
// validate the terminal config and handle default values
validateConfigParameterFromArray({
config: config,
name: 'terminal',
possibleValues: [true, false, 'auto'],
defaultValue: 'auto',
});
validateConfigParameterFromArray({
config: config,
name: 'docked',
possibleValues: [true, false, 'docked'],
defaultValue: 'docked',
});
validateConfigParameterFromArray({
config: config,
name: 'xterm',
possibleValues: [true, false, 'xterm'],
defaultValue: false,
});
}
beforeLaunch(config: AppConfigStyle) {
// if config.terminal is "yes" or "auto", let's add a <py-terminal> to
// the document, unless it's already present.
const { terminal: t, docked: d, xterm: x } = config;
const auto = t === true || t === 'auto';
const docked = d === true || d === 'docked';
const xterm = x === true || x === 'xterm';
if (auto && $('py-terminal', document) === null) {
logger.info('No <py-terminal> found, adding one');
const termElem = document.createElement('py-terminal');
if (auto) termElem.setAttribute('auto', '');
if (docked) termElem.setAttribute('docked', '');
if (xterm) termElem.setAttribute('xterm', '');
document.body.appendChild(termElem);
}
}
afterSetup(_interpreter: InterpreterClient) {
// the Python interpreter has been initialized and we are ready to
// execute user code:
//
// 1. define the "py-terminal" custom element, either a <pre> element
// or using xterm.js
//
// 2. if there is a <py-terminal> tag on the page, it will register
// a Stdio listener just before the user code executes, ensuring
// that we capture all the output
//
// 3. everything which was written to stdout BEFORE this moment will
// NOT be shown on the py-terminal; in particular, pyodide
// startup messages will not be shown (but they will go to the
// console as usual).
//
// 4. (in the future we might want to add an option to start the
// capture earlier, but I don't think it's important now).
const PyTerminal = _interpreter.config.xterm ? make_PyTerminal_xterm(this.app) : make_PyTerminal_pre(this.app);
customElements.define('py-terminal', PyTerminal);
}
}
abstract class PyTerminalBaseClass extends HTMLElement implements Stdio {
autoShowOnNextLine: boolean;
isAuto() {
return this.hasAttribute('auto');
}
isDocked() {
return this.hasAttribute('docked');
}
setupPosition(app: PyScriptApp) {
if (this.isAuto()) {
this.classList.add('py-terminal-hidden');
this.autoShowOnNextLine = true;
} else {
this.autoShowOnNextLine = false;
}
if (this.isDocked()) {
this.classList.add('py-terminal-docked');
}
logger.info('Registering stdio listener');
app.registerStdioListener(this);
}
abstract stdout_writeline(msg: string): void;
abstract stderr_writeline(msg: string): void;
}
function make_PyTerminal_pre(app: PyScriptApp) {
/** The <py-terminal> custom element, which automatically register a stdio
* listener to capture and display stdout/stderr
*/
class PyTerminalPre extends PyTerminalBaseClass {
outElem: HTMLElement;
connectedCallback() {
// should we use a shadowRoot instead? It looks unnecessarily
// complicated to me, but I'm not really sure about the
// implications
this.outElem = document.createElement('pre');
this.outElem.classList.add('py-terminal');
this.appendChild(this.outElem);
this.setupPosition(app);
}
// implementation of the Stdio interface
stdout_writeline(msg: string) {
this.outElem.innerText += msg + '\n';
if (this.isDocked()) {
this.scrollTop = this.scrollHeight;
}
if (this.autoShowOnNextLine) {
this.classList.remove('py-terminal-hidden');
this.autoShowOnNextLine = false;
}
}
stderr_writeline(msg: string) {
this.stdout_writeline(msg);
}
// end of the Stdio interface
}
return PyTerminalPre;
}
declare const Terminal: typeof TerminalType;
function make_PyTerminal_xterm(app: PyScriptApp) {
/** The <py-terminal> custom element, which automatically register a stdio
* listener to capture and display stdout/stderr
*/
class PyTerminalXterm extends PyTerminalBaseClass {
outElem: HTMLDivElement;
_moduleResolved: boolean;
xtermReady: Promise<TerminalType>;
xterm: TerminalType;
cachedStdOut: Array<string>;
cachedStdErr: Array<string>;
_xterm_cdn_base_url = 'https://cdn.jsdelivr.net/npm/xterm@5.1.0';
constructor() {
super();
this.cachedStdOut = [];
this.cachedStdErr = [];
// While this is false, store writes to stdout/stderr to a buffer
// when the xterm.js is actually ready, we will "replay" those writes
// and set this to true
this._moduleResolved = false;
//Required to make xterm appear properly
this.style.width = '100%';
this.style.height = '100%';
}
async connectedCallback() {
//guard against initializing a tag twice
if (knownPyTerminalTags.has(this)) return;
knownPyTerminalTags.add(this);
this.outElem = document.createElement('div');
//this.outElem.className = 'py-terminal';
this.appendChild(this.outElem);
this.setupPosition(app);
this.xtermReady = this._setupXterm();
await this.xtermReady;
}
/**
* Fetch the xtermjs library from CDN an initialize it.
* @private
* @returns the associated xterm.js Terminal
*/
async _setupXterm() {
if (this.xterm == undefined) {
//need to initialize the Terminal for this element
// eslint-disable-next-line
// @ts-ignore
if (globalThis.Terminal == undefined) {
//load xterm module from cdn
//eslint-disable-next-line
//@ts-ignore
await import(this._xterm_cdn_base_url + '/lib/xterm.js');
const cssTag = document.createElement('link');
cssTag.type = 'text/css';
cssTag.rel = 'stylesheet';
cssTag.href = this._xterm_cdn_base_url + '/css/xterm.css';
document.head.appendChild(cssTag);
}
//Create xterm, add addons
this.xterm = new Terminal({ screenReaderMode: true, cols: 80 });
// xterm must only 'open' into a visible DOM element
// If terminal is still hidden, open during first write
if (!this.autoShowOnNextLine) this.xterm.open(this);
this._moduleResolved = true;
//Write out any messages output while xterm was loading
this.cachedStdOut.forEach((value: string): void => this.stdout_writeline(value));
this.cachedStdErr.forEach((value: string): void => this.stderr_writeline(value));
} else {
this._moduleResolved = true;
}
return this.xterm;
}
// implementation of the Stdio interface
stdout_writeline(msg: string) {
if (this._moduleResolved) {
this.xterm.writeln(msg);
//this.outElem.innerText += msg + '\n';
if (this.isDocked()) {
this.scrollTop = this.scrollHeight;
}
if (this.autoShowOnNextLine) {
this.classList.remove('py-terminal-hidden');
this.autoShowOnNextLine = false;
this.xterm.open(this);
}
} else {
//if xtermjs not loaded, cache messages
this.cachedStdOut.push(msg);
}
}
stderr_writeline(msg: string) {
this.stdout_writeline(msg);
}
// end of the Stdio interface
}
return PyTerminalXterm;
}

View File

@@ -1,34 +0,0 @@
import html
from textwrap import dedent
from js import console
from markdown import markdown
from pyscript import Plugin
console.warn(
"WARNING: This plugin is still in a very experimental phase and will likely change"
" and potentially break in the future releases. Use it with caution."
)
class MyPlugin(Plugin):
def configure(self, config):
console.log(f"configuration received: {config}")
def afterStartup(self, interpreter):
console.log("interpreter received:", interpreter)
plugin = MyPlugin("py-markdown")
@plugin.register_custom_element("py-md")
class PyMarkdown:
def __init__(self, element):
self.element = element
def connect(self):
unescaped_content = html.unescape(self.element.originalInnerHTML)
original = dedent(unescaped_content)
inner = markdown(original, extensions=["markdown.extensions.fenced_code"])
self.element.innerHTML = inner

View File

@@ -1,212 +0,0 @@
import html
import js
from pyscript import Plugin
js.console.warn(
"WARNING: This plugin is still in a very experimental phase and will likely change"
" and potentially break in the future releases. Use it with caution."
)
plugin = Plugin("PyTutorial")
# TODO: Part of the CSS is hidden in examples.css ---->> IMPORTANT: move it here!!
# TODO: Python files running and <py-script src="bla.py"> not in the config are not available...
# TODO: We can totally implement this in Python
PAGE_SCRIPT = """
const viewCodeButton = document.getElementById("view-code-button");
const codeSection = document.getElementById("code-section");
const handleClick = () => {
if (codeSection.classList.contains("code-section-hidden")) {
codeSection.classList.remove("code-section-hidden");
codeSection.classList.add("code-section-visible");
} else {
codeSection.classList.remove("code-section-visible");
codeSection.classList.add("code-section-hidden");
}
}
viewCodeButton.addEventListener("click", handleClick)
viewCodeButton.addEventListener("keydown", (e) => {
if (e.key === " " || e.key === "Enter" || e.key === "Spacebar") {
handleClick();
}
})
"""
TEMPLATE_CODE_SECTION = """
<div id="view-code-button" role="button" aria-pressed="false" tabindex="0">View Code</div>
<div id="code-section" class="code-section-hidden">
<p>index.html</p>
<pre class="prism-code language-html">
<code class="language-html">
{source}
</code>
</pre>
{modules_section}
</div>
"""
TEMPLATE_PY_MODULE_SECTION = """
<p>{module_name}</p>
<pre class="prism-code language-python">
<code class="language-python">
{source}
</code>
</pre>
"""
@plugin.register_custom_element("py-tutor")
class PyTutor:
def __init__(self, element):
self.element = element
def append_script_to_page(self):
"""
Append the JS script (PAGE_SCRIPT) to the page body in order to attach the
click and keydown events to show/hide the source code section on the page.
"""
el = js.document.createElement("script")
el.type = "text/javascript"
try:
el.appendChild(js.document.createTextNode(PAGE_SCRIPT))
except BaseException:
el.text = PAGE_SCRIPT
js.document.body.appendChild(el)
def add_prism(self):
# Add The CSS
link = js.document.createElement("link")
link.type = "text/css"
link.rel = "stylesheet"
js.document.head.appendChild(link)
link.href = "./assets/prism/prism.min.css"
# Add the JS file
script = js.document.createElement("script")
script.type = "text/javascript"
script.src = "./assets/prism/prism.min.js"
js.document.head.appendChild(script)
def _create_code_section(self, source, module_paths=None, parent=None):
"""
Get source and the path to modules to be displayed, create a new `code`
`section` where it's contents use TEMPLATE_CODE_SECTION with `source` and
`modules_paths` to display the information it needs.
Args:
source (str): source within a <py-tutor> tag that needs to be displaed
module_paths (list(str)): list of paths to modules that needs to be shown
parent(HTMLElement, optional): Element where the code section will be appended
to. I None is passed parent == document.body.
Defaults to None.
Returns:
(None)
"""
if not parent:
parent = js.document.body
js.console.info("Creating code introspection section.")
modules_section = self.create_modules_section(module_paths)
js.console.info("Creating new code section element.")
el = js.document.createElement("section")
el.classList.add("code")
el.innerHTML = TEMPLATE_CODE_SECTION.format(
source=source, modules_section=modules_section
)
parent.appendChild(el)
@classmethod
def create_modules_section(cls, module_paths=None):
"""Create the HTML content for all modules passed in `module_paths`. More specifically,
reads the content of each module and calls PyTytor.create_module_section
Args:
module_paths (list(str)): list of paths to modules that needs to be shown
Returns:
(str) HTML code with the content of each module in `module_path`, ready to be
attached to the DOM
"""
js.console.info(f"Module paths to parse: {module_paths}")
if not module_paths:
return ""
return "\n\n".join([cls.create_module_section(m) for m in module_paths])
@staticmethod
def create_module_section(module_path):
"""Create the HTML content for the module passed as `module_path`.
More specifically, reads the content of module and calls PyTytor.create_module_section
Args:
module_paths (list(str)): list of paths to modules that needs to be shown
Returns:
(str) HTML code with the content of each module in `module_path`, ready to be
attached to the DOM
"""
js.console.info(f"Creating module section: {module_path}")
with open(module_path) as fp:
content = fp.read()
return TEMPLATE_PY_MODULE_SECTION.format(
module_name=module_path, source=content
)
def create_page_code_section(self):
"""
Create all the code content to be displayed on a page. More specifically:
* get the HTML code within the <py-tutor> tag
* get the source code from all files specified in the py-tytor `modules` attribute
* create the HTML to be attached on the page using the content created in
the previous 2 items and apply them to TEMPLATE_CODE_SECTION
Returns:
(None)
"""
# Get the content of all the modules that were passed to be documented
module_paths = self.element.getAttribute("modules")
if module_paths:
js.console.info(f"Module paths detected: {module_paths}")
module_paths = str(module_paths).split(";")
# Get the inner HTML content of the py-tutor tag and document that
tutor_tag_innerHTML = html.escape(self.element.innerHTML)
self._create_code_section(tutor_tag_innerHTML, module_paths)
def connect(self):
"""
Handler meant to be called when the Plugin CE (Custom Element) is attached
to the page.
As so, it's the entry point that coordinates the whole plugin workflow and
is responsible for calling the right steps in order:
* identify what parts of the App (page) that are within the py-tutor tag
to be documented as well as any modules specified as attribute
* inject the button to show/hide button and related modal
* inject the JS code that attaches the click event to the button
* build the modal that shows/hides with the correct page/modules code
"""
# Create the core do show the source code on the page
self.create_page_code_section()
# append the script needed to show source first...
self.append_script_to_page()
# inject the prism JS library dependency
self.add_prism()

View File

@@ -1,111 +0,0 @@
import { $ } from 'basic-devtools';
import type { AppConfig } from '../pyconfig';
import type { UserError } from '../exceptions';
import { showWarning } from '../utils';
import { Plugin } from '../plugin';
import { getLogger } from '../logger';
import { InterpreterClient } from '../interpreter_client';
const logger = getLogger('py-splashscreen');
const AUTOCLOSE_LOADER_DEPRECATED = `
The setting autoclose_loader is deprecated. Please use the
following instead:<br>
<pre>
&lt;py-config&gt;
[splashscreen]
autoclose = false
&lt;/py-config&gt;
</pre>`;
export class SplashscreenPlugin extends Plugin {
elem: PySplashscreen;
autoclose: boolean;
enabled: boolean;
configure(
config: AppConfig & { splashscreen?: { autoclose?: boolean; enabled?: boolean }; autoclose_loader?: boolean },
) {
// the officially supported setting is config.splashscreen.autoclose,
// but we still also support the old config.autoclose_loader (with a
// deprecation warning)
this.autoclose = true;
this.enabled = true;
if ('autoclose_loader' in config) {
this.autoclose = config.autoclose_loader;
showWarning(AUTOCLOSE_LOADER_DEPRECATED, 'html');
}
if (config.splashscreen) {
this.autoclose = config.splashscreen.autoclose ?? true;
this.enabled = config.splashscreen.enabled ?? true;
}
}
beforeLaunch(_config: AppConfig) {
if (!this.enabled) {
return;
}
// add the splashscreen to the DOM
logger.info('add py-splashscreen');
customElements.define('py-splashscreen', PySplashscreen);
this.elem = <PySplashscreen>document.createElement('py-splashscreen');
document.body.append(this.elem);
document.addEventListener('py-status-message', (e: CustomEvent) => {
const msg = e.detail as string;
this.elem.log(msg);
});
}
afterStartup(_interpreter: InterpreterClient) {
if (this.autoclose && this.enabled) {
this.elem.close();
}
}
onUserError(_error: UserError) {
if (this.elem !== undefined && this.enabled) {
// Remove the splashscreen so users can see the banner better
this.elem.close();
}
}
}
export class PySplashscreen extends HTMLElement {
widths: string[];
label: string;
mount_name: string;
details: HTMLElement;
operation: HTMLElement;
constructor() {
super();
}
connectedCallback() {
this.innerHTML = `<div id="pyscript_loading_splash" class="py-overlay">
<div class="py-pop-up">
<div class="smooth spinner"></div>
<div id="pyscript-loading-label" class="label">
<div id="pyscript-operation-details">
</div>
</div>
</div>
</div>`;
this.mount_name = this.id.split('-').join('_');
this.operation = $('#pyscript-operation', document) as HTMLElement;
this.details = $('#pyscript-operation-details', document) as HTMLElement;
}
log(msg: string) {
const newLog = document.createElement('p');
newLog.innerText = msg;
this.details.appendChild(newLog);
}
close() {
logger.info('Closing');
this.remove();
}
}

View File

@@ -1,133 +0,0 @@
import { $ } from 'basic-devtools';
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 { pyDisplay } from '../pyexec';
import { make_PyRepl } from '../components/pyrepl';
type PyScriptTag = InstanceType<ReturnType<typeof make_PyScript>>;
/**
* 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(options: { interpreter: InterpreterClient; src: string; pyScriptTag: PyScriptTag }): void {
if (options.pyScriptTag.hasAttribute('output')) {
const targeted_io = new TargetedStdio(options.pyScriptTag, 'output', true, true);
options.pyScriptTag.stdout_manager = targeted_io;
this._stdioMultiplexer.addListener(targeted_io);
}
if (options.pyScriptTag.hasAttribute('stderr')) {
const targeted_io = new TargetedStdio(options.pyScriptTag, 'stderr', false, true);
options.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(options: {
interpreter: InterpreterClient;
src: string;
pyScriptTag: PyScriptTag;
result: any;
}): void {
if (options.pyScriptTag.stdout_manager != null) {
this._stdioMultiplexer.removeListener(options.pyScriptTag.stdout_manager);
options.pyScriptTag.stdout_manager = null;
}
if (options.pyScriptTag.stderr_manager != null) {
this._stdioMultiplexer.removeListener(options.pyScriptTag.stderr_manager);
options.pyScriptTag.stderr_manager = null;
}
}
beforePyReplExec(options: {
interpreter: InterpreterClient;
src: string;
outEl: HTMLElement;
pyReplTag: InstanceType<ReturnType<typeof make_PyRepl>>;
}): 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);
}
}
async afterPyReplExec(options: {
interpreter: InterpreterClient;
src: string;
outEl: HTMLElement;
pyReplTag: InstanceType<ReturnType<typeof make_PyRepl>>;
result: any;
}): Promise<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 ($('#' + outputId, document)) {
await 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
await 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;
}
}
}

View File

@@ -1,266 +0,0 @@
import toml from '@hoodmane/toml-j0.4';
import { getLogger } from './logger';
import { version } from './version';
import { readTextFromPath, htmlDecode, createDeprecationWarning } from './utils';
import { UserError, ErrorCode } from './exceptions';
const logger = getLogger('py-config');
export interface AppConfig extends Record<string, any> {
name?: string;
description?: string;
version?: string;
schema_version?: number;
type?: string;
author_name?: string;
author_email?: string;
license?: string;
interpreters?: InterpreterConfig[];
// TODO: Remove `runtimes` once the deprecation cycle is over
runtimes?: InterpreterConfig[];
packages?: string[];
fetch?: FetchConfig[];
plugins?: string[];
pyscript?: PyScriptMetadata;
execution_thread?: string; // "main" or "worker"
}
export type FetchConfig = {
from?: string;
to_folder?: string;
to_file?: string;
files?: string[];
};
export type InterpreterConfig = {
src?: string;
name?: string;
lang?: string;
};
export type PyScriptMetadata = {
version?: string;
time?: string;
};
const allKeys = Object.entries({
string: ['name', 'description', 'version', 'type', 'author_name', 'author_email', 'license', 'execution_thread'],
number: ['schema_version'],
array: ['runtimes', 'interpreters', 'packages', 'fetch', 'plugins'],
});
export const defaultConfig: AppConfig = {
schema_version: 1,
type: 'app',
interpreters: [
{
src: 'https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js',
name: 'pyodide-0.23.2',
lang: 'python',
},
],
// This is for backward compatibility, we need to remove it in the future
runtimes: [],
packages: [],
fetch: [],
plugins: [],
execution_thread: 'main',
};
export function loadConfigFromElement(el: Element): AppConfig {
let srcConfig: AppConfig;
let inlineConfig: AppConfig;
if (el === null) {
srcConfig = {};
inlineConfig = {};
} else {
const configType = el.getAttribute('type') || 'toml';
srcConfig = extractFromSrc(el, configType);
inlineConfig = extractFromInline(el, configType);
}
srcConfig = mergeConfig(srcConfig, defaultConfig);
const result = mergeConfig(inlineConfig, srcConfig);
result.pyscript = {
version,
time: new Date().toISOString(),
};
return result;
}
function extractFromSrc(el: Element, configType: string) {
const src = el.getAttribute('src');
if (src) {
logger.info('loading ', src);
return validateConfig(readTextFromPath(src), configType);
}
return {};
}
function extractFromInline(el: Element, configType: string) {
if (el.innerHTML !== '') {
logger.info('loading <py-config> content');
return validateConfig(htmlDecode(el.innerHTML), configType);
}
return {};
}
function fillUserData(inputConfig: AppConfig, resultConfig: AppConfig): AppConfig {
for (const key in inputConfig) {
// fill in all extra keys ignored by the validator
if (!(key in defaultConfig)) {
resultConfig[key] = inputConfig[key];
}
}
return resultConfig;
}
function mergeConfig(inlineConfig: AppConfig, externalConfig: AppConfig): AppConfig {
if (Object.keys(inlineConfig).length === 0 && Object.keys(externalConfig).length === 0) {
return defaultConfig;
} else if (Object.keys(inlineConfig).length === 0) {
return externalConfig;
} else if (Object.keys(externalConfig).length === 0) {
return inlineConfig;
} else {
let merged: AppConfig = {};
for (const [keyType, keys] of allKeys) {
keys.forEach(function (item: string) {
if (keyType === 'boolean') {
merged[item] =
typeof inlineConfig[item] !== 'undefined' ? inlineConfig[item] : externalConfig[item];
} else {
merged[item] = inlineConfig[item] || externalConfig[item];
}
});
}
// fill extra keys from external first
// they will be overridden by inline if extra keys also clash
merged = fillUserData(externalConfig, merged);
merged = fillUserData(inlineConfig, merged);
return merged;
}
}
function parseConfig(configText: string, configType = 'toml'): AppConfig {
if (configType === 'toml') {
// TOML parser is soft and can parse even JSON strings, this additional check prevents it.
if (configText.trim()[0] === '{') {
throw new UserError(
ErrorCode.BAD_CONFIG,
`The config supplied: ${configText} is an invalid TOML and cannot be parsed`,
);
}
try {
return toml.parse(configText) as AppConfig;
} catch (e) {
const err = e as Error;
const errMessage: string = err.toString();
throw new UserError(
ErrorCode.BAD_CONFIG,
`The config supplied: ${configText} is an invalid TOML and cannot be parsed: ${errMessage}`,
);
}
} else if (configType === 'json') {
try {
return JSON.parse(configText) as AppConfig;
} catch (e) {
const err = e as Error;
const errMessage: string = err.toString();
throw new UserError(
ErrorCode.BAD_CONFIG,
`The config supplied: ${configText} is an invalid JSON and cannot be parsed: ${errMessage}`,
);
}
} else {
throw new UserError(
ErrorCode.BAD_CONFIG,
`The type of config supplied '${configType}' is not supported, supported values are ["toml", "json"]`,
);
}
}
function validateConfig(configText: string, configType = 'toml') {
const config = parseConfig(configText, configType);
const finalConfig: AppConfig = {};
for (const [keyType, keys] of allKeys) {
keys.forEach(function (item: string) {
if (validateParamInConfig(item, keyType, config)) {
if (item === 'interpreters') {
finalConfig[item] = [];
const interpreters = config[item];
interpreters.forEach(function (eachInterpreter: InterpreterConfig) {
const interpreterConfig: InterpreterConfig = {};
for (const eachInterpreterParam in eachInterpreter) {
if (validateParamInConfig(eachInterpreterParam, 'string', eachInterpreter)) {
interpreterConfig[eachInterpreterParam] = eachInterpreter[eachInterpreterParam];
}
}
finalConfig[item].push(interpreterConfig);
});
} else if (item === 'runtimes') {
// This code is a bit of a mess, but it's used for backwards
// compatibility with the old runtimes config. It should be
// removed when we remove support for the old config.
// We also need the warning here since we are pushing
// runtimes to `interpreter` and we can't show the warning
// in main.js
createDeprecationWarning(
'The configuration option `config.runtimes` is deprecated. ' +
'Please use `config.interpreters` instead.',
'',
);
finalConfig['interpreters'] = [];
const interpreters = config[item];
interpreters.forEach(function (eachInterpreter: InterpreterConfig) {
const interpreterConfig: InterpreterConfig = {};
for (const eachInterpreterParam in eachInterpreter) {
if (validateParamInConfig(eachInterpreterParam, 'string', eachInterpreter)) {
interpreterConfig[eachInterpreterParam] = eachInterpreter[eachInterpreterParam];
}
}
finalConfig['interpreters'].push(interpreterConfig);
});
} else if (item === 'fetch') {
finalConfig[item] = [];
const fetchList = config[item];
fetchList.forEach(function (eachFetch: FetchConfig) {
const eachFetchConfig: FetchConfig = {};
for (const eachFetchConfigParam in eachFetch) {
const targetType = eachFetchConfigParam === 'files' ? 'array' : 'string';
if (validateParamInConfig(eachFetchConfigParam, targetType, eachFetch)) {
eachFetchConfig[eachFetchConfigParam] = eachFetch[eachFetchConfigParam];
}
}
finalConfig[item].push(eachFetchConfig);
});
} else if (item == 'execution_thread') {
const value = config[item];
if (value !== 'main' && value !== 'worker') {
throw new UserError(
ErrorCode.BAD_CONFIG,
`"${value}" is not a valid value for the property "execution_thread". The only valid values are "main" and "worker"`,
);
}
finalConfig[item] = value;
} else {
finalConfig[item] = config[item];
}
}
});
}
return fillUserData(config, finalConfig);
}
function validateParamInConfig(paramName: string, paramType: string, config: object): boolean {
if (paramName in config) {
return paramType === 'array' ? Array.isArray(config[paramName]) : typeof config[paramName] === paramType;
}
return false;
}

View File

@@ -1,73 +0,0 @@
import { getLogger } from './logger';
import { ensureUniqueId } from './utils';
import { UserError, ErrorCode } from './exceptions';
import { InterpreterClient } from './interpreter_client';
import type { PyProxyCallable } from 'pyodide';
const logger = getLogger('pyexec');
export async function pyExec(
interpreter: InterpreterClient,
pysrc: string,
outElem: HTMLElement,
): Promise<{ result: any }> {
ensureUniqueId(outElem);
if (await interpreter._remote.pyscript_internal.uses_top_level_await(pysrc)) {
const err = new UserError(
ErrorCode.TOP_LEVEL_AWAIT,
'The use of top-level "await", "async for", and ' +
'"async with" has been removed.' +
'\nPlease write a coroutine containing ' +
'your code and schedule it using asyncio.ensure_future() or similar.' +
'\nSee https://docs.pyscript.net/latest/guides/asyncio.html for more information.',
);
displayPyException(err, outElem);
return { result: undefined };
}
try {
return await interpreter.run(pysrc, outElem.id);
} catch (e) {
const err = e as Error;
// XXX: currently we display exceptions in the same position as
// the output. But we probably need a better way to do that,
// e.g. allowing plugins to intercept exceptions and display them
// in a configurable way.
displayPyException(err, outElem);
return { result: undefined };
}
}
/**
* Javascript API to call the python display() function
*
* Expected usage:
* pyDisplay(interpreter, obj);
* pyDisplay(interpreter, obj, { target: targetID });
*/
export async function pyDisplay(interpreter: InterpreterClient, obj: any, kwargs: { [k: string]: any } = {}) {
const display = (await interpreter.globals.get('display')) as PyProxyCallable;
try {
await display.callKwargs(obj, kwargs);
} finally {
display.destroy();
}
}
export function displayPyException(err: Error, errElem: HTMLElement) {
const pre = document.createElement('pre');
pre.className = 'py-error';
if (err.name === 'PythonError') {
// err.message contains the python-level traceback (i.e. a string
// starting with: "Traceback (most recent call last) ..."
logger.error('Python exception:\n' + err.message);
pre.innerText = err.message;
} else {
// this is very likely a normal JS exception. The best we can do is to
// display it as is.
logger.error('Non-python exception:\n' + err.toString());
pre.innerText = err.toString();
}
errElem.appendChild(pre);
}

View File

@@ -1,50 +0,0 @@
from _pyscript_js import showWarning
from ._event_handling import when
from ._event_loop import LOOP as loop
from ._event_loop import run_until_complete
from ._html import (
HTML,
Element,
add_classes,
create,
display,
write,
)
from ._plugin import Plugin
# these are set by _set_version_info
__version__ = None
version_info = None
def __getattr__(attr):
if attr == "js":
global js
import js
from _pyscript_js import showWarning
# Deprecated after 2023.03.1
showWarning(
"<code>pyscript.js</code> is deprecated, please use <code>import js</code> instead.",
"html",
)
return js
raise AttributeError(f"module 'pyscript' has no attribute '{attr}'")
__all__ = [
"HTML",
"write",
"display",
"Element",
"add_classes",
"create",
"run_until_complete",
"loop",
"Plugin",
"__version__",
"version_info",
"showWarning",
"when",
]

View File

@@ -1,61 +0,0 @@
from _pyscript_js import showWarning
class DeprecatedGlobal:
"""
Proxy for globals which are deprecated.
The intendend usage is as follows:
# in the global namespace
Element = pyscript.DeprecatedGlobal('Element', pyscript.Element, "...")
console = pyscript.DeprecatedGlobal('console', js.console, "...")
...
The proxy forwards __getattr__ and __call__ to the underlying object, and
emit a warning on the first usage.
This way users see a warning only if they actually access the top-level
name.
"""
def __init__(self, name, obj, message):
self.__name = name
self.__obj = obj
self.__message = message
self.__warning_already_shown = False
def __repr__(self):
return f"<DeprecatedGlobal({self.__name!r})>"
def _show_warning(self, message):
"""
NOTE: this is overridden by unit tests
"""
showWarning(message, "html") # noqa: F821
def _show_warning_maybe(self):
if self.__warning_already_shown:
return
self._show_warning(self.__message)
self.__warning_already_shown = True
def __getattr__(self, attr):
self._show_warning_maybe()
return getattr(self.__obj, attr)
def __call__(self, *args, **kwargs):
self._show_warning_maybe()
return self.__obj(*args, **kwargs)
def __iter__(self):
self._show_warning_maybe()
return iter(self.__obj)
def __getitem__(self, key):
self._show_warning_maybe()
return self.__obj[key]
def __setitem__(self, key, value):
self._show_warning_maybe()
self.__obj[key] = value

View File

@@ -1,29 +0,0 @@
import inspect
import js
from pyodide.ffi.wrappers import add_event_listener
def when(event_type=None, selector=None):
"""
Decorates a function and passes py-* events to the decorated function
The events might or not be an argument of the decorated function
"""
def decorator(func):
elements = js.document.querySelectorAll(selector)
sig = inspect.signature(func)
# Function doesn't receive events
if not sig.parameters:
def wrapper(*args, **kwargs):
func()
for el in elements:
add_event_listener(el, event_type, wrapper)
else:
for el in elements:
add_event_listener(el, event_type, func)
return func
return decorator

View File

@@ -1,81 +0,0 @@
import asyncio
import contextvars
from collections.abc import Callable
from contextlib import contextmanager
from typing import Any
from js import setTimeout
from pyodide.ffi import create_once_callable
from pyodide.webloop import WebLoop
class PyscriptWebLoop(WebLoop):
def __init__(self):
super().__init__()
self._ready = False
self._usercode = False
self._deferred_handles = []
def call_later(
self,
delay: float,
callback: Callable[..., Any],
*args: Any,
context: contextvars.Context | None = None,
) -> asyncio.Handle:
"""Based on call_later from Pyodide's webloop
With some unneeded stuff removed and a mechanism for deferring tasks
scheduled from user code.
"""
if delay < 0:
raise ValueError("Can't schedule in the past")
h = asyncio.Handle(callback, args, self, context=context)
def run_handle():
if h.cancelled():
return
h._run()
if self._ready or not self._usercode:
setTimeout(create_once_callable(run_handle), delay * 1000)
else:
self._deferred_handles.append((run_handle, self.time() + delay))
return h
def _schedule_deferred_tasks(self):
asyncio._set_running_loop(self)
t = self.time()
for [run_handle, delay] in self._deferred_handles:
delay = delay - t
if delay < 0:
delay = 0
setTimeout(create_once_callable(run_handle), delay * 1000)
self._ready = True
self._deferred_handles = []
LOOP = None
def install_pyscript_loop():
global LOOP
LOOP = PyscriptWebLoop()
asyncio.set_event_loop(LOOP)
def schedule_deferred_tasks():
LOOP._schedule_deferred_tasks()
@contextmanager
def defer_user_asyncio():
LOOP._usercode = True
try:
yield
finally:
LOOP._usercode = False
def run_until_complete(f):
return LOOP.run_until_complete(f)

View File

@@ -1,149 +0,0 @@
from textwrap import dedent
import js
from _pyscript_js import deepQuerySelector
from . import _internal
from ._mime import format_mime as _format_mime
class HTML:
"""
Wrap a string so that display() can render it as plain HTML
"""
def __init__(self, html):
self._html = html
def _repr_html_(self):
return self._html
def write(element_id, value, append=False, exec_id=0):
"""Writes value to the element with id "element_id"""
Element(element_id).write(value=value, append=append)
js.console.warn(
dedent(
"""PyScript Deprecation Warning: PyScript.write is
marked as deprecated and will be removed sometime soon. Please, use
Element(<id>).write instead."""
)
)
def display(*values, target=None, append=True):
if target is None:
target = _internal.DISPLAY_TARGET
if target is None:
raise Exception(
"Implicit target not allowed here. Please use display(..., target=...)"
)
for v in values:
Element(target).write(v, append=append)
class Element:
def __init__(self, element_id, element=None):
self._id = element_id
self._element = element
@property
def id(self):
return self._id
@property
def element(self):
"""Return the dom element"""
if not self._element:
self._element = deepQuerySelector(f"#{self._id}")
return self._element
@property
def value(self):
return self.element.value
@property
def innerHtml(self):
return self.element.innerHTML
def write(self, value, append=False):
html, mime_type = _format_mime(value)
if html == "\n":
return
if append:
child = js.document.createElement("div")
self.element.appendChild(child)
if append and self.element.children:
out_element = self.element.children[-1]
else:
out_element = self.element
if mime_type in ("application/javascript", "text/html"):
script_element = js.document.createRange().createContextualFragment(html)
out_element.appendChild(script_element)
else:
out_element.innerHTML = html
def clear(self):
if hasattr(self.element, "value"):
self.element.value = ""
else:
self.write("", append=False)
def select(self, query, from_content=False):
el = self.element
if from_content:
el = el.content
_el = el.querySelector(query)
if _el:
return Element(_el.id, _el)
else:
js.console.warn(f"WARNING: can't find element matching query {query}")
def clone(self, new_id=None, to=None):
if new_id is None:
new_id = self.element.id
clone = self.element.cloneNode(True)
clone.id = new_id
if to:
to.element.appendChild(clone)
# Inject it into the DOM
to.element.after(clone)
else:
# Inject it into the DOM
self.element.after(clone)
return Element(clone.id, clone)
def remove_class(self, classname):
classList = self.element.classList
if isinstance(classname, list):
classList.remove(*classname)
else:
classList.remove(classname)
def add_class(self, classname):
classList = self.element.classList
if isinstance(classname, list):
classList.add(*classname)
else:
self.element.classList.add(classname)
def add_classes(element, class_list):
classList = element.classList
classList.add(*class_list.split(" "))
def create(what, id_=None, classes=""):
element = js.document.createElement(what)
if id_:
element.id = id_
add_classes(element, classes)
return Element(id_, element)

View File

@@ -1,115 +0,0 @@
import ast
from collections import namedtuple
from contextlib import contextmanager
from js import Object
from pyodide.code import eval_code
from pyodide.ffi import JsProxy
from ._event_loop import (
defer_user_asyncio,
install_pyscript_loop,
schedule_deferred_tasks,
)
VersionInfo = namedtuple("version_info", ("year", "month", "minor", "releaselevel"))
def set_version_info(version_from_interpreter: str):
from . import __dict__ as pyscript_dict
"""Sets the __version__ and version_info properties from provided JSON data
Args:
version_from_interpreter (str): A "dotted" representation of the version:
YYYY.MM.m(m).releaselevel
Year, Month, and Minor should be integers; releaselevel can be any string
"""
version_parts = version_from_interpreter.split(".")
year = int(version_parts[0])
month = int(version_parts[1])
minor = int(version_parts[2])
if len(version_parts) > 3:
releaselevel = version_parts[3]
else:
releaselevel = ""
version_info = VersionInfo(year, month, minor, releaselevel)
pyscript_dict["__version__"] = version_from_interpreter
pyscript_dict["version_info"] = version_info
class TopLevelAwaitFinder(ast.NodeVisitor):
def is_source_top_level_await(self, source):
self.async_found = False
node = ast.parse(source)
self.generic_visit(node)
return self.async_found
def visit_Await(self, node):
self.async_found = True
def visit_AsyncFor(self, node):
self.async_found = True
def visit_AsyncWith(self, node):
self.async_found = True
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
pass # Do not visit children of async function defs
def uses_top_level_await(source: str) -> bool:
return TopLevelAwaitFinder().is_source_top_level_await(source)
DISPLAY_TARGET = None
@contextmanager
def display_target(target_id):
global DISPLAY_TARGET
DISPLAY_TARGET = target_id
try:
yield
finally:
DISPLAY_TARGET = None
def run_pyscript(code: str, id: str = None) -> JsProxy:
"""Execute user code inside context managers.
Uses the __main__ global namespace.
The output is wrapped inside a JavaScript object since an object is not
thenable. This is so we do not accidentally `await` the result of the python
execution, even if it's awaitable (Future, Task, etc.)
Parameters
----------
code :
The code to run
id :
The id for the default display target (or None if no default display target).
Returns
-------
A Js Object of the form {result: the_result}
"""
import __main__
with display_target(id), defer_user_asyncio():
result = eval_code(code, globals=__main__.__dict__)
return Object.new(result=result)
__all__ = [
"set_version_info",
"uses_top_level_await",
"run_pyscript",
"install_pyscript_loop",
"schedule_deferred_tasks",
]

View File

@@ -1,113 +0,0 @@
import base64
import html
import io
import re
from js import console
MIME_METHODS = {
"__repr__": "text/plain",
"_repr_html_": "text/html",
"_repr_markdown_": "text/markdown",
"_repr_svg_": "image/svg+xml",
"_repr_png_": "image/png",
"_repr_pdf_": "application/pdf",
"_repr_jpeg_": "image/jpeg",
"_repr_latex": "text/latex",
"_repr_json_": "application/json",
"_repr_javascript_": "application/javascript",
"savefig": "image/png",
}
def render_image(mime, value, meta):
# If the image value is using bytes we should convert it to base64
# otherwise it will return raw bytes and the browser will not be able to
# render it.
if isinstance(value, bytes):
value = base64.b64encode(value).decode("utf-8")
# This is the pattern of base64 strings
base64_pattern = re.compile(
r"^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$"
)
# If value doesn't match the base64 pattern we should encode it to base64
if len(value) > 0 and not base64_pattern.match(value):
value = base64.b64encode(value.encode("utf-8")).decode("utf-8")
data = f"data:{mime};charset=utf-8;base64,{value}"
attrs = " ".join(['{k}="{v}"' for k, v in meta.items()])
return f'<img src="{data}" {attrs}></img>'
def identity(value, meta):
return value
MIME_RENDERERS = {
"text/plain": html.escape,
"text/html": identity,
"image/png": lambda value, meta: render_image("image/png", value, meta),
"image/jpeg": lambda value, meta: render_image("image/jpeg", value, meta),
"image/svg+xml": identity,
"application/json": identity,
"application/javascript": lambda value, meta: f"<script>{value}</script>",
}
def eval_formatter(obj, print_method):
"""
Evaluates a formatter method.
"""
if print_method == "__repr__":
return repr(obj)
elif hasattr(obj, print_method):
if print_method == "savefig":
buf = io.BytesIO()
obj.savefig(buf, format="png")
buf.seek(0)
return base64.b64encode(buf.read()).decode("utf-8")
return getattr(obj, print_method)()
elif print_method == "_repr_mimebundle_":
return {}, {}
return None
def format_mime(obj):
"""
Formats object using _repr_x_ methods.
"""
if isinstance(obj, str):
return html.escape(obj), "text/plain"
mimebundle = eval_formatter(obj, "_repr_mimebundle_")
if isinstance(mimebundle, tuple):
format_dict, _ = mimebundle
else:
format_dict = mimebundle
output, not_available = None, []
for method, mime_type in reversed(MIME_METHODS.items()):
if mime_type in format_dict:
output = format_dict[mime_type]
else:
output = eval_formatter(obj, method)
if output is None:
continue
elif mime_type not in MIME_RENDERERS:
not_available.append(mime_type)
continue
break
if output is None:
if not_available:
console.warn(
f"Rendered object requested unavailable MIME renderers: {not_available}"
)
output = repr(output)
mime_type = "text/plain"
elif isinstance(output, tuple):
output, meta = output
else:
meta = {}
return MIME_RENDERERS[mime_type](output, meta), mime_type

View File

@@ -1,66 +0,0 @@
from _pyscript_js import define_custom_element
from js import console
from pyodide.ffi import create_proxy
class Plugin:
def __init__(self, name=None):
if not name:
name = self.__class__.__name__
self.name = name
self._custom_elements = []
self.app = None
def init(self, app):
self.app = app
def configure(self, config):
pass
def afterSetup(self, interpreter):
pass
def afterStartup(self, interpreter):
pass
def beforePyScriptExec(self, interpreter, src, pyScriptTag):
pass
def afterPyScriptExec(self, interpreter, src, pyScriptTag, result):
pass
def beforePyReplExec(self, interpreter, src, outEl, pyReplTag):
pass
def afterPyReplExec(self, interpreter, src, outEl, pyReplTag, result):
pass
def onUserError(self, error):
pass
def register_custom_element(self, tag):
"""
Decorator to register a new custom element as part of a Plugin and associate
tag to it. Internally, it delegates the registration to the PyScript internal
[JS] plugin manager, who actually creates the JS custom element that can be
attached to the page and instantiate an instance of the class passing the custom
element to the plugin constructor.
Exammple:
>> plugin = Plugin("PyTutorial")
>> @plugin.register_custom_element("py-tutor")
>> class PyTutor:
>> def __init__(self, element):
>> self.element = element
"""
# TODO: Ideally would be better to use the logger.
console.info(f"Defining new custom element {tag}")
def wrapper(class_):
# TODO: this is very pyodide specific but will have to do
# until we have JS interface that works across interpreters
define_custom_element(tag, create_proxy(class_)) # noqa: F821
self._custom_elements.append(tag)
return create_proxy(wrapper)

View File

@@ -1,9 +0,0 @@
// This file exists because I can only convince jest to mock real file system
// files, not fake modules. This confuses me because jest.mock has a virtual
// option for mocking things that don't live in the file system but it doesn't
// seem to work.
// @ts-ignore
import python_package from 'pyscript_python_package.esbuild_injected.json';
declare const python_package: { dirs: string[]; files: [string, string][] };
export { python_package };

View File

@@ -1,294 +0,0 @@
import type { AppConfig } from './pyconfig';
import { version } from './version';
import { getLogger } from './logger';
import { Stdio } from './stdio';
import { InstallError, ErrorCode } from './exceptions';
import { robustFetch } from './fetch';
import type { loadPyodide as loadPyodideDeclaration, PyodideInterface, PyProxy, PyProxyDict } from 'pyodide';
import type { ProxyMarked } from 'synclink';
import * as Synclink from 'synclink';
import { showWarning } from './utils';
import { define_custom_element } from './plugin';
import { deepQuerySelector } from './shadow_roots';
import { python_package } from './python_package';
declare const loadPyodide: typeof loadPyodideDeclaration;
const logger = getLogger('pyscript/pyodide');
export type InterpreterInterface = (PyodideInterface & ProxyMarked) | null;
interface Micropip extends PyProxy {
install(packageName: string | string[]): Promise<void>;
}
type FSInterface = {
writeFile(path: string, data: Uint8Array | string, options?: { canOwn?: boolean; encoding?: string }): void;
mkdirTree(path: string): void;
mkdir(path: string): void;
} & ProxyMarked;
type PATHFSInterface = {
resolve(path: string): string;
} & ProxyMarked;
type PATHInterface = {
dirname(path: string): string;
} & ProxyMarked;
type PyScriptInternalModule = ProxyMarked & {
set_version_info(ver: string): void;
uses_top_level_await(code: string): boolean;
run_pyscript(code: string, display_target_id?: string): { result: any };
install_pyscript_loop(): void;
start_loop(): void;
schedule_deferred_tasks(): void;
};
/*
RemoteInterpreter class is responsible to process requests from the
`InterpreterClient` class -- these can be requests for installation of
a package, executing code, etc.
Currently, the only interpreter available is Pyodide as indicated by the
`InterpreterInterface` type above. This serves as a Union of types of
different interpreters which will be added in near future.
Methods available handle loading of the interpreter, initialization,
running code, loading and installation of packages, loading from files etc.
The class will be turned `abstract` in future, to support more runtimes
such as MicroPython.
*/
export class RemoteInterpreter extends Object {
src: string;
interface: InterpreterInterface;
FS: FSInterface;
PATH: PATHInterface;
PATH_FS: PATHFSInterface;
pyscript_internal: PyScriptInternalModule;
globals: PyProxyDict & ProxyMarked;
// TODO: Remove this once `runtimes` is removed!
interpreter: InterpreterInterface & ProxyMarked;
constructor(src = 'https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js') {
super();
this.src = src;
}
/**
* loads the interface for the interpreter and saves an instance of it
* in the `this.interface` property along with calling of other
* additional convenience functions.
* */
/**
* Although `loadPyodide` is used below,
* notice that it is not imported i.e.
* import { loadPyodide } from 'pyodide';
* is not used at the top of this file.
*
* This is because, if it's used, loadPyodide
* behaves mischievously i.e. it tries to load
* additional files but with paths that are wrong such as:
*
* http://127.0.0.1:8080/build/...
* which results in a 404 since `build` doesn't
* contain these files and is clearly the wrong
* path.
*/
async loadInterpreter(config: AppConfig, stdio: Synclink.Remote<Stdio & ProxyMarked>): Promise<void> {
// TODO: move this to "main thread"!
const _pyscript_js_main = { define_custom_element, showWarning, deepQuerySelector };
this.interface = Synclink.proxy(
await loadPyodide({
stdout: (msg: string) => {
stdio.stdout_writeline(msg).syncify();
},
stderr: (msg: string) => {
stdio.stderr_writeline(msg).syncify();
},
fullStdLib: false,
}),
);
this.interface.registerComlink(Synclink);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.FS = this.interface.FS;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
this.PATH = (this.interface as any)._module.PATH;
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
this.PATH_FS = (this.interface as any)._module.PATH_FS;
// TODO: Remove this once `runtimes` is removed!
this.interpreter = this.interface;
this.interface.registerJsModule('_pyscript_js', _pyscript_js_main);
// Write pyscript package into file system
for (const dir of python_package.dirs) {
this.FS.mkdir('/home/pyodide/' + dir);
}
for (const [path, value] of python_package.files) {
this.FS.writeFile('/home/pyodide/' + path, value);
}
//Refresh the module cache so Python consistently finds pyscript module
this.invalidate_module_path_cache();
this.globals = Synclink.proxy(this.interface.globals as PyProxyDict);
logger.info('importing pyscript');
this.pyscript_internal = Synclink.proxy(this.interface.pyimport('pyscript._internal')) as PyProxy &
typeof this.pyscript_internal;
this.pyscript_internal.set_version_info(version);
this.pyscript_internal.install_pyscript_loop();
if (config.packages) {
logger.info('Found packages in configuration to install. Loading micropip...');
await this.loadPackage('micropip');
}
// import some carefully selected names into the global namespace
this.interface.runPython(`
import js
import pyscript
from pyscript import Element, display, HTML
`);
logger.info('pyodide loaded and initialized');
}
/**
* delegates the registration of JS modules to
* the underlying interface.
* */
registerJsModule(name: string, module: object): void {
this.interface.registerJsModule(name, module);
}
/**
* delegates the loading of packages to
* the underlying interface.
* */
async loadPackage(names: string | string[]): Promise<void> {
logger.info(`pyodide.loadPackage: ${names.toString()}`);
// The signature of `loadPackage` changed in Pyodide 0.22; while we generally
// don't support older versions of Pyodide in any given release of PyScript, this
// significant change is useful in some testing scenarios (for now)
const messageCallback = logger.info.bind(logger) as typeof logger.info;
// Comparing version as number to avoid issues with lexicographic comparison
if (Number(this.interpreter.version.split('.')[1]) >= 22) {
await this.interface.loadPackage(names, {
messageCallback,
errorCallback: messageCallback,
});
} else {
// @ts-expect-error Types don't include this deprecated call signature
await this.interface.loadPackage(names, messageCallback, messageCallback);
}
}
/**
* delegates the installation of packages
* (using a package manager, which can be specific to
* the interface) to the underlying interface.
*
* For Pyodide, we use `micropip`
* */
async installPackage(package_name: string | string[]): Promise<void> {
if (package_name.length > 0) {
logger.info(`micropip install ${package_name.toString()}`);
const micropip = this.interface.pyimport('micropip') as Micropip;
try {
await micropip.install(package_name);
micropip.destroy();
} catch (err) {
const e = err as Error;
let fmt_names: string;
if (Array.isArray(package_name)) {
fmt_names = package_name.join(', ');
} else {
fmt_names = package_name;
}
let exceptionMessage = `Unable to install package(s) '${fmt_names}'.`;
// If we can't fetch `package_name` micropip.install throws a huge
// Python traceback in `e.message` this logic is to handle the
// error and throw a more sensible error message instead of the
// huge traceback.
if (e.message.includes("Can't find a pure Python 3 wheel")) {
exceptionMessage +=
` Reason: Can't find a pure Python 3 Wheel for package(s) '${fmt_names}'.` +
`See: https://pyodide.org/en/stable/usage/faq.html#micropip-can-t-find-a-pure-python-wheel ` +
`for more information.`;
} else if (e.message.includes("Can't fetch metadata")) {
exceptionMessage +=
' Unable to find package in PyPI. ' +
'Please make sure you have entered a correct package name.';
} else {
exceptionMessage +=
` Reason: ${e.message}. Please open an issue at ` +
`https://github.com/pyscript/pyscript/issues/new if you require help or ` +
`you think it's a bug.`;
}
logger.error(e);
throw new InstallError(ErrorCode.MICROPIP_INSTALL_ERROR, exceptionMessage);
}
}
}
/**
*
* @param path : the path in the filesystem
* @param url : the url to be fetched
*
* Given a file available at `url` URL (eg: `http://dummy.com/hi.py`), the
* function downloads the file and saves it to the `path` (eg:
* `a/b/c/foo.py`) on the FS.
*
* Example usage: await loadFromFile(`a/b/c/foo.py`,
* `http://dummy.com/hi.py`)
*
* Write content of `http://dummy.com/hi.py` to `a/b/c/foo.py`
*
* NOTE: The `path` parameter expects to have the `filename` in it i.e.
* `a/b/c/foo.py` is valid while `a/b/c` (i.e. only the folders) are
* incorrect.
*
* The path will be resolved relative to the current working directory,
* which is initially `/home/pyodide`. So by default `a/b.py` will be placed
* in `/home/pyodide/a/b.py`, `../a/b.py` will be placed into `/home/a/b.py`
* and `/a/b.py` will be placed into `/a/b.py`.
*/
async loadFileFromURL(path: string, url: string): Promise<void> {
path = this.PATH_FS.resolve(path);
const dir: string = this.PATH.dirname(path);
this.FS.mkdirTree(dir);
// `robustFetch` checks for failures in getting a response
const response = await robustFetch(url);
const buffer = await response.arrayBuffer();
const data = new Uint8Array(buffer);
this.FS.writeFile(path, data, { canOwn: true });
}
/**
* delegates clearing importlib's module path
* caches to the underlying interface
*/
invalidate_module_path_cache(): void {
const importlib = this.interface.pyimport('importlib') as PyProxy & { invalidate_caches(): void };
importlib.invalidate_caches();
}
pyimport(mod_name: string): PyProxy & Synclink.ProxyMarked {
return Synclink.proxy(this.interface.pyimport(mod_name));
}
setHandler(func_name: string, handler: any): void {
const pyscript_module = this.interface.pyimport('pyscript');
pyscript_module[func_name] = handler;
}
}

View File

@@ -1,18 +0,0 @@
import { $ } from 'basic-devtools';
import { WSet } from 'not-so-weak';
// weakly retain shadow root nodes in an iterable way
// so that it's possible to query these and find elements by ID
export const shadowRoots: WSet<ShadowRoot> = new WSet();
// returns an element by ID if present within any of the live shadow roots
const findInShadowRoots = (selector: string): Element | null => {
for (const shadowRoot of shadowRoots) {
const element = $(selector, shadowRoot);
if (element) return element;
}
return null;
};
// find an element by ID either via document or via any live shadow root
export const deepQuerySelector = (selector: string) => $(selector, document) || findInShadowRoots(selector);

View File

@@ -1,127 +0,0 @@
import { $ } from 'basic-devtools';
import { createSingularWarning, escape } from './utils';
export interface Stdio {
stdout_writeline: (msg: string) => void;
stderr_writeline: (msg: string) => void;
}
/** Default implementation of Stdio: stdout and stderr are both sent to the
* console
*/
export const DEFAULT_STDIO: Stdio = {
stdout_writeline: console.log,
stderr_writeline: console.log,
};
/** Stdio provider which captures and store the messages.
* Useful for tests.
*/
export class CaptureStdio implements Stdio {
captured_stdout: string;
captured_stderr: string;
constructor() {
this.reset();
}
reset() {
this.captured_stdout = '';
this.captured_stderr = '';
}
stdout_writeline(msg: string) {
this.captured_stdout += msg + '\n';
}
stderr_writeline(msg: string) {
this.captured_stderr += msg + '\n';
}
}
/** 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 = $('#' + target_id, document);
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 {
_listeners: Stdio[];
constructor() {
this._listeners = [];
}
addListener(obj: 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);
}
stderr_writeline(msg: string) {
for (const obj of this._listeners) obj.stderr_writeline(msg);
}
}

View File

@@ -1,349 +0,0 @@
/* py-config - not a component */
py-config {
display: none;
}
/* py-{el} - components not defined */
py-script:not(:defined) {
display: none;
}
py-repl:not(:defined) {
display: none;
}
py-title:not(:defined) {
display: none;
}
py-inputbox:not(:defined) {
display: none;
}
py-button:not(:defined) {
display: none;
}
py-box:not(:defined) {
display: none;
}
html {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
line-height: 1.5;
}
.spinner::after {
content: '';
box-sizing: border-box;
width: 40px;
height: 40px;
position: absolute;
top: calc(40% - 20px);
left: calc(50% - 20px);
border-radius: 50%;
}
.spinner.smooth::after {
border-top: 4px solid rgba(255, 255, 255, 1);
border-left: 4px solid rgba(255, 255, 255, 1);
border-right: 4px solid rgba(255, 255, 255, 0);
animation: spinner 0.6s linear infinite;
}
@keyframes spinner {
to {
transform: rotate(360deg);
}
}
.label {
text-align: center;
width: 100%;
display: block;
color: rgba(255, 255, 255, 0.8);
font-size: 0.8rem;
margin-top: 6rem;
}
/* Pop-up second layer begin */
.py-overlay {
position: fixed;
display: flex;
justify-content: center;
align-items: center;
color: white;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.5);
transition: opacity 500ms;
visibility: hidden;
color: visible;
opacity: 1;
}
.py-overlay {
visibility: visible;
opacity: 1;
}
.py-pop-up {
text-align: center;
width: 600px;
}
.py-pop-up p {
margin: 5px;
}
.py-pop-up a {
position: absolute;
color: white;
text-decoration: none;
font-size: 200%;
top: 3.5%;
right: 5%;
}
/* Pop-up second layer end */
.alert-banner {
position: relative;
padding: 0.5rem 1.5rem 0.5rem 0.5rem;
margin: 5px 0;
}
.alert-banner p {
margin: 0;
}
.py-error {
background-color: #ffe9e8;
border: solid;
border-color: #f0625f;
color: #9d041c;
}
.py-warning {
background-color: rgb(255, 244, 229);
border: solid;
border-color: #ffa016;
color: #794700;
}
.alert-banner.py-error > #alert-close-button {
color: #9d041c;
}
.alert-banner.py-warning > #alert-close-button {
color: #794700;
}
#alert-close-button {
position: absolute;
right: 0.5rem;
top: 0.5rem;
cursor: pointer;
background: transparent;
border: none;
}
.py-box {
display: flex;
flex-direction: row;
justify-content: flex-start;
}
.py-box div.py-box-child * {
max-width: 100%;
}
.py-repl-box {
flex-direction: column;
}
.py-repl-editor {
--tw-border-opacity: 1;
border-color: rgba(209, 213, 219, var(--tw-border-opacity));
border-width: 1px;
position: relative;
--tw-ring-inset: var(--tw-empty, /*!*/ /*!*/);
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgba(59, 130, 246, 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
position: relative;
box-sizing: border-box;
border-width: 1px;
border-style: solid;
border-color: rgb(209, 213, 219);
}
.editor-box:hover button {
opacity: 1;
}
.py-repl-run-button {
opacity: 0;
bottom: 0.25rem;
right: 0.25rem;
position: absolute;
padding: 0;
line-height: inherit;
color: inherit;
cursor: pointer;
background-color: transparent;
background-image: none;
-webkit-appearance: button;
text-transform: none;
font-family: inherit;
font-size: 100%;
margin: 0;
text-rendering: auto;
letter-spacing: normal;
word-spacing: normal;
line-height: normal;
text-transform: none;
text-indent: 0px;
text-shadow: none;
display: inline-block;
text-align: center;
align-items: flex-start;
cursor: default;
box-sizing: border-box;
background-color: -internal-light-dark(rgb(239, 239, 239), rgb(59, 59, 59));
margin: 0em;
padding: 1px 6px;
border: 0;
}
.py-repl-run-button:hover {
opacity: 1;
}
.py-title {
text-transform: uppercase;
text-align: center;
}
.py-title h1 {
font-weight: 700;
font-size: 1.875rem;
}
.py-input {
padding: 0.5rem;
--tw-border-opacity: 1;
border-color: rgba(209, 213, 219, var(--tw-border-opacity));
border-width: 1px;
border-radius: 0.25rem;
margin-right: 0.75rem;
border-style: solid;
width: auto;
}
.py-box input.py-input {
width: -webkit-fill-available;
}
.central-content {
max-width: 20rem;
margin-left: auto;
margin-right: auto;
}
input {
text-rendering: auto;
color: -internal-light-dark(black, white);
letter-spacing: normal;
word-spacing: normal;
line-height: normal;
text-transform: none;
text-indent: 0px;
text-shadow: none;
display: inline-block;
text-align: start;
appearance: auto;
-webkit-rtl-ordering: logical;
background-color: -internal-light-dark(rgb(255, 255, 255), rgb(59, 59, 59));
margin: 0em;
padding: 1px 2px;
border-width: 2px;
border-style: inset;
border-color: -internal-light-dark(rgb(118, 118, 118), rgb(133, 133, 133));
border-image: initial;
}
.py-button {
--tw-text-opacity: 1;
color: rgba(255, 255, 255, var(--tw-text-opacity));
padding: 0.5rem;
--tw-bg-opacity: 1;
background-color: rgba(37, 99, 235, var(--tw-bg-opacity));
--tw-border-opacity: 1;
border-color: rgba(37, 99, 235, var(--tw-border-opacity));
border-width: 1px;
border-radius: 0.25rem;
cursor: pointer;
}
.py-li-element p {
margin: 5px;
}
.py-li-element p {
display: inline;
}
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
.line-through {
text-decoration: line-through;
}
/* ===== py-terminal plugin ===== */
/* XXX: it would be nice if these rules were stored in e.g. pyterminal.css and
bundled together at build time (by rollup?) */
.py-terminal {
min-height: 10em;
background-color: black;
color: white;
padding: 0.5rem;
overflow: auto;
}
.py-terminal-hidden {
display: none;
}
/* avoid changing the page layout when the terminal is docked and hidden */
html:has(py-terminal[docked]:not(py-terminal[docked].py-terminal-hidden)) {
padding-bottom: 40vh;
}
py-terminal[docked] {
position: fixed;
bottom: 0;
width: 100vw;
max-height: 40vh;
overflow: auto;
}
py-terminal[docked] .py-terminal {
margin: 0;
}

View File

@@ -1,110 +0,0 @@
import { $$ } from 'basic-devtools';
import { _createAlertBanner } from './exceptions';
export function escape(str: string): string {
return str.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export function htmlDecode(input: string): string | null {
const doc = new DOMParser().parseFromString(ltrim(escape(input)), 'text/html');
return doc.documentElement.textContent;
}
export function ltrim(code: string): string {
const lines = code.split('\n');
if (lines.length == 0) return code;
const lengths = lines
.filter(line => line.trim().length != 0)
.map(line => {
return line.match(/^\s*/)?.pop()?.length;
});
const k = Math.min(...lengths);
return k != 0 ? lines.map(line => line.substring(k)).join('\n') : code;
}
let _uniqueIdCounter = 0;
export function ensureUniqueId(el: HTMLElement) {
if (el.id === '') el.id = `py-internal-${_uniqueIdCounter++}`;
}
export function showWarning(msg: string, messageType: 'text' | 'html' = 'text'): void {
_createAlertBanner(msg, 'warning', messageType);
}
export function readTextFromPath(path: string) {
const request = new XMLHttpRequest();
request.open('GET', path, false);
request.send();
const returnValue = request.responseText;
return returnValue;
}
export function inJest(): boolean {
return typeof process === 'object' && process.env.JEST_WORKER_ID !== undefined;
}
export function joinPaths(parts: string[], separator = '/') {
const res = parts
.map(function (part) {
return part.trim().replace(/(^[/]*|[/]*$)/g, '');
})
.filter(p => p !== '' && p !== '.')
.join(separator || '/');
if (parts[0].startsWith('/')) {
return '/' + res;
}
return res;
}
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): void {
const banners = $$('.alert-banner, .py-warning', document);
let bannerCount = 0;
for (const banner of banners) {
if (banner.innerHTML.includes(sentinelText || msg)) {
bannerCount++;
}
}
if (bannerCount == 0) {
_createAlertBanner(msg, 'warning');
}
}
/**
* @returns A new asynchronous lock
* @private
*/
export function createLock(): () => Promise<() => void> {
// This is a promise that is resolved when the lock is open, not resolved when lock is held.
let _lock = Promise.resolve();
/**
* Acquire the async lock
* @returns A zero argument function that releases the lock.
* @private
*/
async function acquireLock() {
const old_lock = _lock;
let releaseLock: () => void;
_lock = new Promise(resolve => (releaseLock = resolve));
await old_lock;
return releaseLock;
}
return acquireLock;
}

View File

@@ -1,10 +0,0 @@
/**
* @fileoverview Version of pyscript
* The version is based on calver which contains YEAR.MONTH.DAY.MODIFIER.
* The Modifier can be an optional text tag, such as "dev", "rc", etc.
*
* We are adding this file because we can't add version in main.js due to
* circular imports.
*/
export const version = '2022.12.1.dev';

View File

@@ -1,10 +0,0 @@
from unittest.mock import Mock
import js
showWarning = Mock()
define_custom_element = Mock()
def deepQuerySelector(selector):
return js.document.querySelector(selector)

View File

@@ -1,29 +0,0 @@
"""All data required for testing examples"""
import sys
from pathlib import Path
import pytest
pyscriptjs = Path(__file__).parents[2]
# add pyscript folder to path
python_source = pyscriptjs / "src" / "python"
sys.path.append(str(python_source))
# add Python plugins folder to path
python_plugins_source = pyscriptjs / "src" / "plugins" / "python"
sys.path.append(str(python_plugins_source))
# patch pyscript module where needed
import pyscript_plugins_tester as ppt # noqa: E402
from pyscript import _plugin # noqa: E402
_plugin.define_custom_element = ppt.define_custom_element
@pytest.fixture()
def plugins_manager():
"""return a new instance of a Test version the PyScript application plugins manager"""
yield ppt.plugins_manager # PluginsManager()
ppt.plugins_manager.reset()

View File

@@ -1,7 +0,0 @@
"""Mock module that emulates some of the pyodide js module features for the sake of tests"""
from unittest.mock import Mock
document = Mock()
console = Mock()
setTimeout = Mock()
Object = Mock()

View File

@@ -1,120 +0,0 @@
import xml.dom
from xml.dom.minidom import Node # nosec
import js
import pyscript
class classList:
"""Class that (lightly) emulates the behaviour of HTML Nodes ClassList"""
def __init__(self):
self._classes = []
def add(self, classname: str):
"""Add classname to the classList"""
self._classes.append(classname)
def remove(self, classname: str):
"""Remove classname from the classList"""
self._classes.remove(classname)
class PluginsManager:
"""
Emulator of PyScript PluginsManager that can be used to simulate plugins lifecycle events
TODO: Currently missing most of the lifecycle events in PluginsManager implementation. Need
to add more than just addPythonPlugin
"""
def __init__(self):
self.plugins = []
# mapping containing all the custom elements createed by plugins
self._custom_elements = {}
def addPythonPlugin(self, pluginInstance: pyscript.Plugin):
"""
Add a pluginInstance to the plugins managed by the PluginManager and calls
pluginInstance.init(self) to initialized the plugin with the manager
"""
pluginInstance.init(self)
self.plugins.append(pluginInstance)
def reset(self):
"""
Unregister all plugins and related custom elements.
"""
for plugin in self.plugins:
plugin.app = None
self.plugins = []
self._custom_elements = {}
class CustomElement:
def __init__(self, plugin_class: pyscript.Plugin):
self.pyPluginInstance = plugin_class(self)
self.attributes = {}
self.innerHTML = ""
def connectedCallback(self):
return self.pyPluginInstance.connect()
def getAttribute(self, attr: str):
return self.attributes.get(attr)
def define_custom_element(tag, plugin_class: pyscript.Plugin):
"""
Mock method to emulate the behaviour of the PyScript `define_custom_element`
that basically creates a new CustomElement passing plugin_class as Python
proxy object. For more info check out the logic of the original implementation at:
src/plugin.ts:define_custom_element
"""
ce = CustomElement(plugin_class)
plugins_manager._custom_elements[tag] = ce
plugins_manager = PluginsManager()
# Init pyscript testing mocks
impl = xml.dom.getDOMImplementation()
class Node:
"""
Represent an HTML Node.
This classes us an abstraction on top of xml.dom.minidom.Node
"""
def __init__(self, el: Node):
self._el = el
self.classList = classList()
# Automatic delegation is a simple and short boilerplate:
def __getattr__(self, attr: str):
return getattr(self._el, attr)
def createElement(self, *args, **kws):
newEl = self._el.createElement(*args, **kws)
return Node(newEl)
class Document(Node):
"""
Represent an HTML Document.
This classes us an abstraction on top of xml.dom.minidom.Document
"""
def __init__(self):
self._el = impl.createDocument(None, "document", None)
js.document = doc = Document()
js.document.head = doc.createElement("head")
js.document.body = doc.createElement("body")

View File

@@ -1,207 +0,0 @@
import sys
import textwrap
from unittest.mock import Mock
import js
import pyscript
from pyscript import HTML, Element
from pyscript._deprecated_globals import DeprecatedGlobal
from pyscript._internal import set_version_info, uses_top_level_await
from pyscript._mime import format_mime
class TestElement:
def test_id_is_correct(self):
el = Element("something")
assert el.id == "something"
def test_element(self, monkeypatch):
el = Element("something")
document = Mock()
call_result = "some_result"
document.querySelector = Mock(return_value=call_result)
monkeypatch.setattr(js, "document", document)
assert not el._element
real_element = el.element
assert real_element
assert document.querySelector.call_count == 1
document.querySelector.assert_called_with("#something")
assert real_element == call_result
def test_format_mime_str():
obj = "just a string"
out, mime = format_mime(obj)
assert out == obj
assert mime == "text/plain"
def test_format_mime_str_escaping():
obj = "<p>hello</p>"
out, mime = format_mime(obj)
assert out == "&lt;p&gt;hello&lt;/p&gt;"
assert mime == "text/plain"
def test_format_mime_repr_escaping():
out, mime = format_mime(sys)
assert out == "&lt;module 'sys' (built-in)&gt;"
assert mime == "text/plain"
def test_format_mime_HTML():
obj = HTML("<p>hello</p>")
out, mime = format_mime(obj)
assert out == "<p>hello</p>"
assert mime == "text/html"
def test_uses_top_level_await():
# Basic Case
src = "x = 1"
assert uses_top_level_await(src) is False
# Comments are not top-level await
src = textwrap.dedent(
"""
#await async for async with asyncio
"""
)
assert uses_top_level_await(src) is False
# Top-level-await cases
src = textwrap.dedent(
"""
async def foo():
pass
await foo
"""
)
assert uses_top_level_await(src) is True
src = textwrap.dedent(
"""
async with object():
pass
"""
)
assert uses_top_level_await(src) is True
src = textwrap.dedent(
"""
async for _ in range(10):
pass
"""
)
assert uses_top_level_await(src) is True
# Acceptable await/async for/async with cases
src = textwrap.dedent(
"""
async def foo():
await foo()
"""
)
assert uses_top_level_await(src) is False
src = textwrap.dedent(
"""
async def foo():
async with object():
pass
"""
)
assert uses_top_level_await(src) is False
src = textwrap.dedent(
"""
async def foo():
async for _ in range(10):
pass
"""
)
assert uses_top_level_await(src) is False
def test_set_version_info():
version_string = "1234.56.78.ABCD"
set_version_info(version_string)
assert pyscript.__version__ == version_string
assert pyscript.version_info == (1234, 56, 78, "ABCD")
class MyDeprecatedGlobal(DeprecatedGlobal):
"""
A subclass of DeprecatedGlobal, for tests.
Instead of showing warnings into the DOM (which we don't have inside unit
tests), we record the warnings into a field.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.warnings = []
def _show_warning(self, message):
self.warnings.append(message)
class TestDeprecatedGlobal:
def test_repr(self):
glob = MyDeprecatedGlobal("foo", None, "my message")
assert repr(glob) == "<DeprecatedGlobal('foo')>"
def test_show_warning_override(self):
"""
Test that our overriding of _show_warning actually works.
"""
glob = MyDeprecatedGlobal("foo", None, "my message")
glob._show_warning("foo")
glob._show_warning("bar")
assert glob.warnings == ["foo", "bar"]
def test_getattr(self):
class MyFakeObject:
name = "FooBar"
glob = MyDeprecatedGlobal("MyFakeObject", MyFakeObject, "this is my warning")
assert glob.name == "FooBar"
assert glob.warnings == ["this is my warning"]
def test_dont_show_warning_twice(self):
class MyFakeObject:
name = "foo"
surname = "bar"
glob = MyDeprecatedGlobal("MyFakeObject", MyFakeObject, "this is my warning")
assert glob.name == "foo"
assert glob.surname == "bar"
assert len(glob.warnings) == 1
def test_call(self):
def foo(x, y):
return x + y
glob = MyDeprecatedGlobal("foo", foo, "this is my warning")
assert glob(1, y=2) == 3
assert glob.warnings == ["this is my warning"]
def test_iter(self):
d = {"a": 1, "b": 2, "c": 3}
glob = MyDeprecatedGlobal("d", d, "this is my warning")
assert list(glob) == ["a", "b", "c"]
assert glob.warnings == ["this is my warning"]
def test_getitem(self):
d = {"a": 1, "b": 2, "c": 3}
glob = MyDeprecatedGlobal("d", d, "this is my warning")
assert glob["a"] == 1
assert glob.warnings == ["this is my warning"]
def test_setitem(self):
d = {"a": 1, "b": 2, "c": 3}
glob = MyDeprecatedGlobal("d", d, "this is my warning")
glob["a"] = 100
assert glob.warnings == ["this is my warning"]
assert glob["a"] == 100

View File

@@ -1,171 +0,0 @@
import html
from unittest.mock import Mock
import js
import py_markdown
import py_tutor
import pyscript_plugins_tester as ppt
TUTOR_SOURCE = """
<py-config>
packages = [
"folium",
"pandas"
]
plugins = [
"../build/plugins/python/py_tutor.py"
]
</py-config>
<py-script>
import folium
import json
import pandas as pd
from pyodide.http import open_url
# the rest of the code goes one
</py-script>
"""
class TestPyMarkdown:
def test_plugin_hooks(self, monkeypatch):
console_mock = Mock()
monkeypatch.setattr(py_markdown, "console", console_mock)
config = "just a config"
interpreter = "just an interpreter"
py_markdown.plugin.configure(config)
console_mock.log.assert_called_with("configuration received: just a config")
py_markdown.plugin.afterStartup(interpreter)
console_mock.log.assert_called_with(
"interpreter received:", "just an interpreter"
)
class TestPyTutor:
def check_prism_added(self):
"""
Assert that the add_prism method has been correctly executed and the
related prism assets have been added to the page head
"""
# GIVEN a previous call to py_tutor.plugin.append_script_to_page
head = js.document.head
# EXPECT the head to contain a link element pointing to the prism.min.css
links = head.getElementsByTagName("link")
assert len(links) == 1
link = links[0]
assert link.type == "text/css"
assert link.rel == "stylesheet"
assert link.href == "./assets/prism/prism.min.css"
# EXPECT the head to contain a script src == prism.min.js
scripts = head.getElementsByTagName("script")
assert len(scripts) == 1
script = scripts[0]
assert script.type == "text/javascript"
assert script.src == "./assets/prism/prism.min.js"
def check_append_script_to_page(self):
"""
Assert that the append_script_to_page has been correctly executed and the
py_tutor.PAGE_SCRIPT code needed for the plugin JS animation has been added
to the page body
"""
# GIVEN a previous call to py_tutor.plugin.append_script_to_page
body = js.document.body
# EXPECT the body of the page to contain a script of type text/javascript
# and that contains the py_tutor.PAGE_SCRIPT script
scripts = body.getElementsByTagName("script")
assert len(scripts) == 1
script = scripts[0]
assert script.type == "text/javascript"
# Check the actual JS script code
# To do so we have 2 methods (it depends on browser support so we check either...)
if script.childNodes:
# in this case it means the content has been added as a child element
node = script.childNodes[0]
assert node.data == py_tutor.PAGE_SCRIPT
else:
assert script.text == py_tutor.PAGE_SCRIPT
def check_create_code_section(self):
"""
Assert that the create_code_section has been correctly executed and the
related code section has been created and added to the page.
"""
# GIVEN a previous call to py_tutor.plugin.check_create_code_section
console = py_tutor.js.console
# EXPECT the console to have the messages printed by the plugin while
# executing the plugin operations
console.info.assert_any_call("Creating code introspection section.")
console.info.assert_any_call("Creating new code section element.")
# EXPECT the page body to contain a section with the input source code
body = js.document.body
sections = body.getElementsByTagName("section")
section = sections[0]
assert "code" in section.classList._classes
section_innerHTML = py_tutor.TEMPLATE_CODE_SECTION.format(
source=html.escape(TUTOR_SOURCE), modules_section=""
)
assert html.escape(TUTOR_SOURCE) in section.innerHTML
assert section.innerHTML == section_innerHTML
def test_connected_calls(self, plugins_manager: ppt.PluginsManager):
"""
Test that all parts of the plugin have been added to the page body and head
properly. This test effectively calls `self.check_prism_added`,
`self.check_append_script_to_page` and `check_create_code_section` assert
the new nodes have been added properly.
"""
# GIVEN THAT we add the plugin to the app plugin manager
# this will:
# - init the plugin instance passing the plugins_manager as parent app
# - add the plugin instance to plugins_manager.plugins
assert not py_tutor.plugin.app
plugins_manager.addPythonPlugin(py_tutor.plugin)
# EXPECT: the plugin app to now be the plugin manager
assert py_tutor.plugin.app == plugins_manager
tutor_ce = plugins_manager._custom_elements["py-tutor"]
# tutor_ce_python_instance = tutor_ce.pyPluginInstance
# GIVEN: The following innerHTML on the ce elements
tutor_ce.innerHTML = TUTOR_SOURCE
# GIVEN: the CustomElement connectedCallback gets called
tutor_ce.connectedCallback()
# EXPECT: the
self.check_prism_added()
self.check_append_script_to_page()
self.check_create_code_section()
def test_plugin_registered(self, plugins_manager: ppt.PluginsManager):
"""
Test that, when registered, plugin actually has an app attribute set
and that it's present in plugins manager plugins list.
"""
# EXPECT py_tutor.plugin to not have any app associate
assert not py_tutor.plugin.app
# EXPECT: the plugin manager to not have any plugin registered
assert not plugins_manager.plugins
# GIVEN THAT we add the plugin to the app plugin manager
plugins_manager.addPythonPlugin(py_tutor.plugin)
# EXPECT: the plugin app to now be the plugin manager
assert py_tutor.plugin.app == plugins_manager
assert "py-tutor" in py_tutor.plugin._custom_elements
# EXPECT: the pytutor.plugin manager to be part of
assert py_tutor.plugin in plugins_manager.plugins

View File

@@ -1,62 +0,0 @@
import { calculateFetchPaths } from '../../src/plugins/calculateFetchPaths';
import { FetchConfig } from '../../src/pyconfig';
describe('CalculateFetchPaths', () => {
it('should calculate paths when only from is provided', () => {
const fetch_cfg: FetchConfig[] = [{ from: 'http://a.com/data.csv' }];
const res = calculateFetchPaths(fetch_cfg);
expect(res).toStrictEqual([{ url: 'http://a.com/data.csv', path: 'data.csv' }]);
});
it('should calculate paths when only files is provided', () => {
const fetch_cfg: FetchConfig[] = [{ files: ['foo/__init__.py', 'foo/mod.py', 'foo2/mod.py'] }];
const res = calculateFetchPaths(fetch_cfg);
expect(res).toStrictEqual([
{ url: 'foo/__init__.py', path: 'foo/__init__.py' },
{ url: 'foo/mod.py', path: 'foo/mod.py' },
{ url: 'foo2/mod.py', path: 'foo2/mod.py' },
]);
});
it('should calculate paths when files and to_folder is provided', () => {
const fetch_cfg: FetchConfig[] = [{ files: ['foo/__init__.py', 'foo/mod.py'], to_folder: '/my/lib/' }];
const res = calculateFetchPaths(fetch_cfg);
expect(res).toStrictEqual([
{ url: 'foo/__init__.py', path: '/my/lib/foo/__init__.py' },
{ url: 'foo/mod.py', path: '/my/lib/foo/mod.py' },
]);
});
it('should calculate paths when from and files and to_folder is provided', () => {
const fetch_cfg: FetchConfig[] = [
{ from: 'http://a.com/download/', files: ['foo/__init__.py', 'foo/mod.py'], to_folder: '/my/lib/' },
];
const res = calculateFetchPaths(fetch_cfg);
expect(res).toStrictEqual([
{ url: 'http://a.com/download/foo/__init__.py', path: '/my/lib/foo/__init__.py' },
{ url: 'http://a.com/download/foo/mod.py', path: '/my/lib/foo/mod.py' },
]);
});
it("should error out while calculating paths when filename cannot be determined from 'from'", () => {
const fetch_cfg: FetchConfig[] = [{ from: 'http://google.com/', to_folder: '/tmp' }];
expect(() => calculateFetchPaths(fetch_cfg)).toThrowError(
"Couldn't determine the filename from the path http://google.com/",
);
});
it('should calculate paths when to_file is explicitly supplied', () => {
const fetch_cfg: FetchConfig[] = [{ from: 'http://a.com/data.csv?version=1', to_file: 'pkg/tmp/data.csv' }];
const res = calculateFetchPaths(fetch_cfg);
expect(res).toStrictEqual([{ path: 'pkg/tmp/data.csv', url: 'http://a.com/data.csv?version=1' }]);
});
it('should error out when both to_file and files parameters are provided', () => {
const fetch_cfg: FetchConfig[] = [
{ from: 'http://a.com/data.csv?version=1', to_file: 'pkg/tmp/data.csv', files: ['a.py', 'b.py'] },
];
expect(() => calculateFetchPaths(fetch_cfg)).toThrowError(
"Cannot use 'to_file' and 'files' parameters together!",
);
});
});

View File

@@ -1,123 +0,0 @@
import { expect, it, jest, describe, afterEach } from '@jest/globals';
import { _createAlertBanner, UserError, FetchError, ErrorCode } from '../../src/exceptions';
describe('Test _createAlertBanner', () => {
afterEach(() => {
// Ensure we always have a clean body
document.body.innerHTML = `<div>Hello World</div>`;
});
it("error level shouldn't contain close button", async () => {
_createAlertBanner('Something went wrong!', 'error');
const banner = document.getElementsByClassName('alert-banner');
const closeButton = document.getElementById('alert-close-button');
expect(banner.length).toBe(1);
expect(banner[0].innerHTML).toBe('Something went wrong!');
expect(closeButton).toBeNull();
});
it('warning level should contain close button', async () => {
_createAlertBanner('This is a warning', 'warning');
const banner = document.getElementsByClassName('alert-banner');
const closeButton = document.getElementById('alert-close-button');
expect(banner.length).toBe(1);
expect(banner[0].innerHTML).toContain('This is a warning');
expect(closeButton).not.toBeNull();
});
it('error level banner should log to console', async () => {
const logSpy = jest.spyOn(console, 'error');
_createAlertBanner('Something went wrong!');
expect(logSpy).toHaveBeenCalledWith('Something went wrong!');
});
it('warning level banner should log to console', async () => {
const logSpy = jest.spyOn(console, 'warn');
_createAlertBanner('This warning', 'warning');
expect(logSpy).toHaveBeenCalledWith('This warning');
});
it('close button should remove element from page', async () => {
let banner = document.getElementsByClassName('alert-banner');
expect(banner.length).toBe(0);
_createAlertBanner('Warning!', 'warning');
// Just a sanity check
banner = document.getElementsByClassName('alert-banner');
expect(banner.length).toBe(1);
const closeButton = document.getElementById('alert-close-button');
if (closeButton) {
closeButton.click();
// Confirm that clicking the close button, removes the element
banner = document.getElementsByClassName('alert-banner');
expect(banner.length).toBe(0);
} else {
fail('Unable to find close button on the page, but should exist');
}
});
it("toggling logging off on error alert shouldn't log to console", async () => {
const errorLogSpy = jest.spyOn(console, 'error');
_createAlertBanner('Test error', 'error', 'text', false);
expect(errorLogSpy).not.toHaveBeenCalledWith('Test error');
});
it("toggling logging off on warning alert shouldn't log to console", async () => {
const warnLogSpy = jest.spyOn(console, 'warn');
_createAlertBanner('Test warning', 'warning', 'text', false);
expect(warnLogSpy).not.toHaveBeenCalledWith('Test warning');
});
it('_createAlertbanner messageType text writes message to content', async () => {
let banner = document.getElementsByClassName('alert-banner');
expect(banner.length).toBe(0);
const message = '<p>Test message</p>';
_createAlertBanner(message, 'error', 'text');
banner = document.getElementsByClassName('alert-banner');
expect(banner.length).toBe(1);
expect(banner[0].innerHTML).toBe('&lt;p&gt;Test message&lt;/p&gt;');
expect(banner[0].textContent).toBe(message);
});
it('_createAlertbanner messageType html writes message to innerHTML', async () => {
let banner = document.getElementsByClassName('alert-banner');
expect(banner.length).toBe(0);
const message = '<p>Test message</p>';
_createAlertBanner(message, 'error', 'html');
banner = document.getElementsByClassName('alert-banner');
expect(banner.length).toBe(1);
expect(banner[0].innerHTML).toBe(message);
expect(banner[0].textContent).toBe('Test message');
});
});
describe('Test Exceptions', () => {
it('UserError contains errorCode and shows in message', async () => {
const errorCode = ErrorCode.BAD_CONFIG;
const message = 'Test error';
const userError = new UserError(ErrorCode.BAD_CONFIG, message);
expect(userError.errorCode).toBe(errorCode);
expect(userError.message).toBe(`(${errorCode}): ${message}`);
});
it('FetchError contains errorCode and shows in message', async () => {
const errorCode = ErrorCode.FETCH_NOT_FOUND_ERROR;
const message = 'Test error';
const fetchError = new FetchError(errorCode, message);
expect(fetchError.errorCode).toBe(errorCode);
expect(fetchError.message).toBe(`(${errorCode}): ${message}`);
});
});

View File

@@ -1,107 +0,0 @@
import { describe, it, expect, jest } from '@jest/globals';
import { FetchError, ErrorCode } from '../../src/exceptions';
import { robustFetch } from '../../src/fetch';
import { Response } from 'node-fetch';
describe('robustFetch', () => {
it('should return a response object', async () => {
global.fetch = jest.fn(() => Promise.resolve(new Response((status = '200'), 'Hello World')));
const response = await robustFetch('https://pyscript.net');
expect(response).toBeInstanceOf(Response);
expect(response.status).toBe(200);
});
it('receiving a 404 when fetching should throw FetchError with the right errorCode', async () => {
global.fetch = jest.fn(() => Promise.resolve(new Response('Not Found', { status: 404 })));
const url = 'https://pyscript.net/non-existent-page';
const expectedError = new FetchError(
ErrorCode.FETCH_NOT_FOUND_ERROR,
`Fetching from URL ${url} failed with error 404 (Not Found). ` + `Are your filename and path correct?`,
);
expect(() => robustFetch(url)).rejects.toThrow(expectedError);
});
it('receiving a 401 when fetching should throw FetchError with the right errorCode', async () => {
global.fetch = jest.fn(() => Promise.resolve(new Response('', { status: 401 })));
const url = 'https://pyscript.net/protected-page';
const expectedError = new FetchError(
ErrorCode.FETCH_UNAUTHORIZED_ERROR,
`Fetching from URL ${url} failed with error 401 (Unauthorized). ` + `Are your filename and path correct?`,
);
expect(() => robustFetch(url)).rejects.toThrow(expectedError);
});
it('receiving a 403 when fetching should throw FetchError with the right errorCode', async () => {
global.fetch = jest.fn(() => Promise.resolve(new Response('', { status: 403 })));
const url = 'https://pyscript.net/secret-page';
const expectedError = new FetchError(
ErrorCode.FETCH_FORBIDDEN_ERROR,
`Fetching from URL ${url} failed with error 403 (Forbidden). ` + `Are your filename and path correct?`,
);
expect(() => robustFetch(url)).rejects.toThrow(expectedError);
});
it('receiving a 500 when fetching should throw FetchError with the right errorCode', async () => {
global.fetch = jest.fn(() => Promise.resolve(new Response('Not Found', { status: 500 })));
const url = 'https://pyscript.net/protected-page';
const expectedError = new FetchError(
ErrorCode.FETCH_SERVER_ERROR,
`Fetching from URL ${url} failed with error 500 (Internal Server Error). ` +
`Are your filename and path correct?`,
);
expect(() => robustFetch(url)).rejects.toThrow(expectedError);
});
it('receiving a 503 when fetching should throw FetchError with the right errorCode', async () => {
global.fetch = jest.fn(() => Promise.resolve(new Response('Not Found', { status: 503 })));
const url = 'https://pyscript.net/protected-page';
const expectedError = new FetchError(
ErrorCode.FETCH_UNAVAILABLE_ERROR,
`Fetching from URL ${url} failed with error 503 (Service Unavailable). ` +
`Are your filename and path correct?`,
);
expect(() => robustFetch(url)).rejects.toThrow(expectedError);
});
it('handle TypeError when using a bad url', async () => {
global.fetch = jest.fn(() => Promise.reject(new TypeError('Failed to fetch')));
const url = 'https://pyscript.net/protected-page';
const expectedError = new FetchError(
ErrorCode.FETCH_ERROR,
`Fetching from URL ${url} failed with error 'Failed to fetch'. Are your filename and path correct?`,
);
expect(() => robustFetch(url)).rejects.toThrow(expectedError);
});
it('handle failed to fetch when using local file', async () => {
global.fetch = jest.fn(() => Promise.reject(new TypeError('Failed to fetch')));
const url = './my-awesome-pyscript.py';
const expectedError = new FetchError(
ErrorCode.FETCH_ERROR,
`PyScript: Access to local files
(using [[fetch]] configurations in &lt;py-config&gt;)
is not available when directly opening a HTML file;
you must use a webserver to serve the additional files.
See <a style="text-decoration: underline;" href="https://github.com/pyscript/pyscript/issues/257#issuecomment-1119595062">this reference</a>
on starting a simple webserver with Python.
`,
);
expect(() => robustFetch(url)).rejects.toThrow(expectedError);
});
});

View File

@@ -1,46 +0,0 @@
import { jest } from '@jest/globals';
import { getLogger } from '../../src/logger';
describe('getLogger', () => {
it('getLogger caches results', () => {
let a1 = getLogger('a');
let b = getLogger('b');
let a2 = getLogger('a');
expect(a1).not.toBe(b);
expect(a1).toBe(a2);
});
it('logger.info prints to console.info', () => {
console.info = jest.fn();
const logger = getLogger('prefix1');
logger.info('hello world');
expect(console.info).toHaveBeenCalledWith('[prefix1] hello world');
});
it('logger.info handles multiple args', () => {
console.info = jest.fn();
const logger = getLogger('prefix2');
logger.info('hello', 'world', 1, 2, 3);
expect(console.info).toHaveBeenCalledWith('[prefix2] hello', 'world', 1, 2, 3);
});
it('logger.{debug,warn,error} also works', () => {
console.info = jest.fn();
console.debug = jest.fn();
console.warn = jest.fn();
console.error = jest.fn();
const logger = getLogger('prefix3');
logger.debug('this is a debug');
logger.warn('this is a warning');
logger.error('this is an error');
expect(console.info).not.toHaveBeenCalled();
expect(console.debug).toHaveBeenCalledWith('[prefix3] this is a debug');
expect(console.warn).toHaveBeenCalledWith('[prefix3] this is a warning');
expect(console.error).toHaveBeenCalledWith('[prefix3] this is an error');
});
});

View File

@@ -1,68 +0,0 @@
import { describe, it, beforeEach, expect } from '@jest/globals';
import { UserError, ErrorCode } from '../../src/exceptions';
import { PyScriptApp } from '../../src/main';
describe('Test withUserErrorHandler', () => {
class MyApp extends PyScriptApp {
myRealMain: any;
constructor(myRealMain) {
super();
this.myRealMain = myRealMain;
}
async _realMain() {
this.myRealMain();
}
}
beforeEach(() => {
// Ensure we always have a clean body
document.body.innerHTML = `<div>Hello World</div>`;
});
it("userError doesn't stop execution", async () => {
function myRealMain() {
throw new UserError(ErrorCode.GENERIC, 'Computer says no');
}
const app = new MyApp(myRealMain);
await app.main();
const banners = document.getElementsByClassName('alert-banner');
expect(banners.length).toBe(1);
expect(banners[0].innerHTML).toBe('(PY0000): Computer says no');
});
it('userError escapes by default', async () => {
function myRealMain() {
throw new UserError(ErrorCode.GENERIC, 'hello <br>');
}
const app = new MyApp(myRealMain);
await app.main();
const banners = document.getElementsByClassName('alert-banner');
expect(banners.length).toBe(1);
expect(banners[0].innerHTML).toBe('(PY0000): hello &lt;br&gt;');
});
it("userError messageType=html don't escape", async () => {
function myRealMain() {
throw new UserError(ErrorCode.GENERIC, 'hello <br>', 'html');
}
const app = new MyApp(myRealMain);
await app.main();
const banners = document.getElementsByClassName('alert-banner');
expect(banners.length).toBe(1);
expect(banners[0].innerHTML).toBe('(PY0000): hello <br>');
});
it('any other exception should stop execution and raise', async () => {
function myRealMain() {
throw new Error('Explosions!');
}
const app = new MyApp(myRealMain);
expect(app.main()).rejects.toThrow(new Error('Explosions!'));
});
});

View File

@@ -1,116 +0,0 @@
import { validateConfigParameter, validateConfigParameterFromArray } from '../../src/plugin';
import { UserError } from '../../src/exceptions';
describe('validateConfigParameter', () => {
const validator = a => a.charAt(0) === 'a';
it('should not change a matching config option', () => {
const pyconfig = { a: 'a1', dummy: 'dummy' };
validateConfigParameter({
config: pyconfig,
name: 'a',
validator: validator,
defaultValue: 'a_default',
hintMessage: "Should start with 'a'",
});
expect(pyconfig).toStrictEqual({ a: 'a1', dummy: 'dummy' });
});
it('should set the default value if no value is present', () => {
const pyconfig = { dummy: 'dummy' };
validateConfigParameter({
config: pyconfig,
name: 'a',
validator: validator,
defaultValue: 'a_default',
hintMessage: "Should start with 'a'",
});
expect(pyconfig).toStrictEqual({ a: 'a_default', dummy: 'dummy' });
});
it('should error if the provided value is not valid', () => {
const pyconfig = { a: 'NotValidValue', dummy: 'dummy' };
const func = () =>
validateConfigParameter({
config: pyconfig,
name: 'a',
validator: validator,
defaultValue: 'a_default',
hintMessage: "Should start with 'a'",
});
expect(func).toThrow(UserError);
expect(func).toThrow('(PY1000): Invalid value "NotValidValue" for config.a. Should start with \'a\'');
});
it('should error if the provided default is not valid', () => {
const pyconfig = { a: 'a1', dummy: 'dummy' };
const func = () =>
validateConfigParameter({
config: pyconfig,
name: 'a',
validator: validator,
defaultValue: 'NotValidDefault',
hintMessage: "Should start with 'a'",
});
expect(func).toThrow(Error);
expect(func).toThrow(
'Default value "NotValidDefault" for a is not a valid argument, according to the provided validator function. Should start with \'a\'',
);
});
});
describe('validateConfigParameterFromArray', () => {
const possibilities = ['a1', 'a2', true, 42, 'a_default'];
it('should not change a matching config option', () => {
const pyconfig = { a: 'a1', dummy: 'dummy' };
validateConfigParameterFromArray({
config: pyconfig,
name: 'a',
possibleValues: possibilities,
defaultValue: 'a_default',
});
expect(pyconfig).toStrictEqual({ a: 'a1', dummy: 'dummy' });
});
it('should set the default value if no value is present', () => {
const pyconfig = { dummy: 'dummy' };
validateConfigParameterFromArray({
config: pyconfig,
name: 'a',
possibleValues: possibilities,
defaultValue: 'a_default',
});
expect(pyconfig).toStrictEqual({ a: 'a_default', dummy: 'dummy' });
});
it('should error if the provided value is not in possible_values', () => {
const pyconfig = { a: 'NotValidValue', dummy: 'dummy' };
const func = () =>
validateConfigParameterFromArray({
config: pyconfig,
name: 'a',
possibleValues: possibilities,
defaultValue: 'a_default',
});
expect(func).toThrow(Error);
expect(func).toThrow(
'(PY1000): Invalid value "NotValidValue" for config.a. The only accepted values are: ["a1", "a2", true, 42, "a_default"]',
);
});
it('should error if the provided default is not in possible_values', () => {
const pyconfig = { a: 'a1', dummy: 'dummy' };
const func = () =>
validateConfigParameterFromArray({
config: pyconfig,
name: 'a',
possibleValues: possibilities,
defaultValue: 'NotValidDefault',
});
expect(func).toThrow(Error);
expect(func).toThrow(
'Default value "NotValidDefault" for a is not a valid argument, according to the provided validator function. The only accepted values are: ["a1", "a2", true, 42, "a_default"]',
);
});
});

View File

@@ -1,168 +0,0 @@
import { jest, describe, it, expect } from '@jest/globals';
import { loadConfigFromElement, defaultConfig } from '../../src/pyconfig';
import { version } from '../../src/version';
import { UserError } from '../../src/exceptions';
// inspired by trump typos
const covfefeConfig = {
name: 'covfefe',
interpreters: [
{
src: '/demo/covfefe.js',
name: 'covfefe',
lang: 'covfefe',
},
],
wonderful: 'disgrace',
};
const covfefeConfigToml = `
name = "covfefe"
wonderful = "hijacked"
[[interpreters]]
src = "/demo/covfefe.js"
name = "covfefe"
lang = "covfefe"
`;
// ideally, I would like to be able to just do "new HTMLElement" in the tests
// below, but it is not permitted. The easiest work around is to create a fake
// custom element: not that we are not using any specific feature of custom
// elements: the sole purpose to FakeElement is to be able to instantiate them
// in the tests.
class FakeElement extends HTMLElement {
constructor() {
super();
}
}
customElements.define('fake-element', FakeElement);
function make_config_element(attrs) {
const el = new FakeElement();
for (const [key, value] of Object.entries(attrs)) {
el.setAttribute(key, value as string);
}
return el;
}
describe('loadConfigFromElement', () => {
const xhrMockClass = () => ({
open: jest.fn(),
send: jest.fn(),
responseText: JSON.stringify(covfefeConfig),
});
// @ts-ignore
window.XMLHttpRequest = jest.fn().mockImplementation(xhrMockClass);
it('should load the default config', () => {
const config = loadConfigFromElement(null);
expect(config).toBe(defaultConfig);
expect(config.pyscript.version).toBe(version);
});
it('an empty <py-config> should load the default config', () => {
const el = make_config_element({});
let config = loadConfigFromElement(el);
expect(config).toBe(defaultConfig);
expect(config.pyscript.version).toBe(version);
});
it('should load the JSON config from inline', () => {
const el = make_config_element({ type: 'json' });
el.innerHTML = JSON.stringify(covfefeConfig);
const config = loadConfigFromElement(el);
expect(config.interpreters[0].lang).toBe('covfefe');
expect(config.pyscript?.time).not.toBeNull();
// schema_version wasn't present in `inline config` but is still set due to merging with default
expect(config.schema_version).toBe(1);
});
it('should load the JSON config from src attribute', () => {
const el = make_config_element({ type: 'json', src: '/covfefe.json' });
const config = loadConfigFromElement(el);
expect(config.interpreters[0].lang).toBe('covfefe');
expect(config.pyscript?.time).not.toBeNull();
// wonderful is an extra key supplied by the user and is unaffected by merging process
expect(config.wonderful).toBe('disgrace');
// schema_version wasn't present in `config from src` but is still set due to merging with default
expect(config.schema_version).toBe(1);
});
it('should load the JSON config from both inline and src', () => {
const el = make_config_element({ type: 'json', src: '/covfefe.json' });
el.innerHTML = JSON.stringify({ version: '0.2a', wonderful: 'hijacked' });
const config = loadConfigFromElement(el);
expect(config.interpreters[0].lang).toBe('covfefe');
expect(config.pyscript?.time).not.toBeNull();
// config from src had an extra key "wonderful" with value "disgrace"
// inline config had the same extra key "wonderful" with value "hijacked"
// the merge process works for extra keys that clash as well
// so the final value is "hijacked" since inline takes precedence over src
expect(config.wonderful).toBe('hijacked');
// version wasn't present in `config from src` but is still set due to merging with default and inline
expect(config.version).toBe('0.2a');
});
it('should be able to load an inline TOML config', () => {
// TOML is the default type
const el = make_config_element({});
el.innerHTML = covfefeConfigToml;
const config = loadConfigFromElement(el);
expect(config.interpreters[0].lang).toBe('covfefe');
expect(config.pyscript?.time).not.toBeNull();
// schema_version wasn't present in `inline config` but is still set due to merging with default
expect(config.schema_version).toBe(1);
expect(config.wonderful).toBe('hijacked');
});
it('should NOT be able to load an inline config in JSON format with type as TOML', () => {
const el = make_config_element({});
el.innerHTML = JSON.stringify(covfefeConfig);
expect(() => loadConfigFromElement(el)).toThrow(
/config supplied: {.*} is an invalid TOML and cannot be parsed/,
);
});
it('should NOT be able to load an inline config in TOML format with type as JSON', () => {
const el = make_config_element({ type: 'json' });
el.innerHTML = covfefeConfigToml;
expect(() => loadConfigFromElement(el)).toThrow(UserError);
});
it('should NOT be able to load an inline TOML config with a JSON config from src with type as toml', () => {
const el = make_config_element({ src: '/covfefe.json' });
el.innerHTML = covfefeConfigToml;
expect(() => loadConfigFromElement(el)).toThrow(
/config supplied: {.*} is an invalid TOML and cannot be parsed/,
);
});
it('should NOT be able to load an inline TOML config with a JSON config from src with type as json', () => {
const el = make_config_element({ type: 'json', src: '/covfefe.json' });
el.innerHTML = covfefeConfigToml;
expect(() => loadConfigFromElement(el)).toThrow(UserError);
});
it('should error out when passing an invalid JSON', () => {
const el = make_config_element({ type: 'json' });
el.innerHTML = '[[';
expect(() => loadConfigFromElement(el)).toThrow(UserError);
});
it('should error out when passing an invalid TOML', () => {
const el = make_config_element({});
el.innerHTML = '[[';
expect(() => loadConfigFromElement(el)).toThrow(UserError);
});
it('should not escape characters like &', () => {
const el = make_config_element({ type: 'json' });
el.innerHTML = JSON.stringify({
fetch: [{ from: 'https://datausa.io/api/data?drilldowns=Nation&measures=Population' }],
});
const config = loadConfigFromElement(el);
expect(config.fetch[0].from).toBe('https://datausa.io/api/data?drilldowns=Nation&measures=Population');
});
});

View File

@@ -1,89 +0,0 @@
import type { AppConfig } from '../../src/pyconfig';
import { InterpreterClient } from '../../src/interpreter_client';
import { CaptureStdio } from '../../src/stdio';
import * as Synclink from 'synclink';
import { describe, beforeAll, afterAll, it, expect } from '@jest/globals';
// We can't import RemoteInterpreter at top level because we need to mock the
// Python package in setup.ts
// But we can import the types at top level.
// TODO: is there a better way to handle this?
import type { RemoteInterpreter } from '../../src/remote_interpreter';
describe('RemoteInterpreter', () => {
let interpreter: InterpreterClient;
let stdio: CaptureStdio = new CaptureStdio();
let RemoteInterpreter;
const { port1, port2 } = new Synclink.FakeMessageChannel() as unknown as MessageChannel;
beforeAll(async () => {
const SRC = '../pyscriptjs/node_modules/pyodide/pyodide.js';
const config: AppConfig = { interpreters: [{ src: SRC }], packages: [] };
// Dynamic import of RemoteInterpreter sees our mocked Python package.
({ RemoteInterpreter } = await import('../../src/remote_interpreter'));
const remote_interpreter = new RemoteInterpreter(SRC);
port1.start();
port2.start();
Synclink.expose(remote_interpreter, port2);
const wrapped_remote_interpreter = Synclink.wrap(port1);
interpreter = new InterpreterClient(
config,
stdio,
wrapped_remote_interpreter as Synclink.Remote<RemoteInterpreter>,
);
/**
* Since import { loadPyodide } from 'pyodide';
* is not used inside `src/pyodide.ts`, the function
* `interpreter.loadInterpreter();` below which calls
* `loadPyodide()` results in an expected issue of:
* ReferenceError: loadPyodide is not defined
*
* To make jest happy, while also not importing
* explicitly inside `src/pyodide.ts`, the
* following lines - so as to dynamically import
* and make it available in the global namespace
* - are used.
*
* Pyodide uses a "really hacky" method to get the
* URL/Path where packages/package data are stored;
* it throws an error, catches it, and parses it. In
* Jest, this calculated path is different than in
* the browser/Node, so files cannot be found and the
* test fails. We set indexURL below the correct location
* to fix this.
* See https://github.com/pyodide/pyodide/blob/7dfee03a82c19069f714a09da386547aeefef242/src/js/pyodide.ts#L161-L179
*/
const pyodideSpec = await import('pyodide');
global.loadPyodide = async options =>
pyodideSpec.loadPyodide(Object.assign({ indexURL: '../pyscriptjs/node_modules/pyodide/' }, options));
await interpreter.initializeRemote();
});
afterAll(async () => {
port1.close();
port2.close();
});
it('should check if interpreter is an instance of abstract Interpreter', async () => {
expect(interpreter).toBeInstanceOf(InterpreterClient);
});
it('should check if interpreter can run python code asynchronously', async () => {
expect((await interpreter.run('2+3')).result).toBe(5);
});
it('should capture stdout', async () => {
stdio.reset();
await interpreter.run("print('hello')");
expect(stdio.captured_stdout).toBe('hello\n');
});
it('should check if interpreter is able to load a package', async () => {
stdio.reset();
await interpreter._remote.loadPackage('numpy');
await interpreter.run('import numpy as np');
await interpreter.run('x = np.ones((10,))');
await interpreter.run('print(x)');
expect(stdio.captured_stdout).toBe('[1. 1. 1. 1. 1. 1. 1. 1. 1. 1.]\n');
});
});

View File

@@ -1,8 +0,0 @@
import { jest } from '@jest/globals';
import { directoryManifest } from '../../directoryManifest.mjs';
jest.unstable_mockModule('../../src/python_package', async () => ({
python_package: await directoryManifest('./src/python/'),
}));
globalThis.jest = jest;

View File

@@ -1,136 +0,0 @@
import { expect } from '@jest/globals';
import { type Stdio, CaptureStdio, StdioMultiplexer, TargetedStdio } from '../../src/stdio';
describe('CaptureStdio', () => {
it('captured streams are initialized to empty string', () => {
let stdio = new CaptureStdio();
expect(stdio.captured_stdout).toBe('');
expect(stdio.captured_stderr).toBe('');
});
it('stdout() and stderr() captures', () => {
let stdio = new CaptureStdio();
stdio.stdout_writeline('hello');
stdio.stdout_writeline('world');
stdio.stderr_writeline('this is an error');
expect(stdio.captured_stdout).toBe('hello\nworld\n');
expect(stdio.captured_stderr).toBe('this is an error\n');
});
it('reset() works', () => {
let stdio = new CaptureStdio();
stdio.stdout_writeline('aaa');
stdio.stderr_writeline('bbb');
stdio.reset();
expect(stdio.captured_stdout).toBe('');
expect(stdio.captured_stderr).toBe('');
});
});
describe('StdioMultiplexer', () => {
let a: CaptureStdio;
let b: CaptureStdio;
let multi: StdioMultiplexer;
beforeEach(() => {
a = new CaptureStdio();
b = new CaptureStdio();
multi = new StdioMultiplexer();
});
it('works without listeners', () => {
// no listeners, messages are ignored
multi.stdout_writeline('out 1');
multi.stderr_writeline('err 1');
expect(a.captured_stdout).toBe('');
expect(a.captured_stderr).toBe('');
expect(b.captured_stdout).toBe('');
expect(b.captured_stderr).toBe('');
});
it('redirects to multiple listeners', () => {
multi.addListener(a);
multi.stdout_writeline('out 1');
multi.stderr_writeline('err 1');
multi.addListener(b);
multi.stdout_writeline('out 2');
multi.stderr_writeline('err 2');
expect(a.captured_stdout).toBe('out 1\nout 2\n');
expect(a.captured_stderr).toBe('err 1\nerr 2\n');
expect(b.captured_stdout).toBe('out 2\n');
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>');
});
});

View File

@@ -1,78 +0,0 @@
import { beforeEach, expect, describe, it } from '@jest/globals';
import { ensureUniqueId, joinPaths, createSingularWarning } from '../../src/utils';
describe('Utils', () => {
let element: HTMLElement;
beforeEach(() => {
element = document.createElement('div');
});
it('ensureUniqueId sets unique id on element', async () => {
expect(element.id).toBe('');
ensureUniqueId(element);
expect(element.id).toBe('py-internal-0');
});
it('ensureUniqueId sets unique id with increasing counter', async () => {
const secondElement = document.createElement('div');
expect(element.id).toBe('');
expect(secondElement.id).toBe('');
ensureUniqueId(element);
ensureUniqueId(secondElement);
// The counter will have been incremented on
// the previous test, make sure it keeps increasing
expect(element.id).toBe('py-internal-1');
expect(secondElement.id).toBe('py-internal-2');
});
});
describe('JoinPaths', () => {
it('should remove trailing slashes from the beginning and the end', () => {
const paths: string[] = ['///abc/d/e///'];
const joinedPath = joinPaths(paths);
expect(joinedPath).toStrictEqual('/abc/d/e');
});
it('should not remove slashes from the middle to preserve protocols such as http', () => {
const paths: string[] = ['http://google.com', '///data.txt'];
const joinedPath = joinPaths(paths);
expect(joinedPath).toStrictEqual('http://google.com/data.txt');
});
it('should not join paths when they are empty strings', () => {
const paths: string[] = ['', '///hhh/ll/pp///', '', 'kkk'];
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'),
);
});
});
});

View File

@@ -1,19 +0,0 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"_version": "3.0.0",
"include": ["src/**/*"],
"exclude": ["node_modules/*", "__sapper__/*", "public/*", "src/interpreter_worker/*"],
"compilerOptions": {
"moduleResolution": "node",
"target": "ES2020",
"module": "ES2020",
"types": ["jest", "node"],
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"isolatedModules": true,
"forceConsistentCasingInFileNames": true,
"lib": ["es2017", "dom", "DOM.Iterable"]
}
}