mirror of
https://github.com/pyscript/pyscript.git
synced 2025-12-19 18:27:29 -05:00
Merge remote-tracking branch 'origin/main' into antocuni/py-terminal
This commit is contained in:
2
.github/workflows/publish-release.yml
vendored
2
.github/workflows/publish-release.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
|||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Generate index.html in snapshot
|
- name: Generate index.html in snapshot
|
||||||
working-directory: .
|
working-directory: .
|
||||||
run: sed 's#_PATH_#https://pyscript.net/releases/${{ github.ref_name }}/#' ./public/index.html > ./pyscript.core/dist/index.html
|
run: sed 's#_PATH_#https://pyscript.net/releases/${{ github.ref_name }}/#' ./public/index.html > ./pyscript.core/dist/index.html
|
||||||
|
|||||||
8
.github/workflows/publish-snapshot.yml
vendored
8
.github/workflows/publish-snapshot.yml
vendored
@@ -39,13 +39,13 @@ jobs:
|
|||||||
${{ runner.os }}-build-${{ env.cache-name }}-
|
${{ runner.os }}-build-${{ env.cache-name }}-
|
||||||
${{ runner.os }}-build-
|
${{ runner.os }}-build-
|
||||||
${{ runner.os }}-
|
${{ runner.os }}-
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install
|
run: npm install
|
||||||
|
|
||||||
- name: Build Pyscript.core
|
- name: Build Pyscript.core
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Configure AWS credentials
|
- name: Configure AWS credentials
|
||||||
uses: aws-actions/configure-aws-credentials@v4
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
with:
|
with:
|
||||||
@@ -58,4 +58,4 @@ jobs:
|
|||||||
|
|
||||||
- name: Copy to Snapshot
|
- name: Copy to Snapshot
|
||||||
run: >
|
run: >
|
||||||
aws s3 sync ./dist/ s3://pyscript.net/snapshots/${{ inputs.snapshot_version }}/
|
aws s3 sync ./dist/ s3://pyscript.net/snapshots/${{ inputs.snapshot_version }}/
|
||||||
|
|||||||
11
.github/workflows/publish-unstable.yml
vendored
11
.github/workflows/publish-unstable.yml
vendored
@@ -1,20 +1,13 @@
|
|||||||
name: "Publish Unstable"
|
name: "Publish Unstable"
|
||||||
|
|
||||||
on:
|
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:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- pyscript.core/**
|
- pyscript.core/**
|
||||||
- examples/**
|
- examples/**
|
||||||
|
|
||||||
pull_request: # Run on any PR that modifies files under pyscriptjs/ and examples/
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- pyscriptjs/**
|
|
||||||
- examples/**
|
|
||||||
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -53,7 +46,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Generate index.html in snapshot
|
- name: Generate index.html in snapshot
|
||||||
working-directory: .
|
working-directory: .
|
||||||
run: sed 's#_PATH_#https://pyscript.net/unstable/#' ./public/index.html > ./pyscript.core/dist/index.html
|
run: sed 's#_PATH_#https://pyscript.net/unstable/#' ./public/index.html > ./pyscript.core/dist/index.html
|
||||||
|
|||||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 3
|
fetch-depth: 3
|
||||||
|
|
||||||
# display a git log: when you run CI on PRs, github automatically
|
# 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
|
# merges the PR into main and run the CI on that commit. The idea
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -51,7 +51,6 @@ coverage.xml
|
|||||||
*.py,cover
|
*.py,cover
|
||||||
.hypothesis/
|
.hypothesis/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
pyscriptjs/examples
|
|
||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
*.mo
|
*.mo
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# This is the configuration for pre-commit, a local framework for managing pre-commit hooks
|
# This is the configuration for pre-commit, a local framework for managing pre-commit hooks
|
||||||
# Check out the docs at: https://pre-commit.com/
|
# Check out the docs at: https://pre-commit.com/
|
||||||
ci:
|
ci:
|
||||||
skip: [eslint]
|
#skip: [eslint]
|
||||||
autoupdate_schedule: monthly
|
autoupdate_schedule: monthly
|
||||||
|
|
||||||
default_stages: [commit]
|
default_stages: [commit]
|
||||||
@@ -24,13 +24,6 @@ repos:
|
|||||||
exclude: pyscript\.core/dist|\.min\.js$
|
exclude: pyscript\.core/dist|\.min\.js$
|
||||||
- id: trailing-whitespace
|
- 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
|
- repo: https://github.com/psf/black
|
||||||
rev: 23.1.0
|
rev: 23.1.0
|
||||||
hooks:
|
hooks:
|
||||||
@@ -52,14 +45,9 @@ repos:
|
|||||||
exclude: pyscript\.core/test|pyscript\.core/dist|pyscript\.core/types|pyscript.core/src/stdlib/pyscript.js|pyscript\.sw/
|
exclude: pyscript\.core/test|pyscript\.core/dist|pyscript\.core/types|pyscript.core/src/stdlib/pyscript.js|pyscript\.sw/
|
||||||
args: [--tab-width, "4"]
|
args: [--tab-width, "4"]
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
- repo: https://github.com/pycqa/isort
|
||||||
rev: v8.36.0
|
rev: 5.12.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: eslint
|
- id: isort
|
||||||
files: pyscriptjs/src/.*\.[jt]sx?$ # *.js, *.jsx, *.ts and *.tsx
|
name: isort (python)
|
||||||
types: [file]
|
args: [--profile, black]
|
||||||
additional_dependencies:
|
|
||||||
- eslint@8.25.0
|
|
||||||
- typescript@5.0.4
|
|
||||||
- "@typescript-eslint/eslint-plugin@5.58.0"
|
|
||||||
- "@typescript-eslint/parser@5.58.0"
|
|
||||||
|
|||||||
9
Makefile
9
Makefile
@@ -2,7 +2,6 @@ tag := latest
|
|||||||
git_hash ?= $(shell git log -1 --pretty=format:%h)
|
git_hash ?= $(shell git log -1 --pretty=format:%h)
|
||||||
|
|
||||||
base_dir ?= $(shell git rev-parse --show-toplevel)
|
base_dir ?= $(shell git rev-parse --show-toplevel)
|
||||||
src_dir ?= $(base_dir)/pyscriptjs/src
|
|
||||||
examples ?= ../$(base_dir)/examples
|
examples ?= ../$(base_dir)/examples
|
||||||
app_dir ?= $(shell git rev-parse --show-prefix)
|
app_dir ?= $(shell git rev-parse --show-prefix)
|
||||||
|
|
||||||
@@ -101,14 +100,6 @@ test-examples:
|
|||||||
mkdir -p test_results
|
mkdir -p test_results
|
||||||
$(PYTEST_EXE) -vv $(ARGS) pyscript.core/tests/integration/ --log-cli-level=warning --junitxml=test_results/integration.xml -k 'zz_examples'
|
$(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
|
fmt: fmt-py fmt-ts
|
||||||
@echo "Format completed"
|
@echo "Format completed"
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,7 @@
|
|||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://pyscript.net/latest/pyscript.css"
|
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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ now = datetime.now()
|
|||||||
display(now.strftime("%m/%d/%Y, %H:%M:%S"))
|
display(now.strftime("%m/%d/%Y, %H:%M:%S"))
|
||||||
</py-script>
|
</py-script>
|
||||||
</body>
|
</body>
|
||||||
</html></pre>
|
</html></pre
|
||||||
|
>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -7,32 +7,7 @@ dynamic = ["version"]
|
|||||||
|
|
||||||
[tool.codespell]
|
[tool.codespell]
|
||||||
ignore-words-list = "afterall"
|
ignore-words-list = "afterall"
|
||||||
skip = "pyscriptjs/node_modules/*,*.js,*.json"
|
skip = "*.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
|
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
include-package-data = false
|
include-package-data = false
|
||||||
|
|||||||
12
pyscript.core/package-lock.json
generated
12
pyscript.core/package-lock.json
generated
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "@pyscript/core",
|
"name": "@pyscript/core",
|
||||||
"version": "0.2.5",
|
"version": "0.2.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@pyscript/core",
|
"name": "@pyscript/core",
|
||||||
"version": "0.2.5",
|
"version": "0.2.7",
|
||||||
"license": "APACHE-2.0",
|
"license": "APACHE-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ungap/with-resolvers": "^0.1.0",
|
"@ungap/with-resolvers": "^0.1.0",
|
||||||
"basic-devtools": "^0.1.6",
|
"basic-devtools": "^0.1.6",
|
||||||
"polyscript": "^0.4.8",
|
"polyscript": "^0.4.11",
|
||||||
"type-checked-collections": "^0.1.7"
|
"type-checked-collections": "^0.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1782,9 +1782,9 @@
|
|||||||
"integrity": "sha512-yyVAOFKTAElc7KdLt2+UKGExNYwYb/Y/WE9i+1ezCQsJE8gbKSjewfpRqK2nQgZ4d4hhAAGgDCOcIZVilqE5UA=="
|
"integrity": "sha512-yyVAOFKTAElc7KdLt2+UKGExNYwYb/Y/WE9i+1ezCQsJE8gbKSjewfpRqK2nQgZ4d4hhAAGgDCOcIZVilqE5UA=="
|
||||||
},
|
},
|
||||||
"node_modules/polyscript": {
|
"node_modules/polyscript": {
|
||||||
"version": "0.4.8",
|
"version": "0.4.11",
|
||||||
"resolved": "https://registry.npmjs.org/polyscript/-/polyscript-0.4.8.tgz",
|
"resolved": "https://registry.npmjs.org/polyscript/-/polyscript-0.4.11.tgz",
|
||||||
"integrity": "sha512-YlgjdMeEnv/i6WOqkh7gc52iSPY1l/psA+egu7z1GNrjwq6udw4WuQPz3rHRbaFhTUdYsVulLd8SBugjbVH6sQ==",
|
"integrity": "sha512-wNvCUJp003OR/Q9C0eZJ84MHYeJiMtPTt1pqtsRQ0odRV/M1b3qVQ23oD5DAjq1weXQv1EdfpILwFOpw6VnirA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ungap/structured-clone": "^1.2.0",
|
"@ungap/structured-clone": "^1.2.0",
|
||||||
"@ungap/with-resolvers": "^0.1.0",
|
"@ungap/with-resolvers": "^0.1.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@pyscript/core",
|
"name": "@pyscript/core",
|
||||||
"version": "0.2.5",
|
"version": "0.2.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "PyScript",
|
"description": "PyScript",
|
||||||
"module": "./index.js",
|
"module": "./index.js",
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ungap/with-resolvers": "^0.1.0",
|
"@ungap/with-resolvers": "^0.1.0",
|
||||||
"basic-devtools": "^0.1.6",
|
"basic-devtools": "^0.1.6",
|
||||||
"polyscript": "^0.4.8",
|
"polyscript": "^0.4.11",
|
||||||
"type-checked-collections": "^0.1.7"
|
"type-checked-collections": "^0.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,62 +1,17 @@
|
|||||||
import TYPES from "./types.js";
|
import TYPES from "./types.js";
|
||||||
import hooks from "./hooks.js";
|
|
||||||
|
|
||||||
const DONE = "py:all-done";
|
|
||||||
|
|
||||||
const {
|
|
||||||
onAfterRun,
|
|
||||||
onAfterRunAsync,
|
|
||||||
codeAfterRunWorker,
|
|
||||||
codeAfterRunWorkerAsync,
|
|
||||||
} = hooks;
|
|
||||||
|
|
||||||
const waitForIt = [];
|
const waitForIt = [];
|
||||||
const codes = [];
|
|
||||||
|
|
||||||
const codeFor = (element) => {
|
for (const [TYPE] of TYPES) {
|
||||||
const isAsync = element.hasAttribute("async");
|
const selectors = [`script[type="${TYPE}"]`, `${TYPE}-script`];
|
||||||
const { promise, resolve } = Promise.withResolvers();
|
for (const element of document.querySelectorAll(selectors.join(","))) {
|
||||||
const type = `${DONE}:${waitForIt.push(promise)}`;
|
const { promise, resolve } = Promise.withResolvers();
|
||||||
|
waitForIt.push(promise);
|
||||||
// resolve each promise once notified
|
element.addEventListener(`${TYPE}:done`, resolve, { once: true });
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// 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
|
// wait for all the things then cleanup
|
||||||
Promise.all(waitForIt).then(() => {
|
Promise.all(waitForIt).then(() => {
|
||||||
// cleanup unnecessary hooks
|
dispatchEvent(new Event("py:all-done"));
|
||||||
for (const code of codes) {
|
|
||||||
onAfterRun.delete(code);
|
|
||||||
onAfterRunAsync.delete(code);
|
|
||||||
codeAfterRunWorker.delete(code);
|
|
||||||
codeAfterRunWorkerAsync.delete(code);
|
|
||||||
}
|
|
||||||
dispatchEvent(new Event(DONE));
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const syntaxError = (type, url, { message }) => {
|
|||||||
const configs = new Map();
|
const configs = new Map();
|
||||||
|
|
||||||
for (const [TYPE] of TYPES) {
|
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;
|
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} ) */
|
/** @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
|
// 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 });
|
configs.set(TYPE, { config: parsed, plugins, error });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ const exportedConfig = {};
|
|||||||
export { exportedConfig as config, hooks };
|
export { exportedConfig as config, hooks };
|
||||||
|
|
||||||
for (const [TYPE, interpreter] of TYPES) {
|
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);
|
const { config, plugins, error } = configs.get(TYPE);
|
||||||
|
|
||||||
// create a unique identifier when/if needed
|
// 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>`
|
// define the module as both `<script type="py">` and `<py-script>`
|
||||||
// but only if the config didn't throw an error
|
// but only if the config didn't throw an error
|
||||||
if (!error) {
|
if (!error) {
|
||||||
// possible early errors sent by polyscript
|
// ensure plugins are bootstrapped already before custom type definition
|
||||||
const errors = new Map();
|
// 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, {
|
define(TYPE, {
|
||||||
config,
|
config,
|
||||||
interpreter,
|
interpreter,
|
||||||
env: `${TYPE}-script`,
|
env: `${TYPE}-script`,
|
||||||
version: config?.interpreter,
|
version: config?.interpreter,
|
||||||
onerror(error, element) {
|
onerror(error, element) {
|
||||||
errors.set(element, error);
|
errors.set(element, error);
|
||||||
},
|
},
|
||||||
...workerHooks,
|
...workerHooks,
|
||||||
onWorkerReady(_, xworker) {
|
onWorkerReady(_, xworker) {
|
||||||
assign(xworker.sync, sync);
|
assign(xworker.sync, sync);
|
||||||
for (const callback of hooks.onWorkerReady)
|
for (const callback of hooks.onWorkerReady)
|
||||||
callback(_, xworker);
|
callback(_, xworker);
|
||||||
},
|
},
|
||||||
onBeforeRun(wrap, element) {
|
onBeforeRun(wrap, element) {
|
||||||
currentElement = element;
|
currentElement = element;
|
||||||
bootstrapNodeAndPlugins(wrap, element, before, "onBeforeRun");
|
bootstrapNodeAndPlugins(
|
||||||
},
|
wrap,
|
||||||
onBeforeRunAsync(wrap, element) {
|
element,
|
||||||
currentElement = element;
|
before,
|
||||||
bootstrapNodeAndPlugins(
|
"onBeforeRun",
|
||||||
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),
|
|
||||||
);
|
);
|
||||||
} else {
|
},
|
||||||
// resolve PyScriptElement to allow connectedCallback
|
onBeforeRunAsync(wrap, element) {
|
||||||
element._wrap.resolve(wrap);
|
currentElement = element;
|
||||||
}
|
bootstrapNodeAndPlugins(
|
||||||
console.debug("[pyscript/main] PyScript Ready");
|
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
|
// export the used config without allowing leaks through it
|
||||||
exportedConfig[TYPE] = structuredClone(config);
|
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.
|
* A `Worker` facade able to bootstrap on the worker thread only a PyScript module.
|
||||||
* @param {string} file the python file to run ina worker.
|
* @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
|
// and as `pyodide` is the only default interpreter that can deal with
|
||||||
// all the features we need to deliver pyscript out there.
|
// all the features we need to deliver pyscript out there.
|
||||||
const xworker = XWorker.call(new Hook(null, workerHooks), file, {
|
const xworker = XWorker.call(new Hook(null, workerHooks), file, {
|
||||||
...options,
|
|
||||||
type: "pyodide",
|
type: "pyodide",
|
||||||
|
...options,
|
||||||
});
|
});
|
||||||
assign(xworker.sync, sync);
|
assign(xworker.sync, sync);
|
||||||
return xworker;
|
return xworker;
|
||||||
|
|||||||
@@ -29,8 +29,15 @@
|
|||||||
# pyscript.magic_js. This is the blessed way to access them from pyscript,
|
# 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.
|
# 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.display import HTML, display
|
||||||
|
from pyscript.magic_js import (
|
||||||
|
RUNNING_IN_WORKER,
|
||||||
|
PyWorker,
|
||||||
|
current_target,
|
||||||
|
document,
|
||||||
|
sync,
|
||||||
|
window,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from pyscript.event_handling import when
|
from pyscript.event_handling import when
|
||||||
@@ -38,6 +45,5 @@ except:
|
|||||||
from pyscript.util import NotSupported
|
from pyscript.util import NotSupported
|
||||||
|
|
||||||
when = NotSupported(
|
when = NotSupported(
|
||||||
"pyscript.when",
|
"pyscript.when", "pyscript.when currently not available with this interpreter"
|
||||||
"pyscript.when currently not available with this interpreter"
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import html
|
|||||||
import io
|
import io
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from pyscript.magic_js import document, window, current_target
|
from pyscript.magic_js import current_target, document, window
|
||||||
|
|
||||||
_MIME_METHODS = {
|
_MIME_METHODS = {
|
||||||
"__repr__": "text/plain",
|
"__repr__": "text/plain",
|
||||||
@@ -148,14 +148,30 @@ def _write(element, value, append=False):
|
|||||||
def display(*values, target=None, append=True):
|
def display(*values, target=None, append=True):
|
||||||
if target is None:
|
if target is None:
|
||||||
target = current_target()
|
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)
|
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
|
# 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,
|
# points to the visual element holding the displayed values. In that case,
|
||||||
# use that.
|
# use that.
|
||||||
if element.tagName == 'SCRIPT' and hasattr(element, 'target'):
|
if element.tagName == "SCRIPT" and hasattr(element, "target"):
|
||||||
element = element.target
|
element = element.target
|
||||||
|
|
||||||
for v in values:
|
for v in values:
|
||||||
|
if not append:
|
||||||
|
element.replaceChildren()
|
||||||
_write(element, v, append=append)
|
_write(element, v, append=append)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pyscript.util import NotSupported
|
|
||||||
import js as globalThis
|
import js as globalThis
|
||||||
|
from pyscript.util import NotSupported
|
||||||
|
|
||||||
RUNNING_IN_WORKER = not hasattr(globalThis, "document")
|
RUNNING_IN_WORKER = not hasattr(globalThis, "document")
|
||||||
|
|
||||||
@@ -7,8 +7,9 @@ if RUNNING_IN_WORKER:
|
|||||||
import polyscript
|
import polyscript
|
||||||
|
|
||||||
PyWorker = NotSupported(
|
PyWorker = NotSupported(
|
||||||
'pyscript.PyWorker',
|
"pyscript.PyWorker",
|
||||||
'pyscript.PyWorker works only when running in the main thread')
|
"pyscript.PyWorker works only when running in the main thread",
|
||||||
|
)
|
||||||
window = polyscript.xworker.window
|
window = polyscript.xworker.window
|
||||||
document = window.document
|
document = window.document
|
||||||
sync = polyscript.xworker.sync
|
sync = polyscript.xworker.sync
|
||||||
@@ -21,11 +22,12 @@ if RUNNING_IN_WORKER:
|
|||||||
else:
|
else:
|
||||||
import _pyscript
|
import _pyscript
|
||||||
from _pyscript import PyWorker
|
from _pyscript import PyWorker
|
||||||
|
|
||||||
window = globalThis
|
window = globalThis
|
||||||
document = globalThis.document
|
document = globalThis.document
|
||||||
sync = NotSupported(
|
sync = NotSupported(
|
||||||
'pyscript.sync',
|
"pyscript.sync", "pyscript.sync works only when running in a worker"
|
||||||
'pyscript.sync works only when running in a worker')
|
)
|
||||||
|
|
||||||
# in MAIN the current element target exist, just use it
|
# in MAIN the current element target exist, just use it
|
||||||
def current_target():
|
def current_target():
|
||||||
|
|||||||
30
pyscript.core/test/display.html
Normal file
30
pyscript.core/test/display.html
Normal 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>
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import random
|
import random
|
||||||
|
from datetime import datetime as dt
|
||||||
|
|
||||||
from pyscript import display
|
from pyscript import display
|
||||||
from pyweb import pydom
|
from pyweb import pydom
|
||||||
from pyweb.base import when
|
from pyweb.base import when
|
||||||
from datetime import datetime as dt
|
|
||||||
|
|
||||||
|
|
||||||
@when("click", "#just-a-button")
|
@when("click", "#just-a-button")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import pytest
|
|
||||||
from pyscript import document, when
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pyscript import document, when
|
||||||
from pyweb import pydom
|
from pyweb import pydom
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
###### magic monkey patching ######
|
###### magic monkey patching ######
|
||||||
import sys
|
|
||||||
import builtins
|
import builtins
|
||||||
from pyscript import sync
|
import sys
|
||||||
|
|
||||||
from pyodide.code import eval_code
|
from pyodide.code import eval_code
|
||||||
|
from pyscript import sync
|
||||||
|
|
||||||
sys.stdout = sync
|
sys.stdout = sync
|
||||||
builtins.input = sync.readline
|
builtins.input = sync.readline
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from pyscript import display, sync
|
|
||||||
|
|
||||||
import a
|
import a
|
||||||
|
from pyscript import display, sync
|
||||||
|
|
||||||
display("Hello World", target="test", append=True)
|
display("Hello World", target="test", append=True)
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ def pytest_configure(config):
|
|||||||
--no-fake-server, but because of how pytest works, they are available only
|
--no-fake-server, but because of how pytest works, they are available only
|
||||||
if this is the "root conftest" for the test session.
|
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 # does NOT work
|
||||||
$ py.test tests/integration/ # works
|
$ py.test tests/integration/ # works
|
||||||
@@ -70,10 +70,9 @@ def pytest_configure(config):
|
|||||||
"""
|
"""
|
||||||
if not hasattr(config.option, "dev"):
|
if not hasattr(config.option, "dev"):
|
||||||
msg = """
|
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:
|
is not supported. Please use one of the following commands:
|
||||||
- pytest tests/integration
|
- pytest tests/integration
|
||||||
- pytest tests/py-unit
|
|
||||||
- pytest tests/*
|
- pytest tests/*
|
||||||
- cd tests/integration; pytest
|
- cd tests/integration; pytest
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -2,10 +2,20 @@ import re
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from .support import PyScriptTest, skip_worker, only_main
|
from .support import PyScriptTest, only_main, skip_worker
|
||||||
|
|
||||||
|
|
||||||
class TestBasic(PyScriptTest):
|
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):
|
def test_script_py_hello(self):
|
||||||
self.pyscript_run(
|
self.pyscript_run(
|
||||||
"""
|
"""
|
||||||
@@ -96,10 +106,6 @@ class TestBasic(PyScriptTest):
|
|||||||
assert "hello pyscript" in self.console.log.lines
|
assert "hello pyscript" in self.console.log.lines
|
||||||
self.check_py_errors("Exception: this is an error")
|
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
|
# check that we show the traceback in the page. Note that here we
|
||||||
# display the "raw" python traceback, without the "[pyexec] Python
|
# display the "raw" python traceback, without the "[pyexec] Python
|
||||||
# exception:" line (which is useful in the console, but not for the
|
# 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")
|
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
|
## error in DOM
|
||||||
tb_lines = self.page.locator(".py-error").inner_text().splitlines()
|
tb_lines = self.page.locator(".py-error").inner_text().splitlines()
|
||||||
assert tb_lines[0] == "Traceback (most recent call last):"
|
assert tb_lines[0] == "Traceback (most recent call last):"
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import html
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import html
|
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from .support import (
|
from .support import (
|
||||||
|
PageErrors,
|
||||||
PyScriptTest,
|
PyScriptTest,
|
||||||
filter_inner_text,
|
filter_inner_text,
|
||||||
filter_page_content,
|
filter_page_content,
|
||||||
wait_for_render,
|
|
||||||
skip_worker,
|
|
||||||
only_main,
|
only_main,
|
||||||
|
skip_worker,
|
||||||
|
wait_for_render,
|
||||||
)
|
)
|
||||||
|
|
||||||
DISPLAY_OUTPUT_ID_PATTERN = r'script-py[id^="py-"]'
|
DISPLAY_OUTPUT_ID_PATTERN = r'script-py[id^="py-"]'
|
||||||
@@ -72,6 +73,67 @@ class TestDisplay(PyScriptTest):
|
|||||||
mydiv = self.page.locator("#mydiv")
|
mydiv = self.page.locator("#mydiv")
|
||||||
assert mydiv.inner_text() == "hello world"
|
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")
|
@skip_worker("NEXT: display(target=...) does not work")
|
||||||
def test_tag_target_attribute(self):
|
def test_tag_target_attribute(self):
|
||||||
self.pyscript_run(
|
self.pyscript_run(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from .support import PyScriptTest, filter_inner_text, only_main
|
from .support import PyScriptTest, filter_inner_text, only_main
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
reason="SKIPPING EXAMPLES: these should be moved elsewhere and updated"
|
||||||
)
|
)
|
||||||
@with_execution_thread(None)
|
@with_execution_thread(None)
|
||||||
@pytest.mark.usefixtures("chdir")
|
|
||||||
class TestExamples(PyScriptTest):
|
class TestExamples(PyScriptTest):
|
||||||
"""
|
"""
|
||||||
Each example requires the same three tests:
|
Each example requires the same three tests:
|
||||||
@@ -26,11 +25,6 @@ class TestExamples(PyScriptTest):
|
|||||||
- Testing that the page contains appropriate content after rendering
|
- 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):
|
def test_hello_world(self):
|
||||||
self.goto("examples/hello_world.html")
|
self.goto("examples/hello_world.html")
|
||||||
self.wait_for_pyscript()
|
self.wait_for_pyscript()
|
||||||
|
|||||||
@@ -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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
build
|
|
||||||
node_modules
|
|
||||||
|
|
||||||
|
|
||||||
# Ignore all HTML files
|
|
||||||
*.html
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
arrowParens: 'avoid',
|
|
||||||
bracketSameLine: true,
|
|
||||||
singleQuote: true,
|
|
||||||
printWidth: 120,
|
|
||||||
semi: true,
|
|
||||||
tabWidth: 4,
|
|
||||||
trailingComma: 'all',
|
|
||||||
};
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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');
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
module.exports = '';
|
|
||||||
@@ -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 = '';
|
|
||||||
@@ -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, '/');
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
10525
pyscriptjs/package-lock.json
generated
10525
pyscriptjs/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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><py-script></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">
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
||||||
<title>PyScript Hello World</title>
|
|
||||||
<link rel="stylesheet" href="https://pyscript.net/latest/pyscript.css" />
|
|
||||||
<script defer src="https://pyscript.net/latest/pyscript.js"></script>
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
Hello world! <br>
|
|
||||||
This is the current date and time, as computed by Python:
|
|
||||||
<py-script>
|
|
||||||
from datetime import datetime
|
|
||||||
now = datetime.now()
|
|
||||||
now.strftime("%m/%d/%Y, %H:%M:%S")
|
|
||||||
</py-script>
|
|
||||||
</body>
|
|
||||||
</html></pre
|
|
||||||
>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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 <py-config>)
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -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' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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';
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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 }];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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>
|
|
||||||
<py-config>
|
|
||||||
[splashscreen]
|
|
||||||
autoclose = false
|
|
||||||
</py-config>
|
|
||||||
</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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { $$ } from 'basic-devtools';
|
|
||||||
|
|
||||||
import { _createAlertBanner } from './exceptions';
|
|
||||||
|
|
||||||
export function escape(str: string): string {
|
|
||||||
return str.replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
@@ -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)
|
|
||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
@@ -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")
|
|
||||||
@@ -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 == "<p>hello</p>"
|
|
||||||
assert mime == "text/plain"
|
|
||||||
|
|
||||||
|
|
||||||
def test_format_mime_repr_escaping():
|
|
||||||
out, mime = format_mime(sys)
|
|
||||||
assert out == "<module 'sys' (built-in)>"
|
|
||||||
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
|
|
||||||
@@ -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
|
|
||||||
@@ -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!",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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('<p>Test message</p>');
|
|
||||||
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}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 <py-config>)
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 <br>');
|
|
||||||
});
|
|
||||||
|
|
||||||
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!'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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"]',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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;
|
|
||||||
@@ -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>');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user