mirror of
https://github.com/pyscript/pyscript.git
synced 2025-12-19 18:27:29 -05:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1849d28e0 | ||
|
|
ad2af2392b | ||
|
|
957ab69c21 | ||
|
|
f5d49ee52c | ||
|
|
06138bbb48 | ||
|
|
ae66d13d57 | ||
|
|
5aaeebf32c | ||
|
|
a6b0964185 | ||
|
|
dd86169f2a | ||
|
|
84c7d69db9 | ||
|
|
ca9b565adc | ||
|
|
b2d1018db1 | ||
|
|
c4e25d879e | ||
|
|
c82dbb755e | ||
|
|
1ed77321a5 | ||
|
|
e36a57eb06 | ||
|
|
ee3cd76022 | ||
|
|
eb31e51a45 | ||
|
|
c8c2dd0806 | ||
|
|
e525d54be0 | ||
|
|
7b9f7c13f5 | ||
|
|
7582cbef9c | ||
|
|
b395cde49c | ||
|
|
9f46234f71 | ||
|
|
f4c4edeb29 | ||
|
|
7166c32384 | ||
|
|
ed126889ae | ||
|
|
0d0ea96435 | ||
|
|
fafdf74007 | ||
|
|
999897df12 | ||
|
|
d47fb58ede | ||
|
|
f316341e73 | ||
|
|
8c46fcabf7 | ||
|
|
e4ff4d8fab | ||
|
|
f20a0003ed | ||
|
|
6c938dfe3b | ||
|
|
d884586a82 | ||
|
|
f8f7ba89c1 | ||
|
|
67d47511d5 | ||
|
|
6f49f18937 | ||
|
|
7b8ef7ebe2 | ||
|
|
461ae38763 | ||
|
|
4b90ebdef5 | ||
|
|
15c19aa708 | ||
|
|
d0406be84c | ||
|
|
aab015b9b8 | ||
|
|
a1e5a05b49 | ||
|
|
f1a787e031 | ||
|
|
b41cfb7b60 | ||
|
|
1c675307e1 | ||
|
|
ac56f82c6d | ||
|
|
2ac5ca79d7 | ||
|
|
cb9ee6f7e2 | ||
|
|
9abaef33bd | ||
|
|
320a537db2 | ||
|
|
9b775ce015 | ||
|
|
66f72eda1e | ||
|
|
39ca29749c | ||
|
|
85da548447 | ||
|
|
9985787e4b | ||
|
|
18ec6ce775 | ||
|
|
ed6d0136b8 | ||
|
|
e7216d26e7 | ||
|
|
d1a0d8ea98 | ||
|
|
04222b0d03 | ||
|
|
8ec3381789 | ||
|
|
9bd4737708 | ||
|
|
c49cb9231b | ||
|
|
d1d1c5740f | ||
|
|
1a05ea5fd2 | ||
|
|
5b4e8527da |
19
.github/workflows/prepare-release.yml
vendored
19
.github/workflows/prepare-release.yml
vendored
@@ -19,7 +19,22 @@ jobs:
|
|||||||
- name: Install node
|
- name: Install node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.x
|
node-version: 20.x
|
||||||
|
|
||||||
|
- name: Python venv
|
||||||
|
run: python -m venv env
|
||||||
|
|
||||||
|
- name: Activate Python
|
||||||
|
run: source env/bin/activate
|
||||||
|
|
||||||
|
- name: Update pip
|
||||||
|
run: pip install --upgrade pip
|
||||||
|
|
||||||
|
- name: Install PyMinifier
|
||||||
|
run: pip install --ignore-requires-python python-minifier
|
||||||
|
|
||||||
|
- name: Install Setuptools
|
||||||
|
run: pip install setuptools
|
||||||
|
|
||||||
- name: Cache node modules
|
- name: Cache node modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -35,7 +50,7 @@ jobs:
|
|||||||
${{ runner.os }}-
|
${{ runner.os }}-
|
||||||
|
|
||||||
- name: NPM Install
|
- name: NPM Install
|
||||||
run: npm install && npx playwright install
|
run: npm install && npx playwright install chromium
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|||||||
23
.github/workflows/publish-release.yml
vendored
23
.github/workflows/publish-release.yml
vendored
@@ -21,7 +21,22 @@ jobs:
|
|||||||
- name: Install node
|
- name: Install node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.x
|
node-version: 20.x
|
||||||
|
|
||||||
|
- name: Python venv
|
||||||
|
run: python -m venv env
|
||||||
|
|
||||||
|
- name: Activate Python
|
||||||
|
run: source env/bin/activate
|
||||||
|
|
||||||
|
- name: Update pip
|
||||||
|
run: pip install --upgrade pip
|
||||||
|
|
||||||
|
- name: Install PyMinifier
|
||||||
|
run: pip install --ignore-requires-python python-minifier
|
||||||
|
|
||||||
|
- name: Install Setuptools
|
||||||
|
run: pip install setuptools
|
||||||
|
|
||||||
- name: Cache node modules
|
- name: Cache node modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -37,7 +52,7 @@ jobs:
|
|||||||
${{ runner.os }}-
|
${{ runner.os }}-
|
||||||
|
|
||||||
- name: npm install
|
- name: npm install
|
||||||
run: npm install && npx playwright install
|
run: npm install && npx playwright install chromium
|
||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
@@ -46,6 +61,10 @@ jobs:
|
|||||||
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
|
||||||
|
|
||||||
|
- name: Generate release.tar from snapshot and put it in dist/
|
||||||
|
working-directory: .
|
||||||
|
run: tar -cvf ../release.tar * && mv ../release.tar .
|
||||||
|
|
||||||
- name: Configure AWS credentials
|
- name: Configure AWS credentials
|
||||||
uses: aws-actions/configure-aws-credentials@v4
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
19
.github/workflows/publish-snapshot.yml
vendored
19
.github/workflows/publish-snapshot.yml
vendored
@@ -25,7 +25,22 @@ jobs:
|
|||||||
- name: Install node
|
- name: Install node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.x
|
node-version: 20.x
|
||||||
|
|
||||||
|
- name: Python venv
|
||||||
|
run: python -m venv env
|
||||||
|
|
||||||
|
- name: Activate Python
|
||||||
|
run: source env/bin/activate
|
||||||
|
|
||||||
|
- name: Update pip
|
||||||
|
run: pip install --upgrade pip
|
||||||
|
|
||||||
|
- name: Install PyMinifier
|
||||||
|
run: pip install --ignore-requires-python python-minifier
|
||||||
|
|
||||||
|
- name: Install Setuptools
|
||||||
|
run: pip install setuptools
|
||||||
|
|
||||||
- name: Cache node modules
|
- name: Cache node modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -41,7 +56,7 @@ jobs:
|
|||||||
${{ runner.os }}-
|
${{ runner.os }}-
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: npm install && npx playwright install
|
run: npm install && npx playwright install chromium
|
||||||
|
|
||||||
- name: Build Pyscript.core
|
- name: Build Pyscript.core
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|||||||
19
.github/workflows/publish-unstable.yml
vendored
19
.github/workflows/publish-unstable.yml
vendored
@@ -26,7 +26,22 @@ jobs:
|
|||||||
- name: Install node
|
- name: Install node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.x
|
node-version: 20.x
|
||||||
|
|
||||||
|
- name: Python venv
|
||||||
|
run: python -m venv env
|
||||||
|
|
||||||
|
- name: Activate Python
|
||||||
|
run: source env/bin/activate
|
||||||
|
|
||||||
|
- name: Update pip
|
||||||
|
run: pip install --upgrade pip
|
||||||
|
|
||||||
|
- name: Install PyMinifier
|
||||||
|
run: pip install --ignore-requires-python python-minifier
|
||||||
|
|
||||||
|
- name: Install Setuptools
|
||||||
|
run: pip install setuptools
|
||||||
|
|
||||||
- name: Cache node modules
|
- name: Cache node modules
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -42,7 +57,7 @@ jobs:
|
|||||||
${{ runner.os }}-
|
${{ runner.os }}-
|
||||||
|
|
||||||
- name: NPM Install
|
- name: NPM Install
|
||||||
run: npm install && npx playwright install
|
run: npm install && npx playwright install chromium
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|||||||
14
.github/workflows/test.yml
vendored
14
.github/workflows/test.yml
vendored
@@ -69,12 +69,7 @@ jobs:
|
|||||||
make setup
|
make setup
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: make build
|
run: make build # Integration tests run in the build step.
|
||||||
|
|
||||||
- name: Integration Tests
|
|
||||||
#run: make test-integration-parallel
|
|
||||||
run: |
|
|
||||||
make test-integration
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
@@ -83,10 +78,3 @@ jobs:
|
|||||||
pyscript.core/dist/
|
pyscript.core/dist/
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
if: success() || failure()
|
|
||||||
with:
|
|
||||||
name: test_results
|
|
||||||
path: test_results/
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|||||||
16
.github/workflows/test_report.yml
vendored
16
.github/workflows/test_report.yml
vendored
@@ -1,16 +0,0 @@
|
|||||||
name: Test Report
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: ['\[CI\] Test']
|
|
||||||
types:
|
|
||||||
- completed
|
|
||||||
jobs:
|
|
||||||
report:
|
|
||||||
runs-on: ubuntu-latest-8core
|
|
||||||
steps:
|
|
||||||
- uses: dorny/test-reporter@v1.9.0
|
|
||||||
with:
|
|
||||||
artifact: test_results
|
|
||||||
name: Test reports
|
|
||||||
path: "*.xml"
|
|
||||||
reporter: java-junit
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -142,6 +142,7 @@ coverage/
|
|||||||
test_results
|
test_results
|
||||||
|
|
||||||
# @pyscript/core npm artifacts
|
# @pyscript/core npm artifacts
|
||||||
|
pyscript.core/test-results/*
|
||||||
pyscript.core/core.*
|
pyscript.core/core.*
|
||||||
pyscript.core/dist
|
pyscript.core/dist
|
||||||
pyscript.core/dist.zip
|
pyscript.core/dist.zip
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ ci:
|
|||||||
default_stages: [commit]
|
default_stages: [commit]
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.5.0
|
rev: v4.6.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-builtin-literals
|
- id: check-builtin-literals
|
||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
@@ -25,13 +25,13 @@ repos:
|
|||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
|
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 24.3.0
|
rev: 24.8.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
exclude: pyscript\.core/src/stdlib/pyscript/__init__\.py
|
exclude: pyscript\.core/src/stdlib/pyscript/__init__\.py
|
||||||
|
|
||||||
- repo: https://github.com/codespell-project/codespell
|
- repo: https://github.com/codespell-project/codespell
|
||||||
rev: v2.2.6
|
rev: v2.3.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: codespell # See 'pyproject.toml' for args
|
- id: codespell # See 'pyproject.toml' for args
|
||||||
exclude: \.js\.map$
|
exclude: \.js\.map$
|
||||||
|
|||||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,5 +1,15 @@
|
|||||||
# Release Notes
|
# Release Notes
|
||||||
|
|
||||||
|
## 2024.05.21
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
### Bug fixes
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
- `py-editor` run buttons now display a spinner when disabled, which occurs when the editor is running code.
|
||||||
|
|
||||||
## 2023.05.01
|
## 2023.05.01
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -59,9 +59,9 @@ If you would like to contribute to PyScript, but you aren't sure where to begin,
|
|||||||
|
|
||||||
## Setting up your local environment and developing
|
## Setting up your local environment and developing
|
||||||
|
|
||||||
If you would like to contribute to PyScript, you will need to set up a local development environment. The [following instructions](https://pyscript.github.io/docs/latest/development/setting-up-environment.html) will help you get started.
|
If you would like to contribute to PyScript, you will need to set up a local development environment. The [following instructions](https://docs.pyscript.net/latest/contributing/#set-up-your-development-environment) will help you get started.
|
||||||
|
|
||||||
You can also read about PyScript's [development process](https://pyscript.github.io/docs/latest/development/developing.html) to learn how to contribute code to PyScript, how to run tests and what's the PR etiquette of the community!
|
You can also read about PyScript's [development process](https://docs.pyscript.net/latest/developers/) to learn how to contribute code to PyScript, how to run tests and what's the PR etiquette of the community!
|
||||||
|
|
||||||
## License terms for contributions
|
## License terms for contributions
|
||||||
|
|
||||||
|
|||||||
17
Makefile
17
Makefile
@@ -12,7 +12,7 @@ all:
|
|||||||
@echo "make clean - clean up auto-generated assets."
|
@echo "make clean - clean up auto-generated assets."
|
||||||
@echo "make build - build PyScript."
|
@echo "make build - build PyScript."
|
||||||
@echo "make precommit-check - run the precommit checks (run eslint)."
|
@echo "make precommit-check - run the precommit checks (run eslint)."
|
||||||
@echo "make test-integration - run all integration tests sequentially."
|
@echo "make test - run all automated tests in playwright."
|
||||||
@echo "make fmt - format the code."
|
@echo "make fmt - format the code."
|
||||||
@echo "make fmt-check - check the code formatting.\n"
|
@echo "make fmt-check - check the code formatting.\n"
|
||||||
|
|
||||||
@@ -45,7 +45,6 @@ ifeq ($(VIRTUAL_ENV),)
|
|||||||
false
|
false
|
||||||
else
|
else
|
||||||
python -m pip install -r requirements.txt
|
python -m pip install -r requirements.txt
|
||||||
playwright install
|
|
||||||
endif
|
endif
|
||||||
|
|
||||||
# Clean up generated assets.
|
# Clean up generated assets.
|
||||||
@@ -56,21 +55,15 @@ clean:
|
|||||||
|
|
||||||
# Build PyScript.
|
# Build PyScript.
|
||||||
build:
|
build:
|
||||||
cd pyscript.core && npx playwright install && npm run build
|
cd pyscript.core && npx playwright install chromium && npm run build
|
||||||
|
|
||||||
# Run the precommit checks (run eslint).
|
# Run the precommit checks (run eslint).
|
||||||
precommit-check:
|
precommit-check:
|
||||||
pre-commit run --all-files
|
pre-commit run --all-files
|
||||||
|
|
||||||
# Run all integration tests sequentially.
|
# Run all automated tests in playwright.
|
||||||
test-integration:
|
test:
|
||||||
mkdir -p test_results
|
cd pyscript.core && npm run test:integration
|
||||||
pytest -vv $(ARGS) pyscript.core/tests/integration/ --log-cli-level=warning --junitxml=test_results/integration.xml
|
|
||||||
|
|
||||||
# Run all integration tests in parallel.
|
|
||||||
test-integration-parallel:
|
|
||||||
mkdir -p test_results
|
|
||||||
pytest --numprocesses auto -vv $(ARGS) pyscript.core/tests/integration/ --log-cli-level=warning --junitxml=test_results/integration.xml
|
|
||||||
|
|
||||||
# Format the code.
|
# Format the code.
|
||||||
fmt: fmt-py
|
fmt: fmt-py
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -38,11 +38,11 @@ To try PyScript, import the appropriate pyscript files into the `<head>` tag of
|
|||||||
<head>
|
<head>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://pyscript.net/releases/2023.11.2/core.css"
|
href="https://pyscript.net/releases/2024.8.2/core.css"
|
||||||
/>
|
/>
|
||||||
<script
|
<script
|
||||||
type="module"
|
type="module"
|
||||||
src="https://pyscript.net/releases/2023.11.2/core.js"
|
src="https://pyscript.net/releases/2024.8.2/core.js"
|
||||||
></script>
|
></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -67,10 +67,31 @@ Check out the [official docs](https://docs.pyscript.net/) for more detailed docu
|
|||||||
|
|
||||||
## How to Contribute
|
## How to Contribute
|
||||||
|
|
||||||
Read the [contributing guide](CONTRIBUTING.md) to learn about our development process, reporting bugs and improvements, creating issues and asking questions.
|
Read the [contributing guide](https://docs.pyscript.net/latest/contributing/) to learn about our development process, reporting bugs and improvements, creating issues and asking questions.
|
||||||
|
|
||||||
Check out the [developing process](https://pyscript.github.io/docs/latest/contributing) documentation for more information on how to setup your development environment.
|
Check out the [developing process](https://docs.pyscript.net/latest/developers/) documentation for more information on how to setup your development environment.
|
||||||
|
|
||||||
|
For technical details of the code, please see the [README](pyscript.core/README) in `pyscript.core`.
|
||||||
|
|
||||||
## Governance
|
## Governance
|
||||||
|
|
||||||
The [PyScript organization governance](https://github.com/pyscript/governance) is documented in a separate repository.
|
The [PyScript organization governance](https://github.com/pyscript/governance) is documented in a separate repository.
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
To cut a new release of PyScript simply
|
||||||
|
[add a new release](https://github.com/pyscript/pyscript/releases) while
|
||||||
|
remembering to write a comprehensive changelog. A [GitHub action](https://github.com/pyscript/pyscript/blob/main/.github/workflows/publish-release.yml)
|
||||||
|
will kick in and ensure the release is described and deployed to a URL with the
|
||||||
|
pattern: https://pyscript.net/releases/YYYY.M.v/ (year/month/version - as per
|
||||||
|
our [CalVer](https://calver.org/) versioning scheme).
|
||||||
|
|
||||||
|
Then, the following three separate repositories need updating:
|
||||||
|
|
||||||
|
- [Documentation](https://github.com/pyscript/docs) - Change the `version.json`
|
||||||
|
file in the root of the directory and then `node version-update.js`.
|
||||||
|
- [Homepage](https://github.com/pyscript/pyscript.net) - Ensure the version
|
||||||
|
referenced in `index.html` is the latest version.
|
||||||
|
- [PSDC](https://pyscript.com) - Use discord or Anaconda Slack (if you work at
|
||||||
|
Anaconda) to let the PSDC team know there's a new version, so they can update
|
||||||
|
their project templates.
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
env: {
|
|
||||||
browser: true,
|
|
||||||
es2021: true,
|
|
||||||
},
|
|
||||||
extends: "eslint:recommended",
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
env: {
|
|
||||||
node: true,
|
|
||||||
},
|
|
||||||
files: [".eslintrc.{js,cjs}"],
|
|
||||||
parserOptions: {
|
|
||||||
sourceType: "script",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: "latest",
|
|
||||||
sourceType: "module",
|
|
||||||
},
|
|
||||||
ignorePatterns: ["3rd-party"],
|
|
||||||
rules: {
|
|
||||||
"no-implicit-globals": ["error"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
.eslintrc.cjs
|
|
||||||
.pytest_cache/
|
|
||||||
node_modules/
|
|
||||||
rollup/
|
|
||||||
test/
|
|
||||||
tests/
|
|
||||||
src/stdlib/_pyscript
|
|
||||||
src/stdlib/pyscript.py
|
|
||||||
package-lock.json
|
|
||||||
tsconfig.json
|
|
||||||
@@ -12,7 +12,7 @@ Clone this repository then run `npm install` within its folder.
|
|||||||
|
|
||||||
Use `npm run build` to create all artifacts and _dist_ files.
|
Use `npm run build` to create all artifacts and _dist_ files.
|
||||||
|
|
||||||
Use `npm run server` to test locally, via the `http://localhost:8080/test/` url, smoke tests or to test manually anything you'd like to check.
|
Use `npm run server` to test locally, via the `http://localhost:8080/tests/` url, smoke tests or to test manually anything you'd like to check.
|
||||||
|
|
||||||
### Artifacts
|
### Artifacts
|
||||||
|
|
||||||
@@ -37,13 +37,25 @@ make setup
|
|||||||
|
|
||||||
This will create a tests environment [in the root of the project, named `./env`]and install all the dependencies needed to run the tests.
|
This will create a tests environment [in the root of the project, named `./env`]and install all the dependencies needed to run the tests.
|
||||||
|
|
||||||
After the command has completed and the tests environment has been created, you can run the **integration tests** with
|
After the command has completed and the tests environment has been created, you can run the **automated tests** with
|
||||||
the following command:
|
the following command:
|
||||||
|
|
||||||
```
|
```
|
||||||
make test-integration
|
make test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(This essentially runs the `npm run test:integration` command in the right place. This is defined in PyScript's `package.json` file.)
|
||||||
|
|
||||||
|
Tests are found in the `tests` directory. These are organised into three locations:
|
||||||
|
|
||||||
|
1. `python` - the Python based test suite to exercise Python code **within** PyScript.
|
||||||
|
2. `javascript` - JavaScript tests to exercise PyScript itself, in the browser.
|
||||||
|
3. `manual` - containing tests to run manually in a browser, due to the complex nature of the tests.
|
||||||
|
|
||||||
|
We use [Playwright](https://playwright.dev/) to automate the running of the Python and JavaScript test suites. We use [uPyTest](https://github.com/ntoll/upytest) as a test framework for the Python test suite. uPyTest is a "PyTest inspired" framework for running tests in the browser on both MicroPython and Pyodide.
|
||||||
|
|
||||||
|
The automated (Playwright) tests are specified in the `tests/integration.spec.js` file.
|
||||||
|
|
||||||
## `pyscript` python package
|
## `pyscript` python package
|
||||||
|
|
||||||
The `pyscript` package available in _Python_ lives in the folder `src/stdlib/pyscript/`.
|
The `pyscript` package available in _Python_ lives in the folder `src/stdlib/pyscript/`.
|
||||||
|
|||||||
22
pyscript.core/eslint.config.mjs
Normal file
22
pyscript.core/eslint.config.mjs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import globals from "globals";
|
||||||
|
import js from "@eslint/js";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
ignores: ["**/3rd-party/"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.es2021,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-implicit-globals": ["error"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
1767
pyscript.core/package-lock.json
generated
1767
pyscript.core/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@pyscript/core",
|
"name": "@pyscript/core",
|
||||||
"version": "0.4.22",
|
"version": "0.5.15",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "PyScript",
|
"description": "PyScript",
|
||||||
"module": "./index.js",
|
"module": "./index.js",
|
||||||
@@ -8,6 +8,15 @@
|
|||||||
"jsdelivr": "./jsdelivr.js",
|
"jsdelivr": "./jsdelivr.js",
|
||||||
"browser": "./index.js",
|
"browser": "./index.js",
|
||||||
"main": "./index.js",
|
"main": "./index.js",
|
||||||
|
"files": [
|
||||||
|
"./dist/",
|
||||||
|
"./src/",
|
||||||
|
"./types/",
|
||||||
|
"./index.js",
|
||||||
|
"./jsdelivr.js",
|
||||||
|
"LICENSE",
|
||||||
|
"README.md"
|
||||||
|
],
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./types/core.d.ts",
|
"types": "./types/core.d.ts",
|
||||||
@@ -16,17 +25,23 @@
|
|||||||
"./css": {
|
"./css": {
|
||||||
"import": "./dist/core.css"
|
"import": "./dist/core.css"
|
||||||
},
|
},
|
||||||
|
"./storage": {
|
||||||
|
"import": "./dist/storage.js"
|
||||||
|
},
|
||||||
"./package.json": "./package.json"
|
"./package.json": "./package.json"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"server": "npx static-handler --coi .",
|
"server": "echo \"➡️ TESTS @ $(tput bold)http://localhost:8080/tests/$(tput sgr0)\"; npx static-handler --coi .",
|
||||||
"build": "export ESLINT_USE_FLAT_CONFIG=false; npm run build:3rd-party && npm run build:stdlib && npm run build:plugins && npm run build:core && eslint src/ && npm run ts && npm run test:mpy",
|
"build": "export ESLINT_USE_FLAT_CONFIG=true;npm run build:3rd-party && npm run build:stdlib && npm run build:plugins && npm run build:core && npm run build:tests-index && if [ -z \"$NO_MIN\" ]; then eslint src/ && npm run ts && npm run test:integration; fi",
|
||||||
"build:core": "rm -rf dist && rollup --config rollup/core.config.js && cp src/3rd-party/*.css dist/",
|
"build:core": "rm -rf dist && rollup --config rollup/core.config.js && cp src/3rd-party/*.css dist/",
|
||||||
|
"build:flatted": "node rollup/flatted.cjs",
|
||||||
"build:plugins": "node rollup/plugins.cjs",
|
"build:plugins": "node rollup/plugins.cjs",
|
||||||
"build:stdlib": "node rollup/stdlib.cjs",
|
"build:stdlib": "node rollup/stdlib.cjs",
|
||||||
"build:3rd-party": "node rollup/3rd-party.cjs",
|
"build:3rd-party": "node rollup/3rd-party.cjs",
|
||||||
|
"build:tests-index": "node rollup/build_test_index.cjs",
|
||||||
"clean:3rd-party": "rm src/3rd-party/*.js && rm src/3rd-party/*.css",
|
"clean:3rd-party": "rm src/3rd-party/*.js && rm src/3rd-party/*.css",
|
||||||
"test:mpy": "static-handler --coi . 2>/dev/null & SH_PID=$!; EXIT_CODE=0; playwright test --fully-parallel test/ || EXIT_CODE=$?; kill $SH_PID 2>/dev/null; exit $EXIT_CODE",
|
"test:integration": "static-handler --coi . 2>/dev/null & SH_PID=$!; EXIT_CODE=0; playwright test --fully-parallel tests/js_tests.spec.js tests/py_tests.spec.js || EXIT_CODE=$?; kill $SH_PID 2>/dev/null; exit $EXIT_CODE",
|
||||||
|
"test:ws": "bun tests/ws/index.js & playwright test tests/ws.spec.js",
|
||||||
"dev": "node dev.cjs",
|
"dev": "node dev.cjs",
|
||||||
"release": "npm run build && npm run zip",
|
"release": "npm run build && npm run zip",
|
||||||
"size": "echo -e \"\\033[1mdist/*.js file size\\033[0m\"; for js in $(ls dist/*.js); do cat $js | brotli > ._; echo -e \"\\033[2m$js:\\033[0m $(du -h --apparent-size ._ | sed -e 's/[[:space:]]*._//')\"; rm ._; done",
|
"size": "echo -e \"\\033[1mdist/*.js file size\\033[0m\"; for js in $(ls dist/*.js); do cat $js | brotli > ._; echo -e \"\\033[2m$js:\\033[0m $(du -h --apparent-size ._ | sed -e 's/[[:space:]]*._//')\"; rm ._; done",
|
||||||
@@ -41,33 +56,39 @@
|
|||||||
"license": "APACHE-2.0",
|
"license": "APACHE-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ungap/with-resolvers": "^0.1.0",
|
"@ungap/with-resolvers": "^0.1.0",
|
||||||
|
"@webreflection/idb-map": "^0.3.1",
|
||||||
"basic-devtools": "^0.1.6",
|
"basic-devtools": "^0.1.6",
|
||||||
"polyscript": "^0.12.6",
|
"polyscript": "^0.15.11",
|
||||||
|
"sabayon": "^0.5.2",
|
||||||
"sticky-module": "^0.1.1",
|
"sticky-module": "^0.1.1",
|
||||||
"to-json-callback": "^0.1.1",
|
"to-json-callback": "^0.1.1",
|
||||||
"type-checked-collections": "^0.1.7"
|
"type-checked-collections": "^0.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@codemirror/commands": "^6.5.0",
|
"@codemirror/commands": "^6.6.2",
|
||||||
"@codemirror/lang-python": "^6.1.5",
|
"@codemirror/lang-python": "^6.1.6",
|
||||||
"@codemirror/language": "^6.10.1",
|
"@codemirror/language": "^6.10.3",
|
||||||
"@codemirror/state": "^6.4.1",
|
"@codemirror/state": "^6.4.1",
|
||||||
"@codemirror/view": "^6.26.3",
|
"@codemirror/view": "^6.34.1",
|
||||||
"@playwright/test": "^1.43.1",
|
"@playwright/test": "1.45.3",
|
||||||
"@rollup/plugin-commonjs": "^25.0.7",
|
"@rollup/plugin-commonjs": "^28.0.0",
|
||||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
"@rollup/plugin-node-resolve": "^15.3.0",
|
||||||
"@rollup/plugin-terser": "^0.4.4",
|
"@rollup/plugin-terser": "^0.4.4",
|
||||||
"@webreflection/toml-j0.4": "^1.1.3",
|
"@webreflection/toml-j0.4": "^1.1.3",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"chokidar": "^3.6.0",
|
"bun": "^1.1.29",
|
||||||
|
"chokidar": "^4.0.1",
|
||||||
|
"codedent": "^0.1.2",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"eslint": "^9.1.1",
|
"eslint": "^9.11.1",
|
||||||
"rollup": "^4.16.4",
|
"flatted": "^3.3.1",
|
||||||
|
"rollup": "^4.22.5",
|
||||||
"rollup-plugin-postcss": "^4.0.2",
|
"rollup-plugin-postcss": "^4.0.2",
|
||||||
"rollup-plugin-string": "^3.0.0",
|
"rollup-plugin-string": "^3.0.0",
|
||||||
"static-handler": "^0.4.3",
|
"static-handler": "^0.5.3",
|
||||||
"typescript": "^5.4.5",
|
"string-width": "^7.2.0",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
"xterm": "^5.3.0",
|
"xterm": "^5.3.0",
|
||||||
"xterm-readline": "^1.1.1"
|
"xterm-readline": "^1.1.1"
|
||||||
},
|
},
|
||||||
|
|||||||
73
pyscript.core/rollup/build_test_index.cjs
Normal file
73
pyscript.core/rollup/build_test_index.cjs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const { join } = require("node:path");
|
||||||
|
const { lstatSync, readdirSync, writeFileSync } = require("node:fs");
|
||||||
|
|
||||||
|
// folders to not consider while crawling
|
||||||
|
const EXCLUDE_DIR = new Set(["ws"]);
|
||||||
|
|
||||||
|
const TEST_DIR = join(__dirname, "..", "tests");
|
||||||
|
|
||||||
|
const TEST_INDEX = join(TEST_DIR, "index.html");
|
||||||
|
|
||||||
|
const crawl = (path, tree = {}) => {
|
||||||
|
for (const file of readdirSync(path)) {
|
||||||
|
const current = join(path, file);
|
||||||
|
if (current === TEST_INDEX) continue;
|
||||||
|
if (lstatSync(current).isDirectory()) {
|
||||||
|
if (EXCLUDE_DIR.has(file)) continue;
|
||||||
|
const sub = {};
|
||||||
|
tree[file] = sub;
|
||||||
|
crawl(current, sub);
|
||||||
|
if (!Reflect.ownKeys(sub).length) {
|
||||||
|
delete tree[file];
|
||||||
|
}
|
||||||
|
} else if (file.endsWith(".html")) {
|
||||||
|
const name = file === "index.html" ? "." : file.slice(0, -5);
|
||||||
|
tree[name] = current.replace(TEST_DIR, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tree;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createList = (tree) => {
|
||||||
|
const ul = ["<ul>"];
|
||||||
|
for (const [key, value] of Object.entries(tree)) {
|
||||||
|
ul.push("<li>");
|
||||||
|
if (typeof value === "string") {
|
||||||
|
ul.push(`<a href=".${value}">${key}<small>.html</small></a>`);
|
||||||
|
} else {
|
||||||
|
if ("." in value) {
|
||||||
|
ul.push(`<strong><a href=".${value["."]}">${key}</a></strong>`);
|
||||||
|
delete value["."];
|
||||||
|
} else {
|
||||||
|
ul.push(`<strong><span>${key}</span></strong>`);
|
||||||
|
}
|
||||||
|
if (Reflect.ownKeys(value).length) ul.push(createList(value));
|
||||||
|
}
|
||||||
|
ul.push("</li>");
|
||||||
|
}
|
||||||
|
ul.push("</ul>");
|
||||||
|
return ul.join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
TEST_INDEX,
|
||||||
|
`<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PyScript tests</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; }
|
||||||
|
a {
|
||||||
|
display: block;
|
||||||
|
transition: opacity .3s;
|
||||||
|
}
|
||||||
|
a, span { opacity: .7; }
|
||||||
|
a:hover { opacity: 1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>${createList(crawl(TEST_DIR))}</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
);
|
||||||
@@ -40,4 +40,17 @@ export default [
|
|||||||
warn(warning);
|
warn(warning);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
input: "./src/storage.js",
|
||||||
|
plugins: plugins.concat(
|
||||||
|
process.env.NO_MIN
|
||||||
|
? [nodeResolve(), commonjs()]
|
||||||
|
: [nodeResolve(), commonjs(), terser()],
|
||||||
|
),
|
||||||
|
output: {
|
||||||
|
esModule: true,
|
||||||
|
dir: "./dist",
|
||||||
|
sourcemap: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
17
pyscript.core/rollup/flatted.cjs
Normal file
17
pyscript.core/rollup/flatted.cjs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const { writeFileSync, readFileSync } = require("node:fs");
|
||||||
|
const { join } = require("node:path");
|
||||||
|
|
||||||
|
const flatted = "# https://www.npmjs.com/package/flatted\n\n";
|
||||||
|
const source = join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"node_modules",
|
||||||
|
"flatted",
|
||||||
|
"python",
|
||||||
|
"flatted.py",
|
||||||
|
);
|
||||||
|
const dest = join(__dirname, "..", "src", "stdlib", "pyscript", "flatted.py");
|
||||||
|
|
||||||
|
const clear = (str) => String(str).replace(/^#.*/gm, "").trimStart();
|
||||||
|
|
||||||
|
writeFileSync(dest, flatted + clear(readFileSync(source)));
|
||||||
@@ -4,13 +4,50 @@ const {
|
|||||||
statSync,
|
statSync,
|
||||||
writeFileSync,
|
writeFileSync,
|
||||||
} = require("node:fs");
|
} = require("node:fs");
|
||||||
|
|
||||||
|
const { spawnSync } = require("node:child_process");
|
||||||
|
|
||||||
const { join } = require("node:path");
|
const { join } = require("node:path");
|
||||||
|
|
||||||
|
const dedent = require("codedent");
|
||||||
|
|
||||||
const crawl = (path, json) => {
|
const crawl = (path, json) => {
|
||||||
for (const file of readdirSync(path)) {
|
for (const file of readdirSync(path)) {
|
||||||
const full = join(path, file);
|
const full = join(path, file);
|
||||||
if (/\.py$/.test(file)) json[file] = readFileSync(full).toString();
|
if (/\.py$/.test(file)) {
|
||||||
else if (statSync(full).isDirectory() && !file.endsWith("_"))
|
if (process.env.NO_MIN) json[file] = readFileSync(full).toString();
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
output: [error, result],
|
||||||
|
} = spawnSync("pyminify", [
|
||||||
|
"--remove-literal-statements",
|
||||||
|
full,
|
||||||
|
]);
|
||||||
|
if (error) {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
json[file] = result.toString();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
console.log(
|
||||||
|
dedent(`
|
||||||
|
\x1b[1m⚠️ is your env activated?\x1b[0m
|
||||||
|
\x1b[2mYou need a Python env to run \x1b[0mpyminify\x1b[2m.\x1b[0m
|
||||||
|
\x1b[2mTo do so, you can try the following:\x1b[0m
|
||||||
|
python -m venv env
|
||||||
|
source env/bin/activate
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install --ignore-requires-python python-minifier
|
||||||
|
pip install setuptools
|
||||||
|
\x1b[2mand you can then try \x1b[0mnpm run build\x1b[2m again.\x1b[0m
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (statSync(full).isDirectory() && !file.endsWith("_"))
|
||||||
crawl(full, (json[file] = {}));
|
crawl(full, (json[file] = {}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ const configDetails = async (config, type) => {
|
|||||||
|
|
||||||
const conflictError = (reason) => new Error(`(${CONFLICTING_CODE}): ${reason}`);
|
const conflictError = (reason) => new Error(`(${CONFLICTING_CODE}): ${reason}`);
|
||||||
|
|
||||||
|
const relative_url = (url, base = location.href) => new URL(url, base).href;
|
||||||
|
|
||||||
const syntaxError = (type, url, { message }) => {
|
const syntaxError = (type, url, { message }) => {
|
||||||
let str = `(${BAD_CONFIG}): Invalid ${type}`;
|
let str = `(${BAD_CONFIG}): Invalid ${type}`;
|
||||||
if (url) str += ` @ ${url}`;
|
if (url) str += ` @ ${url}`;
|
||||||
@@ -108,7 +110,7 @@ for (const [TYPE] of TYPES) {
|
|||||||
if (!error && config) {
|
if (!error && config) {
|
||||||
try {
|
try {
|
||||||
const { json, toml, text, url } = await configDetails(config, type);
|
const { json, toml, text, url } = await configDetails(config, type);
|
||||||
if (url) configURL = new URL(url, location.href).href;
|
if (url) configURL = relative_url(url);
|
||||||
config = text;
|
config = text;
|
||||||
if (json || type === "json") {
|
if (json || type === "json") {
|
||||||
try {
|
try {
|
||||||
@@ -153,4 +155,4 @@ for (const [TYPE] of TYPES) {
|
|||||||
configs.set(TYPE, { config: parsed, configURL, plugins, error });
|
configs.set(TYPE, { config: parsed, configURL, plugins, error });
|
||||||
}
|
}
|
||||||
|
|
||||||
export default configs;
|
export { configs, relative_url };
|
||||||
|
|||||||
@@ -42,3 +42,34 @@ mpy-config {
|
|||||||
.mpy-editor-run-button:disabled {
|
.mpy-editor-run-button:disabled {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes spinner {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.py-editor-run-button:disabled > *,
|
||||||
|
.mpy-editor-run-button:disabled > * {
|
||||||
|
display: none; /* hide all the child elements of the run button when it is disabled */
|
||||||
|
}
|
||||||
|
.py-editor-run-button:disabled,
|
||||||
|
.mpy-editor-run-button:disabled {
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
.py-editor-run-button:disabled::before,
|
||||||
|
.mpy-editor-run-button:disabled::before {
|
||||||
|
content: "";
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 100%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
margin-top: -23px; /* hardcoded value to center the spinner on the run button */
|
||||||
|
margin-left: -26px; /* hardcoded value to center the spinner on the run button */
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #aaa;
|
||||||
|
border-top-color: #000;
|
||||||
|
background-color: #fff;
|
||||||
|
animation: spinner 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
define,
|
define,
|
||||||
defineProperty,
|
defineProperty,
|
||||||
dispatch,
|
dispatch,
|
||||||
|
isSync,
|
||||||
queryTarget,
|
queryTarget,
|
||||||
unescape,
|
unescape,
|
||||||
whenDefined,
|
whenDefined,
|
||||||
@@ -19,15 +20,22 @@ import {
|
|||||||
|
|
||||||
import "./all-done.js";
|
import "./all-done.js";
|
||||||
import TYPES from "./types.js";
|
import TYPES from "./types.js";
|
||||||
import configs from "./config.js";
|
import { configs, relative_url } from "./config.js";
|
||||||
import sync from "./sync.js";
|
import sync from "./sync.js";
|
||||||
import bootstrapNodeAndPlugins from "./plugins-helper.js";
|
import bootstrapNodeAndPlugins from "./plugins-helper.js";
|
||||||
import { ErrorCode } from "./exceptions.js";
|
import { ErrorCode } from "./exceptions.js";
|
||||||
import { robustFetch as fetch, getText } from "./fetch.js";
|
import { robustFetch as fetch, getText } from "./fetch.js";
|
||||||
import { hooks, main, worker, codeFor, createFunction } from "./hooks.js";
|
import {
|
||||||
|
hooks,
|
||||||
|
main,
|
||||||
|
worker,
|
||||||
|
codeFor,
|
||||||
|
createFunction,
|
||||||
|
inputFailure,
|
||||||
|
} from "./hooks.js";
|
||||||
|
|
||||||
import { stdlib, optional } from "./stdlib.js";
|
import { stdlib, optional } from "./stdlib.js";
|
||||||
export { stdlib, optional };
|
export { stdlib, optional, inputFailure };
|
||||||
|
|
||||||
// generic helper to disambiguate between custom element and script
|
// generic helper to disambiguate between custom element and script
|
||||||
const isScript = ({ tagName }) => tagName === "SCRIPT";
|
const isScript = ({ tagName }) => tagName === "SCRIPT";
|
||||||
@@ -77,6 +85,7 @@ const [
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
TYPES,
|
TYPES,
|
||||||
|
relative_url,
|
||||||
exportedPyWorker as PyWorker,
|
exportedPyWorker as PyWorker,
|
||||||
exportedMPWorker as MPWorker,
|
exportedMPWorker as MPWorker,
|
||||||
exportedHooks as hooks,
|
exportedHooks as hooks,
|
||||||
@@ -84,6 +93,9 @@ export {
|
|||||||
exportedWhenDefined as whenDefined,
|
exportedWhenDefined as whenDefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const offline_interpreter = (config) =>
|
||||||
|
config?.interpreter && relative_url(config.interpreter);
|
||||||
|
|
||||||
const hooked = new Map();
|
const hooked = new Map();
|
||||||
|
|
||||||
for (const [TYPE, interpreter] of TYPES) {
|
for (const [TYPE, interpreter] of TYPES) {
|
||||||
@@ -147,6 +159,7 @@ for (const [TYPE, interpreter] of TYPES) {
|
|||||||
// enrich the Python env with some JS utility for main
|
// enrich the Python env with some JS utility for main
|
||||||
interpreter.registerJsModule("_pyscript", {
|
interpreter.registerJsModule("_pyscript", {
|
||||||
PyWorker,
|
PyWorker,
|
||||||
|
js_import: (...urls) => Promise.all(urls.map((url) => import(url))),
|
||||||
get target() {
|
get target() {
|
||||||
return isScript(currentElement)
|
return isScript(currentElement)
|
||||||
? currentElement.target.id
|
? currentElement.target.id
|
||||||
@@ -190,15 +203,13 @@ for (const [TYPE, interpreter] of TYPES) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isScript(element)) {
|
if (isScript(element)) {
|
||||||
const {
|
const isAsync = !isSync(element);
|
||||||
attributes: { async: isAsync, target },
|
const target = element.getAttribute("target");
|
||||||
} = element;
|
const show = target
|
||||||
const hasTarget = !!target?.value;
|
? queryTarget(element, target)
|
||||||
const show = hasTarget
|
|
||||||
? queryTarget(element, target.value)
|
|
||||||
: document.createElement("script-py");
|
: document.createElement("script-py");
|
||||||
|
|
||||||
if (!hasTarget) {
|
if (!target) {
|
||||||
const { head, body } = document;
|
const { head, body } = document;
|
||||||
if (head.contains(element)) body.append(show);
|
if (head.contains(element)) body.append(show);
|
||||||
else element.after(show);
|
else element.after(show);
|
||||||
@@ -294,7 +305,7 @@ for (const [TYPE, interpreter] of TYPES) {
|
|||||||
interpreter,
|
interpreter,
|
||||||
hooks,
|
hooks,
|
||||||
env: `${TYPE}-script`,
|
env: `${TYPE}-script`,
|
||||||
version: config?.interpreter,
|
version: offline_interpreter(config),
|
||||||
onerror(error, element) {
|
onerror(error, element) {
|
||||||
errors.set(element, error);
|
errors.set(element, error);
|
||||||
},
|
},
|
||||||
@@ -319,7 +330,7 @@ for (const [TYPE, interpreter] of TYPES) {
|
|||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
if (!this.executed) {
|
if (!this.executed) {
|
||||||
this.executed = true;
|
this.executed = true;
|
||||||
const isAsync = this.hasAttribute("async");
|
const isAsync = !isSync(this);
|
||||||
const { io, run, runAsync } = await this._wrap
|
const { io, run, runAsync } = await this._wrap
|
||||||
.promise;
|
.promise;
|
||||||
this.srcCode = await fetchSource(
|
this.srcCode = await fetchSource(
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const createFunction = (self, name) => {
|
|||||||
const SetFunction = typedSet({ typeof: "function" });
|
const SetFunction = typedSet({ typeof: "function" });
|
||||||
const SetString = typedSet({ typeof: "string" });
|
const SetString = typedSet({ typeof: "string" });
|
||||||
|
|
||||||
const inputFailure = `
|
export const inputFailure = `
|
||||||
import builtins
|
import builtins
|
||||||
def input(prompt=""):
|
def input(prompt=""):
|
||||||
raise Exception("\\n ".join([
|
raise Exception("\\n ".join([
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// PyScript py-editor plugin
|
// PyScript py-editor plugin
|
||||||
import { Hook, XWorker, dedent } from "polyscript/exports";
|
import { Hook, XWorker, dedent, defineProperties } from "polyscript/exports";
|
||||||
import { TYPES, stdlib } from "../core.js";
|
import { TYPES, offline_interpreter, relative_url, stdlib } from "../core.js";
|
||||||
|
import { notify } from "./error.js";
|
||||||
|
|
||||||
const RUN_BUTTON = `<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>`;
|
const RUN_BUTTON = `<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>`;
|
||||||
|
|
||||||
@@ -23,6 +24,11 @@ const hooks = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validate = (config, result) => {
|
||||||
|
if (typeof result === "boolean") throw `Invalid source: ${config}`;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
async function execute({ currentTarget }) {
|
async function execute({ currentTarget }) {
|
||||||
const { env, pySrc, outDiv } = this;
|
const { env, pySrc, outDiv } = this;
|
||||||
const hasRunButton = !!currentTarget;
|
const hasRunButton = !!currentTarget;
|
||||||
@@ -34,14 +40,39 @@ async function execute({ currentTarget }) {
|
|||||||
|
|
||||||
if (!envs.has(env)) {
|
if (!envs.has(env)) {
|
||||||
const srcLink = URL.createObjectURL(new Blob([""]));
|
const srcLink = URL.createObjectURL(new Blob([""]));
|
||||||
const details = { type: this.interpreter };
|
const details = {
|
||||||
|
type: this.interpreter,
|
||||||
|
serviceWorker: this.serviceWorker,
|
||||||
|
};
|
||||||
const { config } = this;
|
const { config } = this;
|
||||||
if (config) {
|
if (config) {
|
||||||
details.configURL = config;
|
// verify that config can be parsed and used
|
||||||
const { parse } = config.endsWith(".toml")
|
try {
|
||||||
? await import(/* webpackIgnore: true */ "../3rd-party/toml.js")
|
details.configURL = relative_url(config);
|
||||||
: JSON;
|
if (config.endsWith(".toml")) {
|
||||||
details.config = parse(await fetch(config).then((r) => r.text()));
|
const [{ parse }, toml] = await Promise.all([
|
||||||
|
import(
|
||||||
|
/* webpackIgnore: true */ "../3rd-party/toml.js"
|
||||||
|
),
|
||||||
|
fetch(config).then((r) => r.ok && r.text()),
|
||||||
|
]);
|
||||||
|
details.config = parse(validate(config, toml));
|
||||||
|
} else if (config.endsWith(".json")) {
|
||||||
|
const json = await fetch(config).then(
|
||||||
|
(r) => r.ok && r.json(),
|
||||||
|
);
|
||||||
|
details.config = validate(config, json);
|
||||||
|
} else {
|
||||||
|
details.configURL = relative_url("./config.txt");
|
||||||
|
details.config = JSON.parse(config);
|
||||||
|
}
|
||||||
|
details.version = offline_interpreter(details.config);
|
||||||
|
} catch (error) {
|
||||||
|
notify(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
details.config = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
const xworker = XWorker.call(new Hook(null, hooks), srcLink, details);
|
const xworker = XWorker.call(new Hook(null, hooks), srcLink, details);
|
||||||
@@ -57,12 +88,15 @@ async function execute({ currentTarget }) {
|
|||||||
|
|
||||||
// wait for the env then set the target div
|
// wait for the env then set the target div
|
||||||
// before executing the current code
|
// before executing the current code
|
||||||
envs.get(env).then((xworker) => {
|
return envs.get(env).then((xworker) => {
|
||||||
xworker.onerror = ({ error }) => {
|
xworker.onerror = ({ error }) => {
|
||||||
if (hasRunButton) {
|
if (hasRunButton) {
|
||||||
outDiv.innerHTML += `<span style='color:red'>${
|
outDiv.insertAdjacentHTML(
|
||||||
|
"beforeend",
|
||||||
|
`<span style='color:red'>${
|
||||||
error.message || error
|
error.message || error
|
||||||
}</span>\n`;
|
}</span>\n`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
console.error(error);
|
console.error(error);
|
||||||
};
|
};
|
||||||
@@ -73,31 +107,41 @@ async function execute({ currentTarget }) {
|
|||||||
const { sync } = xworker;
|
const { sync } = xworker;
|
||||||
sync.write = (str) => {
|
sync.write = (str) => {
|
||||||
if (hasRunButton) outDiv.innerText += `${str}\n`;
|
if (hasRunButton) outDiv.innerText += `${str}\n`;
|
||||||
|
else console.log(str);
|
||||||
};
|
};
|
||||||
sync.writeErr = (str) => {
|
sync.writeErr = (str) => {
|
||||||
if (hasRunButton) {
|
if (hasRunButton) {
|
||||||
outDiv.innerHTML += `<span style='color:red'>${str}</span>\n`;
|
outDiv.insertAdjacentHTML(
|
||||||
|
"beforeend",
|
||||||
|
`<span style='color:red'>${str}</span>\n`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
notify(str);
|
||||||
|
console.error(str);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
sync.runAsync(pySrc).then(enable, enable);
|
sync.runAsync(pySrc).then(enable, enable);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const makeRunButton = (listener, type) => {
|
const makeRunButton = (handler, type) => {
|
||||||
const runButton = document.createElement("button");
|
const runButton = document.createElement("button");
|
||||||
runButton.className = `absolute ${type}-editor-run-button`;
|
runButton.className = `absolute ${type}-editor-run-button`;
|
||||||
runButton.innerHTML = RUN_BUTTON;
|
runButton.innerHTML = RUN_BUTTON;
|
||||||
runButton.setAttribute("aria-label", "Python Script Run Button");
|
runButton.setAttribute("aria-label", "Python Script Run Button");
|
||||||
runButton.addEventListener("click", listener);
|
runButton.addEventListener("click", async (event) => {
|
||||||
|
runButton.blur();
|
||||||
|
await handler.handleEvent(event);
|
||||||
|
});
|
||||||
return runButton;
|
return runButton;
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeEditorDiv = (listener, type) => {
|
const makeEditorDiv = (handler, type) => {
|
||||||
const editorDiv = document.createElement("div");
|
const editorDiv = document.createElement("div");
|
||||||
editorDiv.className = `${type}-editor-input`;
|
editorDiv.className = `${type}-editor-input`;
|
||||||
editorDiv.setAttribute("aria-label", "Python Script Area");
|
editorDiv.setAttribute("aria-label", "Python Script Area");
|
||||||
|
|
||||||
const runButton = makeRunButton(listener, type);
|
const runButton = makeRunButton(handler, type);
|
||||||
const editorShadowContainer = document.createElement("div");
|
const editorShadowContainer = document.createElement("div");
|
||||||
|
|
||||||
// avoid outer elements intercepting key events (reveal as example)
|
// avoid outer elements intercepting key events (reveal as example)
|
||||||
@@ -117,15 +161,15 @@ const makeOutDiv = (type) => {
|
|||||||
return outDiv;
|
return outDiv;
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeBoxDiv = (listener, type) => {
|
const makeBoxDiv = (handler, type) => {
|
||||||
const boxDiv = document.createElement("div");
|
const boxDiv = document.createElement("div");
|
||||||
boxDiv.className = `${type}-editor-box`;
|
boxDiv.className = `${type}-editor-box`;
|
||||||
|
|
||||||
const editorDiv = makeEditorDiv(listener, type);
|
const editorDiv = makeEditorDiv(handler, type);
|
||||||
const outDiv = makeOutDiv(type);
|
const outDiv = makeOutDiv(type);
|
||||||
boxDiv.append(editorDiv, outDiv);
|
boxDiv.append(editorDiv, outDiv);
|
||||||
|
|
||||||
return [boxDiv, outDiv];
|
return [boxDiv, outDiv, editorDiv.querySelector("button")];
|
||||||
};
|
};
|
||||||
|
|
||||||
const init = async (script, type, interpreter) => {
|
const init = async (script, type, interpreter) => {
|
||||||
@@ -135,7 +179,7 @@ const init = async (script, type, interpreter) => {
|
|||||||
{ python },
|
{ python },
|
||||||
{ indentUnit },
|
{ indentUnit },
|
||||||
{ keymap },
|
{ keymap },
|
||||||
{ defaultKeymap },
|
{ defaultKeymap, indentWithTab },
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
import(/* webpackIgnore: true */ "../3rd-party/codemirror.js"),
|
import(/* webpackIgnore: true */ "../3rd-party/codemirror.js"),
|
||||||
import(/* webpackIgnore: true */ "../3rd-party/codemirror_state.js"),
|
import(/* webpackIgnore: true */ "../3rd-party/codemirror_state.js"),
|
||||||
@@ -147,10 +191,19 @@ const init = async (script, type, interpreter) => {
|
|||||||
import(/* webpackIgnore: true */ "../3rd-party/codemirror_commands.js"),
|
import(/* webpackIgnore: true */ "../3rd-party/codemirror_commands.js"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isSetup = script.hasAttribute("setup");
|
let isSetup = script.hasAttribute("setup");
|
||||||
const hasConfig = script.hasAttribute("config");
|
const hasConfig = script.hasAttribute("config");
|
||||||
|
const serviceWorker = script.getAttribute("service-worker");
|
||||||
const env = `${interpreter}-${script.getAttribute("env") || getID(type)}`;
|
const env = `${interpreter}-${script.getAttribute("env") || getID(type)}`;
|
||||||
|
|
||||||
|
// helps preventing too lazy ServiceWorker initialization on button run
|
||||||
|
if (serviceWorker) {
|
||||||
|
new XWorker("data:application/javascript,postMessage(0)", {
|
||||||
|
type: "dummy",
|
||||||
|
serviceWorker,
|
||||||
|
}).onmessage = ({ target }) => target.terminate();
|
||||||
|
}
|
||||||
|
|
||||||
if (hasConfig && configs.has(env)) {
|
if (hasConfig && configs.has(env)) {
|
||||||
throw new SyntaxError(
|
throw new SyntaxError(
|
||||||
configs.get(env)
|
configs.get(env)
|
||||||
@@ -161,15 +214,29 @@ const init = async (script, type, interpreter) => {
|
|||||||
|
|
||||||
configs.set(env, hasConfig);
|
configs.set(env, hasConfig);
|
||||||
|
|
||||||
const source = script.src
|
let source = script.textContent;
|
||||||
? await fetch(script.src).then((b) => b.text())
|
|
||||||
: script.textContent;
|
// verify the src points to a valid file that can be parsed
|
||||||
|
const { src } = script;
|
||||||
|
if (src) {
|
||||||
|
try {
|
||||||
|
source = validate(
|
||||||
|
src,
|
||||||
|
await fetch(src).then((b) => b.ok && b.text()),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
notify(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
|
// allow the listener to be overridden at distance
|
||||||
|
handleEvent: execute,
|
||||||
|
serviceWorker,
|
||||||
interpreter,
|
interpreter,
|
||||||
env,
|
env,
|
||||||
config:
|
config: hasConfig && script.getAttribute("config"),
|
||||||
hasConfig &&
|
|
||||||
new URL(script.getAttribute("config"), location.href).href,
|
|
||||||
get pySrc() {
|
get pySrc() {
|
||||||
return isSetup ? source : editor.state.doc.toString();
|
return isSetup ? source : editor.state.doc.toString();
|
||||||
},
|
},
|
||||||
@@ -178,14 +245,84 @@ const init = async (script, type, interpreter) => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let target;
|
||||||
|
defineProperties(script, {
|
||||||
|
target: { get: () => target },
|
||||||
|
handleEvent: {
|
||||||
|
get: () => context.handleEvent,
|
||||||
|
set: (callback) => {
|
||||||
|
// do not bother with logic if it was set back as its original handler
|
||||||
|
if (callback === execute) context.handleEvent = execute;
|
||||||
|
// in every other case be sure that if the listener override returned
|
||||||
|
// `false` nothing happens, otherwise keep doing what it always did
|
||||||
|
else {
|
||||||
|
context.handleEvent = async (event) => {
|
||||||
|
// trap the currentTarget ASAP (if any)
|
||||||
|
// otherwise it gets lost asynchronously
|
||||||
|
const { currentTarget } = event;
|
||||||
|
// augment a code snapshot before invoking the override
|
||||||
|
defineProperties(event, {
|
||||||
|
code: { value: context.pySrc },
|
||||||
|
});
|
||||||
|
// avoid executing the default handler if the override returned `false`
|
||||||
|
if ((await callback(event)) !== false)
|
||||||
|
await execute.call(context, { currentTarget });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
get: () => context.pySrc,
|
||||||
|
set: (insert) => {
|
||||||
|
if (isSetup) return;
|
||||||
|
editor.update([
|
||||||
|
editor.state.update({
|
||||||
|
changes: {
|
||||||
|
from: 0,
|
||||||
|
to: editor.state.doc.length,
|
||||||
|
insert,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
process: {
|
||||||
|
/**
|
||||||
|
* Simulate a setup node overriding the source to evaluate.
|
||||||
|
* @param {string} code the Python code to evaluate.
|
||||||
|
* @param {boolean} asRunButtonAction invoke the `Run` button handler.
|
||||||
|
* @returns {Promise<...>} fulfill once code has been evaluated.
|
||||||
|
*/
|
||||||
|
value(code, asRunButtonAction = false) {
|
||||||
|
if (asRunButtonAction) return listener();
|
||||||
|
const wasSetup = isSetup;
|
||||||
|
const wasSource = source;
|
||||||
|
isSetup = true;
|
||||||
|
source = code;
|
||||||
|
const restore = () => {
|
||||||
|
isSetup = wasSetup;
|
||||||
|
source = wasSource;
|
||||||
|
};
|
||||||
|
return context
|
||||||
|
.handleEvent({ currentTarget: null })
|
||||||
|
.then(restore, restore);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const notifyEditor = () => {
|
||||||
|
const event = new Event(`${type}-editor`, { bubbles: true });
|
||||||
|
script.dispatchEvent(event);
|
||||||
|
};
|
||||||
|
|
||||||
if (isSetup) {
|
if (isSetup) {
|
||||||
execute.call(context, { currentTarget: null });
|
await context.handleEvent({ currentTarget: null });
|
||||||
|
notifyEditor();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selector = script.getAttribute("target");
|
const selector = script.getAttribute("target");
|
||||||
|
|
||||||
let target;
|
|
||||||
if (selector) {
|
if (selector) {
|
||||||
target =
|
target =
|
||||||
document.getElementById(selector) ||
|
document.getElementById(selector) ||
|
||||||
@@ -202,8 +339,7 @@ const init = async (script, type, interpreter) => {
|
|||||||
if (!target.hasAttribute("root")) target.setAttribute("root", target.id);
|
if (!target.hasAttribute("root")) target.setAttribute("root", target.id);
|
||||||
|
|
||||||
// @see https://github.com/JeffersGlass/mkdocs-pyscript/blob/main/mkdocs_pyscript/js/makeblocks.js
|
// @see https://github.com/JeffersGlass/mkdocs-pyscript/blob/main/mkdocs_pyscript/js/makeblocks.js
|
||||||
const listener = execute.bind(context);
|
const [boxDiv, outDiv, runButton] = makeBoxDiv(context, type);
|
||||||
const [boxDiv, outDiv] = makeBoxDiv(listener, type);
|
|
||||||
boxDiv.dataset.env = script.hasAttribute("env") ? env : interpreter;
|
boxDiv.dataset.env = script.hasAttribute("env") ? env : interpreter;
|
||||||
|
|
||||||
const inputChild = boxDiv.querySelector(`.${type}-editor-input > div`);
|
const inputChild = boxDiv.querySelector(`.${type}-editor-input > div`);
|
||||||
@@ -216,8 +352,9 @@ const init = async (script, type, interpreter) => {
|
|||||||
const doc = dedent(script.textContent).trim();
|
const doc = dedent(script.textContent).trim();
|
||||||
|
|
||||||
// preserve user indentation, if any
|
// preserve user indentation, if any
|
||||||
const indentation = /^(\s+)/m.test(doc) ? RegExp.$1 : " ";
|
const indentation = /^([ \t]+)/m.test(doc) ? RegExp.$1 : " ";
|
||||||
|
|
||||||
|
const listener = () => runButton.click();
|
||||||
const editor = new EditorView({
|
const editor = new EditorView({
|
||||||
extensions: [
|
extensions: [
|
||||||
indentUnit.of(indentation),
|
indentUnit.of(indentation),
|
||||||
@@ -227,14 +364,19 @@ const init = async (script, type, interpreter) => {
|
|||||||
{ key: "Ctrl-Enter", run: listener, preventDefault: true },
|
{ key: "Ctrl-Enter", run: listener, preventDefault: true },
|
||||||
{ key: "Cmd-Enter", run: listener, preventDefault: true },
|
{ key: "Cmd-Enter", run: listener, preventDefault: true },
|
||||||
{ key: "Shift-Enter", run: listener, preventDefault: true },
|
{ key: "Shift-Enter", run: listener, preventDefault: true },
|
||||||
|
// @see https://codemirror.net/examples/tab/
|
||||||
|
indentWithTab,
|
||||||
]),
|
]),
|
||||||
basicSetup,
|
basicSetup,
|
||||||
],
|
],
|
||||||
|
foldGutter: true,
|
||||||
|
gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"],
|
||||||
parent,
|
parent,
|
||||||
doc,
|
doc,
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.focus();
|
editor.focus();
|
||||||
|
notifyEditor();
|
||||||
};
|
};
|
||||||
|
|
||||||
// avoid too greedy MutationObserver operations at distance
|
// avoid too greedy MutationObserver operations at distance
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
// PyScript py-terminal plugin
|
// PyScript py-terminal plugin
|
||||||
import { TYPES, hooks } from "../core.js";
|
import { TYPES, relative_url } from "../core.js";
|
||||||
import { notify } from "./error.js";
|
import { notify } from "./error.js";
|
||||||
import { customObserver, defineProperties } from "polyscript/exports";
|
import { customObserver } from "polyscript/exports";
|
||||||
|
|
||||||
// will contain all valid selectors
|
// will contain all valid selectors
|
||||||
const SELECTORS = [];
|
const SELECTORS = [];
|
||||||
|
|
||||||
|
// avoid processing same elements twice
|
||||||
|
const processed = new WeakSet();
|
||||||
|
|
||||||
// show the error on main and
|
// show the error on main and
|
||||||
// stops the module from keep executing
|
// stops the module from keep executing
|
||||||
const notifyAndThrow = (message) => {
|
const notifyAndThrow = (message) => {
|
||||||
@@ -15,265 +18,10 @@ const notifyAndThrow = (message) => {
|
|||||||
|
|
||||||
const onceOnMain = ({ attributes: { worker } }) => !worker;
|
const onceOnMain = ({ attributes: { worker } }) => !worker;
|
||||||
|
|
||||||
const bootstrapped = new WeakSet();
|
|
||||||
|
|
||||||
let addStyle = true;
|
let addStyle = true;
|
||||||
|
|
||||||
// this callback will be serialized as string and it never needs
|
for (const type of TYPES.keys()) {
|
||||||
// to be invoked multiple times. Each xworker here is bootstrapped
|
const selector = `script[type="${type}"][terminal],${type}-script[terminal]`;
|
||||||
// only once thanks to the `sync.is_pyterminal()` check.
|
|
||||||
const workerReady = ({ interpreter, io, run, type }, { sync }) => {
|
|
||||||
if (!sync.is_pyterminal()) return;
|
|
||||||
|
|
||||||
// in workers it's always safe to grab the polyscript currentScript
|
|
||||||
// the ugly `_` dance is due MicroPython not able to import via:
|
|
||||||
// `from polyscript.currentScript import terminal as __terminal__`
|
|
||||||
run(
|
|
||||||
"from polyscript import currentScript as _; __terminal__ = _.terminal; del _",
|
|
||||||
);
|
|
||||||
|
|
||||||
let data = "";
|
|
||||||
const { pyterminal_read, pyterminal_write } = sync;
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
const generic = {
|
|
||||||
isatty: false,
|
|
||||||
write(buffer) {
|
|
||||||
data = decoder.decode(buffer);
|
|
||||||
pyterminal_write(data);
|
|
||||||
return buffer.length;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// This part works already in both Pyodide and MicroPython
|
|
||||||
io.stderr = (error) => {
|
|
||||||
pyterminal_write(String(error.message || error));
|
|
||||||
};
|
|
||||||
|
|
||||||
// MicroPython has no code or code.interact()
|
|
||||||
// This part patches it in a way that simulates
|
|
||||||
// the code.interact() module in Pyodide.
|
|
||||||
if (type === "mpy") {
|
|
||||||
// monkey patch global input otherwise broken in MicroPython
|
|
||||||
interpreter.registerJsModule("_pyscript_input", {
|
|
||||||
input: pyterminal_read,
|
|
||||||
});
|
|
||||||
run("from _pyscript_input import input");
|
|
||||||
|
|
||||||
// this is needed to avoid truncated unicode in MicroPython
|
|
||||||
// the reason is that `linebuffer` false just send one byte
|
|
||||||
// per time and readline here doesn't like it much.
|
|
||||||
// MicroPython also has issues with code-points and
|
|
||||||
// replProcessChar(byte) but that function accepts only
|
|
||||||
// one byte per time so ... we have an issue!
|
|
||||||
// @see https://github.com/pyscript/pyscript/pull/2018
|
|
||||||
// @see https://github.com/WebReflection/buffer-points
|
|
||||||
const bufferPoints = (stdio) => {
|
|
||||||
const bytes = [];
|
|
||||||
let needed = 0;
|
|
||||||
return (buffer) => {
|
|
||||||
let written = 0;
|
|
||||||
for (const byte of buffer) {
|
|
||||||
bytes.push(byte);
|
|
||||||
// @see https://encoding.spec.whatwg.org/#utf-8-bytes-needed
|
|
||||||
if (needed) needed--;
|
|
||||||
else if (0xc2 <= byte && byte <= 0xdf) needed = 1;
|
|
||||||
else if (0xe0 <= byte && byte <= 0xef) needed = 2;
|
|
||||||
else if (0xf0 <= byte && byte <= 0xf4) needed = 3;
|
|
||||||
if (!needed) {
|
|
||||||
written += bytes.length;
|
|
||||||
stdio(new Uint8Array(bytes.splice(0)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return written;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
io.stdout = bufferPoints(generic.write);
|
|
||||||
|
|
||||||
// tiny shim of the code module with only interact
|
|
||||||
// to bootstrap a REPL like environment
|
|
||||||
interpreter.registerJsModule("code", {
|
|
||||||
interact() {
|
|
||||||
let input = "";
|
|
||||||
let length = 1;
|
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const acc = [];
|
|
||||||
const handlePoints = bufferPoints((buffer) => {
|
|
||||||
acc.push(...buffer);
|
|
||||||
pyterminal_write(decoder.decode(buffer));
|
|
||||||
});
|
|
||||||
|
|
||||||
// avoid duplicating the output produced by the input
|
|
||||||
io.stdout = (buffer) =>
|
|
||||||
length++ > input.length ? handlePoints(buffer) : 0;
|
|
||||||
|
|
||||||
interpreter.replInit();
|
|
||||||
|
|
||||||
// loop forever waiting for user inputs
|
|
||||||
(function repl() {
|
|
||||||
const out = decoder.decode(new Uint8Array(acc.splice(0)));
|
|
||||||
// print in current line only the last line produced by the REPL
|
|
||||||
const data = `${pyterminal_read(out.split("\n").at(-1))}\r`;
|
|
||||||
length = 0;
|
|
||||||
input = encoder.encode(data);
|
|
||||||
for (const c of input) interpreter.replProcessChar(c);
|
|
||||||
repl();
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
interpreter.setStdout(generic);
|
|
||||||
interpreter.setStderr(generic);
|
|
||||||
interpreter.setStdin({
|
|
||||||
isatty: false,
|
|
||||||
stdin: () => pyterminal_read(data),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const pyTerminal = async (element) => {
|
|
||||||
// lazy load these only when a valid terminal is found
|
|
||||||
const [{ Terminal }, { Readline }, { FitAddon }, { WebLinksAddon }] =
|
|
||||||
await Promise.all([
|
|
||||||
import(/* webpackIgnore: true */ "../3rd-party/xterm.js"),
|
|
||||||
import(/* webpackIgnore: true */ "../3rd-party/xterm-readline.js"),
|
|
||||||
import(/* webpackIgnore: true */ "../3rd-party/xterm_addon-fit.js"),
|
|
||||||
import(
|
|
||||||
/* webpackIgnore: true */ "../3rd-party/xterm_addon-web-links.js"
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const readline = new Readline();
|
|
||||||
|
|
||||||
// common main thread initialization for both worker
|
|
||||||
// or main case, bootstrapping the terminal on its target
|
|
||||||
const init = (options) => {
|
|
||||||
let target = element;
|
|
||||||
const selector = element.getAttribute("target");
|
|
||||||
if (selector) {
|
|
||||||
target =
|
|
||||||
document.getElementById(selector) ||
|
|
||||||
document.querySelector(selector);
|
|
||||||
if (!target) throw new Error(`Unknown target ${selector}`);
|
|
||||||
} else {
|
|
||||||
target = document.createElement("py-terminal");
|
|
||||||
target.style.display = "block";
|
|
||||||
element.after(target);
|
|
||||||
}
|
|
||||||
const terminal = new Terminal({
|
|
||||||
theme: {
|
|
||||||
background: "#191A19",
|
|
||||||
foreground: "#F5F2E7",
|
|
||||||
},
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
const fitAddon = new FitAddon();
|
|
||||||
terminal.loadAddon(fitAddon);
|
|
||||||
terminal.loadAddon(readline);
|
|
||||||
terminal.loadAddon(new WebLinksAddon());
|
|
||||||
terminal.open(target);
|
|
||||||
fitAddon.fit();
|
|
||||||
terminal.focus();
|
|
||||||
defineProperties(element, {
|
|
||||||
terminal: { value: terminal },
|
|
||||||
process: {
|
|
||||||
value: async (code) => {
|
|
||||||
// this loop is the only way I could find to actually simulate
|
|
||||||
// the user input char after char in a way that works in both
|
|
||||||
// MicroPython and Pyodide
|
|
||||||
for (const line of code.split(/(?:\r|\n|\r\n)/)) {
|
|
||||||
terminal.paste(`${line}\n`);
|
|
||||||
do {
|
|
||||||
await new Promise((resolve) =>
|
|
||||||
setTimeout(resolve, 0),
|
|
||||||
);
|
|
||||||
} while (!readline.activeRead?.resolve);
|
|
||||||
readline.activeRead.resolve(line);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return terminal;
|
|
||||||
};
|
|
||||||
|
|
||||||
// branch logic for the worker
|
|
||||||
if (element.hasAttribute("worker")) {
|
|
||||||
// add a hook on the main thread to setup all sync helpers
|
|
||||||
// also bootstrapping the XTerm target on main *BUT* ...
|
|
||||||
hooks.main.onWorker.add(function worker(_, xworker) {
|
|
||||||
// ... as multiple workers will add multiple callbacks
|
|
||||||
// be sure no xworker is ever initialized twice!
|
|
||||||
if (bootstrapped.has(xworker)) return;
|
|
||||||
bootstrapped.add(xworker);
|
|
||||||
|
|
||||||
// still cleanup this callback for future scripts/workers
|
|
||||||
hooks.main.onWorker.delete(worker);
|
|
||||||
|
|
||||||
init({
|
|
||||||
disableStdin: false,
|
|
||||||
cursorBlink: true,
|
|
||||||
cursorStyle: "block",
|
|
||||||
});
|
|
||||||
|
|
||||||
xworker.sync.is_pyterminal = () => true;
|
|
||||||
xworker.sync.pyterminal_read = readline.read.bind(readline);
|
|
||||||
xworker.sync.pyterminal_write = readline.write.bind(readline);
|
|
||||||
});
|
|
||||||
|
|
||||||
// setup remote thread JS/Python code for whenever the
|
|
||||||
// worker is ready to become a terminal
|
|
||||||
hooks.worker.onReady.add(workerReady);
|
|
||||||
} else {
|
|
||||||
// in the main case, just bootstrap XTerm without
|
|
||||||
// allowing any input as that's not possible / awkward
|
|
||||||
hooks.main.onReady.add(function main({ interpreter, io, run, type }) {
|
|
||||||
console.warn("py-terminal is read only on main thread");
|
|
||||||
hooks.main.onReady.delete(main);
|
|
||||||
|
|
||||||
// on main, it's easy to trash and clean the current terminal
|
|
||||||
globalThis.__py_terminal__ = init({
|
|
||||||
disableStdin: true,
|
|
||||||
cursorBlink: false,
|
|
||||||
cursorStyle: "underline",
|
|
||||||
});
|
|
||||||
run("from js import __py_terminal__ as __terminal__");
|
|
||||||
delete globalThis.__py_terminal__;
|
|
||||||
|
|
||||||
io.stderr = (error) => {
|
|
||||||
readline.write(String(error.message || error));
|
|
||||||
};
|
|
||||||
|
|
||||||
if (type === "mpy") {
|
|
||||||
interpreter.setStdin = Object; // as no-op
|
|
||||||
interpreter.setStderr = Object; // as no-op
|
|
||||||
interpreter.setStdout = ({ write }) => {
|
|
||||||
io.stdout = write;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = "";
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
const generic = {
|
|
||||||
isatty: false,
|
|
||||||
write(buffer) {
|
|
||||||
data = decoder.decode(buffer);
|
|
||||||
readline.write(data);
|
|
||||||
return buffer.length;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
interpreter.setStdout(generic);
|
|
||||||
interpreter.setStderr(generic);
|
|
||||||
interpreter.setStdin({
|
|
||||||
isatty: false,
|
|
||||||
stdin: () => readline.read(data),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const key of TYPES.keys()) {
|
|
||||||
const selector = `script[type="${key}"][terminal],${key}-script[terminal]`;
|
|
||||||
SELECTORS.push(selector);
|
SELECTORS.push(selector);
|
||||||
customObserver.set(selector, async (element) => {
|
customObserver.set(selector, async (element) => {
|
||||||
// we currently support only one terminal on main as in "classic"
|
// we currently support only one terminal on main as in "classic"
|
||||||
@@ -287,11 +35,26 @@ for (const key of TYPES.keys()) {
|
|||||||
document.head.append(
|
document.head.append(
|
||||||
Object.assign(document.createElement("link"), {
|
Object.assign(document.createElement("link"), {
|
||||||
rel: "stylesheet",
|
rel: "stylesheet",
|
||||||
href: new URL("./xterm.css", import.meta.url),
|
href: relative_url("./xterm.css", import.meta.url),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await pyTerminal(element);
|
if (processed.has(element)) return;
|
||||||
|
processed.add(element);
|
||||||
|
|
||||||
|
const bootstrap = (module) => module.default(element);
|
||||||
|
|
||||||
|
// we can't be smart with template literals for the dynamic import
|
||||||
|
// or bundlers are incapable of producing multiple files around
|
||||||
|
if (type === "mpy") {
|
||||||
|
await import(/* webpackIgnore: true */ "./py-terminal/mpy.js").then(
|
||||||
|
bootstrap,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await import(/* webpackIgnore: true */ "./py-terminal/py.js").then(
|
||||||
|
bootstrap,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
252
pyscript.core/src/plugins/py-terminal/mpy.js
Normal file
252
pyscript.core/src/plugins/py-terminal/mpy.js
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
// PyScript pyodide terminal plugin
|
||||||
|
import { hooks, inputFailure } from "../../core.js";
|
||||||
|
import { defineProperties } from "polyscript/exports";
|
||||||
|
|
||||||
|
const bootstrapped = new WeakSet();
|
||||||
|
|
||||||
|
// this callback will be serialized as string and it never needs
|
||||||
|
// to be invoked multiple times. Each xworker here is bootstrapped
|
||||||
|
// only once thanks to the `sync.is_pyterminal()` check.
|
||||||
|
const workerReady = ({ interpreter, io, run, type }, { sync }) => {
|
||||||
|
if (type !== "mpy" || !sync.is_pyterminal()) return;
|
||||||
|
|
||||||
|
const { pyterminal_ready, pyterminal_read, pyterminal_write } = sync;
|
||||||
|
|
||||||
|
interpreter.registerJsModule("_pyscript_input", {
|
||||||
|
input: pyterminal_read,
|
||||||
|
});
|
||||||
|
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
"from _pyscript_input import input",
|
||||||
|
"from polyscript import currentScript as _",
|
||||||
|
"__terminal__ = _.terminal",
|
||||||
|
"del _",
|
||||||
|
].join(";"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingReturn = new Uint8Array([13]);
|
||||||
|
io.stdout = (buffer) => {
|
||||||
|
if (buffer[0] === 10) pyterminal_write(missingReturn);
|
||||||
|
pyterminal_write(buffer);
|
||||||
|
};
|
||||||
|
io.stderr = (error) => {
|
||||||
|
pyterminal_write(String(error.message || error));
|
||||||
|
};
|
||||||
|
|
||||||
|
// tiny shim of the code module with only interact
|
||||||
|
// to bootstrap a REPL like environment
|
||||||
|
interpreter.registerJsModule("code", {
|
||||||
|
interact() {
|
||||||
|
const encoder = new TextEncoderStream();
|
||||||
|
encoder.readable.pipeTo(
|
||||||
|
new WritableStream({
|
||||||
|
write(buffer) {
|
||||||
|
for (const c of buffer) interpreter.replProcessChar(c);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const writer = encoder.writable.getWriter();
|
||||||
|
sync.pyterminal_stream_write = (buffer) => writer.write(buffer);
|
||||||
|
|
||||||
|
interpreter.replInit();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pyterminal_ready();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async (element) => {
|
||||||
|
// lazy load these only when a valid terminal is found
|
||||||
|
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
|
||||||
|
import(/* webpackIgnore: true */ "../../3rd-party/xterm.js"),
|
||||||
|
import(/* webpackIgnore: true */ "../../3rd-party/xterm_addon-fit.js"),
|
||||||
|
import(
|
||||||
|
/* webpackIgnore: true */ "../../3rd-party/xterm_addon-web-links.js"
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const terminalOptions = {
|
||||||
|
disableStdin: false,
|
||||||
|
cursorBlink: true,
|
||||||
|
cursorStyle: "block",
|
||||||
|
};
|
||||||
|
|
||||||
|
let stream;
|
||||||
|
|
||||||
|
// common main thread initialization for both worker
|
||||||
|
// or main case, bootstrapping the terminal on its target
|
||||||
|
const init = () => {
|
||||||
|
let target = element;
|
||||||
|
const selector = element.getAttribute("target");
|
||||||
|
if (selector) {
|
||||||
|
target =
|
||||||
|
document.getElementById(selector) ||
|
||||||
|
document.querySelector(selector);
|
||||||
|
if (!target) throw new Error(`Unknown target ${selector}`);
|
||||||
|
} else {
|
||||||
|
target = document.createElement("py-terminal");
|
||||||
|
target.style.display = "block";
|
||||||
|
element.after(target);
|
||||||
|
}
|
||||||
|
const terminal = new Terminal({
|
||||||
|
theme: {
|
||||||
|
background: "#191A19",
|
||||||
|
foreground: "#F5F2E7",
|
||||||
|
},
|
||||||
|
...terminalOptions,
|
||||||
|
});
|
||||||
|
const fitAddon = new FitAddon();
|
||||||
|
terminal.loadAddon(fitAddon);
|
||||||
|
terminal.loadAddon(new WebLinksAddon());
|
||||||
|
terminal.open(target);
|
||||||
|
fitAddon.fit();
|
||||||
|
terminal.focus();
|
||||||
|
defineProperties(element, {
|
||||||
|
terminal: { value: terminal },
|
||||||
|
process: {
|
||||||
|
value: async (code) => {
|
||||||
|
for (const line of code.split(/(?:\r\n|\r|\n)/)) {
|
||||||
|
await stream.write(`${line}\r`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return terminal;
|
||||||
|
};
|
||||||
|
|
||||||
|
// branch logic for the worker
|
||||||
|
if (element.hasAttribute("worker")) {
|
||||||
|
// add a hook on the main thread to setup all sync helpers
|
||||||
|
// also bootstrapping the XTerm target on main *BUT* ...
|
||||||
|
hooks.main.onWorker.add(function worker(_, xworker) {
|
||||||
|
// ... as multiple workers will add multiple callbacks
|
||||||
|
// be sure no xworker is ever initialized twice!
|
||||||
|
if (bootstrapped.has(xworker)) return;
|
||||||
|
bootstrapped.add(xworker);
|
||||||
|
|
||||||
|
// still cleanup this callback for future scripts/workers
|
||||||
|
hooks.main.onWorker.delete(worker);
|
||||||
|
|
||||||
|
const terminal = init();
|
||||||
|
|
||||||
|
const { sync } = xworker;
|
||||||
|
|
||||||
|
// handle the read mode on input
|
||||||
|
let promisedChunks = null;
|
||||||
|
let readChunks = "";
|
||||||
|
|
||||||
|
sync.is_pyterminal = () => true;
|
||||||
|
|
||||||
|
// put the terminal in a read-only state
|
||||||
|
// frees the worker on \r
|
||||||
|
sync.pyterminal_read = (buffer) => {
|
||||||
|
terminal.write(buffer);
|
||||||
|
promisedChunks = Promise.withResolvers();
|
||||||
|
return promisedChunks.promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
// write if not reading input
|
||||||
|
sync.pyterminal_write = (buffer) => {
|
||||||
|
if (!promisedChunks) terminal.write(buffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
// add the onData terminal listener which forwards to the worker
|
||||||
|
// everything typed in a queued char-by-char way
|
||||||
|
sync.pyterminal_ready = () => {
|
||||||
|
let queue = Promise.resolve();
|
||||||
|
stream = {
|
||||||
|
write: (buffer) =>
|
||||||
|
(queue = queue.then(() =>
|
||||||
|
sync.pyterminal_stream_write(buffer),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
terminal.onData((buffer) => {
|
||||||
|
if (promisedChunks) {
|
||||||
|
// handle backspace on input
|
||||||
|
if (buffer === "\x7f") {
|
||||||
|
// avoid over-greedy backspace
|
||||||
|
if (readChunks.length) {
|
||||||
|
readChunks = readChunks.slice(0, -1);
|
||||||
|
// override previous char position
|
||||||
|
// put an empty space to clear the char
|
||||||
|
// move back position again
|
||||||
|
buffer = "\b \b";
|
||||||
|
} else buffer = "";
|
||||||
|
} else readChunks += buffer;
|
||||||
|
if (buffer) {
|
||||||
|
terminal.write(buffer);
|
||||||
|
if (readChunks.endsWith("\r")) {
|
||||||
|
terminal.write("\n");
|
||||||
|
promisedChunks.resolve(readChunks.slice(0, -1));
|
||||||
|
promisedChunks = null;
|
||||||
|
readChunks = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stream.write(buffer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// setup remote thread JS/Python code for whenever the
|
||||||
|
// worker is ready to become a terminal
|
||||||
|
hooks.worker.onReady.add(workerReady);
|
||||||
|
} else {
|
||||||
|
// ⚠️ In an ideal world the inputFailure should never be used on main.
|
||||||
|
// However, Pyodide still can't compete with MicroPython REPL mode
|
||||||
|
// so while it's OK to keep that entry on main as default, we need
|
||||||
|
// to remove it ASAP from `mpy` use cases, otherwise MicroPython would
|
||||||
|
// also throw whenever an `input(...)` is required / digited.
|
||||||
|
hooks.main.codeBeforeRun.delete(inputFailure);
|
||||||
|
|
||||||
|
// in the main case, just bootstrap XTerm without
|
||||||
|
// allowing any input as that's not possible / awkward
|
||||||
|
hooks.main.onReady.add(function main({ interpreter, io, run, type }) {
|
||||||
|
if (type !== "mpy") return;
|
||||||
|
|
||||||
|
hooks.main.onReady.delete(main);
|
||||||
|
|
||||||
|
const terminal = init();
|
||||||
|
|
||||||
|
const missingReturn = new Uint8Array([13]);
|
||||||
|
io.stdout = (buffer) => {
|
||||||
|
if (buffer[0] === 10) terminal.write(missingReturn);
|
||||||
|
terminal.write(buffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
// expose the __terminal__ one-off reference
|
||||||
|
globalThis.__py_terminal__ = terminal;
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
"from js import prompt as input",
|
||||||
|
"from js import __py_terminal__ as __terminal__",
|
||||||
|
].join(";"),
|
||||||
|
);
|
||||||
|
delete globalThis.__py_terminal__;
|
||||||
|
|
||||||
|
// NOTE: this is NOT the same as the one within
|
||||||
|
// the onWorkerReady callback!
|
||||||
|
interpreter.registerJsModule("code", {
|
||||||
|
interact() {
|
||||||
|
const encoder = new TextEncoderStream();
|
||||||
|
encoder.readable.pipeTo(
|
||||||
|
new WritableStream({
|
||||||
|
write(buffer) {
|
||||||
|
for (const c of buffer)
|
||||||
|
interpreter.replProcessChar(c);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
stream = encoder.writable.getWriter();
|
||||||
|
terminal.onData((buffer) => stream.write(buffer));
|
||||||
|
|
||||||
|
interpreter.replInit();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
179
pyscript.core/src/plugins/py-terminal/py.js
Normal file
179
pyscript.core/src/plugins/py-terminal/py.js
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
// PyScript py-terminal plugin
|
||||||
|
import { hooks } from "../../core.js";
|
||||||
|
import { defineProperties } from "polyscript/exports";
|
||||||
|
|
||||||
|
const bootstrapped = new WeakSet();
|
||||||
|
|
||||||
|
// this callback will be serialized as string and it never needs
|
||||||
|
// to be invoked multiple times. Each xworker here is bootstrapped
|
||||||
|
// only once thanks to the `sync.is_pyterminal()` check.
|
||||||
|
const workerReady = ({ interpreter, io, run, type }, { sync }) => {
|
||||||
|
if (type !== "py" || !sync.is_pyterminal()) return;
|
||||||
|
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
"from polyscript import currentScript as _",
|
||||||
|
"__terminal__ = _.terminal",
|
||||||
|
"del _",
|
||||||
|
].join(";"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let data = "";
|
||||||
|
const { pyterminal_read, pyterminal_write } = sync;
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const generic = {
|
||||||
|
isatty: false,
|
||||||
|
write(buffer) {
|
||||||
|
data = decoder.decode(buffer);
|
||||||
|
pyterminal_write(data);
|
||||||
|
return buffer.length;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
io.stderr = (error) => {
|
||||||
|
pyterminal_write(String(error.message || error));
|
||||||
|
};
|
||||||
|
|
||||||
|
interpreter.setStdout(generic);
|
||||||
|
interpreter.setStderr(generic);
|
||||||
|
interpreter.setStdin({
|
||||||
|
isatty: false,
|
||||||
|
stdin: () => pyterminal_read(data),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async (element) => {
|
||||||
|
// lazy load these only when a valid terminal is found
|
||||||
|
const [{ Terminal }, { Readline }, { FitAddon }, { WebLinksAddon }] =
|
||||||
|
await Promise.all([
|
||||||
|
import(/* webpackIgnore: true */ "../../3rd-party/xterm.js"),
|
||||||
|
import(
|
||||||
|
/* webpackIgnore: true */ "../../3rd-party/xterm-readline.js"
|
||||||
|
),
|
||||||
|
import(
|
||||||
|
/* webpackIgnore: true */ "../../3rd-party/xterm_addon-fit.js"
|
||||||
|
),
|
||||||
|
import(
|
||||||
|
/* webpackIgnore: true */ "../../3rd-party/xterm_addon-web-links.js"
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const readline = new Readline();
|
||||||
|
|
||||||
|
// common main thread initialization for both worker
|
||||||
|
// or main case, bootstrapping the terminal on its target
|
||||||
|
const init = (options) => {
|
||||||
|
let target = element;
|
||||||
|
const selector = element.getAttribute("target");
|
||||||
|
if (selector) {
|
||||||
|
target =
|
||||||
|
document.getElementById(selector) ||
|
||||||
|
document.querySelector(selector);
|
||||||
|
if (!target) throw new Error(`Unknown target ${selector}`);
|
||||||
|
} else {
|
||||||
|
target = document.createElement("py-terminal");
|
||||||
|
target.style.display = "block";
|
||||||
|
element.after(target);
|
||||||
|
}
|
||||||
|
const terminal = new Terminal({
|
||||||
|
theme: {
|
||||||
|
background: "#191A19",
|
||||||
|
foreground: "#F5F2E7",
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
const fitAddon = new FitAddon();
|
||||||
|
terminal.loadAddon(fitAddon);
|
||||||
|
terminal.loadAddon(readline);
|
||||||
|
terminal.loadAddon(new WebLinksAddon());
|
||||||
|
terminal.open(target);
|
||||||
|
fitAddon.fit();
|
||||||
|
terminal.focus();
|
||||||
|
defineProperties(element, {
|
||||||
|
terminal: { value: terminal },
|
||||||
|
process: {
|
||||||
|
value: async (code) => {
|
||||||
|
for (const line of code.split(/(?:\r\n|\r|\n)/)) {
|
||||||
|
terminal.paste(`${line}`);
|
||||||
|
terminal.write("\r\n");
|
||||||
|
do {
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, 0),
|
||||||
|
);
|
||||||
|
} while (!readline.activeRead?.resolve);
|
||||||
|
readline.activeRead.resolve(line);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return terminal;
|
||||||
|
};
|
||||||
|
|
||||||
|
// branch logic for the worker
|
||||||
|
if (element.hasAttribute("worker")) {
|
||||||
|
// add a hook on the main thread to setup all sync helpers
|
||||||
|
// also bootstrapping the XTerm target on main *BUT* ...
|
||||||
|
hooks.main.onWorker.add(function worker(_, xworker) {
|
||||||
|
// ... as multiple workers will add multiple callbacks
|
||||||
|
// be sure no xworker is ever initialized twice!
|
||||||
|
if (bootstrapped.has(xworker)) return;
|
||||||
|
bootstrapped.add(xworker);
|
||||||
|
|
||||||
|
// still cleanup this callback for future scripts/workers
|
||||||
|
hooks.main.onWorker.delete(worker);
|
||||||
|
|
||||||
|
init({
|
||||||
|
disableStdin: false,
|
||||||
|
cursorBlink: true,
|
||||||
|
cursorStyle: "block",
|
||||||
|
});
|
||||||
|
|
||||||
|
xworker.sync.is_pyterminal = () => true;
|
||||||
|
xworker.sync.pyterminal_read = readline.read.bind(readline);
|
||||||
|
xworker.sync.pyterminal_write = readline.write.bind(readline);
|
||||||
|
});
|
||||||
|
|
||||||
|
// setup remote thread JS/Python code for whenever the
|
||||||
|
// worker is ready to become a terminal
|
||||||
|
hooks.worker.onReady.add(workerReady);
|
||||||
|
} else {
|
||||||
|
// in the main case, just bootstrap XTerm without
|
||||||
|
// allowing any input as that's not possible / awkward
|
||||||
|
hooks.main.onReady.add(function main({ interpreter, io, run, type }) {
|
||||||
|
if (type !== "py") return;
|
||||||
|
|
||||||
|
console.warn("py-terminal is read only on main thread");
|
||||||
|
hooks.main.onReady.delete(main);
|
||||||
|
|
||||||
|
// on main, it's easy to trash and clean the current terminal
|
||||||
|
globalThis.__py_terminal__ = init({
|
||||||
|
disableStdin: true,
|
||||||
|
cursorBlink: false,
|
||||||
|
cursorStyle: "underline",
|
||||||
|
});
|
||||||
|
run("from js import __py_terminal__ as __terminal__");
|
||||||
|
delete globalThis.__py_terminal__;
|
||||||
|
|
||||||
|
io.stderr = (error) => {
|
||||||
|
readline.write(String(error.message || error));
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = "";
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const generic = {
|
||||||
|
isatty: false,
|
||||||
|
write(buffer) {
|
||||||
|
data = decoder.decode(buffer);
|
||||||
|
readline.write(data);
|
||||||
|
return buffer.length;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
interpreter.setStdout(generic);
|
||||||
|
interpreter.setStderr(generic);
|
||||||
|
interpreter.setStdin({
|
||||||
|
isatty: false,
|
||||||
|
stdin: () => readline.read(data),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -37,7 +37,7 @@ const python = [
|
|||||||
"_path = None",
|
"_path = None",
|
||||||
];
|
];
|
||||||
|
|
||||||
const ignore = new Ignore(python, "./pyweb");
|
const ignore = new Ignore(python, "-");
|
||||||
|
|
||||||
const write = (base, literal) => {
|
const write = (base, literal) => {
|
||||||
for (const [key, value] of entries(literal)) {
|
for (const [key, value] of entries(literal)) {
|
||||||
|
|||||||
@@ -29,17 +29,25 @@
|
|||||||
# 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 polyscript import lazy_py_modules as py_import
|
||||||
from pyscript.display import HTML, display
|
from pyscript.display import HTML, display
|
||||||
from pyscript.fetch import fetch
|
from pyscript.fetch import fetch
|
||||||
from pyscript.magic_js import (
|
from pyscript.magic_js import (
|
||||||
RUNNING_IN_WORKER,
|
RUNNING_IN_WORKER,
|
||||||
PyWorker,
|
PyWorker,
|
||||||
|
config,
|
||||||
current_target,
|
current_target,
|
||||||
document,
|
document,
|
||||||
|
js_import,
|
||||||
js_modules,
|
js_modules,
|
||||||
sync,
|
sync,
|
||||||
window,
|
window,
|
||||||
)
|
)
|
||||||
|
from pyscript.storage import Storage, storage
|
||||||
|
from pyscript.websocket import WebSocket
|
||||||
|
|
||||||
|
if not RUNNING_IN_WORKER:
|
||||||
|
from pyscript.workers import create_named_worker, workers
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from pyscript.event_handling import when
|
from pyscript.event_handling import when
|
||||||
|
|||||||
@@ -19,27 +19,36 @@ def when(event_type=None, selector=None):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
|
|
||||||
|
from pyscript.web import Element, ElementCollection
|
||||||
|
|
||||||
if isinstance(selector, str):
|
if isinstance(selector, str):
|
||||||
elements = document.querySelectorAll(selector)
|
elements = document.querySelectorAll(selector)
|
||||||
else:
|
|
||||||
# TODO: This is a hack that will be removed when pyscript becomes a package
|
# TODO: This is a hack that will be removed when pyscript becomes a package
|
||||||
# and we can better manage the imports without circular dependencies
|
# and we can better manage the imports without circular dependencies
|
||||||
from pyweb import pydom
|
elif isinstance(selector, Element):
|
||||||
|
elements = [selector._dom_element]
|
||||||
if isinstance(selector, pydom.Element):
|
elif isinstance(selector, ElementCollection):
|
||||||
elements = [selector._js]
|
elements = [el._dom_element for el in selector]
|
||||||
elif isinstance(selector, pydom.ElementCollection):
|
|
||||||
elements = [el._js for el in selector]
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(
|
if isinstance(selector, list):
|
||||||
f"Invalid selector: {selector}. Selector must"
|
elements = selector
|
||||||
" be a string, a pydom.Element or a pydom.ElementCollection."
|
else:
|
||||||
)
|
elements = [selector]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sig = inspect.signature(func)
|
sig = inspect.signature(func)
|
||||||
# Function doesn't receive events
|
# Function doesn't receive events
|
||||||
if not sig.parameters:
|
if not sig.parameters:
|
||||||
|
|
||||||
|
# Function is async: must be awaited
|
||||||
|
if inspect.iscoroutinefunction(func):
|
||||||
|
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
await func()
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
func()
|
func()
|
||||||
|
|
||||||
@@ -47,13 +56,14 @@ def when(event_type=None, selector=None):
|
|||||||
wrapper = func
|
wrapper = func
|
||||||
|
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
# TODO: this is currently an quick hack to get micropython working but we need
|
# TODO: this is very ugly hack to get micropython working because inspect.signature
|
||||||
# to actually properly replace inspect.signature with something else
|
# doesn't exist, but we need to actually properly replace inspect.signature.
|
||||||
|
# It may be actually better to not try any magic for now and raise the error
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
try:
|
try:
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
except TypeError as e:
|
except TypeError as e:
|
||||||
if "takes 0 positional arguments" in str(e):
|
if "takes" in str(e) and "positional arguments" in str(e):
|
||||||
return func()
|
return func()
|
||||||
|
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
import js
|
import js
|
||||||
|
from pyscript.util import as_bytearray
|
||||||
|
|
||||||
|
|
||||||
### wrap the response to grant Pythonic results
|
### wrap the response to grant Pythonic results
|
||||||
@@ -12,14 +13,6 @@ class _Response:
|
|||||||
def __getattr__(self, attr):
|
def __getattr__(self, attr):
|
||||||
return getattr(self._response, attr)
|
return getattr(self._response, attr)
|
||||||
|
|
||||||
def _as_bytearray(self, buffer):
|
|
||||||
ui8a = js.Uint8Array.new(buffer)
|
|
||||||
size = ui8a.length
|
|
||||||
ba = bytearray(size)
|
|
||||||
for i in range(0, size):
|
|
||||||
ba[i] = ui8a[i]
|
|
||||||
return ba
|
|
||||||
|
|
||||||
# exposed methods with Pythonic results
|
# exposed methods with Pythonic results
|
||||||
async def arrayBuffer(self):
|
async def arrayBuffer(self):
|
||||||
buffer = await self._response.arrayBuffer()
|
buffer = await self._response.arrayBuffer()
|
||||||
@@ -27,14 +20,14 @@ class _Response:
|
|||||||
if hasattr(buffer, "to_py"):
|
if hasattr(buffer, "to_py"):
|
||||||
return buffer.to_py()
|
return buffer.to_py()
|
||||||
# shims in MicroPython
|
# shims in MicroPython
|
||||||
return memoryview(self._as_bytearray(buffer))
|
return memoryview(as_bytearray(buffer))
|
||||||
|
|
||||||
async def blob(self):
|
async def blob(self):
|
||||||
return await self._response.blob()
|
return await self._response.blob()
|
||||||
|
|
||||||
async def bytearray(self):
|
async def bytearray(self):
|
||||||
buffer = await self._response.arrayBuffer()
|
buffer = await self._response.arrayBuffer()
|
||||||
return self._as_bytearray(buffer)
|
return as_bytearray(buffer)
|
||||||
|
|
||||||
async def json(self):
|
async def json(self):
|
||||||
return json.loads(await self.text())
|
return json.loads(await self.text())
|
||||||
|
|||||||
148
pyscript.core/src/stdlib/pyscript/flatted.py
Normal file
148
pyscript.core/src/stdlib/pyscript/flatted.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# https://www.npmjs.com/package/flatted
|
||||||
|
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
|
||||||
|
class _Known:
|
||||||
|
def __init__(self):
|
||||||
|
self.key = []
|
||||||
|
self.value = []
|
||||||
|
|
||||||
|
|
||||||
|
class _String:
|
||||||
|
def __init__(self, value):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
|
def _array_keys(value):
|
||||||
|
keys = []
|
||||||
|
i = 0
|
||||||
|
for _ in value:
|
||||||
|
keys.append(i)
|
||||||
|
i += 1
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def _object_keys(value):
|
||||||
|
keys = []
|
||||||
|
for key in value:
|
||||||
|
keys.append(key)
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def _is_array(value):
|
||||||
|
return isinstance(value, list) or isinstance(value, tuple)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_object(value):
|
||||||
|
return isinstance(value, dict)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_string(value):
|
||||||
|
return isinstance(value, str)
|
||||||
|
|
||||||
|
|
||||||
|
def _index(known, input, value):
|
||||||
|
input.append(value)
|
||||||
|
index = str(len(input) - 1)
|
||||||
|
known.key.append(value)
|
||||||
|
known.value.append(index)
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _loop(keys, input, known, output):
|
||||||
|
for key in keys:
|
||||||
|
value = output[key]
|
||||||
|
if isinstance(value, _String):
|
||||||
|
_ref(key, input[int(value.value)], input, known, output)
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def _ref(key, value, input, known, output):
|
||||||
|
if _is_array(value) and not value in known:
|
||||||
|
known.append(value)
|
||||||
|
value = _loop(_array_keys(value), input, known, value)
|
||||||
|
elif _is_object(value) and not value in known:
|
||||||
|
known.append(value)
|
||||||
|
value = _loop(_object_keys(value), input, known, value)
|
||||||
|
|
||||||
|
output[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def _relate(known, input, value):
|
||||||
|
if _is_string(value) or _is_array(value) or _is_object(value):
|
||||||
|
try:
|
||||||
|
return known.value[known.key.index(value)]
|
||||||
|
except:
|
||||||
|
return _index(known, input, value)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _transform(known, input, value):
|
||||||
|
if _is_array(value):
|
||||||
|
output = []
|
||||||
|
for val in value:
|
||||||
|
output.append(_relate(known, input, val))
|
||||||
|
return output
|
||||||
|
|
||||||
|
if _is_object(value):
|
||||||
|
obj = {}
|
||||||
|
for key in value:
|
||||||
|
obj[key] = _relate(known, input, value[key])
|
||||||
|
return obj
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap(value):
|
||||||
|
if _is_string(value):
|
||||||
|
return _String(value)
|
||||||
|
|
||||||
|
if _is_array(value):
|
||||||
|
i = 0
|
||||||
|
for val in value:
|
||||||
|
value[i] = _wrap(val)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
elif _is_object(value):
|
||||||
|
for key in value:
|
||||||
|
value[key] = _wrap(value[key])
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def parse(value, *args, **kwargs):
|
||||||
|
json = _json.loads(value, *args, **kwargs)
|
||||||
|
wrapped = []
|
||||||
|
for value in json:
|
||||||
|
wrapped.append(_wrap(value))
|
||||||
|
|
||||||
|
input = []
|
||||||
|
for value in wrapped:
|
||||||
|
if isinstance(value, _String):
|
||||||
|
input.append(value.value)
|
||||||
|
else:
|
||||||
|
input.append(value)
|
||||||
|
|
||||||
|
value = input[0]
|
||||||
|
|
||||||
|
if _is_array(value):
|
||||||
|
return _loop(_array_keys(value), input, [value], value)
|
||||||
|
|
||||||
|
if _is_object(value):
|
||||||
|
return _loop(_object_keys(value), input, [value], value)
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def stringify(value, *args, **kwargs):
|
||||||
|
known = _Known()
|
||||||
|
input = []
|
||||||
|
output = []
|
||||||
|
i = int(_index(known, input, value))
|
||||||
|
while i < len(input):
|
||||||
|
output.append(_transform(known, input, input[i]))
|
||||||
|
i += 1
|
||||||
|
return _json.dumps(output, *args, **kwargs)
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import js as globalThis
|
import js as globalThis
|
||||||
|
from polyscript import config as _config
|
||||||
from polyscript import js_modules
|
from polyscript import js_modules
|
||||||
from pyscript.util import NotSupported
|
from pyscript.util import NotSupported
|
||||||
|
|
||||||
RUNNING_IN_WORKER = not hasattr(globalThis, "document")
|
RUNNING_IN_WORKER = not hasattr(globalThis, "document")
|
||||||
|
|
||||||
|
config = json.loads(globalThis.JSON.stringify(_config))
|
||||||
|
|
||||||
|
if "MicroPython" in sys.version:
|
||||||
|
config["type"] = "mpy"
|
||||||
|
else:
|
||||||
|
config["type"] = "py"
|
||||||
|
|
||||||
|
|
||||||
# allow `from pyscript.js_modules.xxx import yyy`
|
# allow `from pyscript.js_modules.xxx import yyy`
|
||||||
class JSModule:
|
class JSModule:
|
||||||
@@ -32,24 +41,21 @@ if RUNNING_IN_WORKER:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
globalThis.SharedArrayBuffer.new(4)
|
|
||||||
import js
|
import js
|
||||||
|
|
||||||
window = polyscript.xworker.window
|
window = polyscript.xworker.window
|
||||||
document = window.document
|
document = window.document
|
||||||
js.document = document
|
js.document = document
|
||||||
|
# this is the same as js_import on main and it lands modules on main
|
||||||
|
js_import = window.Function(
|
||||||
|
"return (...urls) => Promise.all(urls.map((url) => import(url)))"
|
||||||
|
)()
|
||||||
except:
|
except:
|
||||||
globalThis.console.debug("SharedArrayBuffer is not available")
|
message = "Unable to use `window` or `document` -> https://docs.pyscript.net/latest/faq/#sharedarraybuffer"
|
||||||
# in this scenario none of the utilities would work
|
globalThis.console.warn(message)
|
||||||
# as expected so we better export these as NotSupported
|
window = NotSupported("pyscript.window", message)
|
||||||
window = NotSupported(
|
document = NotSupported("pyscript.document", message)
|
||||||
"pyscript.window",
|
js_import = None
|
||||||
"pyscript.window in workers works only via SharedArrayBuffer",
|
|
||||||
)
|
|
||||||
document = NotSupported(
|
|
||||||
"pyscript.document",
|
|
||||||
"pyscript.document in workers works only via SharedArrayBuffer",
|
|
||||||
)
|
|
||||||
|
|
||||||
sync = polyscript.xworker.sync
|
sync = polyscript.xworker.sync
|
||||||
|
|
||||||
@@ -60,7 +66,7 @@ if RUNNING_IN_WORKER:
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
import _pyscript
|
import _pyscript
|
||||||
from _pyscript import PyWorker
|
from _pyscript import PyWorker, js_import
|
||||||
|
|
||||||
window = globalThis
|
window = globalThis
|
||||||
document = globalThis.document
|
document = globalThis.document
|
||||||
|
|||||||
60
pyscript.core/src/stdlib/pyscript/storage.py
Normal file
60
pyscript.core/src/stdlib/pyscript/storage.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from polyscript import storage as _storage
|
||||||
|
from pyscript.flatted import parse as _parse
|
||||||
|
from pyscript.flatted import stringify as _stringify
|
||||||
|
|
||||||
|
|
||||||
|
# convert a Python value into an IndexedDB compatible entry
|
||||||
|
def _to_idb(value):
|
||||||
|
if value is None:
|
||||||
|
return _stringify(["null", 0])
|
||||||
|
if isinstance(value, (bool, float, int, str, list, dict, tuple)):
|
||||||
|
return _stringify(["generic", value])
|
||||||
|
if isinstance(value, bytearray):
|
||||||
|
return _stringify(["bytearray", [v for v in value]])
|
||||||
|
if isinstance(value, memoryview):
|
||||||
|
return _stringify(["memoryview", [v for v in value]])
|
||||||
|
raise TypeError(f"Unexpected value: {value}")
|
||||||
|
|
||||||
|
|
||||||
|
# convert an IndexedDB compatible entry into a Python value
|
||||||
|
def _from_idb(value):
|
||||||
|
(
|
||||||
|
kind,
|
||||||
|
result,
|
||||||
|
) = _parse(value)
|
||||||
|
if kind == "null":
|
||||||
|
return None
|
||||||
|
if kind == "generic":
|
||||||
|
return result
|
||||||
|
if kind == "bytearray":
|
||||||
|
return bytearray(result)
|
||||||
|
if kind == "memoryview":
|
||||||
|
return memoryview(bytearray(result))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class Storage(dict):
|
||||||
|
def __init__(self, store):
|
||||||
|
super().__init__({k: _from_idb(v) for k, v in store.entries()})
|
||||||
|
self.__store__ = store
|
||||||
|
|
||||||
|
def __delitem__(self, attr):
|
||||||
|
self.__store__.delete(attr)
|
||||||
|
super().__delitem__(attr)
|
||||||
|
|
||||||
|
def __setitem__(self, attr, value):
|
||||||
|
self.__store__.set(attr, _to_idb(value))
|
||||||
|
super().__setitem__(attr, value)
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
self.__store__.clear()
|
||||||
|
super().clear()
|
||||||
|
|
||||||
|
async def sync(self):
|
||||||
|
await self.__store__.sync()
|
||||||
|
|
||||||
|
|
||||||
|
async def storage(name="", storage_class=Storage):
|
||||||
|
if not name:
|
||||||
|
raise ValueError("The storage name must be defined")
|
||||||
|
return storage_class(await _storage(f"@pyscript/{name}"))
|
||||||
@@ -1,3 +1,15 @@
|
|||||||
|
import js
|
||||||
|
|
||||||
|
|
||||||
|
def as_bytearray(buffer):
|
||||||
|
ui8a = js.Uint8Array.new(buffer)
|
||||||
|
size = ui8a.length
|
||||||
|
ba = bytearray(size)
|
||||||
|
for i in range(0, size):
|
||||||
|
ba[i] = ui8a[i]
|
||||||
|
return ba
|
||||||
|
|
||||||
|
|
||||||
class NotSupported:
|
class NotSupported:
|
||||||
"""
|
"""
|
||||||
Small helper that raises exceptions if you try to get/set any attribute on
|
Small helper that raises exceptions if you try to get/set any attribute on
|
||||||
|
|||||||
1176
pyscript.core/src/stdlib/pyscript/web.py
Normal file
1176
pyscript.core/src/stdlib/pyscript/web.py
Normal file
File diff suppressed because it is too large
Load Diff
71
pyscript.core/src/stdlib/pyscript/websocket.py
Normal file
71
pyscript.core/src/stdlib/pyscript/websocket.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import js
|
||||||
|
from pyscript.ffi import create_proxy
|
||||||
|
from pyscript.util import as_bytearray
|
||||||
|
|
||||||
|
code = "code"
|
||||||
|
protocols = "protocols"
|
||||||
|
reason = "reason"
|
||||||
|
methods = ["onclose", "onerror", "onmessage", "onopen"]
|
||||||
|
|
||||||
|
|
||||||
|
class EventMessage:
|
||||||
|
def __init__(self, event):
|
||||||
|
self._event = event
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
value = getattr(self._event, attr)
|
||||||
|
|
||||||
|
if attr == "data" and not isinstance(value, str):
|
||||||
|
if hasattr(value, "to_py"):
|
||||||
|
return value.to_py()
|
||||||
|
# shims in MicroPython
|
||||||
|
return memoryview(as_bytearray(value))
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocket(object):
|
||||||
|
CONNECTING = 0
|
||||||
|
OPEN = 1
|
||||||
|
CLOSING = 2
|
||||||
|
CLOSED = 3
|
||||||
|
|
||||||
|
def __init__(self, **kw):
|
||||||
|
url = kw["url"]
|
||||||
|
if protocols in kw:
|
||||||
|
socket = js.WebSocket.new(url, kw[protocols])
|
||||||
|
else:
|
||||||
|
socket = js.WebSocket.new(url)
|
||||||
|
object.__setattr__(self, "_ws", socket)
|
||||||
|
|
||||||
|
for t in methods:
|
||||||
|
if t in kw:
|
||||||
|
# Pyodide fails at setting socket[t] directly
|
||||||
|
setattr(socket, t, create_proxy(kw[t]))
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
return getattr(self._ws, attr)
|
||||||
|
|
||||||
|
def __setattr__(self, attr, value):
|
||||||
|
if attr in methods:
|
||||||
|
m = lambda e: value(EventMessage(e))
|
||||||
|
setattr(self._ws, attr, create_proxy(m))
|
||||||
|
else:
|
||||||
|
setattr(self._ws, attr, value)
|
||||||
|
|
||||||
|
def close(self, **kw):
|
||||||
|
if code in kw and reason in kw:
|
||||||
|
self._ws.close(kw[code], kw[reason])
|
||||||
|
elif code in kw:
|
||||||
|
self._ws.close(kw[code])
|
||||||
|
else:
|
||||||
|
self._ws.close()
|
||||||
|
|
||||||
|
def send(self, data):
|
||||||
|
if isinstance(data, str):
|
||||||
|
self._ws.send(data)
|
||||||
|
else:
|
||||||
|
buffer = js.Uint8Array.new(len(data))
|
||||||
|
for pos, b in enumerate(data):
|
||||||
|
buffer[pos] = b
|
||||||
|
self._ws.send(buffer)
|
||||||
43
pyscript.core/src/stdlib/pyscript/workers.py
Normal file
43
pyscript.core/src/stdlib/pyscript/workers.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import js as _js
|
||||||
|
from polyscript import workers as _workers
|
||||||
|
|
||||||
|
_get = _js.Reflect.get
|
||||||
|
|
||||||
|
|
||||||
|
def _set(script, name, value=""):
|
||||||
|
script.setAttribute(name, value)
|
||||||
|
|
||||||
|
|
||||||
|
# this solves an inconsistency between Pyodide and MicroPython
|
||||||
|
# @see https://github.com/pyscript/pyscript/issues/2106
|
||||||
|
class _ReadOnlyProxy:
|
||||||
|
def __getitem__(self, name):
|
||||||
|
return _get(_workers, name)
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return _get(_workers, name)
|
||||||
|
|
||||||
|
|
||||||
|
workers = _ReadOnlyProxy()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_named_worker(src="", name="", config=None, type="py"):
|
||||||
|
from json import dumps
|
||||||
|
|
||||||
|
if not src:
|
||||||
|
raise ValueError("Named workers require src")
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
raise ValueError("Named workers require a name")
|
||||||
|
|
||||||
|
s = _js.document.createElement("script")
|
||||||
|
s.type = type
|
||||||
|
s.src = src
|
||||||
|
_set(s, "worker")
|
||||||
|
_set(s, "name", name)
|
||||||
|
|
||||||
|
if config:
|
||||||
|
_set(s, "config", isinstance(config, str) and config or dumps(config))
|
||||||
|
|
||||||
|
_js.document.body.append(s)
|
||||||
|
return await workers[name]
|
||||||
@@ -1 +0,0 @@
|
|||||||
from .pydom import dom as pydom
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
from pyodide.ffi import to_js
|
|
||||||
from pyscript import window
|
|
||||||
|
|
||||||
|
|
||||||
class Device:
|
|
||||||
"""Device represents a media input or output device, such as a microphone,
|
|
||||||
camera, or headset.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, device):
|
|
||||||
self._js = device
|
|
||||||
|
|
||||||
@property
|
|
||||||
def id(self):
|
|
||||||
return self._js.deviceId
|
|
||||||
|
|
||||||
@property
|
|
||||||
def group(self):
|
|
||||||
return self._js.groupId
|
|
||||||
|
|
||||||
@property
|
|
||||||
def kind(self):
|
|
||||||
return self._js.kind
|
|
||||||
|
|
||||||
@property
|
|
||||||
def label(self):
|
|
||||||
return self._js.label
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
|
||||||
return getattr(self, key)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def load(cls, audio=False, video=True):
|
|
||||||
"""Load the device stream."""
|
|
||||||
options = window.Object.new()
|
|
||||||
options.audio = audio
|
|
||||||
if isinstance(video, bool):
|
|
||||||
options.video = video
|
|
||||||
else:
|
|
||||||
# TODO: Think this can be simplified but need to check it on the pyodide side
|
|
||||||
|
|
||||||
# TODO: this is pyodide specific. shouldn't be!
|
|
||||||
options.video = window.Object.new()
|
|
||||||
for k in video:
|
|
||||||
setattr(
|
|
||||||
options.video,
|
|
||||||
k,
|
|
||||||
to_js(video[k], dict_converter=window.Object.fromEntries),
|
|
||||||
)
|
|
||||||
|
|
||||||
stream = await window.navigator.mediaDevices.getUserMedia(options)
|
|
||||||
return stream
|
|
||||||
|
|
||||||
async def get_stream(self):
|
|
||||||
key = self.kind.replace("input", "").replace("output", "")
|
|
||||||
options = {key: {"deviceId": {"exact": self.id}}}
|
|
||||||
|
|
||||||
return await self.load(**options)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_devices() -> list[dict]:
|
|
||||||
"""
|
|
||||||
Return the list of the currently available media input and output devices,
|
|
||||||
such as microphones, cameras, headsets, and so forth.
|
|
||||||
|
|
||||||
Output:
|
|
||||||
|
|
||||||
list(dict) - list of dictionaries representing the available media devices.
|
|
||||||
Each dictionary has the following keys:
|
|
||||||
* deviceId: a string that is an identifier for the represented device
|
|
||||||
that is persisted across sessions. It is un-guessable by other
|
|
||||||
applications and unique to the origin of the calling application.
|
|
||||||
It is reset when the user clears cookies (for Private Browsing, a
|
|
||||||
different identifier is used that is not persisted across sessions).
|
|
||||||
|
|
||||||
* groupId: a string that is a group identifier. Two devices have the same
|
|
||||||
group identifier if they belong to the same physical device — for
|
|
||||||
example a monitor with both a built-in camera and a microphone.
|
|
||||||
|
|
||||||
* kind: an enumerated value that is either "videoinput", "audioinput"
|
|
||||||
or "audiooutput".
|
|
||||||
|
|
||||||
* label: a string describing this device (for example "External USB
|
|
||||||
Webcam").
|
|
||||||
|
|
||||||
Note: the returned list will omit any devices that are blocked by the document
|
|
||||||
Permission Policy: microphone, camera, speaker-selection (for output devices),
|
|
||||||
and so on. Access to particular non-default devices is also gated by the
|
|
||||||
Permissions API, and the list will omit devices for which the user has not
|
|
||||||
granted explicit permission.
|
|
||||||
"""
|
|
||||||
# https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices
|
|
||||||
return [
|
|
||||||
Device(obj) for obj in await window.navigator.mediaDevices.enumerateDevices()
|
|
||||||
]
|
|
||||||
@@ -1,550 +0,0 @@
|
|||||||
try:
|
|
||||||
from typing import Any
|
|
||||||
except ImportError:
|
|
||||||
Any = "Any"
|
|
||||||
|
|
||||||
try:
|
|
||||||
import warnings
|
|
||||||
except ImportError:
|
|
||||||
# TODO: For now it probably means we are in MicroPython. We should figure
|
|
||||||
# out the "right" way to handle this. For now we just ignore the warning
|
|
||||||
# and logging to console
|
|
||||||
class warnings:
|
|
||||||
@staticmethod
|
|
||||||
def warn(*args, **kwargs):
|
|
||||||
print("WARNING: ", *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
from functools import cached_property
|
|
||||||
except ImportError:
|
|
||||||
# TODO: same comment about micropython as above
|
|
||||||
cached_property = property
|
|
||||||
|
|
||||||
try:
|
|
||||||
from pyodide.ffi import JsProxy
|
|
||||||
except ImportError:
|
|
||||||
# TODO: same comment about micropython as above
|
|
||||||
def JsProxy(obj):
|
|
||||||
return obj
|
|
||||||
|
|
||||||
|
|
||||||
from pyscript import display, document, window
|
|
||||||
|
|
||||||
alert = window.alert
|
|
||||||
|
|
||||||
|
|
||||||
class BaseElement:
|
|
||||||
def __init__(self, js_element):
|
|
||||||
self._js = js_element
|
|
||||||
self._parent = None
|
|
||||||
self.style = StyleProxy(self)
|
|
||||||
self._proxies = {}
|
|
||||||
|
|
||||||
def __eq__(self, obj):
|
|
||||||
"""Check if the element is the same as the other element by comparing
|
|
||||||
the underlying JS element"""
|
|
||||||
return isinstance(obj, BaseElement) and obj._js == self._js
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parent(self):
|
|
||||||
if self._parent:
|
|
||||||
return self._parent
|
|
||||||
|
|
||||||
if self._js.parentElement:
|
|
||||||
self._parent = self.__class__(self._js.parentElement)
|
|
||||||
|
|
||||||
return self._parent
|
|
||||||
|
|
||||||
@property
|
|
||||||
def __class(self):
|
|
||||||
return self.__class__ if self.__class__ != PyDom else Element
|
|
||||||
|
|
||||||
def create(self, type_, is_child=True, classes=None, html=None, label=None):
|
|
||||||
js_el = document.createElement(type_)
|
|
||||||
element = self.__class(js_el)
|
|
||||||
|
|
||||||
if classes:
|
|
||||||
for class_ in classes:
|
|
||||||
element.add_class(class_)
|
|
||||||
|
|
||||||
if html is not None:
|
|
||||||
element.html = html
|
|
||||||
|
|
||||||
if label is not None:
|
|
||||||
element.label = label
|
|
||||||
|
|
||||||
if is_child:
|
|
||||||
self.append(element)
|
|
||||||
|
|
||||||
return element
|
|
||||||
|
|
||||||
def find(self, selector):
|
|
||||||
"""Return an ElementCollection representing all the child elements that
|
|
||||||
match the specified selector.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
selector (str): A string containing a selector expression
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ElementCollection: A collection of elements matching the selector
|
|
||||||
"""
|
|
||||||
elements = self._js.querySelectorAll(selector)
|
|
||||||
if not elements:
|
|
||||||
return None
|
|
||||||
return ElementCollection([Element(el) for el in elements])
|
|
||||||
|
|
||||||
|
|
||||||
class Element(BaseElement):
|
|
||||||
@property
|
|
||||||
def children(self):
|
|
||||||
return [self.__class__(el) for el in self._js.children]
|
|
||||||
|
|
||||||
def append(self, child):
|
|
||||||
# TODO: this is Pyodide specific for now!!!!!!
|
|
||||||
# if we get passed a JSProxy Element directly we just map it to the
|
|
||||||
# higher level Python element
|
|
||||||
if isinstance(child, JsProxy):
|
|
||||||
return self.append(Element(child))
|
|
||||||
|
|
||||||
elif isinstance(child, Element):
|
|
||||||
self._js.appendChild(child._js)
|
|
||||||
|
|
||||||
return child
|
|
||||||
|
|
||||||
elif isinstance(child, ElementCollection):
|
|
||||||
for el in child:
|
|
||||||
self.append(el)
|
|
||||||
|
|
||||||
# -------- Pythonic Interface to Element -------- #
|
|
||||||
@property
|
|
||||||
def html(self):
|
|
||||||
return self._js.innerHTML
|
|
||||||
|
|
||||||
@html.setter
|
|
||||||
def html(self, value):
|
|
||||||
self._js.innerHTML = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def text(self):
|
|
||||||
return self._js.textContent
|
|
||||||
|
|
||||||
@text.setter
|
|
||||||
def text(self, value):
|
|
||||||
self._js.textContent = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def content(self):
|
|
||||||
# TODO: This breaks with with standard template elements. Define how to best
|
|
||||||
# handle this specifica use case. Just not support for now?
|
|
||||||
if self._js.tagName == "TEMPLATE":
|
|
||||||
warnings.warn(
|
|
||||||
"Content attribute not supported for template elements.", stacklevel=2
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
return self._js.innerHTML
|
|
||||||
|
|
||||||
@content.setter
|
|
||||||
def content(self, value):
|
|
||||||
# TODO: (same comment as above)
|
|
||||||
if self._js.tagName == "TEMPLATE":
|
|
||||||
warnings.warn(
|
|
||||||
"Content attribute not supported for template elements.", stacklevel=2
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
display(value, target=self.id)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def id(self):
|
|
||||||
return self._js.id
|
|
||||||
|
|
||||||
@id.setter
|
|
||||||
def id(self, value):
|
|
||||||
self._js.id = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def options(self):
|
|
||||||
if "options" in self._proxies:
|
|
||||||
return self._proxies["options"]
|
|
||||||
|
|
||||||
if not self._js.tagName.lower() in {"select", "datalist", "optgroup"}:
|
|
||||||
raise AttributeError(
|
|
||||||
f"Element {self._js.tagName} has no options attribute."
|
|
||||||
)
|
|
||||||
self._proxies["options"] = OptionsProxy(self)
|
|
||||||
return self._proxies["options"]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def value(self):
|
|
||||||
return self._js.value
|
|
||||||
|
|
||||||
@value.setter
|
|
||||||
def value(self, value):
|
|
||||||
# in order to avoid confusion to the user, we don't allow setting the
|
|
||||||
# value of elements that don't have a value attribute
|
|
||||||
if not hasattr(self._js, "value"):
|
|
||||||
raise AttributeError(
|
|
||||||
f"Element {self._js.tagName} has no value attribute. If you want to "
|
|
||||||
"force a value attribute, set it directly using the `_js.value = <value>` "
|
|
||||||
"javascript API attribute instead."
|
|
||||||
)
|
|
||||||
self._js.value = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def selected(self):
|
|
||||||
return self._js.selected
|
|
||||||
|
|
||||||
@selected.setter
|
|
||||||
def selected(self, value):
|
|
||||||
# in order to avoid confusion to the user, we don't allow setting the
|
|
||||||
# value of elements that don't have a value attribute
|
|
||||||
if not hasattr(self._js, "selected"):
|
|
||||||
raise AttributeError(
|
|
||||||
f"Element {self._js.tagName} has no value attribute. If you want to "
|
|
||||||
"force a value attribute, set it directly using the `_js.value = <value>` "
|
|
||||||
"javascript API attribute instead."
|
|
||||||
)
|
|
||||||
self._js.selected = value
|
|
||||||
|
|
||||||
def clone(self, new_id=None):
|
|
||||||
clone = Element(self._js.cloneNode(True))
|
|
||||||
clone.id = new_id
|
|
||||||
|
|
||||||
return clone
|
|
||||||
|
|
||||||
def remove_class(self, classname):
|
|
||||||
classList = self._js.classList
|
|
||||||
if isinstance(classname, list):
|
|
||||||
classList.remove(*classname)
|
|
||||||
else:
|
|
||||||
classList.remove(classname)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def add_class(self, classname):
|
|
||||||
classList = self._js.classList
|
|
||||||
if isinstance(classname, list):
|
|
||||||
classList.add(*classname)
|
|
||||||
else:
|
|
||||||
self._js.classList.add(classname)
|
|
||||||
return self
|
|
||||||
|
|
||||||
@property
|
|
||||||
def classes(self):
|
|
||||||
classes = self._js.classList.values()
|
|
||||||
return [x for x in classes]
|
|
||||||
|
|
||||||
def show_me(self):
|
|
||||||
self._js.scrollIntoView()
|
|
||||||
|
|
||||||
def snap(
|
|
||||||
self,
|
|
||||||
to: BaseElement | str = None,
|
|
||||||
width: int | None = None,
|
|
||||||
height: int | None = None,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Captures a snapshot of a video element. (Only available for video elements)
|
|
||||||
|
|
||||||
Inputs:
|
|
||||||
|
|
||||||
* to: element where to save the snapshot of the video frame to
|
|
||||||
* width: width of the image
|
|
||||||
* height: height of the image
|
|
||||||
|
|
||||||
Output:
|
|
||||||
(Element) canvas element where the video frame snapshot was drawn into
|
|
||||||
"""
|
|
||||||
if self._js.tagName != "VIDEO":
|
|
||||||
raise AttributeError("Snap method is only available for video Elements")
|
|
||||||
|
|
||||||
if to is None:
|
|
||||||
canvas = self.create("canvas")
|
|
||||||
if width is None:
|
|
||||||
width = self._js.width
|
|
||||||
if height is None:
|
|
||||||
height = self._js.height
|
|
||||||
canvas._js.width = width
|
|
||||||
canvas._js.height = height
|
|
||||||
|
|
||||||
elif isistance(to, Element):
|
|
||||||
if to._js.tagName != "CANVAS":
|
|
||||||
raise TypeError("Element to snap to must a canvas.")
|
|
||||||
canvas = to
|
|
||||||
elif getattr(to, "tagName", "") == "CANVAS":
|
|
||||||
canvas = Element(to)
|
|
||||||
elif isinstance(to, str):
|
|
||||||
canvas = pydom[to][0]
|
|
||||||
if canvas._js.tagName != "CANVAS":
|
|
||||||
raise TypeError("Element to snap to must a be canvas.")
|
|
||||||
|
|
||||||
canvas.draw(self, width, height)
|
|
||||||
|
|
||||||
return canvas
|
|
||||||
|
|
||||||
def download(self, filename: str = "snapped.png") -> None:
|
|
||||||
"""Download the current element (only available for canvas elements) with the filename
|
|
||||||
provided in input.
|
|
||||||
|
|
||||||
Inputs:
|
|
||||||
* filename (str): name of the file being downloaded
|
|
||||||
|
|
||||||
Output:
|
|
||||||
None
|
|
||||||
"""
|
|
||||||
if self._js.tagName != "CANVAS":
|
|
||||||
raise AttributeError(
|
|
||||||
"The download method is only available for canvas Elements"
|
|
||||||
)
|
|
||||||
|
|
||||||
link = self.create("a")
|
|
||||||
link._js.download = filename
|
|
||||||
link._js.href = self._js.toDataURL()
|
|
||||||
link._js.click()
|
|
||||||
|
|
||||||
def draw(self, what, width, height):
|
|
||||||
"""Draw `what` on the current element (only available for canvas elements).
|
|
||||||
|
|
||||||
Inputs:
|
|
||||||
|
|
||||||
* what (canvas image source): An element to draw into the context. The specification permits any canvas
|
|
||||||
image source, specifically, an HTMLImageElement, an SVGImageElement, an HTMLVideoElement,
|
|
||||||
an HTMLCanvasElement, an ImageBitmap, an OffscreenCanvas, or a VideoFrame.
|
|
||||||
"""
|
|
||||||
if self._js.tagName != "CANVAS":
|
|
||||||
raise AttributeError(
|
|
||||||
"The draw method is only available for canvas Elements"
|
|
||||||
)
|
|
||||||
|
|
||||||
if isinstance(what, Element):
|
|
||||||
what = what._js
|
|
||||||
|
|
||||||
# https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage
|
|
||||||
self._js.getContext("2d").drawImage(what, 0, 0, width, height)
|
|
||||||
|
|
||||||
|
|
||||||
class OptionsProxy:
|
|
||||||
"""This class represents the options of a select element. It
|
|
||||||
allows to access to add and remove options by using the `add` and `remove` methods.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, element: Element) -> None:
|
|
||||||
self._element = element
|
|
||||||
if self._element._js.tagName.lower() != "select":
|
|
||||||
raise AttributeError(
|
|
||||||
f"Element {self._element._js.tagName} has no options attribute."
|
|
||||||
)
|
|
||||||
|
|
||||||
def add(
|
|
||||||
self,
|
|
||||||
value: Any = None,
|
|
||||||
html: str = None,
|
|
||||||
text: str = None,
|
|
||||||
before: Element | int = None,
|
|
||||||
**kws,
|
|
||||||
) -> None:
|
|
||||||
"""Add a new option to the select element"""
|
|
||||||
# create the option element and set the attributes
|
|
||||||
option = document.createElement("option")
|
|
||||||
if value is not None:
|
|
||||||
kws["value"] = value
|
|
||||||
if html is not None:
|
|
||||||
option.innerHTML = html
|
|
||||||
if text is not None:
|
|
||||||
kws["text"] = text
|
|
||||||
|
|
||||||
for key, value in kws.items():
|
|
||||||
option.setAttribute(key, value)
|
|
||||||
|
|
||||||
if before:
|
|
||||||
if isinstance(before, Element):
|
|
||||||
before = before._js
|
|
||||||
|
|
||||||
self._element._js.add(option, before)
|
|
||||||
|
|
||||||
def remove(self, item: int) -> None:
|
|
||||||
"""Remove the option at the specified index"""
|
|
||||||
self._element._js.remove(item)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""Remove all the options"""
|
|
||||||
for i in range(len(self)):
|
|
||||||
self.remove(0)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def options(self):
|
|
||||||
"""Return the list of options"""
|
|
||||||
return [Element(opt) for opt in self._element._js.options]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def selected(self):
|
|
||||||
"""Return the selected option"""
|
|
||||||
return self.options[self._element._js.selectedIndex]
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
yield from self.options
|
|
||||||
|
|
||||||
def __len__(self):
|
|
||||||
return len(self.options)
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"{self.__class__.__name__} (length: {len(self)}) {self.options}"
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
|
||||||
return self.options[key]
|
|
||||||
|
|
||||||
|
|
||||||
class StyleProxy: # (dict):
|
|
||||||
def __init__(self, element: Element) -> None:
|
|
||||||
self._element = element
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def _style(self):
|
|
||||||
return self._element._js.style
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
|
||||||
return self._style.getPropertyValue(key)
|
|
||||||
|
|
||||||
def __setitem__(self, key, value):
|
|
||||||
self._style.setProperty(key, value)
|
|
||||||
|
|
||||||
def remove(self, key):
|
|
||||||
self._style.removeProperty(key)
|
|
||||||
|
|
||||||
def set(self, **kws):
|
|
||||||
for k, v in kws.items():
|
|
||||||
self._element._js.style.setProperty(k, v)
|
|
||||||
|
|
||||||
# CSS Properties
|
|
||||||
# Reference: https://github.com/microsoft/TypeScript/blob/main/src/lib/dom.generated.d.ts#L3799C1-L5005C2
|
|
||||||
# Following prperties automatically generated from the above reference using
|
|
||||||
# tools/codegen_css_proxy.py
|
|
||||||
@property
|
|
||||||
def visible(self):
|
|
||||||
return self._element._js.style.visibility
|
|
||||||
|
|
||||||
@visible.setter
|
|
||||||
def visible(self, value):
|
|
||||||
self._element._js.style.visibility = value
|
|
||||||
|
|
||||||
|
|
||||||
class StyleCollection:
|
|
||||||
def __init__(self, collection: "ElementCollection") -> None:
|
|
||||||
self._collection = collection
|
|
||||||
|
|
||||||
def __get__(self, obj, objtype=None):
|
|
||||||
return obj._get_attribute("style")
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
|
||||||
return self._collection._get_attribute("style")[key]
|
|
||||||
|
|
||||||
def __setitem__(self, key, value):
|
|
||||||
for element in self._collection._elements:
|
|
||||||
element.style[key] = value
|
|
||||||
|
|
||||||
def remove(self, key):
|
|
||||||
for element in self._collection._elements:
|
|
||||||
element.style.remove(key)
|
|
||||||
|
|
||||||
|
|
||||||
class ElementCollection:
|
|
||||||
def __init__(self, elements: [Element]) -> None:
|
|
||||||
self._elements = elements
|
|
||||||
self.style = StyleCollection(self)
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
|
||||||
# If it's an integer we use it to access the elements in the collection
|
|
||||||
if isinstance(key, int):
|
|
||||||
return self._elements[key]
|
|
||||||
# If it's a slice we use it to support slice operations over the elements
|
|
||||||
# in the collection
|
|
||||||
elif isinstance(key, slice):
|
|
||||||
return ElementCollection(self._elements[key])
|
|
||||||
|
|
||||||
# If it's anything else (basically a string) we use it as a selector
|
|
||||||
# TODO: Write tests!
|
|
||||||
elements = self._element.querySelectorAll(key)
|
|
||||||
return ElementCollection([Element(el) for el in elements])
|
|
||||||
|
|
||||||
def __len__(self):
|
|
||||||
return len(self._elements)
|
|
||||||
|
|
||||||
def __eq__(self, obj):
|
|
||||||
"""Check if the element is the same as the other element by comparing
|
|
||||||
the underlying JS element"""
|
|
||||||
return isinstance(obj, ElementCollection) and obj._elements == self._elements
|
|
||||||
|
|
||||||
def _get_attribute(self, attr, index=None):
|
|
||||||
if index is None:
|
|
||||||
return [getattr(el, attr) for el in self._elements]
|
|
||||||
|
|
||||||
# As JQuery, when getting an attr, only return it for the first element
|
|
||||||
return getattr(self._elements[index], attr)
|
|
||||||
|
|
||||||
def _set_attribute(self, attr, value):
|
|
||||||
for el in self._elements:
|
|
||||||
setattr(el, attr, value)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def html(self):
|
|
||||||
return self._get_attribute("html")
|
|
||||||
|
|
||||||
@html.setter
|
|
||||||
def html(self, value):
|
|
||||||
self._set_attribute("html", value)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def value(self):
|
|
||||||
return self._get_attribute("value")
|
|
||||||
|
|
||||||
@value.setter
|
|
||||||
def value(self, value):
|
|
||||||
self._set_attribute("value", value)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def children(self):
|
|
||||||
return self._elements
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
yield from self._elements
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"{self.__class__.__name__} (length: {len(self._elements)}) {self._elements}"
|
|
||||||
|
|
||||||
|
|
||||||
class DomScope:
|
|
||||||
def __getattr__(self, __name: str):
|
|
||||||
element = document[f"#{__name}"]
|
|
||||||
if element:
|
|
||||||
return element[0]
|
|
||||||
|
|
||||||
|
|
||||||
class PyDom(BaseElement):
|
|
||||||
# Add objects we want to expose to the DOM namespace since this class instance is being
|
|
||||||
# remapped as "the module" itself
|
|
||||||
BaseElement = BaseElement
|
|
||||||
Element = Element
|
|
||||||
ElementCollection = ElementCollection
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
# PyDom is a special case of BaseElement where we don't want to create a new JS element
|
|
||||||
# and it really doesn't have a need for styleproxy or parent to to call to __init__
|
|
||||||
# (which actually fails in MP for some reason)
|
|
||||||
self._js = document
|
|
||||||
self._parent = None
|
|
||||||
self._proxies = {}
|
|
||||||
self.ids = DomScope()
|
|
||||||
self.body = Element(document.body)
|
|
||||||
self.head = Element(document.head)
|
|
||||||
|
|
||||||
def create(self, type_, classes=None, html=None):
|
|
||||||
return super().create(type_, is_child=False, classes=classes, html=html)
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
|
||||||
elements = self._js.querySelectorAll(key)
|
|
||||||
if not elements:
|
|
||||||
return None
|
|
||||||
return ElementCollection([Element(el) for el in elements])
|
|
||||||
|
|
||||||
|
|
||||||
dom = PyDom()
|
|
||||||
72
pyscript.core/src/storage.js
Normal file
72
pyscript.core/src/storage.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { ArrayBuffer, TypedArray } from "sabayon/shared";
|
||||||
|
import IDBMapSync from "@webreflection/idb-map/sync";
|
||||||
|
import { parse, stringify } from "flatted";
|
||||||
|
|
||||||
|
const to_idb = (value) => {
|
||||||
|
if (value == null) return stringify(["null", 0]);
|
||||||
|
/* eslint-disable no-fallthrough */
|
||||||
|
switch (typeof value) {
|
||||||
|
case "object": {
|
||||||
|
if (value instanceof TypedArray)
|
||||||
|
return stringify(["memoryview", [...value]]);
|
||||||
|
if (value instanceof ArrayBuffer)
|
||||||
|
return stringify(["bytearray", [...new Uint8Array(value)]]);
|
||||||
|
}
|
||||||
|
case "string":
|
||||||
|
case "number":
|
||||||
|
case "boolean":
|
||||||
|
return stringify(["generic", value]);
|
||||||
|
default:
|
||||||
|
throw new TypeError(`Unexpected value: ${String(value)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const from_idb = (value) => {
|
||||||
|
const [kind, result] = parse(value);
|
||||||
|
if (kind === "null") return null;
|
||||||
|
if (kind === "generic") return result;
|
||||||
|
if (kind === "bytearray") return new Uint8Array(value).buffer;
|
||||||
|
if (kind === "memoryview") return new Uint8Array(value);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// this export simulate pyscript.storage exposed in the Python world
|
||||||
|
export const storage = async (name) => {
|
||||||
|
if (!name) throw new SyntaxError("The storage name must be defined");
|
||||||
|
|
||||||
|
const store = new IDBMapSync(`@pyscript/${name}`);
|
||||||
|
const map = new Map();
|
||||||
|
await store.sync();
|
||||||
|
for (const [k, v] of store.entries()) map.set(k, from_idb(v));
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
map.clear();
|
||||||
|
store.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sync = async () => {
|
||||||
|
await store.sync();
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Proxy(map, {
|
||||||
|
ownKeys: (map) => [...map.keys()],
|
||||||
|
has: (map, name) => map.has(name),
|
||||||
|
get: (map, name) => {
|
||||||
|
if (name === "clear") return clear;
|
||||||
|
if (name === "sync") return sync;
|
||||||
|
return map.get(name);
|
||||||
|
},
|
||||||
|
set: (map, name, value) => {
|
||||||
|
map.set(name, value);
|
||||||
|
store.set(name, to_idb(value));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
deleteProperty: (map, name) => {
|
||||||
|
if (map.has(name)) {
|
||||||
|
map.delete(name);
|
||||||
|
store.delete(name);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<link rel="stylesheet" href="../dist/core.css">
|
|
||||||
<script type="module" src="../dist/core.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<py-script async>
|
|
||||||
import asyncio
|
|
||||||
print('foo')
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
print('bar')
|
|
||||||
</py-script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
// PyScript Error Plugin
|
|
||||||
import { hooks } from '@pyscript/core';
|
|
||||||
|
|
||||||
hooks.onBeforeRun.add(function override(pyScript) {
|
|
||||||
// be sure this override happens only once
|
|
||||||
hooks.onBeforeRun.delete(override);
|
|
||||||
|
|
||||||
// trap generic `stderr` to propagate to it regardless
|
|
||||||
const { stderr } = pyScript.io;
|
|
||||||
|
|
||||||
// override it with our own logic
|
|
||||||
pyScript.io.stderr = (...args) => {
|
|
||||||
// grab the message of the first argument (Error)
|
|
||||||
const [ { message } ] = args;
|
|
||||||
// show it
|
|
||||||
notify(message);
|
|
||||||
// still let other plugins or PyScript itself do the rest
|
|
||||||
return stderr(...args);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Error hook utilities
|
|
||||||
|
|
||||||
// Custom function to show notifications
|
|
||||||
function notify(message) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = message;
|
|
||||||
div.style.cssText = `
|
|
||||||
border: 1px solid red;
|
|
||||||
background: #ffdddd;
|
|
||||||
color: black;
|
|
||||||
font-family: courier, monospace;
|
|
||||||
white-space: pre;
|
|
||||||
overflow-x: auto;
|
|
||||||
padding: 8px;
|
|
||||||
margin-top: 8px;
|
|
||||||
`;
|
|
||||||
document.body.append(div);
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
|
|
||||||
test('MicroPython display', async ({ page }) => {
|
|
||||||
await page.goto('http://localhost:8080/test/mpy.html');
|
|
||||||
await page.waitForSelector('html.done.worker');
|
|
||||||
const body = await page.evaluate(() => document.body.innerText);
|
|
||||||
await expect(body.trim()).toBe([
|
|
||||||
'M-PyScript Main 1',
|
|
||||||
'M-PyScript Main 2',
|
|
||||||
'M-PyScript Worker',
|
|
||||||
].join('\n'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('MicroPython hooks', async ({ page }) => {
|
|
||||||
const logs = [];
|
|
||||||
page.on('console', msg => {
|
|
||||||
const text = msg.text();
|
|
||||||
if (!text.startsWith('['))
|
|
||||||
logs.push(text);
|
|
||||||
});
|
|
||||||
await page.goto('http://localhost:8080/test/hooks.html');
|
|
||||||
await page.waitForSelector('html.done.worker');
|
|
||||||
await expect(logs.join('\n')).toBe([
|
|
||||||
'main onReady',
|
|
||||||
'main onBeforeRun',
|
|
||||||
'main codeBeforeRun',
|
|
||||||
'actual code in main',
|
|
||||||
'main codeAfterRun',
|
|
||||||
'main onAfterRun',
|
|
||||||
'worker onReady',
|
|
||||||
'worker onBeforeRun',
|
|
||||||
'worker codeBeforeRun',
|
|
||||||
'actual code in worker',
|
|
||||||
'worker codeAfterRun',
|
|
||||||
'worker onAfterRun',
|
|
||||||
].join('\n'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('MicroPython + Pyodide js_modules', async ({ page }) => {
|
|
||||||
const logs = [];
|
|
||||||
page.on('console', msg => {
|
|
||||||
const text = msg.text();
|
|
||||||
if (!text.startsWith('['))
|
|
||||||
logs.push(text);
|
|
||||||
});
|
|
||||||
await page.goto('http://localhost:8080/test/js_modules.html');
|
|
||||||
await page.waitForSelector('html.done');
|
|
||||||
await expect(logs.length).toBe(6);
|
|
||||||
await expect(logs[0]).toBe(logs[1]);
|
|
||||||
await expect(logs[1]).toBe(logs[2]);
|
|
||||||
await expect(logs[3]).toBe(logs[4]);
|
|
||||||
await expect(logs[4]).toBe(logs[5]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('MicroPython + configURL', async ({ page }) => {
|
|
||||||
const logs = [];
|
|
||||||
page.on('console', msg => {
|
|
||||||
const text = msg.text();
|
|
||||||
if (!text.startsWith('['))
|
|
||||||
logs.push(text);
|
|
||||||
});
|
|
||||||
await page.goto('http://localhost:8080/test/config-url.html');
|
|
||||||
await page.waitForSelector('html.main.worker');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Pyodide + terminal on Main', async ({ page }) => {
|
|
||||||
await page.goto('http://localhost:8080/test/py-terminal-main.html');
|
|
||||||
await page.waitForSelector('html.ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
test('Pyodide + terminal on Worker', async ({ page }) => {
|
|
||||||
await page.goto('http://localhost:8080/test/py-terminal-worker.html');
|
|
||||||
await page.waitForSelector('html.ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Pyodide + multiple terminals via Worker', async ({ page }) => {
|
|
||||||
await page.goto('http://localhost:8080/test/py-terminals.html');
|
|
||||||
await page.waitForSelector('html.first.second');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('MicroPython + Pyodide fetch', async ({ page }) => {
|
|
||||||
await page.goto('http://localhost:8080/test/fetch.html');
|
|
||||||
await page.waitForSelector('html.mpy.py');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('MicroPython + Pyodide ffi', async ({ page }) => {
|
|
||||||
await page.goto('http://localhost:8080/test/ffi.html');
|
|
||||||
await page.waitForSelector('html.mpy.py');
|
|
||||||
});
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>PyDom Example</title>
|
|
||||||
<link rel="stylesheet" href="../dist/core.css">
|
|
||||||
<script type="module" src="../dist/core.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script type="py" src="pydom.py"></script>
|
|
||||||
|
|
||||||
<button id="just-a-button">Click For Time</button>
|
|
||||||
<button id="color-button">Click For Color</button>
|
|
||||||
<button id="color-reset-button">Reset Color</button>
|
|
||||||
|
|
||||||
<div id="result"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import random
|
|
||||||
import time
|
|
||||||
from datetime import datetime as dt
|
|
||||||
|
|
||||||
from pyscript import display, when
|
|
||||||
from pyweb import pydom
|
|
||||||
|
|
||||||
|
|
||||||
@when("click", "#just-a-button")
|
|
||||||
def on_click():
|
|
||||||
try:
|
|
||||||
timenow = dt.now()
|
|
||||||
except NotImplementedError:
|
|
||||||
# In this case we assume it's not implemented because we are using MycroPython
|
|
||||||
tnow = time.localtime()
|
|
||||||
tstr = "{:02d}/{:02d}/{:04d} {:02d}:{:02d}:{:02d}"
|
|
||||||
timenow = tstr.format(tnow[2], tnow[1], tnow[0], *tnow[2:])
|
|
||||||
|
|
||||||
display(f"Hello from PyScript, time is: {timenow}", append=False, target="result")
|
|
||||||
|
|
||||||
|
|
||||||
@when("click", "#color-button")
|
|
||||||
def on_color_click(event):
|
|
||||||
btn = pydom["#result"]
|
|
||||||
btn.style["background-color"] = f"#{random.randrange(0x1000000):06x}"
|
|
||||||
|
|
||||||
|
|
||||||
@when("click", "#color-reset-button")
|
|
||||||
def reset_color(*args, **kwargs):
|
|
||||||
pydom["#result"].style["background-color"] = "white"
|
|
||||||
|
|
||||||
|
|
||||||
# btn_reset = pydom["#color-reset-button"][0].when('click', reset_color)
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>PyDom Example (MicroPython)</title>
|
|
||||||
<link rel="stylesheet" href="../dist/core.css">
|
|
||||||
<script type="module" src="../dist/core.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script type="mpy" src="pydom.py"></script>
|
|
||||||
|
|
||||||
<button id="just-a-button">Click For Time</button>
|
|
||||||
<button id="color-button">Click For Color</button>
|
|
||||||
<button id="color-reset-button">Reset Color</button>
|
|
||||||
|
|
||||||
<div id="result"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<title>PyDom Test Suite</title>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<link rel="stylesheet" href="../../dist/core.css">
|
|
||||||
<script type="module" src="../../dist/core.js"></script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
@import url("https://fonts.googleapis.com/css?family=Roboto:100,400");
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
*:before, *:after {
|
|
||||||
box-sizing: inherit;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: "Courier New", Courier, "Lucida Sans Typewriter", "Lucida Typewriter", monospace;
|
|
||||||
font-size: 14px; font-style: normal; font-variant: normal; font-weight: 400; line-height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 { font-size: 24px; font-weight: 700; line-height: 26.4px; }
|
|
||||||
h2 { font-size: 14px; font-weight: 700; line-height: 15.4px; }
|
|
||||||
|
|
||||||
#tests-terminal{
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script type="py" src="./run_tests.py" config="./tests.toml"></script>
|
|
||||||
|
|
||||||
<h1>pyscript.dom Tests</h1>
|
|
||||||
<p>You can pass test parameters to this test suite by passing them as query params on the url.
|
|
||||||
For instance, to pass "-v -s --pdb" to pytest, you would use the following url:
|
|
||||||
<label style="color: blue">?-v&-s&--pdb</label>
|
|
||||||
</p>
|
|
||||||
<div id="tests-terminal"></div>
|
|
||||||
|
|
||||||
<template id="test_card_with_element_template">
|
|
||||||
<p>This is a test. {foo}</p>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div id="test_id_selector" style="visibility: hidden;">You found test_id_selector</div>
|
|
||||||
<div id="test_class_selector" class="a-test-class" style="visibility: hidden;">You found test_class_selector</div>
|
|
||||||
<div id="test_selector_w_children" class="a-test-class" style="visibility: hidden;">
|
|
||||||
<div id="test_selector_w_children_child_1" class="a-test-class" style="visibility: hidden;">Child 1</div>
|
|
||||||
<div id="test_selector_w_children_child_2" style="visibility: hidden;">Child 2</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="div-no-classes"></div>
|
|
||||||
|
|
||||||
<div style="visibility: hidden;">
|
|
||||||
<h2>Test Read and Write</h2>
|
|
||||||
<div id="test_rr_div">Content test_rr_div</div>
|
|
||||||
<h3 id="test_rr_h3">Content test_rr_h3</h3>
|
|
||||||
|
|
||||||
<div id="multi-elem-div" class="multi-elems">Content multi-elem-div</div>
|
|
||||||
<p id="multi-elem-p" class="multi-elems">Content multi-elem-p</p>
|
|
||||||
<h2 id="multi-elem-h2" class="multi-elems">Content multi-elem-h2</h2>
|
|
||||||
|
|
||||||
<form>
|
|
||||||
<input id="test_rr_input_text" type="text" value="Content test_rr_input_text">
|
|
||||||
<input id="test_rr_input_button" type="button" value="Content test_rr_input_button">
|
|
||||||
<input id="test_rr_input_email" type="email" value="Content test_rr_input_email">
|
|
||||||
<input id="test_rr_input_password" type="password" value="Content test_rr_input_password">
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<select id="test_select_element"></select>
|
|
||||||
<select id="test_select_element_w_options">
|
|
||||||
<option value="1">Option 1</option>
|
|
||||||
<option value="2" selected="selected">Option 2</option>
|
|
||||||
</select>
|
|
||||||
<select id="test_select_element_to_clear">
|
|
||||||
<option value="1">Option 1</option>
|
|
||||||
<option value="2">Option 2</option>
|
|
||||||
<option value="4">Option 4</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<select id="test_select_element_to_remove">
|
|
||||||
<option value="1">Option 1</option>
|
|
||||||
<option value="2">Option 2</option>
|
|
||||||
<option value="3">Option 3</option>
|
|
||||||
<option value="4">Option 4</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<div id="element-creation-test"></div>
|
|
||||||
|
|
||||||
<button id="a-test-button">I'm a button to be clicked</button>
|
|
||||||
<button>I'm another button you can click</button>
|
|
||||||
<button id="a-third-button">2 is better than 3 :)</button>
|
|
||||||
|
|
||||||
<div id="element-append-tests"></div>
|
|
||||||
<p class="collection"></p>
|
|
||||||
<div class="collection"></div>
|
|
||||||
<h3 class="collection"></h3>
|
|
||||||
|
|
||||||
<div id="element_attribute_tests"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<script defer>
|
|
||||||
console.log("remapping console.log")
|
|
||||||
const terminalDiv = document.getElementById("tests-terminal");
|
|
||||||
const log = console.log.bind(console)
|
|
||||||
let testsStarted = false;
|
|
||||||
console.log = (...args) => {
|
|
||||||
let txt = args.join(" ");
|
|
||||||
let token = "<br>";
|
|
||||||
if (txt.endsWith("FAILED"))
|
|
||||||
token = " ❌<br>";
|
|
||||||
else if (txt.endsWith("PASSED"))
|
|
||||||
token = " ✅<br>";
|
|
||||||
if (testsStarted)
|
|
||||||
terminalDiv.innerHTML += args.join(" ") + token;
|
|
||||||
|
|
||||||
log(...args)
|
|
||||||
|
|
||||||
// if we got the flag that tests are starting, then we can start logging
|
|
||||||
if (args.join(" ") == "tests starting")
|
|
||||||
testsStarted = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
print("tests starting")
|
|
||||||
import pytest
|
|
||||||
from pyscript import window
|
|
||||||
|
|
||||||
args = window.location.search.replace("?", "").split("&")
|
|
||||||
|
|
||||||
pytest.main(args)
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
packages = [
|
|
||||||
"pytest"
|
|
||||||
]
|
|
||||||
|
|
||||||
[[fetch]]
|
|
||||||
from = "tests/"
|
|
||||||
files = ["__init__.py", "conftest.py", "test_dom.py"]
|
|
||||||
to_folder = "tests"
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from js import document, localStorage
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def before_tests():
|
|
||||||
"""
|
|
||||||
Ensure browser storage is always reset to empty. Remove the app
|
|
||||||
placeholder. Reset the page title.
|
|
||||||
"""
|
|
||||||
localStorage.clear()
|
|
||||||
# app_placeholder = document.querySelector("pyper-app")
|
|
||||||
# if app_placeholder:
|
|
||||||
# app_placeholder.remove()
|
|
||||||
document.querySelector("title").innerText = "Web API PyTest Suite"
|
|
||||||
@@ -1,459 +0,0 @@
|
|||||||
from unittest import mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from pyscript import document, when
|
|
||||||
from pyweb import pydom
|
|
||||||
|
|
||||||
|
|
||||||
class TestDocument:
|
|
||||||
def test__element(self):
|
|
||||||
assert pydom._js == document
|
|
||||||
|
|
||||||
def test_no_parent(self):
|
|
||||||
assert pydom.parent is None
|
|
||||||
|
|
||||||
def test_create_element(self):
|
|
||||||
new_el = pydom.create("div")
|
|
||||||
assert isinstance(new_el, pydom.BaseElement)
|
|
||||||
assert new_el._js.tagName == "DIV"
|
|
||||||
# EXPECT the new element to be associated with the document
|
|
||||||
assert new_el.parent == None
|
|
||||||
|
|
||||||
|
|
||||||
def test_getitem_by_id():
|
|
||||||
# GIVEN an existing element on the page with a known text content
|
|
||||||
id_ = "test_id_selector"
|
|
||||||
txt = "You found test_id_selector"
|
|
||||||
selector = f"#{id_}"
|
|
||||||
# EXPECT the element to be found by id
|
|
||||||
result = pydom[selector]
|
|
||||||
div = result[0]
|
|
||||||
# EXPECT the element text value to match what we expect and what
|
|
||||||
# the JS document.querySelector API would return
|
|
||||||
assert document.querySelector(selector).innerHTML == div.html == txt
|
|
||||||
# EXPECT the results to be of the right types
|
|
||||||
assert isinstance(div, pydom.BaseElement)
|
|
||||||
assert isinstance(result, pydom.ElementCollection)
|
|
||||||
|
|
||||||
|
|
||||||
def test_getitem_by_class():
|
|
||||||
ids = [
|
|
||||||
"test_class_selector",
|
|
||||||
"test_selector_w_children",
|
|
||||||
"test_selector_w_children_child_1",
|
|
||||||
]
|
|
||||||
expected_class = "a-test-class"
|
|
||||||
result = pydom[f".{expected_class}"]
|
|
||||||
div = result[0]
|
|
||||||
|
|
||||||
# EXPECT to find exact number of elements with the class in the page (== 3)
|
|
||||||
assert len(result) == 3
|
|
||||||
|
|
||||||
# EXPECT that all element ids are in the expected list
|
|
||||||
assert [el.id for el in result] == ids
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_n_write_collection_elements():
|
|
||||||
elements = pydom[".multi-elems"]
|
|
||||||
|
|
||||||
for element in elements:
|
|
||||||
assert element.html == f"Content {element.id.replace('#', '')}"
|
|
||||||
|
|
||||||
new_content = "New Content"
|
|
||||||
elements.html = new_content
|
|
||||||
for element in elements:
|
|
||||||
assert element.html == new_content
|
|
||||||
|
|
||||||
|
|
||||||
class TestElement:
|
|
||||||
def test_query(self):
|
|
||||||
# GIVEN an existing element on the page, with at least 1 child element
|
|
||||||
id_ = "test_selector_w_children"
|
|
||||||
parent_div = pydom[f"#{id_}"][0]
|
|
||||||
|
|
||||||
# EXPECT it to be able to query for the first child element
|
|
||||||
div = parent_div.find("div")[0]
|
|
||||||
|
|
||||||
# EXPECT the new element to be associated with the parent
|
|
||||||
assert div.parent == parent_div
|
|
||||||
# EXPECT the new element to be a BaseElement
|
|
||||||
assert isinstance(div, pydom.BaseElement)
|
|
||||||
# EXPECT the div attributes to be == to how they are configured in the page
|
|
||||||
assert div.html == "Child 1"
|
|
||||||
assert div.id == "test_selector_w_children_child_1"
|
|
||||||
|
|
||||||
def test_equality(self):
|
|
||||||
# GIVEN 2 different Elements pointing to the same underlying element
|
|
||||||
id_ = "test_id_selector"
|
|
||||||
selector = f"#{id_}"
|
|
||||||
div = pydom[selector][0]
|
|
||||||
div2 = pydom[selector][0]
|
|
||||||
|
|
||||||
# EXPECT them to be equal
|
|
||||||
assert div == div2
|
|
||||||
# EXPECT them to be different objects
|
|
||||||
assert div is not div2
|
|
||||||
|
|
||||||
# EXPECT their value to always be equal
|
|
||||||
assert div.html == div2.html
|
|
||||||
div.html = "some value"
|
|
||||||
|
|
||||||
assert div.html == div2.html == "some value"
|
|
||||||
|
|
||||||
def test_append_element(self):
|
|
||||||
id_ = "element-append-tests"
|
|
||||||
div = pydom[f"#{id_}"][0]
|
|
||||||
len_children_before = len(div.children)
|
|
||||||
new_el = div.create("p")
|
|
||||||
div.append(new_el)
|
|
||||||
assert len(div.children) == len_children_before + 1
|
|
||||||
assert div.children[-1] == new_el
|
|
||||||
|
|
||||||
def test_append_js_element(self):
|
|
||||||
id_ = "element-append-tests"
|
|
||||||
div = pydom[f"#{id_}"][0]
|
|
||||||
len_children_before = len(div.children)
|
|
||||||
new_el = div.create("p")
|
|
||||||
div.append(new_el._js)
|
|
||||||
assert len(div.children) == len_children_before + 1
|
|
||||||
assert div.children[-1] == new_el
|
|
||||||
|
|
||||||
def test_append_collection(self):
|
|
||||||
id_ = "element-append-tests"
|
|
||||||
div = pydom[f"#{id_}"][0]
|
|
||||||
len_children_before = len(div.children)
|
|
||||||
collection = pydom[".collection"]
|
|
||||||
div.append(collection)
|
|
||||||
assert len(div.children) == len_children_before + len(collection)
|
|
||||||
|
|
||||||
for i in range(len(collection)):
|
|
||||||
assert div.children[-1 - i] == collection[-1 - i]
|
|
||||||
|
|
||||||
def test_read_classes(self):
|
|
||||||
id_ = "test_class_selector"
|
|
||||||
expected_class = "a-test-class"
|
|
||||||
div = pydom[f"#{id_}"][0]
|
|
||||||
assert div.classes == [expected_class]
|
|
||||||
|
|
||||||
def test_add_remove_class(self):
|
|
||||||
id_ = "div-no-classes"
|
|
||||||
classname = "tester-class"
|
|
||||||
div = pydom[f"#{id_}"][0]
|
|
||||||
assert not div.classes
|
|
||||||
div.add_class(classname)
|
|
||||||
same_div = pydom[f"#{id_}"][0]
|
|
||||||
assert div.classes == [classname] == same_div.classes
|
|
||||||
div.remove_class(classname)
|
|
||||||
assert div.classes == [] == same_div.classes
|
|
||||||
|
|
||||||
def test_when_decorator(self):
|
|
||||||
called = False
|
|
||||||
|
|
||||||
just_a_button = pydom["#a-test-button"][0]
|
|
||||||
|
|
||||||
@when("click", just_a_button)
|
|
||||||
def on_click(event):
|
|
||||||
nonlocal called
|
|
||||||
called = True
|
|
||||||
|
|
||||||
# Now let's simulate a click on the button (using the low level JS API)
|
|
||||||
# so we don't risk pydom getting in the way
|
|
||||||
assert not called
|
|
||||||
just_a_button._js.click()
|
|
||||||
|
|
||||||
assert called
|
|
||||||
|
|
||||||
def test_html_attribute(self):
|
|
||||||
# GIVEN an existing element on the page with a known empty text content
|
|
||||||
div = pydom["#element_attribute_tests"][0]
|
|
||||||
|
|
||||||
# WHEN we set the html attribute
|
|
||||||
div.html = "<b>New Content</b>"
|
|
||||||
|
|
||||||
# EXPECT the element html and underlying JS Element innerHTML property
|
|
||||||
# to match what we expect and what
|
|
||||||
assert div.html == div._js.innerHTML == "<b>New Content</b>"
|
|
||||||
assert div.text == div._js.textContent == "New Content"
|
|
||||||
|
|
||||||
def test_text_attribute(self):
|
|
||||||
# GIVEN an existing element on the page with a known empty text content
|
|
||||||
div = pydom["#element_attribute_tests"][0]
|
|
||||||
|
|
||||||
# WHEN we set the html attribute
|
|
||||||
div.text = "<b>New Content</b>"
|
|
||||||
|
|
||||||
# EXPECT the element html and underlying JS Element innerHTML property
|
|
||||||
# to match what we expect and what
|
|
||||||
assert div.html == div._js.innerHTML == "<b>New Content</b>"
|
|
||||||
assert div.text == div._js.textContent == "<b>New Content</b>"
|
|
||||||
|
|
||||||
|
|
||||||
class TestCollection:
|
|
||||||
def test_iter_eq_children(self):
|
|
||||||
elements = pydom[".multi-elems"]
|
|
||||||
assert [el for el in elements] == [el for el in elements.children]
|
|
||||||
assert len(elements) == 3
|
|
||||||
|
|
||||||
def test_slices(self):
|
|
||||||
elements = pydom[".multi-elems"]
|
|
||||||
assert elements[0]
|
|
||||||
_slice = elements[:2]
|
|
||||||
assert len(_slice) == 2
|
|
||||||
for i, el in enumerate(_slice):
|
|
||||||
assert el == elements[i]
|
|
||||||
assert elements[:] == elements
|
|
||||||
|
|
||||||
def test_style_rule(self):
|
|
||||||
selector = ".multi-elems"
|
|
||||||
elements = pydom[selector]
|
|
||||||
for el in elements:
|
|
||||||
assert el.style["background-color"] != "red"
|
|
||||||
|
|
||||||
elements.style["background-color"] = "red"
|
|
||||||
|
|
||||||
for i, el in enumerate(pydom[selector]):
|
|
||||||
assert elements[i].style["background-color"] == "red"
|
|
||||||
assert el.style["background-color"] == "red"
|
|
||||||
|
|
||||||
elements.style.remove("background-color")
|
|
||||||
|
|
||||||
for i, el in enumerate(pydom[selector]):
|
|
||||||
assert el.style["background-color"] != "red"
|
|
||||||
assert elements[i].style["background-color"] != "red"
|
|
||||||
|
|
||||||
def test_when_decorator(self):
|
|
||||||
called = False
|
|
||||||
|
|
||||||
buttons_collection = pydom["button"]
|
|
||||||
|
|
||||||
@when("click", buttons_collection)
|
|
||||||
def on_click(event):
|
|
||||||
nonlocal called
|
|
||||||
called = True
|
|
||||||
|
|
||||||
# Now let's simulate a click on the button (using the low level JS API)
|
|
||||||
# so we don't risk pydom getting in the way
|
|
||||||
assert not called
|
|
||||||
for button in buttons_collection:
|
|
||||||
button._js.click()
|
|
||||||
assert called
|
|
||||||
called = False
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreation:
|
|
||||||
def test_create_document_element(self):
|
|
||||||
new_el = pydom.create("div")
|
|
||||||
new_el.id = "new_el_id"
|
|
||||||
assert isinstance(new_el, pydom.BaseElement)
|
|
||||||
assert new_el._js.tagName == "DIV"
|
|
||||||
# EXPECT the new element to be associated with the document
|
|
||||||
assert new_el.parent == None
|
|
||||||
pydom.body.append(new_el)
|
|
||||||
|
|
||||||
assert pydom["#new_el_id"][0].parent == pydom.body
|
|
||||||
|
|
||||||
def test_create_element_child(self):
|
|
||||||
selector = "#element-creation-test"
|
|
||||||
parent_div = pydom[selector][0]
|
|
||||||
|
|
||||||
# Creating an element from another element automatically creates that element
|
|
||||||
# as a child of the original element
|
|
||||||
new_el = parent_div.create(
|
|
||||||
"p", classes=["code-description"], html="Ciao PyScripters!"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(new_el, pydom.BaseElement)
|
|
||||||
assert new_el._js.tagName == "P"
|
|
||||||
# EXPECT the new element to be associated with the document
|
|
||||||
assert new_el.parent == parent_div
|
|
||||||
|
|
||||||
assert pydom[selector][0].children[0] == new_el
|
|
||||||
|
|
||||||
|
|
||||||
class TestInput:
|
|
||||||
input_ids = [
|
|
||||||
"test_rr_input_text",
|
|
||||||
"test_rr_input_button",
|
|
||||||
"test_rr_input_email",
|
|
||||||
"test_rr_input_password",
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_value(self):
|
|
||||||
for id_ in self.input_ids:
|
|
||||||
expected_type = id_.split("_")[-1]
|
|
||||||
result = pydom[f"#{id_}"]
|
|
||||||
input_el = result[0]
|
|
||||||
assert input_el._js.type == expected_type
|
|
||||||
assert input_el.value == f"Content {id_}" == input_el._js.value
|
|
||||||
|
|
||||||
# Check that we can set the value
|
|
||||||
new_value = f"New Value {expected_type}"
|
|
||||||
input_el.value = new_value
|
|
||||||
assert input_el.value == new_value
|
|
||||||
|
|
||||||
# Check that we can set the value back to the original using
|
|
||||||
# the collection
|
|
||||||
new_value = f"Content {id_}"
|
|
||||||
result.value = new_value
|
|
||||||
assert input_el.value == new_value
|
|
||||||
|
|
||||||
def test_set_value_collection(self):
|
|
||||||
for id_ in self.input_ids:
|
|
||||||
input_el = pydom[f"#{id_}"]
|
|
||||||
|
|
||||||
assert input_el.value[0] == f"Content {id_}" == input_el[0].value
|
|
||||||
|
|
||||||
new_value = f"New Value {id_}"
|
|
||||||
input_el.value = new_value
|
|
||||||
assert input_el.value[0] == new_value == input_el[0].value
|
|
||||||
|
|
||||||
def test_element_without_value(self):
|
|
||||||
result = pydom[f"#tests-terminal"][0]
|
|
||||||
with pytest.raises(AttributeError):
|
|
||||||
result.value = "some value"
|
|
||||||
|
|
||||||
def test_element_without_collection(self):
|
|
||||||
result = pydom[f"#tests-terminal"]
|
|
||||||
with pytest.raises(AttributeError):
|
|
||||||
result.value = "some value"
|
|
||||||
|
|
||||||
def test_element_without_collection(self):
|
|
||||||
result = pydom[f"#tests-terminal"]
|
|
||||||
with pytest.raises(AttributeError):
|
|
||||||
result.value = "some value"
|
|
||||||
|
|
||||||
|
|
||||||
class TestSelect:
|
|
||||||
def test_select_options_iter(self):
|
|
||||||
select = pydom[f"#test_select_element_w_options"][0]
|
|
||||||
|
|
||||||
for i, option in enumerate(select.options, 1):
|
|
||||||
assert option.value == f"{i}"
|
|
||||||
assert option.html == f"Option {i}"
|
|
||||||
|
|
||||||
def test_select_options_len(self):
|
|
||||||
select = pydom[f"#test_select_element_w_options"][0]
|
|
||||||
assert len(select.options) == 2
|
|
||||||
|
|
||||||
def test_select_options_clear(self):
|
|
||||||
select = pydom[f"#test_select_element_to_clear"][0]
|
|
||||||
assert len(select.options) == 3
|
|
||||||
|
|
||||||
select.options.clear()
|
|
||||||
|
|
||||||
assert len(select.options) == 0
|
|
||||||
|
|
||||||
def test_select_element_add(self):
|
|
||||||
# GIVEN the existing select element with no options
|
|
||||||
select = pydom[f"#test_select_element"][0]
|
|
||||||
|
|
||||||
# EXPECT the select element to have no options
|
|
||||||
assert len(select.options) == 0
|
|
||||||
|
|
||||||
# WHEN we add an option
|
|
||||||
select.options.add(value="1", html="Option 1")
|
|
||||||
|
|
||||||
# EXPECT the select element to have 1 option matching the attributes
|
|
||||||
# we passed in
|
|
||||||
assert len(select.options) == 1
|
|
||||||
assert select.options[0].value == "1"
|
|
||||||
assert select.options[0].html == "Option 1"
|
|
||||||
|
|
||||||
# WHEN we add another option (blank this time)
|
|
||||||
select.options.add("")
|
|
||||||
|
|
||||||
# EXPECT the select element to have 2 options
|
|
||||||
assert len(select.options) == 2
|
|
||||||
|
|
||||||
# EXPECT the last option to have an empty value and html
|
|
||||||
assert select.options[1].value == ""
|
|
||||||
assert select.options[1].html == ""
|
|
||||||
|
|
||||||
# WHEN we add another option (this time adding it in between the other 2
|
|
||||||
# options by using an integer index)
|
|
||||||
select.options.add(value="2", html="Option 2", before=1)
|
|
||||||
|
|
||||||
# EXPECT the select element to have 3 options
|
|
||||||
assert len(select.options) == 3
|
|
||||||
|
|
||||||
# EXPECT the middle option to have the value and html we passed in
|
|
||||||
assert select.options[0].value == "1"
|
|
||||||
assert select.options[0].html == "Option 1"
|
|
||||||
assert select.options[1].value == "2"
|
|
||||||
assert select.options[1].html == "Option 2"
|
|
||||||
assert select.options[2].value == ""
|
|
||||||
assert select.options[2].html == ""
|
|
||||||
|
|
||||||
# WHEN we add another option (this time adding it in between the other 2
|
|
||||||
# options but using the option itself)
|
|
||||||
select.options.add(
|
|
||||||
value="3", html="Option 3", before=select.options[2], selected=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# EXPECT the select element to have 3 options
|
|
||||||
assert len(select.options) == 4
|
|
||||||
|
|
||||||
# EXPECT the middle option to have the value and html we passed in
|
|
||||||
assert select.options[0].value == "1"
|
|
||||||
assert select.options[0].html == "Option 1"
|
|
||||||
assert select.options[0].selected == select.options[0]._js.selected == False
|
|
||||||
assert select.options[1].value == "2"
|
|
||||||
assert select.options[1].html == "Option 2"
|
|
||||||
assert select.options[2].value == "3"
|
|
||||||
assert select.options[2].html == "Option 3"
|
|
||||||
assert select.options[2].selected == select.options[2]._js.selected == True
|
|
||||||
assert select.options[3].value == ""
|
|
||||||
assert select.options[3].html == ""
|
|
||||||
|
|
||||||
# WHEN we add another option (this time adding it in between the other 2
|
|
||||||
# options but using the JS element of the option itself)
|
|
||||||
select.options.add(value="2a", html="Option 2a", before=select.options[2]._js)
|
|
||||||
|
|
||||||
# EXPECT the select element to have 3 options
|
|
||||||
assert len(select.options) == 5
|
|
||||||
|
|
||||||
# EXPECT the middle option to have the value and html we passed in
|
|
||||||
assert select.options[0].value == "1"
|
|
||||||
assert select.options[0].html == "Option 1"
|
|
||||||
assert select.options[1].value == "2"
|
|
||||||
assert select.options[1].html == "Option 2"
|
|
||||||
assert select.options[2].value == "2a"
|
|
||||||
assert select.options[2].html == "Option 2a"
|
|
||||||
assert select.options[3].value == "3"
|
|
||||||
assert select.options[3].html == "Option 3"
|
|
||||||
assert select.options[4].value == ""
|
|
||||||
assert select.options[4].html == ""
|
|
||||||
|
|
||||||
def test_select_options_remove(self):
|
|
||||||
# GIVEN the existing select element with 3 options
|
|
||||||
select = pydom[f"#test_select_element_to_remove"][0]
|
|
||||||
|
|
||||||
# EXPECT the select element to have 3 options
|
|
||||||
assert len(select.options) == 4
|
|
||||||
# EXPECT the options to have the values originally set
|
|
||||||
assert select.options[0].value == "1"
|
|
||||||
assert select.options[1].value == "2"
|
|
||||||
assert select.options[2].value == "3"
|
|
||||||
assert select.options[3].value == "4"
|
|
||||||
|
|
||||||
# WHEN we remove the second option (index starts at 0)
|
|
||||||
select.options.remove(1)
|
|
||||||
|
|
||||||
# EXPECT the select element to have 2 options
|
|
||||||
assert len(select.options) == 3
|
|
||||||
# EXPECT the options to have the values originally set but the second
|
|
||||||
assert select.options[0].value == "1"
|
|
||||||
assert select.options[1].value == "3"
|
|
||||||
assert select.options[2].value == "4"
|
|
||||||
|
|
||||||
def test_select_get_selected_option(self):
|
|
||||||
# GIVEN the existing select element with one selected option
|
|
||||||
select = pydom[f"#test_select_element_w_options"][0]
|
|
||||||
|
|
||||||
# WHEN we get the selected option
|
|
||||||
selected_option = select.options.selected
|
|
||||||
|
|
||||||
# EXPECT the selected option to be correct
|
|
||||||
assert selected_option.value == "2"
|
|
||||||
assert selected_option.html == "Option 2"
|
|
||||||
assert selected_option.selected == selected_option._js.selected == True
|
|
||||||
49
pyscript.core/tests/README.md
Normal file
49
pyscript.core/tests/README.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# PyScript Test Suite
|
||||||
|
|
||||||
|
There are three aspects to our test suite. These are reflected in the layout of
|
||||||
|
the test directory:
|
||||||
|
|
||||||
|
1. `python` - contains the Python based test suite to exercise Python code
|
||||||
|
**within** PyScript. These tests are run four differeng ways to ensure all
|
||||||
|
combination of MicroPython/Pyodide and main thread/worker contexts are
|
||||||
|
checked.
|
||||||
|
2. `javascript` - contains JavaScript tests to exercise PyScript _itself_, in
|
||||||
|
the browser.
|
||||||
|
3. `manual` - contains tests to run manually in a browser, due to the complex
|
||||||
|
nature of the tests.
|
||||||
|
|
||||||
|
We use [Playwright](https://playwright.dev/) to automate the running of the
|
||||||
|
Python and JavaScript test suites. We use
|
||||||
|
[uPyTest](https://github.com/ntoll/upytest) as a test framework for the Python
|
||||||
|
test suite. uPyTest is a "PyTest inspired" framework for running tests in the
|
||||||
|
browser on both MicroPython and Pyodide.
|
||||||
|
|
||||||
|
The automated (Playwright) tests are specified in the `integration.spec.js`
|
||||||
|
file in this directory.
|
||||||
|
|
||||||
|
All automatic tests live in either the `python` or `javascript` folders. All
|
||||||
|
the tests in these folder are run by CI or locally run by `make test` in the
|
||||||
|
root of this project. Alternatively, run `npm run test:integration` in the
|
||||||
|
PyScript source directory.
|
||||||
|
|
||||||
|
Similarly, some tests can only be run manually (due to their nature or
|
||||||
|
underlying complexity). These are in the `manual` directory and are in the form
|
||||||
|
of separate directories (each containing an `index.html`) or individual `*.html`
|
||||||
|
files to which you point your browser. Each separate test may exercise
|
||||||
|
JavaScript or Python code (or both), and the context for each separate test is
|
||||||
|
kept carefully isolated.
|
||||||
|
|
||||||
|
Some rules of thumb:
|
||||||
|
|
||||||
|
* We don't test upstream projects: we assume they have their own test suites,
|
||||||
|
and if we find bugs, we file an issue upstream with an example of how to
|
||||||
|
recreate the problem.
|
||||||
|
* We don't test browser functionality, we just have to trust that browsers work
|
||||||
|
as advertised. Once again, if we find an issue, we report upstream.
|
||||||
|
* All test cases should include commentary describing the **intent** and
|
||||||
|
context of the test.
|
||||||
|
* Tests in Python use [uPyTest](https://github.com/ntoll/upytest) (see the
|
||||||
|
README for documentation), an "inspired by PyTest" test framework that works
|
||||||
|
with both MicroPython and Pyodide in the browser. This means that all
|
||||||
|
Python tests should work with both interpreters.
|
||||||
|
* Tests in JavaScript... (Andrea to explain). ;-)
|
||||||
18
pyscript.core/tests/index.html
Normal file
18
pyscript.core/tests/index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PyScript tests</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; }
|
||||||
|
a {
|
||||||
|
display: block;
|
||||||
|
transition: opacity .3s;
|
||||||
|
}
|
||||||
|
a, span { opacity: .7; }
|
||||||
|
a:hover { opacity: 1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body><ul><li><strong><a href="./config/index.html">config</a></strong><ul><li><a href="./config/ambiguous-config.html">ambiguous-config<small>.html</small></a></li><li><a href="./config/same-config.html">same-config<small>.html</small></a></li><li><a href="./config/too-many-config.html">too-many-config<small>.html</small></a></li><li><a href="./config/too-many-py-config.html">too-many-py-config<small>.html</small></a></li></ul></li><li><strong><a href="./issue-7015/index.html">issue-7015</a></strong></li><li><strong><span>javascript</span></strong><ul><li><a href="./javascript/async-listener.html">async-listener<small>.html</small></a></li><li><a href="./javascript/config-url.html">config-url<small>.html</small></a></li><li><a href="./javascript/config_type.html">config_type<small>.html</small></a></li><li><strong><a href="./javascript/fetch/index.html">fetch</a></strong></li><li><a href="./javascript/ffi.html">ffi<small>.html</small></a></li><li><a href="./javascript/hooks.html">hooks<small>.html</small></a></li><li><strong><a href="./javascript/issue-2093/index.html">issue-2093</a></strong></li><li><a href="./javascript/js-storage.html">js-storage<small>.html</small></a></li><li><a href="./javascript/js_modules.html">js_modules<small>.html</small></a></li><li><strong><a href="./javascript/loader/index.html">loader</a></strong></li><li><a href="./javascript/mpy.html">mpy<small>.html</small></a></li><li><a href="./javascript/py-terminal-main.html">py-terminal-main<small>.html</small></a></li><li><a href="./javascript/py-terminal-worker.html">py-terminal-worker<small>.html</small></a></li><li><a href="./javascript/py-terminal.html">py-terminal<small>.html</small></a></li><li><a href="./javascript/py-terminals.html">py-terminals<small>.html</small></a></li><li><a href="./javascript/storage.html">storage<small>.html</small></a></li><li><strong><a href="./javascript/workers/index.html">workers</a></strong><ul><li><a href="./javascript/workers/named.html">named<small>.html</small></a></li></ul></li></ul></li><li><strong><a href="./manual/index.html">manual</a></strong><ul><li><a href="./manual/all-done.html">all-done<small>.html</small></a></li><li><a href="./manual/async.html">async<small>.html</small></a></li><li><a href="./manual/camera.html">camera<small>.html</small></a></li><li><a href="./manual/click.html">click<small>.html</small></a></li><li><a href="./manual/code-a-part.html">code-a-part<small>.html</small></a></li><li><a href="./manual/combo.html">combo<small>.html</small></a></li><li><a href="./manual/config.html">config<small>.html</small></a></li><li><a href="./manual/create-element.html">create-element<small>.html</small></a></li><li><a href="./manual/dialog.html">dialog<small>.html</small></a></li><li><a href="./manual/display.html">display<small>.html</small></a></li><li><a href="./manual/error.html">error<small>.html</small></a></li><li><a href="./manual/html-decode.html">html-decode<small>.html</small></a></li><li><a href="./manual/input.html">input<small>.html</small></a></li><li><a href="./manual/interpreter.html">interpreter<small>.html</small></a></li><li><a href="./manual/multi.html">multi<small>.html</small></a></li><li><a href="./manual/multiple-editors.html">multiple-editors<small>.html</small></a></li><li><a href="./manual/no-error.html">no-error<small>.html</small></a></li><li><a href="./manual/py-editor-failure.html">py-editor-failure<small>.html</small></a></li><li><a href="./manual/py-editor.html">py-editor<small>.html</small></a></li><li><a href="./manual/py_modules.html">py_modules<small>.html</small></a></li><li><a href="./manual/split-config.html">split-config<small>.html</small></a></li><li><a href="./manual/submit.html">submit<small>.html</small></a></li><li><a href="./manual/target.html">target<small>.html</small></a></li><li><a href="./manual/test_display_HTML.html">test_display_HTML<small>.html</small></a></li><li><a href="./manual/test_when.html">test_when<small>.html</small></a></li><li><a href="./manual/worker.html">worker<small>.html</small></a></li></ul></li><li><strong><a href="./no_sab/index.html">no_sab</a></strong></li><li><strong><a href="./piratical/index.html">piratical</a></strong></li><li><strong><a href="./py-editor/index.html">py-editor</a></strong><ul><li><a href="./py-editor/issue-2056.html">issue-2056<small>.html</small></a></li><li><a href="./py-editor/service-worker.html">service-worker<small>.html</small></a></li></ul></li><li><strong><a href="./py-terminals/index.html">py-terminals</a></strong><ul><li><a href="./py-terminals/no-repl.html">no-repl<small>.html</small></a></li><li><a href="./py-terminals/repl.html">repl<small>.html</small></a></li></ul></li><li><strong><a href="./python/index.html">python</a></strong></li><li><strong><a href="./service-worker/index.html">service-worker</a></strong></li></ul></body>
|
||||||
|
</html>
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
import shutil
|
|
||||||
import threading
|
|
||||||
from http.server import HTTPServer as SuperHTTPServer
|
|
||||||
from http.server import SimpleHTTPRequestHandler
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from .support import Logger
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_cmdline_main(config):
|
|
||||||
"""
|
|
||||||
If we pass --clear-http-cache, we don't enter the main pytest logic, but
|
|
||||||
use our custom main instead
|
|
||||||
"""
|
|
||||||
|
|
||||||
def mymain(config, session):
|
|
||||||
print()
|
|
||||||
print("-" * 20, "SmartRouter HTTP cache", "-" * 20)
|
|
||||||
# unfortunately pytest-cache doesn't offer a public API to selectively
|
|
||||||
# clear the cache, so we need to peek its internal. The good news is
|
|
||||||
# that pytest-cache is very old, stable and robust, so it's likely
|
|
||||||
# that this won't break anytime soon.
|
|
||||||
cache = config.cache
|
|
||||||
base = cache._cachedir.joinpath(cache._CACHE_PREFIX_VALUES, "pyscript")
|
|
||||||
if not base.exists():
|
|
||||||
print("No cache found, nothing to do")
|
|
||||||
return 0
|
|
||||||
#
|
|
||||||
print("Requests found in the cache:")
|
|
||||||
for f in base.rglob("*"):
|
|
||||||
if f.is_file():
|
|
||||||
# requests are saved in dirs named pyscript/http:/foo/bar, let's turn
|
|
||||||
# them into a proper url
|
|
||||||
url = str(f.relative_to(base))
|
|
||||||
url = url.replace(":/", "://")
|
|
||||||
print(" ", url)
|
|
||||||
shutil.rmtree(base)
|
|
||||||
print("Cache cleared")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if config.option.clear_http_cache:
|
|
||||||
from _pytest.main import wrap_session
|
|
||||||
|
|
||||||
return wrap_session(config, mymain)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_configure(config):
|
|
||||||
"""
|
|
||||||
THIS IS A WORKAROUND FOR A pytest QUIRK!
|
|
||||||
|
|
||||||
At the moment of writing this conftest defines two new options, --dev and
|
|
||||||
--no-fake-server, but because of how pytest works, they are available only
|
|
||||||
if this is the "root conftest" for the test session.
|
|
||||||
|
|
||||||
This means that if you are in the pyscript.core directory:
|
|
||||||
|
|
||||||
$ py.test # does NOT work
|
|
||||||
$ py.test tests/integration/ # works
|
|
||||||
|
|
||||||
This happens because there is also test py-unit directory, so in the first
|
|
||||||
case the "root conftest" would be tests/conftest.py (which doesn't exist)
|
|
||||||
instead of this.
|
|
||||||
|
|
||||||
There are various workarounds, but for now we can just detect it and
|
|
||||||
inform the user.
|
|
||||||
|
|
||||||
Related StackOverflow answer: https://stackoverflow.com/a/51733980
|
|
||||||
"""
|
|
||||||
if not hasattr(config.option, "dev"):
|
|
||||||
msg = """
|
|
||||||
Running a bare "pytest" command from the pyscript.core directory
|
|
||||||
is not supported. Please use one of the following commands:
|
|
||||||
- pytest tests/integration
|
|
||||||
- pytest tests/*
|
|
||||||
- cd tests/integration; pytest
|
|
||||||
"""
|
|
||||||
pytest.fail(msg)
|
|
||||||
else:
|
|
||||||
if config.option.dev:
|
|
||||||
config.option.headed = True
|
|
||||||
config.option.no_fake_server = True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def logger():
|
|
||||||
return Logger()
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_addoption(parser):
|
|
||||||
parser.addoption(
|
|
||||||
"--no-fake-server",
|
|
||||||
action="store_true",
|
|
||||||
help="Use a real HTTP server instead of http://fakeserver",
|
|
||||||
)
|
|
||||||
parser.addoption(
|
|
||||||
"--dev",
|
|
||||||
action="store_true",
|
|
||||||
help="Automatically open a devtools panel. Implies --headed and --no-fake-server",
|
|
||||||
)
|
|
||||||
parser.addoption(
|
|
||||||
"--clear-http-cache",
|
|
||||||
action="store_true",
|
|
||||||
help="Clear the cache of HTTP requests for SmartRouter",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def browser_type_launch_args(request):
|
|
||||||
"""
|
|
||||||
Override the browser_type_launch_args defined by pytest-playwright to
|
|
||||||
support --devtools.
|
|
||||||
|
|
||||||
NOTE: this has been tested with pytest-playwright==0.3.0. It might break
|
|
||||||
with newer versions of it.
|
|
||||||
"""
|
|
||||||
# this calls the "original" fixture defined by pytest_playwright.py
|
|
||||||
launch_options = request.getfixturevalue("browser_type_launch_args")
|
|
||||||
if request.config.option.dev:
|
|
||||||
launch_options["devtools"] = True
|
|
||||||
return launch_options
|
|
||||||
|
|
||||||
|
|
||||||
class DevServer(SuperHTTPServer):
|
|
||||||
"""
|
|
||||||
Class for wrapper to run SimpleHTTPServer on Thread.
|
|
||||||
Ctrl +Only Thread remains dead when terminated with C.
|
|
||||||
Keyboard Interrupt passes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, base_url, *args, **kwargs):
|
|
||||||
self.base_url = base_url
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
try:
|
|
||||||
self.serve_forever()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
self.server_close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
|
||||||
def dev_server(logger):
|
|
||||||
class MyHTTPRequestHandler(SimpleHTTPRequestHandler):
|
|
||||||
enable_cors_headers = True
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def my_headers(cls):
|
|
||||||
if cls.enable_cors_headers:
|
|
||||||
return {
|
|
||||||
"Cross-Origin-Embedder-Policy": "require-corp",
|
|
||||||
"Cross-Origin-Opener-Policy": "same-origin",
|
|
||||||
}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def end_headers(self):
|
|
||||||
self.send_my_headers()
|
|
||||||
SimpleHTTPRequestHandler.end_headers(self)
|
|
||||||
|
|
||||||
def send_my_headers(self):
|
|
||||||
for k, v in self.my_headers().items():
|
|
||||||
self.send_header(k, v)
|
|
||||||
|
|
||||||
def log_message(self, fmt, *args):
|
|
||||||
logger.log("http_server", fmt % args, color="blue")
|
|
||||||
|
|
||||||
host, port = "localhost", 8080
|
|
||||||
base_url = f"http://{host}:{port}"
|
|
||||||
|
|
||||||
# serve_Run forever under thread
|
|
||||||
server = DevServer(base_url, (host, port), MyHTTPRequestHandler)
|
|
||||||
|
|
||||||
thread = threading.Thread(None, server.run)
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
yield server # Transition to test here
|
|
||||||
|
|
||||||
# End thread
|
|
||||||
server.shutdown()
|
|
||||||
thread.join()
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,476 +0,0 @@
|
|||||||
import re
|
|
||||||
import textwrap
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from .support import (
|
|
||||||
PageErrors,
|
|
||||||
PageErrorsDidNotRaise,
|
|
||||||
PyScriptTest,
|
|
||||||
with_execution_thread,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@with_execution_thread(None)
|
|
||||||
class TestSupport(PyScriptTest):
|
|
||||||
"""
|
|
||||||
These are NOT tests about PyScript.
|
|
||||||
|
|
||||||
They test the PyScriptTest class, i.e. we want to ensure that all the
|
|
||||||
testing machinery that we have works correctly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_basic(self):
|
|
||||||
"""
|
|
||||||
Very basic test, just to check that we can write, serve and read a simple
|
|
||||||
HTML (no pyscript yet)
|
|
||||||
"""
|
|
||||||
doc = """
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<h1>Hello world</h1>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
self.writefile("mytest.html", doc)
|
|
||||||
self.goto("mytest.html")
|
|
||||||
content = self.page.content()
|
|
||||||
assert "<h1>Hello world</h1>" in content
|
|
||||||
|
|
||||||
def test_await_with_run_js(self):
|
|
||||||
self.run_js(
|
|
||||||
"""
|
|
||||||
function resolveAfter200MilliSeconds(x) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
resolve(x);
|
|
||||||
}, 200);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const x = await resolveAfter200MilliSeconds(10);
|
|
||||||
console.log(x);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert self.console.log.lines[-1] == "10"
|
|
||||||
|
|
||||||
def test_console(self):
|
|
||||||
"""
|
|
||||||
Test that we capture console.log messages correctly.
|
|
||||||
"""
|
|
||||||
doc = """
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<script>
|
|
||||||
console.log("my log 1");
|
|
||||||
console.debug("my debug");
|
|
||||||
console.info("my info");
|
|
||||||
console.error("my error");
|
|
||||||
console.warn("my warning");
|
|
||||||
console.log("my log 2");
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
self.writefile("mytest.html", doc)
|
|
||||||
self.goto("mytest.html")
|
|
||||||
assert len(self.console.all.messages) == 6
|
|
||||||
assert self.console.all.lines == [
|
|
||||||
"my log 1",
|
|
||||||
"my debug",
|
|
||||||
"my info",
|
|
||||||
"my error",
|
|
||||||
"my warning",
|
|
||||||
"my log 2",
|
|
||||||
]
|
|
||||||
|
|
||||||
# fmt: off
|
|
||||||
assert self.console.all.text == textwrap.dedent("""
|
|
||||||
my log 1
|
|
||||||
my debug
|
|
||||||
my info
|
|
||||||
my error
|
|
||||||
my warning
|
|
||||||
my log 2
|
|
||||||
""").strip()
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
assert self.console.log.lines == ["my log 1", "my log 2"]
|
|
||||||
assert self.console.debug.lines == ["my debug"]
|
|
||||||
|
|
||||||
def test_check_js_errors_simple(self):
|
|
||||||
doc = """
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<script>throw new Error('this is an error');</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
self.writefile("mytest.html", doc)
|
|
||||||
self.goto("mytest.html")
|
|
||||||
with pytest.raises(PageErrors) as exc:
|
|
||||||
self.check_js_errors()
|
|
||||||
# check that the exception message contains the error message and the
|
|
||||||
# stack trace
|
|
||||||
msg = str(exc.value)
|
|
||||||
expected = textwrap.dedent(
|
|
||||||
f"""
|
|
||||||
JS errors found: 1
|
|
||||||
Error: this is an error
|
|
||||||
at {self.http_server_addr}/mytest.html:.*
|
|
||||||
"""
|
|
||||||
).strip()
|
|
||||||
assert re.search(expected, msg)
|
|
||||||
#
|
|
||||||
# after a call to check_js_errors, the errors are cleared
|
|
||||||
self.check_js_errors()
|
|
||||||
#
|
|
||||||
# JS exceptions are also available in self.console.js_error
|
|
||||||
assert self.console.js_error.lines[0].startswith("Error: this is an error")
|
|
||||||
|
|
||||||
def test_check_js_errors_expected(self):
|
|
||||||
doc = """
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<script>throw new Error('this is an error');</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
self.writefile("mytest.html", doc)
|
|
||||||
self.goto("mytest.html")
|
|
||||||
self.check_js_errors("this is an error")
|
|
||||||
|
|
||||||
def test_check_js_errors_expected_but_didnt_raise(self):
|
|
||||||
doc = """
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<script>throw new Error('this is an error 2');</script>
|
|
||||||
<script>throw new Error('this is an error 4');</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
self.writefile("mytest.html", doc)
|
|
||||||
self.goto("mytest.html")
|
|
||||||
with pytest.raises(PageErrorsDidNotRaise) as exc:
|
|
||||||
self.check_js_errors(
|
|
||||||
"this is an error 1",
|
|
||||||
"this is an error 2",
|
|
||||||
"this is an error 3",
|
|
||||||
"this is an error 4",
|
|
||||||
)
|
|
||||||
#
|
|
||||||
msg = str(exc.value)
|
|
||||||
expected = textwrap.dedent(
|
|
||||||
"""
|
|
||||||
The following JS errors were expected but could not be found:
|
|
||||||
- this is an error 1
|
|
||||||
- this is an error 3
|
|
||||||
"""
|
|
||||||
).strip()
|
|
||||||
assert re.search(expected, msg)
|
|
||||||
|
|
||||||
def test_check_js_errors_multiple(self):
|
|
||||||
doc = """
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<script>throw new Error('error 1');</script>
|
|
||||||
<script>throw new Error('error 2');</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
self.writefile("mytest.html", doc)
|
|
||||||
self.goto("mytest.html")
|
|
||||||
with pytest.raises(PageErrors) as exc:
|
|
||||||
self.check_js_errors()
|
|
||||||
#
|
|
||||||
msg = str(exc.value)
|
|
||||||
expected = textwrap.dedent(
|
|
||||||
"""
|
|
||||||
JS errors found: 2
|
|
||||||
Error: error 1
|
|
||||||
at https://fake_server/mytest.html:.*
|
|
||||||
Error: error 2
|
|
||||||
at https://fake_server/mytest.html:.*
|
|
||||||
"""
|
|
||||||
).strip()
|
|
||||||
assert re.search(expected, msg)
|
|
||||||
#
|
|
||||||
# check that errors are cleared
|
|
||||||
self.check_js_errors()
|
|
||||||
|
|
||||||
def test_check_js_errors_some_expected_but_others_not(self):
|
|
||||||
doc = """
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<script>throw new Error('expected 1');</script>
|
|
||||||
<script>throw new Error('NOT expected 2');</script>
|
|
||||||
<script>throw new Error('expected 3');</script>
|
|
||||||
<script>throw new Error('NOT expected 4');</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
self.writefile("mytest.html", doc)
|
|
||||||
self.goto("mytest.html")
|
|
||||||
with pytest.raises(PageErrors) as exc:
|
|
||||||
self.check_js_errors("expected 1", "expected 3")
|
|
||||||
#
|
|
||||||
msg = str(exc.value)
|
|
||||||
expected = textwrap.dedent(
|
|
||||||
"""
|
|
||||||
JS errors found: 2
|
|
||||||
Error: NOT expected 2
|
|
||||||
at https://fake_server/mytest.html:.*
|
|
||||||
Error: NOT expected 4
|
|
||||||
at https://fake_server/mytest.html:.*
|
|
||||||
"""
|
|
||||||
).strip()
|
|
||||||
assert re.search(expected, msg)
|
|
||||||
|
|
||||||
def test_check_js_errors_expected_not_found_but_other_errors(self):
|
|
||||||
doc = """
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<script>throw new Error('error 1');</script>
|
|
||||||
<script>throw new Error('error 2');</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
self.writefile("mytest.html", doc)
|
|
||||||
self.goto("mytest.html")
|
|
||||||
with pytest.raises(PageErrorsDidNotRaise) as exc:
|
|
||||||
self.check_js_errors("this is not going to be found")
|
|
||||||
#
|
|
||||||
msg = str(exc.value)
|
|
||||||
expected = textwrap.dedent(
|
|
||||||
"""
|
|
||||||
The following JS errors were expected but could not be found:
|
|
||||||
- this is not going to be found
|
|
||||||
---
|
|
||||||
The following JS errors were raised but not expected:
|
|
||||||
Error: error 1
|
|
||||||
at https://fake_server/mytest.html:.*
|
|
||||||
Error: error 2
|
|
||||||
at https://fake_server/mytest.html:.*
|
|
||||||
"""
|
|
||||||
).strip()
|
|
||||||
assert re.search(expected, msg)
|
|
||||||
|
|
||||||
def test_clear_js_errors(self):
|
|
||||||
doc = """
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<script>throw new Error('this is an error');</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
self.writefile("mytest.html", doc)
|
|
||||||
self.goto("mytest.html")
|
|
||||||
self.clear_js_errors()
|
|
||||||
# self.check_js_errors does not raise, because the errors have been
|
|
||||||
# cleared
|
|
||||||
self.check_js_errors()
|
|
||||||
|
|
||||||
def test_wait_for_console_simple(self):
|
|
||||||
"""
|
|
||||||
Test that self.wait_for_console actually waits.
|
|
||||||
If it's buggy, the test will try to read self.console.log BEFORE the
|
|
||||||
log has been written and it will fail.
|
|
||||||
"""
|
|
||||||
doc = """
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<script>
|
|
||||||
setTimeout(function() {
|
|
||||||
console.log('Page loaded!');
|
|
||||||
}, 100);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
self.writefile("mytest.html", doc)
|
|
||||||
self.goto("mytest.html")
|
|
||||||
# we use a timeout of 200ms to give plenty of time to the page to
|
|
||||||
# actually run the setTimeout callback
|
|
||||||
self.wait_for_console("Page loaded!", timeout=200)
|
|
||||||
assert self.console.log.lines[-1] == "Page loaded!"
|
|
||||||
|
|
||||||
def test_wait_for_console_timeout(self):
|
|
||||||
doc = """
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
self.writefile("mytest.html", doc)
|
|
||||||
self.goto("mytest.html")
|
|
||||||
with pytest.raises(TimeoutError):
|
|
||||||
self.wait_for_console("This text will never be printed", timeout=200)
|
|
||||||
|
|
||||||
def test_wait_for_console_dont_wait_if_already_emitted(self):
|
|
||||||
"""
|
|
||||||
If the text is already on the console, wait_for_console() should return
|
|
||||||
immediately without waiting.
|
|
||||||
"""
|
|
||||||
doc = """
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<script>
|
|
||||||
console.log('Hello world')
|
|
||||||
console.log('Page loaded!');
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
self.writefile("mytest.html", doc)
|
|
||||||
self.goto("mytest.html")
|
|
||||||
self.wait_for_console("Page loaded!", timeout=200)
|
|
||||||
assert self.console.log.lines[-2] == "Hello world"
|
|
||||||
assert self.console.log.lines[-1] == "Page loaded!"
|
|
||||||
# the following call should return immediately without waiting
|
|
||||||
self.wait_for_console("Hello world", timeout=1)
|
|
||||||
|
|
||||||
def test_wait_for_console_exception_1(self):
|
|
||||||
"""
|
|
||||||
Test that if a JS exception is raised while waiting for the console, we
|
|
||||||
report the exception and not the timeout.
|
|
||||||
|
|
||||||
There are two main cases:
|
|
||||||
1. there is an exception and the console message does not appear
|
|
||||||
2. there is an exception but the console message appears anyway
|
|
||||||
|
|
||||||
This test checks for case 1. Case 2 is tested by
|
|
||||||
test_wait_for_console_exception_2
|
|
||||||
"""
|
|
||||||
# case 1: there is an exception and the console message does not appear
|
|
||||||
doc = """
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<script>throw new Error('this is an error');</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
self.writefile("mytest.html", doc)
|
|
||||||
# "Page loaded!" will never appear, of course.
|
|
||||||
self.goto("mytest.html")
|
|
||||||
with pytest.raises(PageErrors) as exc:
|
|
||||||
self.wait_for_console("Page loaded!", timeout=200)
|
|
||||||
assert "this is an error" in str(exc.value)
|
|
||||||
assert isinstance(exc.value.__context__, TimeoutError)
|
|
||||||
#
|
|
||||||
# if we use check_js_errors=False, the error are ignored, but we get the
|
|
||||||
# Timeout anyway
|
|
||||||
self.goto("mytest.html")
|
|
||||||
with pytest.raises(TimeoutError):
|
|
||||||
self.wait_for_console("Page loaded!", timeout=200, check_js_errors=False)
|
|
||||||
# we still got a PageErrors, so we need to manually clear it, else the
|
|
||||||
# test fails at teardown
|
|
||||||
self.clear_js_errors()
|
|
||||||
|
|
||||||
def test_wait_for_console_exception_2(self):
|
|
||||||
"""
|
|
||||||
See the description in test_wait_for_console_exception_1.
|
|
||||||
"""
|
|
||||||
# case 2: there is an exception, but the console message appears
|
|
||||||
doc = """
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<script>
|
|
||||||
setTimeout(function() {
|
|
||||||
console.log('Page loaded!');
|
|
||||||
}, 100);
|
|
||||||
throw new Error('this is an error');
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
self.writefile("mytest.html", doc)
|
|
||||||
self.goto("mytest.html")
|
|
||||||
with pytest.raises(PageErrors) as exc:
|
|
||||||
self.wait_for_console("Page loaded!", timeout=200)
|
|
||||||
assert "this is an error" in str(exc.value)
|
|
||||||
#
|
|
||||||
# with check_js_errors=False, the Error is ignored and the
|
|
||||||
# wait_for_console succeeds
|
|
||||||
self.goto("mytest.html")
|
|
||||||
self.wait_for_console("Page loaded!", timeout=200, check_js_errors=False)
|
|
||||||
# clear the errors, else the test fails at teardown
|
|
||||||
self.clear_js_errors()
|
|
||||||
|
|
||||||
def test_wait_for_console_match_substring(self):
|
|
||||||
doc = """
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<script>
|
|
||||||
console.log('Foo Bar Baz');
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
self.writefile("mytest.html", doc)
|
|
||||||
self.goto("mytest.html")
|
|
||||||
with pytest.raises(TimeoutError):
|
|
||||||
self.wait_for_console("Bar", timeout=200)
|
|
||||||
#
|
|
||||||
self.wait_for_console("Bar", timeout=200, match_substring=True)
|
|
||||||
assert self.console.log.lines[-1] == "Foo Bar Baz"
|
|
||||||
|
|
||||||
def test_iter_locator(self):
|
|
||||||
doc = """
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<div>foo</div>
|
|
||||||
<div>bar</div>
|
|
||||||
<div>baz</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
self.writefile("mytest.html", doc)
|
|
||||||
self.goto("mytest.html")
|
|
||||||
divs = self.page.locator("div")
|
|
||||||
assert divs.count() == 3
|
|
||||||
texts = [el.inner_text() for el in self.iter_locator(divs)]
|
|
||||||
assert texts == ["foo", "bar", "baz"]
|
|
||||||
|
|
||||||
def test_smartrouter_cache(self):
|
|
||||||
if self.router is None:
|
|
||||||
pytest.skip("Cannot test SmartRouter with --dev")
|
|
||||||
|
|
||||||
# this is not an image but who cares, I just want the browser to make
|
|
||||||
# an HTTP request
|
|
||||||
URL = "https://raw.githubusercontent.com/pyscript/pyscript/main/README.md"
|
|
||||||
doc = f"""
|
|
||||||
<html>
|
|
||||||
<body>
|
|
||||||
<img src="{URL}">
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
self.writefile("mytest.html", doc)
|
|
||||||
#
|
|
||||||
self.router.clear_cache(URL)
|
|
||||||
self.goto("mytest.html")
|
|
||||||
assert self.router.requests == [
|
|
||||||
(200, "fake_server", "https://fake_server/mytest.html"),
|
|
||||||
(200, "NETWORK", URL),
|
|
||||||
]
|
|
||||||
#
|
|
||||||
# let's visit the page again, now it should be cached
|
|
||||||
self.goto("mytest.html")
|
|
||||||
assert self.router.requests == [
|
|
||||||
# 1st visit
|
|
||||||
(200, "fake_server", "https://fake_server/mytest.html"),
|
|
||||||
(200, "NETWORK", URL),
|
|
||||||
# 2nd visit
|
|
||||||
(200, "fake_server", "https://fake_server/mytest.html"),
|
|
||||||
(200, "CACHED", URL),
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_404(self):
|
|
||||||
"""
|
|
||||||
Test that we capture a 404 in loading a page that does not exist.
|
|
||||||
"""
|
|
||||||
self.goto("this_url_does_not_exist.html")
|
|
||||||
assert [
|
|
||||||
"Failed to load resource: the server responded with a status of 404 (Not Found)"
|
|
||||||
] == self.console.all.lines
|
|
||||||
@@ -1,404 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from .support import PyScriptTest, only_main, skip_worker
|
|
||||||
|
|
||||||
|
|
||||||
class TestBasic(PyScriptTest):
|
|
||||||
def test_pyscript_exports(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import RUNNING_IN_WORKER, PyWorker, window, document, sync, current_target
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert self.console.error.lines == []
|
|
||||||
|
|
||||||
def test_script_py_hello(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
import js
|
|
||||||
js.console.log('hello from script py')
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert self.console.log.lines == ["hello from script py"]
|
|
||||||
|
|
||||||
def test_py_script_hello(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-script>
|
|
||||||
import js
|
|
||||||
js.console.log('hello from py-script')
|
|
||||||
</py-script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert self.console.log.lines == ["hello from py-script"]
|
|
||||||
|
|
||||||
def test_execution_thread(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
import pyscript
|
|
||||||
import js
|
|
||||||
js.console.log("worker?", pyscript.RUNNING_IN_WORKER)
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
assert self.execution_thread in ("main", "worker")
|
|
||||||
in_worker = self.execution_thread == "worker"
|
|
||||||
in_worker = str(in_worker).lower()
|
|
||||||
assert self.console.log.lines[-1] == f"worker? {in_worker}"
|
|
||||||
|
|
||||||
@skip_worker("NEXT: it should show a nice error on the page")
|
|
||||||
def test_no_cors_headers(self):
|
|
||||||
self.disable_cors_headers()
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
import js
|
|
||||||
js.console.log("hello")
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
wait_for_pyscript=False,
|
|
||||||
)
|
|
||||||
assert self.headers == {}
|
|
||||||
if self.execution_thread == "main":
|
|
||||||
self.wait_for_pyscript()
|
|
||||||
assert self.console.log.lines == ["hello"]
|
|
||||||
self.assert_no_banners()
|
|
||||||
else:
|
|
||||||
# XXX adapt and fix the test
|
|
||||||
expected_alert_banner_msg = (
|
|
||||||
'(PY1000): 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: "
|
|
||||||
'{"Cross-Origin-Embedder-Policy":"require-corp",'
|
|
||||||
'"Cross-Origin-Opener-Policy":"same-origin"}. '
|
|
||||||
"The problem may be that one or both of these are missing."
|
|
||||||
)
|
|
||||||
alert_banner = self.page.wait_for_selector(".py-error")
|
|
||||||
assert expected_alert_banner_msg in alert_banner.inner_text()
|
|
||||||
|
|
||||||
def test_print(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
print('hello pyscript')
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert self.console.log.lines[-1] == "hello pyscript"
|
|
||||||
|
|
||||||
@only_main
|
|
||||||
def test_input_exception(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
input("what's your name?")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.check_py_errors(
|
|
||||||
"Exception: input() doesn't work when PyScript runs in the main thread."
|
|
||||||
)
|
|
||||||
|
|
||||||
@skip_worker("NEXT: exceptions should be displayed in the DOM")
|
|
||||||
def test_python_exception(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
print('hello pyscript')
|
|
||||||
raise Exception('this is an error')
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert "hello pyscript" in self.console.log.lines
|
|
||||||
self.check_py_errors("Exception: this is an error")
|
|
||||||
#
|
|
||||||
# check that we show the traceback in the page. Note that here we
|
|
||||||
# display the "raw" python traceback, without the "[pyexec] Python
|
|
||||||
# exception:" line (which is useful in the console, but not for the
|
|
||||||
# user)
|
|
||||||
banner = self.page.locator(".py-error")
|
|
||||||
tb_lines = banner.inner_text().splitlines()
|
|
||||||
assert tb_lines[0] == "Traceback (most recent call last):"
|
|
||||||
assert tb_lines[-1] == "Exception: this is an error"
|
|
||||||
|
|
||||||
@skip_worker("NEXT: py-click doesn't work inside workers")
|
|
||||||
def test_python_exception_in_event_handler(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<button py-click="onclick">Click me</button>
|
|
||||||
<script type="py">
|
|
||||||
def onclick(event):
|
|
||||||
raise Exception("this is an error inside handler")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
self.page.locator("button").click()
|
|
||||||
self.wait_for_console(
|
|
||||||
"Exception: this is an error inside handler", match_substring=True
|
|
||||||
)
|
|
||||||
|
|
||||||
self.check_py_errors("Exception: this is an error inside handler")
|
|
||||||
|
|
||||||
## error in DOM
|
|
||||||
tb_lines = self.page.locator(".py-error").inner_text().splitlines()
|
|
||||||
assert tb_lines[0] == "Traceback (most recent call last):"
|
|
||||||
assert tb_lines[-1] == "Exception: this is an error inside handler"
|
|
||||||
|
|
||||||
@only_main
|
|
||||||
def test_execution_in_order(self):
|
|
||||||
"""
|
|
||||||
Check that they script py tags are executed in the same order they are
|
|
||||||
defined
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">import js; js.console.log('one')</script>
|
|
||||||
<script type="py">js.console.log('two')</script>
|
|
||||||
<script type="py">js.console.log('three')</script>
|
|
||||||
<script type="py">js.console.log('four')</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert self.console.log.lines[-4:] == [
|
|
||||||
"one",
|
|
||||||
"two",
|
|
||||||
"three",
|
|
||||||
"four",
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_escaping_of_angle_brackets(self):
|
|
||||||
"""
|
|
||||||
Check that script tags escape angle brackets
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
import js
|
|
||||||
js.console.log("A", 1<2, 1>2)
|
|
||||||
js.console.log("B <div></div>")
|
|
||||||
</script>
|
|
||||||
<py-script>
|
|
||||||
import js
|
|
||||||
js.console.log("C", 1<2, 1>2)
|
|
||||||
js.console.log("D <div></div>")
|
|
||||||
</py-script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
# in workers the order of execution is not guaranteed, better to play
|
|
||||||
# safe
|
|
||||||
lines = sorted(self.console.log.lines[-4:])
|
|
||||||
assert lines == [
|
|
||||||
"A true false",
|
|
||||||
"B <div></div>",
|
|
||||||
"C true false",
|
|
||||||
"D <div></div>",
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_packages(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
packages = ["asciitree"]
|
|
||||||
</py-config>
|
|
||||||
<script type="py">
|
|
||||||
import js
|
|
||||||
import asciitree
|
|
||||||
js.console.log('hello', asciitree.__name__)
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert self.console.log.lines[-3:] == [
|
|
||||||
"Loading asciitree", # printed by pyodide
|
|
||||||
"Loaded asciitree", # printed by pyodide
|
|
||||||
"hello asciitree", # printed by us
|
|
||||||
]
|
|
||||||
|
|
||||||
@pytest.mark.skip("NEXT: No banner")
|
|
||||||
def test_non_existent_package(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
packages = ["i-dont-exist"]
|
|
||||||
</py-config>
|
|
||||||
<script type="py">
|
|
||||||
print('hello')
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
wait_for_pyscript=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
expected_alert_banner_msg = (
|
|
||||||
"(PY1001): Unable to install package(s) 'i-dont-exist'. "
|
|
||||||
"Unable to find package in PyPI. Please make sure you have "
|
|
||||||
"entered a correct package name."
|
|
||||||
)
|
|
||||||
|
|
||||||
alert_banner = self.page.wait_for_selector(".alert-banner")
|
|
||||||
assert expected_alert_banner_msg in alert_banner.inner_text()
|
|
||||||
self.check_py_errors("Can't fetch metadata for 'i-dont-exist'")
|
|
||||||
|
|
||||||
@pytest.mark.skip("NEXT: No banner")
|
|
||||||
def test_no_python_wheel(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
packages = ["opsdroid"]
|
|
||||||
</py-config>
|
|
||||||
<script type="py">
|
|
||||||
print('hello')
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
wait_for_pyscript=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
expected_alert_banner_msg = (
|
|
||||||
"(PY1001): Unable to install package(s) 'opsdroid'. "
|
|
||||||
"Reason: Can't find a pure Python 3 Wheel for package(s) 'opsdroid'"
|
|
||||||
)
|
|
||||||
|
|
||||||
alert_banner = self.page.wait_for_selector(".alert-banner")
|
|
||||||
assert expected_alert_banner_msg in alert_banner.inner_text()
|
|
||||||
self.check_py_errors("Can't find a pure Python 3 wheel for 'opsdroid'")
|
|
||||||
|
|
||||||
@only_main
|
|
||||||
def test_dynamically_add_py_script_tag(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script>
|
|
||||||
function addPyScriptTag(event) {
|
|
||||||
let tag = document.createElement('py-script');
|
|
||||||
tag.innerHTML = "print('hello world')";
|
|
||||||
document.body.appendChild(tag);
|
|
||||||
}
|
|
||||||
addPyScriptTag()
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
timeout=20000,
|
|
||||||
)
|
|
||||||
self.page.locator("py-script")
|
|
||||||
|
|
||||||
assert self.console.log.lines[-1] == "hello world"
|
|
||||||
|
|
||||||
def test_py_script_src_attribute(self):
|
|
||||||
self.writefile("foo.py", "print('hello from foo')")
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py" src="foo.py"></script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert self.console.log.lines[-1] == "hello from foo"
|
|
||||||
|
|
||||||
@skip_worker("NEXT: banner not shown")
|
|
||||||
def test_py_script_src_not_found(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py" src="foo.py"></script>
|
|
||||||
""",
|
|
||||||
check_js_errors=False,
|
|
||||||
)
|
|
||||||
assert "Failed to load resource" in self.console.error.lines[0]
|
|
||||||
|
|
||||||
# TODO: we need to be sure errors make sense from both main and worker worlds
|
|
||||||
expected_msg = "(PY0404): Fetching from URL foo.py failed with error 404"
|
|
||||||
assert any((expected_msg in line) for line in self.console.error.lines)
|
|
||||||
assert self.assert_banner_message(expected_msg)
|
|
||||||
|
|
||||||
# TODO: ... and we shouldn't: it's a module and we better don't leak in global
|
|
||||||
@pytest.mark.skip("NEXT: we don't expose pyscript on window")
|
|
||||||
def test_js_version(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.page.add_script_tag(content="console.log(pyscript.version)")
|
|
||||||
|
|
||||||
assert (
|
|
||||||
re.match(r"\d{4}\.\d{2}\.\d+(\.[a-zA-Z0-9]+)?", self.console.log.lines[-1])
|
|
||||||
is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: ... and we shouldn't: it's a module and we better don't leak in global
|
|
||||||
@pytest.mark.skip("NEXT: we don't expose pyscript on window")
|
|
||||||
def test_python_version(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
import js
|
|
||||||
js.console.log(pyscript.__version__)
|
|
||||||
js.console.log(str(pyscript.version_info))
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
re.match(r"\d{4}\.\d{2}\.\d+(\.[a-zA-Z0-9]+)?", self.console.log.lines[-2])
|
|
||||||
is not None
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
re.match(
|
|
||||||
r"version_info\(year=\d{4}, month=\d{2}, "
|
|
||||||
r"minor=\d+, releaselevel='([a-zA-Z0-9]+)?'\)",
|
|
||||||
self.console.log.lines[-1],
|
|
||||||
)
|
|
||||||
is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.skip("NEXT: works with <py-script> not with <script>")
|
|
||||||
def test_getPySrc_returns_source_code(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-script>print("hello from py-script")</py-script>
|
|
||||||
<script type="py">print("hello from script py")</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
pyscript_tag = self.page.locator("py-script")
|
|
||||||
assert pyscript_tag.inner_html() == ""
|
|
||||||
assert (
|
|
||||||
pyscript_tag.evaluate("node => node.srcCode")
|
|
||||||
== 'print("hello from py-script")'
|
|
||||||
)
|
|
||||||
script_py_tag = self.page.locator('script[type="py"]')
|
|
||||||
assert (
|
|
||||||
script_py_tag.evaluate("node => node.srcCode")
|
|
||||||
== 'print("hello from script py")'
|
|
||||||
)
|
|
||||||
|
|
||||||
@skip_worker("NEXT: py-click doesn't work inside workers")
|
|
||||||
def test_py_attribute_without_id(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<button py-click="myfunc">Click me</button>
|
|
||||||
<script type="py">
|
|
||||||
def myfunc(event):
|
|
||||||
print("hello world!")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
btn = self.page.wait_for_selector("button")
|
|
||||||
btn.click()
|
|
||||||
self.wait_for_console("hello world!")
|
|
||||||
assert self.console.log.lines[-1] == "hello world!"
|
|
||||||
assert self.console.error.lines == []
|
|
||||||
|
|
||||||
def test_py_all_done_event(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script>
|
|
||||||
addEventListener("py:all-done", () => console.log("2"))
|
|
||||||
</script>
|
|
||||||
<script type="py">
|
|
||||||
print("1")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert self.console.log.lines == ["1", "2"]
|
|
||||||
assert self.console.error.lines == []
|
|
||||||
@@ -1,526 +0,0 @@
|
|||||||
################################################################################
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import html
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytest
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from .support import (
|
|
||||||
PageErrors,
|
|
||||||
PyScriptTest,
|
|
||||||
filter_inner_text,
|
|
||||||
filter_page_content,
|
|
||||||
only_main,
|
|
||||||
skip_worker,
|
|
||||||
wait_for_render,
|
|
||||||
)
|
|
||||||
|
|
||||||
DISPLAY_OUTPUT_ID_PATTERN = r'script-py[id^="py-"]'
|
|
||||||
|
|
||||||
|
|
||||||
class TestDisplay(PyScriptTest):
|
|
||||||
def test_simple_display(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
print('ciao')
|
|
||||||
from pyscript import display
|
|
||||||
display("hello world")
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
timeout=20000,
|
|
||||||
)
|
|
||||||
node_list = self.page.query_selector_all(DISPLAY_OUTPUT_ID_PATTERN)
|
|
||||||
pattern = r"<div>hello world</div>"
|
|
||||||
assert node_list[0].inner_html() == pattern
|
|
||||||
assert len(node_list) == 1
|
|
||||||
|
|
||||||
def test_consecutive_display(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
display('hello 1')
|
|
||||||
</script>
|
|
||||||
<p>hello 2</p>
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
display('hello 3')
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
inner_text = self.page.inner_text("body")
|
|
||||||
lines = inner_text.splitlines()
|
|
||||||
|
|
||||||
lines = [line for line in filter_page_content(lines)] # remove empty lines
|
|
||||||
assert lines == ["hello 1", "hello 2", "hello 3"]
|
|
||||||
|
|
||||||
def test_target_parameter(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_target_parameter_with_sharp(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
display('hello world', target="#mydiv")
|
|
||||||
</script>
|
|
||||||
<div id="mydiv"></div>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
mydiv = self.page.locator("#mydiv")
|
|
||||||
assert mydiv.inner_text() == "hello world"
|
|
||||||
|
|
||||||
def test_non_existing_id_target_raises_value_error(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
display('hello world', target="non-existing")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
error_msg = (
|
|
||||||
f"Invalid selector with id=non-existing. Cannot be found in the page."
|
|
||||||
)
|
|
||||||
self.check_py_errors(f"ValueError: {error_msg}")
|
|
||||||
|
|
||||||
def test_empty_string_target_raises_value_error(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
display('hello world', target="")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.check_py_errors(f"ValueError: Cannot have an empty target")
|
|
||||||
|
|
||||||
def test_non_string_target_values_raise_typerror(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
display("hello False", target=False)
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
error_msg = f"target must be str or None, not bool"
|
|
||||||
self.check_py_errors(f"TypeError: {error_msg}")
|
|
||||||
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
display("hello False", target=123)
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
error_msg = f"target must be str or None, not int"
|
|
||||||
self.check_py_errors(f"TypeError: {error_msg}")
|
|
||||||
|
|
||||||
@skip_worker("NEXT: display(target=...) does not work")
|
|
||||||
def test_tag_target_attribute(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py" target="hello">
|
|
||||||
from pyscript import display
|
|
||||||
display('hello')
|
|
||||||
display("goodbye world", target="goodbye")
|
|
||||||
display('world')
|
|
||||||
</script>
|
|
||||||
<div id="hello"></div>
|
|
||||||
<div id="goodbye"></div>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
hello = self.page.locator("#hello")
|
|
||||||
assert hello.inner_text() == "hello\nworld"
|
|
||||||
|
|
||||||
goodbye = self.page.locator("#goodbye")
|
|
||||||
assert goodbye.inner_text() == "goodbye world"
|
|
||||||
|
|
||||||
@skip_worker("NEXT: display target does not work properly")
|
|
||||||
def test_target_script_py(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<div>ONE</div>
|
|
||||||
<script type="py" id="two">
|
|
||||||
# just a placeholder
|
|
||||||
</script>
|
|
||||||
<div>THREE</div>
|
|
||||||
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
display('TWO', target="two")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
text = self.page.inner_text("body")
|
|
||||||
assert text == "ONE\nTWO\nTHREE"
|
|
||||||
|
|
||||||
@skip_worker("NEXT: display target does not work properly")
|
|
||||||
def test_consecutive_display_target(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py" id="first">
|
|
||||||
from pyscript import display
|
|
||||||
display('hello 1')
|
|
||||||
</script>
|
|
||||||
<p>hello in between 1 and 2</p>
|
|
||||||
<script type="py" id="second">
|
|
||||||
from pyscript import display
|
|
||||||
display('hello 2', target="second")
|
|
||||||
</script>
|
|
||||||
<script type="py" id="third">
|
|
||||||
from pyscript import display
|
|
||||||
display('hello 3')
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
inner_text = self.page.inner_text("body")
|
|
||||||
lines = inner_text.splitlines()
|
|
||||||
lines = [line for line in filter_page_content(lines)] # remove empty lines
|
|
||||||
assert lines == ["hello 1", "hello in between 1 and 2", "hello 2", "hello 3"]
|
|
||||||
|
|
||||||
def test_multiple_display_calls_same_tag(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
display('hello')
|
|
||||||
display('world')
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
tag = self.page.locator("script-py")
|
|
||||||
lines = tag.inner_text().splitlines()
|
|
||||||
assert lines == ["hello", "world"]
|
|
||||||
|
|
||||||
@only_main # with workers, two tags are two separate interpreters
|
|
||||||
def test_implicit_target_from_a_different_tag(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
def say_hello():
|
|
||||||
display('hello')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
say_hello()
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
elems = self.page.locator("script-py")
|
|
||||||
py0 = elems.nth(0)
|
|
||||||
py1 = elems.nth(1)
|
|
||||||
assert py0.inner_text() == ""
|
|
||||||
assert py1.inner_text() == "hello"
|
|
||||||
|
|
||||||
@skip_worker("NEXT: py-click doesn't work")
|
|
||||||
def test_no_explicit_target(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
def display_hello(error):
|
|
||||||
display('hello world')
|
|
||||||
</script>
|
|
||||||
<button id="my-button" py-click="display_hello">Click me</button>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.page.locator("button").click()
|
|
||||||
|
|
||||||
text = self.page.locator("script-py").text_content()
|
|
||||||
assert "hello world" in text
|
|
||||||
|
|
||||||
@skip_worker("NEXT: display target does not work properly")
|
|
||||||
def test_explicit_target_pyscript_tag(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
def display_hello():
|
|
||||||
display('hello', target='second-pyscript-tag')
|
|
||||||
</script>
|
|
||||||
<script type="py" id="second-pyscript-tag">
|
|
||||||
display_hello()
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
text = self.page.locator("script-py").nth(1).inner_text()
|
|
||||||
assert text == "hello"
|
|
||||||
|
|
||||||
@skip_worker("NEXT: display target does not work properly")
|
|
||||||
def test_explicit_target_on_button_tag(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
def display_hello(error):
|
|
||||||
display('hello', target='my-button')
|
|
||||||
</script>
|
|
||||||
<button id="my-button" py-click="display_hello">Click me</button>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.page.locator("text=Click me").click()
|
|
||||||
text = self.page.locator("id=my-button").inner_text()
|
|
||||||
assert "hello" in text
|
|
||||||
|
|
||||||
def test_append_true(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
display('AAA', append=True)
|
|
||||||
display('BBB', append=True)
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
output = self.page.locator("script-py")
|
|
||||||
assert output.inner_text() == "AAA\nBBB"
|
|
||||||
|
|
||||||
def test_append_false(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
display('AAA', append=False)
|
|
||||||
display('BBB', append=False)
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
output = self.page.locator("script-py")
|
|
||||||
assert output.inner_text() == "BBB"
|
|
||||||
|
|
||||||
def test_display_multiple_values(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
hello = 'hello'
|
|
||||||
world = 'world'
|
|
||||||
display(hello, world)
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
output = self.page.locator("script-py")
|
|
||||||
assert output.inner_text() == "hello\nworld"
|
|
||||||
|
|
||||||
def test_display_multiple_append_false(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
display('hello', append=False)
|
|
||||||
display('world', append=False)
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
output = self.page.locator("script-py")
|
|
||||||
assert output.inner_text() == "world"
|
|
||||||
|
|
||||||
# TODO: this is a display.py issue to fix when append=False is used
|
|
||||||
# do not use the first element, just clean up and then append
|
|
||||||
# remove the # display comment once that's done
|
|
||||||
def test_display_multiple_append_false_with_target(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<div id="circle-div"></div>
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
class Circle:
|
|
||||||
r = 0
|
|
||||||
def _repr_svg_(self):
|
|
||||||
return (
|
|
||||||
f'<svg height="{self.r*2}" width="{self.r*2}">'
|
|
||||||
f'<circle cx="{self.r}" cy="{self.r}" r="{self.r}" fill="red" /></svg>'
|
|
||||||
)
|
|
||||||
|
|
||||||
circle = Circle()
|
|
||||||
|
|
||||||
circle.r += 5
|
|
||||||
# display(circle, target="circle-div", append=False)
|
|
||||||
circle.r += 5
|
|
||||||
display(circle, target="circle-div", append=False)
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
innerhtml = self.page.locator("id=circle-div").inner_html()
|
|
||||||
assert (
|
|
||||||
innerhtml
|
|
||||||
== '<svg height="20" width="20"><circle cx="10" cy="10" r="10" fill="red"></circle></svg>' # noqa: E501
|
|
||||||
)
|
|
||||||
assert self.console.error.lines == []
|
|
||||||
|
|
||||||
def test_display_list_dict_tuple(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
l = ['A', 1, '!']
|
|
||||||
d = {'B': 2, 'List': l}
|
|
||||||
t = ('C', 3, '!')
|
|
||||||
display(l, d, t)
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
inner_text = self.page.inner_text("html")
|
|
||||||
filtered_inner_text = filter_inner_text(inner_text)
|
|
||||||
print(filtered_inner_text)
|
|
||||||
assert (
|
|
||||||
filtered_inner_text
|
|
||||||
== "['A', 1, '!']\n{'B': 2, 'List': ['A', 1, '!']}\n('C', 3, '!')"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_display_should_escape(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
display("<p>hello world</p>")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
out = self.page.locator("script-py > div")
|
|
||||||
assert out.inner_html() == html.escape("<p>hello world</p>")
|
|
||||||
assert out.inner_text() == "<p>hello world</p>"
|
|
||||||
|
|
||||||
def test_display_HTML(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display, HTML
|
|
||||||
display(HTML("<p>hello world</p>"))
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
out = self.page.locator("script-py > div")
|
|
||||||
assert out.inner_html() == "<p>hello world</p>"
|
|
||||||
assert out.inner_text() == "hello world"
|
|
||||||
|
|
||||||
@skip_worker("NEXT: matplotlib-pyodide backend does not work")
|
|
||||||
def test_image_display(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config> packages = ["matplotlib"] </py-config>
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
xpoints = [3, 6, 9]
|
|
||||||
ypoints = [1, 2, 3]
|
|
||||||
plt.plot(xpoints, ypoints)
|
|
||||||
display(plt)
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
timeout=30 * 1000,
|
|
||||||
)
|
|
||||||
wait_for_render(self.page, "*", "<img src=['\"]data:image")
|
|
||||||
test = self.page.wait_for_selector("img")
|
|
||||||
img_src = test.get_attribute("src").replace(
|
|
||||||
"data:image/png;charset=utf-8;base64,", ""
|
|
||||||
)
|
|
||||||
img_data = np.asarray(Image.open(io.BytesIO(base64.b64decode(img_src))))
|
|
||||||
with Image.open(
|
|
||||||
os.path.join(os.path.dirname(__file__), "test_assets", "line_plot.png"),
|
|
||||||
) as image:
|
|
||||||
ref_data = np.asarray(image)
|
|
||||||
|
|
||||||
deviation = np.mean(np.abs(img_data - ref_data))
|
|
||||||
assert deviation == 0.0
|
|
||||||
self.assert_no_banners()
|
|
||||||
|
|
||||||
def test_empty_HTML_and_console_output(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
import js
|
|
||||||
print('print from python')
|
|
||||||
js.console.log('print from js')
|
|
||||||
js.console.error('error from js');
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
inner_html = self.page.content()
|
|
||||||
assert re.search("", inner_html)
|
|
||||||
console_text = self.console.all.lines
|
|
||||||
assert "print from python" in console_text
|
|
||||||
assert "print from js" in console_text
|
|
||||||
assert "error from js" in console_text
|
|
||||||
|
|
||||||
def test_text_HTML_and_console_output(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
import js
|
|
||||||
display('this goes to the DOM')
|
|
||||||
print('print from python')
|
|
||||||
js.console.log('print from js')
|
|
||||||
js.console.error('error from js');
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
inner_text = self.page.inner_text("script-py")
|
|
||||||
assert inner_text == "this goes to the DOM"
|
|
||||||
assert self.console.log.lines[-2:] == [
|
|
||||||
"print from python",
|
|
||||||
"print from js",
|
|
||||||
]
|
|
||||||
print(self.console.error.lines)
|
|
||||||
assert self.console.error.lines[-1] == "error from js"
|
|
||||||
|
|
||||||
def test_console_line_break(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
print('1print\\n2print')
|
|
||||||
print('1console\\n2console')
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
console_text = self.console.all.lines
|
|
||||||
assert console_text.index("1print") == (console_text.index("2print") - 1)
|
|
||||||
assert console_text.index("1console") == (console_text.index("2console") - 1)
|
|
||||||
|
|
||||||
@skip_worker("NEXT: display target does not work properly")
|
|
||||||
def test_image_renders_correctly(self):
|
|
||||||
"""
|
|
||||||
This is just a sanity check to make sure that images are rendered
|
|
||||||
in a reasonable way.
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
packages = ["pillow"]
|
|
||||||
</py-config>
|
|
||||||
|
|
||||||
<div id="img-target" />
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
from PIL import Image
|
|
||||||
img = Image.new("RGB", (4, 4), color=(0, 0, 0))
|
|
||||||
display(img, target='img-target', append=False)
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
|
|
||||||
img_src = self.page.locator("img").get_attribute("src")
|
|
||||||
assert img_src.startswith("data:image/png;charset=utf-8;base64")
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 42 KiB |
@@ -1,205 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from .support import PyScriptTest, filter_inner_text, only_main
|
|
||||||
|
|
||||||
|
|
||||||
class TestAsync(PyScriptTest):
|
|
||||||
# ensure_future() and create_task() should behave similarly;
|
|
||||||
# we'll use the same source code to test both
|
|
||||||
coroutine_script = """
|
|
||||||
<script type="py">
|
|
||||||
import js
|
|
||||||
import asyncio
|
|
||||||
js.console.log("first")
|
|
||||||
async def main():
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
js.console.log("third")
|
|
||||||
asyncio.{func}(main())
|
|
||||||
js.console.log("second")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_asyncio_ensure_future(self):
|
|
||||||
self.pyscript_run(self.coroutine_script.format(func="ensure_future"))
|
|
||||||
self.wait_for_console("third")
|
|
||||||
assert self.console.log.lines[-3:] == ["first", "second", "third"]
|
|
||||||
|
|
||||||
def test_asyncio_create_task(self):
|
|
||||||
self.pyscript_run(self.coroutine_script.format(func="create_task"))
|
|
||||||
self.wait_for_console("third")
|
|
||||||
assert self.console.log.lines[-3:] == ["first", "second", "third"]
|
|
||||||
|
|
||||||
def test_asyncio_gather(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py" id="pys">
|
|
||||||
import asyncio
|
|
||||||
import js
|
|
||||||
from pyodide.ffi import to_js
|
|
||||||
|
|
||||||
async def coro(delay):
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
return(delay)
|
|
||||||
|
|
||||||
async def get_results():
|
|
||||||
results = await asyncio.gather(*[coro(d) for d in range(3,0,-1)])
|
|
||||||
js.console.log(str(results)) #Compare to string representation, not Proxy
|
|
||||||
js.console.log("DONE")
|
|
||||||
|
|
||||||
asyncio.ensure_future(get_results())
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.wait_for_console("DONE")
|
|
||||||
assert self.console.log.lines[-2:] == ["[3, 2, 1]", "DONE"]
|
|
||||||
|
|
||||||
@only_main
|
|
||||||
def test_multiple_async(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
import js
|
|
||||||
import asyncio
|
|
||||||
async def a_func():
|
|
||||||
for i in range(3):
|
|
||||||
js.console.log('A', i)
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
asyncio.ensure_future(a_func())
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script type="py">
|
|
||||||
import js
|
|
||||||
import asyncio
|
|
||||||
async def b_func():
|
|
||||||
for i in range(3):
|
|
||||||
js.console.log('B', i)
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
js.console.log('b func done')
|
|
||||||
asyncio.ensure_future(b_func())
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.wait_for_console("b func done")
|
|
||||||
assert self.console.log.lines == [
|
|
||||||
"A 0",
|
|
||||||
"B 0",
|
|
||||||
"A 1",
|
|
||||||
"B 1",
|
|
||||||
"A 2",
|
|
||||||
"B 2",
|
|
||||||
"b func done",
|
|
||||||
]
|
|
||||||
|
|
||||||
@only_main
|
|
||||||
def test_multiple_async_multiple_display_targeted(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py" id="pyA">
|
|
||||||
from pyscript import display
|
|
||||||
import js
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
async def a_func():
|
|
||||||
for i in range(2):
|
|
||||||
display(f'A{i}', target='pyA', append=True)
|
|
||||||
js.console.log("A", i)
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
asyncio.ensure_future(a_func())
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script type="py" id="pyB">
|
|
||||||
from pyscript import display
|
|
||||||
import js
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
async def a_func():
|
|
||||||
for i in range(2):
|
|
||||||
display(f'B{i}', target='pyB', append=True)
|
|
||||||
js.console.log("B", i)
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
js.console.log("B DONE")
|
|
||||||
|
|
||||||
asyncio.ensure_future(a_func())
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.wait_for_console("B DONE")
|
|
||||||
inner_text = self.page.inner_text("html")
|
|
||||||
assert "A0\nA1\nB0\nB1" in filter_inner_text(inner_text)
|
|
||||||
|
|
||||||
def test_async_display_untargeted(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
import asyncio
|
|
||||||
import js
|
|
||||||
|
|
||||||
async def a_func():
|
|
||||||
display('A')
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
js.console.log("DONE")
|
|
||||||
|
|
||||||
asyncio.ensure_future(a_func())
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.wait_for_console("DONE")
|
|
||||||
assert self.page.locator("script-py").inner_text() == "A"
|
|
||||||
|
|
||||||
@only_main
|
|
||||||
def test_sync_and_async_order(self):
|
|
||||||
"""
|
|
||||||
The order of execution is defined as follows:
|
|
||||||
1. first, we execute all the script tags in order
|
|
||||||
2. then, we start all the tasks which were scheduled with create_task
|
|
||||||
|
|
||||||
Note that tasks are started *AFTER* all py-script tags have been
|
|
||||||
executed. That's why the console.log() inside mytask1 and mytask2 are
|
|
||||||
executed after e.g. js.console.log("6").
|
|
||||||
"""
|
|
||||||
src = """
|
|
||||||
<script type="py">
|
|
||||||
import js
|
|
||||||
js.console.log("1")
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script type="py">
|
|
||||||
import asyncio
|
|
||||||
import js
|
|
||||||
|
|
||||||
async def mytask1():
|
|
||||||
js.console.log("7")
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
js.console.log("9")
|
|
||||||
|
|
||||||
js.console.log("2")
|
|
||||||
asyncio.create_task(mytask1())
|
|
||||||
js.console.log("3")
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script type="py">
|
|
||||||
import js
|
|
||||||
js.console.log("4")
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script type="py">
|
|
||||||
import asyncio
|
|
||||||
import js
|
|
||||||
|
|
||||||
async def mytask2():
|
|
||||||
js.console.log("8")
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
js.console.log("10")
|
|
||||||
js.console.log("DONE")
|
|
||||||
|
|
||||||
js.console.log("5")
|
|
||||||
asyncio.create_task(mytask2())
|
|
||||||
js.console.log("6")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
self.pyscript_run(src, wait_for_pyscript=False)
|
|
||||||
self.wait_for_console("DONE")
|
|
||||||
lines = self.console.log.lines[-11:]
|
|
||||||
assert lines == ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "DONE"]
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from .support import PyScriptTest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.xfail(reason="See PR #938")
|
|
||||||
class TestImportmap(PyScriptTest):
|
|
||||||
def test_importmap(self):
|
|
||||||
src = """
|
|
||||||
export function say_hello(who) {
|
|
||||||
console.log("hello from", who);
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
self.writefile("mymod.js", src)
|
|
||||||
#
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="importmap">
|
|
||||||
{
|
|
||||||
"imports": {
|
|
||||||
"mymod": "/mymod.js"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script type="module">
|
|
||||||
import { say_hello } from "mymod";
|
|
||||||
say_hello("JS");
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script type="py">
|
|
||||||
import mymod
|
|
||||||
mymod.say_hello("Python")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert self.console.log.lines == [
|
|
||||||
"hello from JS",
|
|
||||||
"hello from Python",
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_invalid_json(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="importmap">
|
|
||||||
this is not valid JSON
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script type="py">
|
|
||||||
print("hello world")
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
wait_for_pyscript=False,
|
|
||||||
)
|
|
||||||
# this error is raised by the browser itself, when *it* tries to parse
|
|
||||||
# the import map
|
|
||||||
self.check_js_errors("Failed to parse import map")
|
|
||||||
|
|
||||||
self.wait_for_pyscript()
|
|
||||||
assert self.console.log.lines == [
|
|
||||||
"hello world",
|
|
||||||
]
|
|
||||||
# this warning is shown by pyscript, when *we* try to parse the import
|
|
||||||
# map
|
|
||||||
banner = self.page.locator(".py-warning")
|
|
||||||
assert "Failed to parse import map" in banner.inner_text()
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
from .support import PyScriptTest, with_execution_thread
|
|
||||||
|
|
||||||
|
|
||||||
@with_execution_thread(None)
|
|
||||||
class TestSmokeTests(PyScriptTest):
|
|
||||||
"""
|
|
||||||
Each example requires the same three tests:
|
|
||||||
|
|
||||||
- Test that the initial markup loads properly (currently done by
|
|
||||||
testing the <title> tag's content)
|
|
||||||
- Testing that pyscript is loading properly
|
|
||||||
- Testing that the page contains appropriate content after rendering
|
|
||||||
"""
|
|
||||||
|
|
||||||
def test_pydom(self):
|
|
||||||
# Test the full pydom test suite by running it in the browser
|
|
||||||
self.goto("test/pyscript_dom/index.html?-v&-s")
|
|
||||||
assert self.page.title() == "PyDom Test Suite"
|
|
||||||
|
|
||||||
# wait for the test suite to finish
|
|
||||||
self.wait_for_console(
|
|
||||||
"============================= test session starts =============================="
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assert_no_banners()
|
|
||||||
|
|
||||||
results = self.page.inner_html("#tests-terminal")
|
|
||||||
assert results
|
|
||||||
assert "PASSED" in results
|
|
||||||
assert "FAILED" not in results
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from .support import PyScriptTest
|
|
||||||
|
|
||||||
pytest.skip(
|
|
||||||
reason="NEXT: pyscript API changed doesn't expose pyscript to window anymore",
|
|
||||||
allow_module_level=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestInterpreterAccess(PyScriptTest):
|
|
||||||
"""Test accessing Python objects from JS via pyscript.interpreter"""
|
|
||||||
|
|
||||||
def test_interpreter_python_access(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
x = 1
|
|
||||||
def py_func():
|
|
||||||
return 2
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
self.run_js(
|
|
||||||
"""
|
|
||||||
const x = await pyscript.interpreter.globals.get('x');
|
|
||||||
const py_func = await pyscript.interpreter.globals.get('py_func');
|
|
||||||
const py_func_res = await py_func();
|
|
||||||
console.log(`x is ${x}`);
|
|
||||||
console.log(`py_func() returns ${py_func_res}`);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert self.console.log.lines[-2:] == [
|
|
||||||
"x is 1",
|
|
||||||
"py_func() returns 2",
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_interpreter_script_execution(self):
|
|
||||||
"""Test running Python code from js via pyscript.interpreter"""
|
|
||||||
self.pyscript_run("")
|
|
||||||
|
|
||||||
self.run_js(
|
|
||||||
"""
|
|
||||||
const interface = pyscript.interpreter._remote.interface;
|
|
||||||
await interface.runPython('print("Interpreter Ran This")');
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
expected_message = "Interpreter Ran This"
|
|
||||||
assert self.console.log.lines[-1] == expected_message
|
|
||||||
|
|
||||||
py_terminal = self.page.wait_for_selector("py-terminal")
|
|
||||||
assert py_terminal.text_content() == expected_message
|
|
||||||
|
|
||||||
def test_backward_compatibility_runtime_script_execution(self):
|
|
||||||
"""Test running Python code from js via pyscript.runtime"""
|
|
||||||
self.pyscript_run("")
|
|
||||||
|
|
||||||
self.run_js(
|
|
||||||
"""
|
|
||||||
const interface = pyscript.runtime._remote.interpreter;
|
|
||||||
await interface.runPython('print("Interpreter Ran This")');
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
expected_message = "Interpreter Ran This"
|
|
||||||
assert self.console.log.lines[-1] == expected_message
|
|
||||||
|
|
||||||
py_terminal = self.page.wait_for_selector("py-terminal")
|
|
||||||
assert py_terminal.text_content() == expected_message
|
|
||||||
|
|
||||||
def test_backward_compatibility_runtime_python_access(self):
|
|
||||||
"""Test accessing Python objects from JS via pyscript.runtime"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
x = 1
|
|
||||||
def py_func():
|
|
||||||
return 2
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
self.run_js(
|
|
||||||
"""
|
|
||||||
const x = await pyscript.interpreter.globals.get('x');
|
|
||||||
const py_func = await pyscript.interpreter.globals.get('py_func');
|
|
||||||
const py_func_res = await py_func();
|
|
||||||
console.log(`x is ${x}`);
|
|
||||||
console.log(`py_func() returns ${py_func_res}`);
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert self.console.log.lines[-2:] == [
|
|
||||||
"x is 1",
|
|
||||||
"py_func() returns 2",
|
|
||||||
]
|
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from .support import PyScriptTest, skip_worker
|
|
||||||
|
|
||||||
pytest.skip(
|
|
||||||
reason="NEXT: plugins not supported",
|
|
||||||
allow_module_level=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Source code of a simple plugin that creates a Custom Element for testing purposes
|
|
||||||
CE_PLUGIN_CODE = """
|
|
||||||
from pyscript import Plugin
|
|
||||||
from js import console
|
|
||||||
|
|
||||||
plugin = Plugin('py-upper')
|
|
||||||
|
|
||||||
console.log("py_upper Plugin loaded")
|
|
||||||
|
|
||||||
@plugin.register_custom_element('py-up')
|
|
||||||
class Upper:
|
|
||||||
def __init__(self, element):
|
|
||||||
self.element = element
|
|
||||||
|
|
||||||
def connect(self):
|
|
||||||
console.log("Upper plugin connected")
|
|
||||||
return self.element.originalInnerHTML.upper()
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Source of a plugin hooks into the PyScript App lifecycle events
|
|
||||||
HOOKS_PLUGIN_CODE = """
|
|
||||||
from pyscript import Plugin
|
|
||||||
from js import console
|
|
||||||
|
|
||||||
class TestLogger(Plugin):
|
|
||||||
def configure(self, config):
|
|
||||||
console.log('configure called')
|
|
||||||
|
|
||||||
def beforeLaunch(self, config):
|
|
||||||
console.log('beforeLaunch called')
|
|
||||||
|
|
||||||
def afterSetup(self, config):
|
|
||||||
console.log('afterSetup called')
|
|
||||||
|
|
||||||
def afterStartup(self, config):
|
|
||||||
console.log('afterStartup called')
|
|
||||||
|
|
||||||
def beforePyScriptExec(self, interpreter, src, pyScriptTag):
|
|
||||||
console.log(f'beforePyScriptExec called')
|
|
||||||
console.log(f'before_src:{src}')
|
|
||||||
|
|
||||||
def afterPyScriptExec(self, interpreter, src, pyScriptTag, result):
|
|
||||||
console.log(f'afterPyScriptExec called')
|
|
||||||
console.log(f'after_src:{src}')
|
|
||||||
|
|
||||||
def onUserError(self, config):
|
|
||||||
console.log('onUserError called')
|
|
||||||
|
|
||||||
|
|
||||||
plugin = TestLogger()
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Source of script that defines a plugin with only beforePyScriptExec and
|
|
||||||
# afterPyScriptExec methods
|
|
||||||
PYSCRIPT_HOOKS_PLUGIN_CODE = """
|
|
||||||
from pyscript import Plugin
|
|
||||||
from js import console
|
|
||||||
|
|
||||||
class ExecTestLogger(Plugin):
|
|
||||||
|
|
||||||
async def beforePyScriptExec(self, interpreter, src, pyScriptTag):
|
|
||||||
console.log(f'beforePyScriptExec called')
|
|
||||||
console.log(f'before_src:{src}')
|
|
||||||
|
|
||||||
async def afterPyScriptExec(self, interpreter, src, pyScriptTag, result):
|
|
||||||
console.log(f'afterPyScriptExec called')
|
|
||||||
console.log(f'after_src:{src}')
|
|
||||||
console.log(f'result:{result}')
|
|
||||||
|
|
||||||
|
|
||||||
plugin = ExecTestLogger()
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Source of script that defines a plugin with only beforePyScriptExec and
|
|
||||||
# afterPyScriptExec methods
|
|
||||||
PYREPL_HOOKS_PLUGIN_CODE = """
|
|
||||||
from pyscript import Plugin
|
|
||||||
from js import console
|
|
||||||
|
|
||||||
console.warn("This is in pyrepl hooks file")
|
|
||||||
|
|
||||||
class PyReplTestLogger(Plugin):
|
|
||||||
|
|
||||||
def beforePyReplExec(self, interpreter, src, outEl, pyReplTag):
|
|
||||||
console.log(f'beforePyReplExec called')
|
|
||||||
console.log(f'before_src:{src}')
|
|
||||||
|
|
||||||
def afterPyReplExec(self, interpreter, src, outEl, pyReplTag, result):
|
|
||||||
console.log(f'afterPyReplExec called')
|
|
||||||
console.log(f'after_src:{src}')
|
|
||||||
console.log(f'result:{result}')
|
|
||||||
|
|
||||||
|
|
||||||
plugin = PyReplTestLogger()
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Source of a script that doesn't call define a `plugin` attribute
|
|
||||||
NO_PLUGIN_CODE = """
|
|
||||||
from pyscript import Plugin
|
|
||||||
from js import console
|
|
||||||
|
|
||||||
class TestLogger(Plugin):
|
|
||||||
pass
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Source code of a simple plugin that creates a Custom Element for testing purposes
|
|
||||||
CODE_CE_PLUGIN_BAD_RETURNS = """
|
|
||||||
from pyscript import Plugin
|
|
||||||
from js import console
|
|
||||||
|
|
||||||
plugin = Plugin('py-broken')
|
|
||||||
|
|
||||||
@plugin.register_custom_element('py-up')
|
|
||||||
class Upper:
|
|
||||||
def __init__(self, element):
|
|
||||||
self.element = element
|
|
||||||
|
|
||||||
def connect(self):
|
|
||||||
# Just returning something... anything other than a string should be ignore
|
|
||||||
return Plugin
|
|
||||||
"""
|
|
||||||
HTML_TEMPLATE_WITH_TAG = """
|
|
||||||
<py-config>
|
|
||||||
plugins = [
|
|
||||||
"./{plugin_name}.py"
|
|
||||||
]
|
|
||||||
</py-config>
|
|
||||||
|
|
||||||
<{tagname}>
|
|
||||||
{html}
|
|
||||||
</{tagname}>
|
|
||||||
"""
|
|
||||||
HTML_TEMPLATE_NO_TAG = """
|
|
||||||
<py-config>
|
|
||||||
plugins = [
|
|
||||||
"./{plugin_name}.py"
|
|
||||||
]
|
|
||||||
</py-config>
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_test(
|
|
||||||
plugin_name, code, tagname="", html="", template=HTML_TEMPLATE_WITH_TAG
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Prepares the test by writing a new plugin file named `plugin_name`.py, with `code` as its
|
|
||||||
content and run `pyscript_run` on `template` formatted with the above inputs to create the
|
|
||||||
page HTML code.
|
|
||||||
|
|
||||||
For example:
|
|
||||||
|
|
||||||
>> @prepare_test('py-upper', CE_PLUGIN_CODE, tagname='py-up', html="Hello World")
|
|
||||||
>> def my_foo(...):
|
|
||||||
>> ...
|
|
||||||
|
|
||||||
will:
|
|
||||||
|
|
||||||
* write a new `py-upper.py` file to the FS
|
|
||||||
* the contents of `py-upper.py` is equal to CE_PLUGIN_CODE
|
|
||||||
* call self.pyscript_run with the following string:
|
|
||||||
'''
|
|
||||||
<py-config>
|
|
||||||
plugins = [
|
|
||||||
"./py-upper.py"
|
|
||||||
]
|
|
||||||
</py-config>
|
|
||||||
|
|
||||||
<py-up>
|
|
||||||
{html}
|
|
||||||
</py-up>
|
|
||||||
'''
|
|
||||||
* call `my_foo` just like a normal decorator would
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
def dec(f):
|
|
||||||
def _inner(self, *args, **kws):
|
|
||||||
self.writefile(f"{plugin_name}.py", code)
|
|
||||||
page_html = template.format(
|
|
||||||
plugin_name=plugin_name, tagname=tagname, html=html
|
|
||||||
)
|
|
||||||
self.pyscript_run(page_html)
|
|
||||||
return f(self, *args, **kws)
|
|
||||||
|
|
||||||
return _inner
|
|
||||||
|
|
||||||
return dec
|
|
||||||
|
|
||||||
|
|
||||||
class TestPlugin(PyScriptTest):
|
|
||||||
@skip_worker("FIXME: relative paths")
|
|
||||||
@prepare_test("py-upper", CE_PLUGIN_CODE, tagname="py-up", html="Hello World")
|
|
||||||
def test_py_plugin_inline(self):
|
|
||||||
"""Test that a regular plugin that returns new HTML content from connected works"""
|
|
||||||
# GIVEN a plugin that returns the all caps version of the tag innerHTML and logs text
|
|
||||||
# during it's execution/hooks
|
|
||||||
|
|
||||||
# EXPECT the plugin logs to be present in the console logs
|
|
||||||
log_lines = self.console.log.lines
|
|
||||||
for log_line in ["py_upper Plugin loaded", "Upper plugin connected"]:
|
|
||||||
assert log_line in log_lines
|
|
||||||
|
|
||||||
# EXPECT the inner text of the Plugin CustomElement to be all caps
|
|
||||||
rendered_text = self.page.locator("py-up").inner_text()
|
|
||||||
assert rendered_text == "HELLO WORLD"
|
|
||||||
|
|
||||||
@skip_worker("FIXME: relative paths")
|
|
||||||
@prepare_test("hooks_logger", HOOKS_PLUGIN_CODE, template=HTML_TEMPLATE_NO_TAG)
|
|
||||||
def test_execution_hooks(self):
|
|
||||||
"""Test that a Plugin that hooks into the PyScript App events, gets called
|
|
||||||
for each one of them"""
|
|
||||||
# GIVEN a plugin that logs specific strings for each app execution event
|
|
||||||
hooks_available = ["afterSetup", "afterStartup"]
|
|
||||||
hooks_unavailable = [
|
|
||||||
"configure",
|
|
||||||
"beforeLaunch",
|
|
||||||
"beforePyScriptExec",
|
|
||||||
"afterPyScriptExec",
|
|
||||||
"beforePyReplExec",
|
|
||||||
"afterPyReplExec",
|
|
||||||
]
|
|
||||||
|
|
||||||
# EXPECT it to log the correct logs for the events it intercepts
|
|
||||||
log_lines = self.console.log.lines
|
|
||||||
num_calls = {
|
|
||||||
method: log_lines.count(f"{method} called") for method in hooks_available
|
|
||||||
}
|
|
||||||
expected_calls = {method: 1 for method in hooks_available}
|
|
||||||
assert num_calls == expected_calls
|
|
||||||
|
|
||||||
# EXPECT it to NOT be called (hence not log anything) the events that happen
|
|
||||||
# before it's ready, hence is not called
|
|
||||||
unavailable_called = {
|
|
||||||
method: f"{method} called" in log_lines for method in hooks_unavailable
|
|
||||||
}
|
|
||||||
assert unavailable_called == {method: False for method in hooks_unavailable}
|
|
||||||
|
|
||||||
# TODO: It'd be actually better to check that the events get called in order
|
|
||||||
|
|
||||||
@skip_worker("FIXME: relative paths")
|
|
||||||
@prepare_test(
|
|
||||||
"exec_test_logger",
|
|
||||||
PYSCRIPT_HOOKS_PLUGIN_CODE,
|
|
||||||
template=HTML_TEMPLATE_NO_TAG + "\n<script type='py' id='pyid'>x=2; x</script>",
|
|
||||||
)
|
|
||||||
def test_pyscript_exec_hooks(self):
|
|
||||||
"""Test that the beforePyScriptExec and afterPyScriptExec hooks work as intended"""
|
|
||||||
assert self.page.locator("script") is not None
|
|
||||||
|
|
||||||
log_lines: list[str] = self.console.log.lines
|
|
||||||
|
|
||||||
assert "beforePyScriptExec called" in log_lines
|
|
||||||
assert "afterPyScriptExec called" in log_lines
|
|
||||||
|
|
||||||
# These could be made better with a utility function that found log lines
|
|
||||||
# that match a filter function, or start with something
|
|
||||||
assert "before_src:x=2; x" in log_lines
|
|
||||||
assert "after_src:x=2; x" in log_lines
|
|
||||||
assert "result:2" in log_lines
|
|
||||||
|
|
||||||
@skip_worker("FIXME: relative paths")
|
|
||||||
@prepare_test(
|
|
||||||
"pyrepl_test_logger",
|
|
||||||
PYREPL_HOOKS_PLUGIN_CODE,
|
|
||||||
template=HTML_TEMPLATE_NO_TAG + "\n<py-repl id='pyid'>x=2; x</py-repl>",
|
|
||||||
)
|
|
||||||
def test_pyrepl_exec_hooks(self):
|
|
||||||
py_repl = self.page.locator("py-repl")
|
|
||||||
py_repl.locator("button").click()
|
|
||||||
# allow afterPyReplExec to also finish before the test finishes
|
|
||||||
self.wait_for_console("result:2")
|
|
||||||
|
|
||||||
log_lines: list[str] = self.console.log.lines
|
|
||||||
|
|
||||||
assert "beforePyReplExec called" in log_lines
|
|
||||||
assert "afterPyReplExec called" in log_lines
|
|
||||||
|
|
||||||
# These could be made better with a utility function that found log lines
|
|
||||||
# that match a filter function, or start with something
|
|
||||||
assert "before_src:x=2; x" in log_lines
|
|
||||||
assert "after_src:x=2; x" in log_lines
|
|
||||||
assert "result:2" in log_lines
|
|
||||||
|
|
||||||
@skip_worker("FIXME: relative paths")
|
|
||||||
@prepare_test("no_plugin", NO_PLUGIN_CODE)
|
|
||||||
def test_no_plugin_attribute_error(self):
|
|
||||||
"""
|
|
||||||
Test a plugin that do not add the `plugin` attribute to its module
|
|
||||||
"""
|
|
||||||
# GIVEN a Plugin NO `plugin` attribute in it's module
|
|
||||||
error_msg = (
|
|
||||||
"[pyscript/main] Cannot find plugin on Python module no_plugin! Python plugins "
|
|
||||||
'modules must contain a "plugin" attribute. For more information check the '
|
|
||||||
"plugins documentation."
|
|
||||||
)
|
|
||||||
# EXPECT an error for the missing attribute
|
|
||||||
assert error_msg in self.console.error.lines
|
|
||||||
|
|
||||||
@skip_worker("FIXME: relative paths")
|
|
||||||
def test_fetch_python_plugin(self):
|
|
||||||
"""
|
|
||||||
Test that we can fetch a plugin from a remote URL. Note we need to use
|
|
||||||
the 'raw' URL for the plugin, otherwise the request will be rejected
|
|
||||||
by cors policy.
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
plugins = [
|
|
||||||
"https://raw.githubusercontent.com/FabioRosado/pyscript-plugins/main/python/hello-world.py"
|
|
||||||
]
|
|
||||||
|
|
||||||
</py-config>
|
|
||||||
<py-hello-world></py-hello-world>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
hello_element = self.page.locator("py-hello-world")
|
|
||||||
assert hello_element.inner_html() == '<div id="hello">Hello World!</div>'
|
|
||||||
|
|
||||||
def test_fetch_js_plugin(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
plugins = [
|
|
||||||
"https://raw.githubusercontent.com/FabioRosado/pyscript-plugins/main/js/hello-world.js"
|
|
||||||
]
|
|
||||||
</py-config>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
hello_element = self.page.locator("py-hello-world")
|
|
||||||
assert hello_element.inner_html() == "<h1>Hello, world!</h1>"
|
|
||||||
|
|
||||||
def test_fetch_js_plugin_bare(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
plugins = [
|
|
||||||
"https://raw.githubusercontent.com/FabioRosado/pyscript-plugins/main/js/hello-world-base.js"
|
|
||||||
]
|
|
||||||
</py-config>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
hello_element = self.page.locator("py-hello-world")
|
|
||||||
assert hello_element.inner_html() == "<h1>Hello, world!</h1>"
|
|
||||||
|
|
||||||
def test_fetch_plugin_no_file_extension(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
plugins = [
|
|
||||||
"https://non-existent.blah/hello-world"
|
|
||||||
]
|
|
||||||
</py-config>
|
|
||||||
""",
|
|
||||||
wait_for_pyscript=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
expected_msg = (
|
|
||||||
"(PY2000): Unable to load plugin from "
|
|
||||||
"'https://non-existent.blah/hello-world'. Plugins "
|
|
||||||
"need to contain a file extension and be either a "
|
|
||||||
"python or javascript file."
|
|
||||||
)
|
|
||||||
|
|
||||||
assert self.assert_banner_message(expected_msg)
|
|
||||||
|
|
||||||
def test_fetch_js_plugin_non_existent(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
plugins = [
|
|
||||||
"http://non-existent.example.com/hello-world.js"
|
|
||||||
]
|
|
||||||
</py-config>
|
|
||||||
""",
|
|
||||||
wait_for_pyscript=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
expected_msg = (
|
|
||||||
"(PY0001): Fetching from URL "
|
|
||||||
"http://non-existent.example.com/hello-world.js failed "
|
|
||||||
"with error 'Failed to fetch'. Are your filename and "
|
|
||||||
"path correct?"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert self.assert_banner_message(expected_msg)
|
|
||||||
|
|
||||||
def test_fetch_js_no_export(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
plugins = [
|
|
||||||
"https://raw.githubusercontent.com/FabioRosado/pyscript-plugins/main/js/hello-world-no-export.js"
|
|
||||||
]
|
|
||||||
</py-config>
|
|
||||||
""",
|
|
||||||
wait_for_pyscript=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
expected_message = (
|
|
||||||
"(PY2001): Unable to load plugin from "
|
|
||||||
"'https://raw.githubusercontent.com/FabioRosado/pyscript-plugins"
|
|
||||||
"/main/js/hello-world-no-export.js'. "
|
|
||||||
"Plugins need to contain a default export."
|
|
||||||
)
|
|
||||||
|
|
||||||
assert self.assert_banner_message(expected_message)
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from .support import PyScriptTest, with_execution_thread
|
|
||||||
|
|
||||||
|
|
||||||
# Disable the main/worker dual testing, for two reasons:
|
|
||||||
#
|
|
||||||
# 1. the <py-config> logic happens before we start the worker, so there is
|
|
||||||
# no point in running these tests twice
|
|
||||||
#
|
|
||||||
# 2. the logic to inject execution_thread into <py-config> works only with
|
|
||||||
# plain <py-config> tags, but here we want to test all weird combinations
|
|
||||||
# of config
|
|
||||||
@with_execution_thread(None)
|
|
||||||
class TestConfig(PyScriptTest):
|
|
||||||
def test_py_config_inline_pyscript(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
name = "foobar"
|
|
||||||
</py-config>
|
|
||||||
|
|
||||||
<py-script async>
|
|
||||||
from pyscript import window
|
|
||||||
window.console.log("config name:", window.pyConfig.name)
|
|
||||||
</py-script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert self.console.log.lines[-1] == "config name: foobar"
|
|
||||||
|
|
||||||
@pytest.mark.skip("NEXT: works with <py-script> not with <script>")
|
|
||||||
def test_py_config_inline_scriptpy(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
name = "foobar"
|
|
||||||
</py-config>
|
|
||||||
|
|
||||||
<script type="py" async>
|
|
||||||
from pyscript import window
|
|
||||||
window.console.log("config name:", window.pyConfig.name)
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert self.console.log.lines[-1] == "config name: foobar"
|
|
||||||
|
|
||||||
@pytest.mark.skip("NEXT: works with <py-script> not with <script>")
|
|
||||||
def test_py_config_external(self):
|
|
||||||
pyconfig_toml = """
|
|
||||||
name = "app with external config"
|
|
||||||
"""
|
|
||||||
self.writefile("pyconfig.toml", pyconfig_toml)
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config src="pyconfig.toml"></py-config>
|
|
||||||
|
|
||||||
<script type="py" async>
|
|
||||||
from pyscript import window
|
|
||||||
window.console.log("config name:", window.pyConfig.name)
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert self.console.log.lines[-1] == "config name: app with external config"
|
|
||||||
|
|
||||||
def test_invalid_json_config(self):
|
|
||||||
# we need wait_for_pyscript=False because we bail out very soon,
|
|
||||||
# before being able to write 'PyScript page fully initialized'
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config type="json">
|
|
||||||
[[
|
|
||||||
</py-config>
|
|
||||||
""",
|
|
||||||
wait_for_pyscript=False,
|
|
||||||
)
|
|
||||||
banner = self.page.wait_for_selector(".py-error")
|
|
||||||
# assert "Unexpected end of JSON input" in self.console.error.text
|
|
||||||
expected = "(PY1000): Invalid JSON\n" "Unexpected end of JSON input"
|
|
||||||
assert banner.inner_text() == expected
|
|
||||||
|
|
||||||
def test_invalid_toml_config(self):
|
|
||||||
# we need wait_for_pyscript=False because we bail out very soon,
|
|
||||||
# before being able to write 'PyScript page fully initialized'
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
[[
|
|
||||||
</py-config>
|
|
||||||
""",
|
|
||||||
wait_for_pyscript=False,
|
|
||||||
)
|
|
||||||
banner = self.page.wait_for_selector(".py-error")
|
|
||||||
# assert "Expected DoubleQuote" in self.console.error.text
|
|
||||||
expected = (
|
|
||||||
"(PY1000): Invalid TOML\n"
|
|
||||||
"Expected DoubleQuote, Whitespace, or [a-z], [A-Z], "
|
|
||||||
'[0-9], "-", "_" but end of input found.'
|
|
||||||
)
|
|
||||||
assert banner.inner_text() == expected
|
|
||||||
|
|
||||||
def test_ambiguous_py_config(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>name = "first"</py-config>
|
|
||||||
|
|
||||||
<script type="py" config="second.toml"></script>
|
|
||||||
""",
|
|
||||||
wait_for_pyscript=False,
|
|
||||||
)
|
|
||||||
banner = self.page.wait_for_selector(".py-error")
|
|
||||||
expected = "(PY0409): Ambiguous py-config VS config attribute"
|
|
||||||
assert banner.text_content() == expected
|
|
||||||
|
|
||||||
def test_multiple_attributes_py_config(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py" config="first.toml"></script>
|
|
||||||
<script type="py" config="second.toml"></script>
|
|
||||||
""",
|
|
||||||
wait_for_pyscript=False,
|
|
||||||
)
|
|
||||||
banner = self.page.wait_for_selector(".py-error")
|
|
||||||
expected = "(PY0409): Unable to use different configs on main"
|
|
||||||
assert banner.text_content() == expected
|
|
||||||
|
|
||||||
def test_multiple_py_config(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
name = "foobar"
|
|
||||||
</py-config>
|
|
||||||
|
|
||||||
<py-config>
|
|
||||||
name = "this is ignored"
|
|
||||||
</py-config>
|
|
||||||
|
|
||||||
<script type="py">
|
|
||||||
import js
|
|
||||||
#config = js.pyscript_get_config()
|
|
||||||
#js.console.log("config name:", config.name)
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
wait_for_pyscript=False,
|
|
||||||
)
|
|
||||||
banner = self.page.wait_for_selector(".py-error")
|
|
||||||
expected = "(PY0409): Too many py-config"
|
|
||||||
assert banner.text_content() == expected
|
|
||||||
|
|
||||||
def test_paths(self):
|
|
||||||
self.writefile("a.py", "x = 'hello from A'")
|
|
||||||
self.writefile("b.py", "x = 'hello from B'")
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
[[fetch]]
|
|
||||||
files = ["./a.py", "./b.py"]
|
|
||||||
</py-config>
|
|
||||||
|
|
||||||
<script type="py">
|
|
||||||
import js
|
|
||||||
import a, b
|
|
||||||
js.console.log(a.x)
|
|
||||||
js.console.log(b.x)
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert self.console.log.lines[-2:] == [
|
|
||||||
"hello from A",
|
|
||||||
"hello from B",
|
|
||||||
]
|
|
||||||
|
|
||||||
@pytest.mark.skip("NEXT: emit an error if fetch fails")
|
|
||||||
def test_paths_that_do_not_exist(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
[[fetch]]
|
|
||||||
files = ["./f.py"]
|
|
||||||
</py-config>
|
|
||||||
|
|
||||||
<script type="py">
|
|
||||||
print("this should not be printed")
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
wait_for_pyscript=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
expected = "(PY0404): Fetching from URL ./f.py failed with " "error 404"
|
|
||||||
inner_html = self.page.locator(".py-error").inner_html()
|
|
||||||
assert expected in inner_html
|
|
||||||
assert expected in self.console.error.lines[-1]
|
|
||||||
assert self.console.log.lines == []
|
|
||||||
|
|
||||||
def test_paths_from_packages(self):
|
|
||||||
self.writefile("utils/__init__.py", "")
|
|
||||||
self.writefile("utils/a.py", "x = 'hello from A'")
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
[[fetch]]
|
|
||||||
from = "utils"
|
|
||||||
to_folder = "pkg"
|
|
||||||
files = ["__init__.py", "a.py"]
|
|
||||||
</py-config>
|
|
||||||
|
|
||||||
<script type="py">
|
|
||||||
import js
|
|
||||||
from pkg.a import x
|
|
||||||
js.console.log(x)
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert self.console.log.lines[-1] == "hello from A"
|
|
||||||
@@ -1,663 +0,0 @@
|
|||||||
import platform
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from .support import PyScriptTest, skip_worker
|
|
||||||
|
|
||||||
pytest.skip(
|
|
||||||
reason="NEXT: pyscript NEXT doesn't support the REPL yet",
|
|
||||||
allow_module_level=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPyRepl(PyScriptTest):
|
|
||||||
def _replace(self, py_repl, newcode):
|
|
||||||
"""
|
|
||||||
Clear the editor and write new code in it.
|
|
||||||
WARNING: this assumes that the textbox has already the focus
|
|
||||||
"""
|
|
||||||
# clear the editor, write new code
|
|
||||||
if "macOS" in platform.platform():
|
|
||||||
self.page.keyboard.press("Meta+A")
|
|
||||||
else:
|
|
||||||
self.page.keyboard.press("Control+A")
|
|
||||||
|
|
||||||
self.page.keyboard.press("Backspace")
|
|
||||||
self.page.keyboard.type(newcode)
|
|
||||||
|
|
||||||
def test_repl_loads(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-repl></py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
py_repl = self.page.query_selector("py-repl .py-repl-box")
|
|
||||||
assert py_repl
|
|
||||||
|
|
||||||
def test_execute_preloaded_source(self):
|
|
||||||
"""
|
|
||||||
Unfortunately it tests two things at once, but it's impossible to write a
|
|
||||||
smaller test. I think this is the most basic test that we can write.
|
|
||||||
|
|
||||||
We test that:
|
|
||||||
1. the source code that we put in the tag is loaded inside the editor
|
|
||||||
2. clicking the button executes it
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-repl>
|
|
||||||
print('hello from py-repl')
|
|
||||||
</py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
py_repl = self.page.locator("py-repl")
|
|
||||||
src = py_repl.locator("div.cm-content").inner_text()
|
|
||||||
assert "print('hello from py-repl')" in src
|
|
||||||
py_repl.locator("button").click()
|
|
||||||
self.page.wait_for_selector("py-terminal")
|
|
||||||
assert self.console.log.lines[-1] == "hello from py-repl"
|
|
||||||
|
|
||||||
def test_execute_code_typed_by_the_user(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-repl></py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
py_repl = self.page.locator("py-repl")
|
|
||||||
py_repl.type('print("hello")')
|
|
||||||
py_repl.locator("button").click()
|
|
||||||
self.page.wait_for_selector("py-terminal")
|
|
||||||
assert self.console.log.lines[-1] == "hello"
|
|
||||||
|
|
||||||
def test_execute_on_shift_enter(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-repl>
|
|
||||||
print("hello world")
|
|
||||||
</py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.page.wait_for_selector("py-repl .py-repl-run-button")
|
|
||||||
self.page.keyboard.press("Shift+Enter")
|
|
||||||
self.page.wait_for_selector("py-terminal")
|
|
||||||
|
|
||||||
assert self.console.log.lines[-1] == "hello world"
|
|
||||||
|
|
||||||
# Shift-enter should not add a newline to the editor
|
|
||||||
assert self.page.locator(".cm-line").count() == 1
|
|
||||||
|
|
||||||
@skip_worker("FIXME: display()")
|
|
||||||
def test_display(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-repl>
|
|
||||||
display('hello world')
|
|
||||||
</py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
py_repl = self.page.locator("py-repl")
|
|
||||||
py_repl.locator("button").click()
|
|
||||||
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
|
|
||||||
assert out_div.inner_text() == "hello world"
|
|
||||||
|
|
||||||
@skip_worker("TIMEOUT")
|
|
||||||
def test_show_last_expression(self):
|
|
||||||
"""
|
|
||||||
Test that we display() the value of the last expression, as you would
|
|
||||||
expect by a REPL
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-repl>
|
|
||||||
42
|
|
||||||
</py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
py_repl = self.page.locator("py-repl")
|
|
||||||
py_repl.locator("button").click()
|
|
||||||
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
|
|
||||||
assert out_div.inner_text() == "42"
|
|
||||||
|
|
||||||
@skip_worker("TIMEOUT")
|
|
||||||
def test_show_last_expression_with_output(self):
|
|
||||||
"""
|
|
||||||
Test that we display() the value of the last expression, as you would
|
|
||||||
expect by a REPL
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<div id="repl-target"></div>
|
|
||||||
<py-repl output="repl-target">
|
|
||||||
42
|
|
||||||
</py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
py_repl = self.page.locator("py-repl")
|
|
||||||
py_repl.locator("button").click()
|
|
||||||
out_div = py_repl.locator("div.py-repl-output")
|
|
||||||
assert out_div.all_inner_texts()[0] == ""
|
|
||||||
|
|
||||||
out_div = self.page.wait_for_selector("#repl-target")
|
|
||||||
assert out_div.inner_text() == "42"
|
|
||||||
|
|
||||||
@skip_worker("FIXME: display()")
|
|
||||||
def test_run_clears_previous_output(self):
|
|
||||||
"""
|
|
||||||
Check that we clear the previous output of the cell before executing it
|
|
||||||
again
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-repl>
|
|
||||||
display('hello world')
|
|
||||||
</py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
py_repl = self.page.locator("py-repl")
|
|
||||||
self.page.keyboard.press("Shift+Enter")
|
|
||||||
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
|
|
||||||
assert out_div.inner_text() == "hello world"
|
|
||||||
# clear the editor, write new code, execute
|
|
||||||
self._replace(py_repl, "display('another output')")
|
|
||||||
self.page.keyboard.press("Shift+Enter")
|
|
||||||
# test runner can be too fast, the line below should wait for output to change
|
|
||||||
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
|
|
||||||
assert out_div.inner_text() == "another output"
|
|
||||||
|
|
||||||
def test_python_exception(self):
|
|
||||||
"""
|
|
||||||
See also test01_basic::test_python_exception, since it's very similar
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-repl>
|
|
||||||
raise Exception('this is an error')
|
|
||||||
</py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
py_repl = self.page.locator("py-repl")
|
|
||||||
py_repl.locator("button").click()
|
|
||||||
self.page.wait_for_selector(".py-error")
|
|
||||||
#
|
|
||||||
# check that we sent the traceback to the console
|
|
||||||
tb_lines = self.console.error.lines[-1].splitlines()
|
|
||||||
assert tb_lines[0] == "[pyexec] Python exception:"
|
|
||||||
assert tb_lines[1] == "Traceback (most recent call last):"
|
|
||||||
assert tb_lines[-1] == "Exception: this is an error"
|
|
||||||
#
|
|
||||||
# check that we show the traceback in the page
|
|
||||||
err_pre = py_repl.locator("div.py-repl-output > pre.py-error")
|
|
||||||
tb_lines = err_pre.inner_text().splitlines()
|
|
||||||
assert tb_lines[0] == "Traceback (most recent call last):"
|
|
||||||
assert tb_lines[-1] == "Exception: this is an error"
|
|
||||||
#
|
|
||||||
self.check_py_errors("this is an error")
|
|
||||||
|
|
||||||
@skip_worker("FIXME: display()")
|
|
||||||
def test_multiple_repls(self):
|
|
||||||
"""
|
|
||||||
Multiple repls showing in the correct order in the page
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-repl data-testid=="first"> display("first") </py-repl>
|
|
||||||
<py-repl data-testid=="second"> display("second") </py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
first_py_repl = self.page.get_by_text("first")
|
|
||||||
first_py_repl.click()
|
|
||||||
self.page.keyboard.press("Shift+Enter")
|
|
||||||
self.page.wait_for_selector("#py-internal-0-repl-output")
|
|
||||||
assert self.page.inner_text("#py-internal-0-repl-output") == "first"
|
|
||||||
|
|
||||||
second_py_repl = self.page.get_by_text("second")
|
|
||||||
second_py_repl.click()
|
|
||||||
self.page.keyboard.press("Shift+Enter")
|
|
||||||
self.page.wait_for_selector("#py-internal-1-repl-output")
|
|
||||||
assert self.page.inner_text("#py-internal-1-repl-output") == "second"
|
|
||||||
|
|
||||||
@skip_worker("FIXME: display()")
|
|
||||||
def test_python_exception_after_previous_output(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-repl>
|
|
||||||
display('hello world')
|
|
||||||
</py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
py_repl = self.page.locator("py-repl")
|
|
||||||
self.page.keyboard.press("Shift+Enter")
|
|
||||||
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
|
|
||||||
assert out_div.inner_text() == "hello world"
|
|
||||||
#
|
|
||||||
# clear the editor, write new code, execute
|
|
||||||
self._replace(py_repl, "0/0")
|
|
||||||
self.page.keyboard.press("Shift+Enter")
|
|
||||||
# test runner can be too fast, the line below should wait for output to change
|
|
||||||
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
|
|
||||||
assert "hello world" not in out_div.inner_text()
|
|
||||||
assert "ZeroDivisionError" in out_div.inner_text()
|
|
||||||
#
|
|
||||||
self.check_py_errors("ZeroDivisionError")
|
|
||||||
|
|
||||||
@skip_worker("FIXME: js.document")
|
|
||||||
def test_hide_previous_error_after_successful_run(self):
|
|
||||||
"""
|
|
||||||
this tests the fact that a new error div should be created once there's an
|
|
||||||
error but also that it should disappear automatically once the error
|
|
||||||
is fixed
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-repl>
|
|
||||||
raise Exception('this is an error')
|
|
||||||
</py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
py_repl = self.page.locator("py-repl")
|
|
||||||
self.page.keyboard.press("Shift+Enter")
|
|
||||||
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
|
|
||||||
assert "this is an error" in out_div.inner_text()
|
|
||||||
#
|
|
||||||
self._replace(py_repl, "display('hello')")
|
|
||||||
self.page.keyboard.press("Shift+Enter")
|
|
||||||
# test runner can be too fast, the line below should wait for output to change
|
|
||||||
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
|
|
||||||
assert out_div.inner_text() == "hello"
|
|
||||||
#
|
|
||||||
self.check_py_errors("this is an error")
|
|
||||||
|
|
||||||
def test_output_attribute_does_not_exist(self):
|
|
||||||
"""
|
|
||||||
If we try to use an attribute which doesn't exist, we display an error
|
|
||||||
instead
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-repl output="I-dont-exist">
|
|
||||||
print('I will not be executed')
|
|
||||||
</py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
py_repl = self.page.locator("py-repl")
|
|
||||||
py_repl.locator("button").click()
|
|
||||||
|
|
||||||
banner = self.page.wait_for_selector(".py-warning")
|
|
||||||
|
|
||||||
banner_content = banner.inner_text()
|
|
||||||
expected = (
|
|
||||||
'output = "I-dont-exist" does not match the id of any element on the page.'
|
|
||||||
)
|
|
||||||
assert banner_content == expected
|
|
||||||
|
|
||||||
@skip_worker("TIMEOUT")
|
|
||||||
def test_auto_generate(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-repl auto-generate="true">
|
|
||||||
</py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
py_repls = self.page.locator("py-repl")
|
|
||||||
outputs = py_repls.locator("div.py-repl-output")
|
|
||||||
assert py_repls.count() == 1
|
|
||||||
assert outputs.count() == 1
|
|
||||||
#
|
|
||||||
# evaluate the py-repl, and wait for the newly generated one
|
|
||||||
self.page.keyboard.type("'hello'")
|
|
||||||
self.page.keyboard.press("Shift+Enter")
|
|
||||||
self.page.locator('py-repl[exec-id="1"]').wait_for()
|
|
||||||
assert py_repls.count() == 2
|
|
||||||
assert outputs.count() == 2
|
|
||||||
#
|
|
||||||
# now we type something else: the new py-repl should have the focus
|
|
||||||
self.page.keyboard.type("'world'")
|
|
||||||
self.page.keyboard.press("Shift+Enter")
|
|
||||||
self.page.locator('py-repl[exec-id="2"]').wait_for()
|
|
||||||
assert py_repls.count() == 3
|
|
||||||
assert outputs.count() == 3
|
|
||||||
#
|
|
||||||
# check that the code and the outputs are in order
|
|
||||||
out_texts = [el.inner_text() for el in self.iter_locator(outputs)]
|
|
||||||
assert out_texts == ["hello", "world", ""]
|
|
||||||
|
|
||||||
@skip_worker("FIXME: display()")
|
|
||||||
def test_multiple_repls_mixed_display_order(self):
|
|
||||||
"""
|
|
||||||
Displaying several outputs that don't obey the order in which the original
|
|
||||||
repl displays were created using the auto_generate attr
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-repl auto-generate="true" data-testid=="first"> display("root first") </py-repl>
|
|
||||||
<py-repl auto-generate="true" data-testid=="second"> display("root second") </py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
second_py_repl = self.page.get_by_text("root second")
|
|
||||||
second_py_repl.click()
|
|
||||||
self.page.keyboard.press("Shift+Enter")
|
|
||||||
self.page.wait_for_selector("#py-internal-1-repl-output")
|
|
||||||
self.page.keyboard.type("display('second children')")
|
|
||||||
self.page.keyboard.press("Shift+Enter")
|
|
||||||
self.page.wait_for_selector("#py-internal-1-1-repl-output")
|
|
||||||
|
|
||||||
first_py_repl = self.page.get_by_text("root first")
|
|
||||||
first_py_repl.click()
|
|
||||||
self.page.keyboard.press("Shift+Enter")
|
|
||||||
self.page.wait_for_selector("#py-internal-0-repl-output")
|
|
||||||
self.page.keyboard.type("display('first children')")
|
|
||||||
self.page.keyboard.press("Shift+Enter")
|
|
||||||
self.page.wait_for_selector("#py-internal-0-1-repl-output")
|
|
||||||
|
|
||||||
assert self.page.inner_text("#py-internal-1-1-repl-output") == "second children"
|
|
||||||
assert self.page.inner_text("#py-internal-0-1-repl-output") == "first children"
|
|
||||||
|
|
||||||
@skip_worker("FIXME: display()")
|
|
||||||
def test_repl_output_attribute(self):
|
|
||||||
# Test that output attribute sends stdout to the element
|
|
||||||
# with the given ID, but not display()
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<div id="repl-target"></div>
|
|
||||||
<py-repl output="repl-target">
|
|
||||||
print('print from py-repl')
|
|
||||||
display('display from py-repl')
|
|
||||||
</py-repl>
|
|
||||||
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
py_repl = self.page.locator("py-repl")
|
|
||||||
py_repl.locator("button").click()
|
|
||||||
|
|
||||||
target = self.page.wait_for_selector("#repl-target")
|
|
||||||
assert "print from py-repl" in target.inner_text()
|
|
||||||
|
|
||||||
out_div = self.page.wait_for_selector("#py-internal-0-repl-output")
|
|
||||||
assert out_div.inner_text() == "display from py-repl"
|
|
||||||
|
|
||||||
self.assert_no_banners()
|
|
||||||
|
|
||||||
@skip_worker("FIXME: js.document")
|
|
||||||
def test_repl_output_display_async(self):
|
|
||||||
# py-repls running async code are not expected to
|
|
||||||
# send display to element element
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<div id="repl-target"></div>
|
|
||||||
<script type="py">
|
|
||||||
import asyncio
|
|
||||||
import js
|
|
||||||
|
|
||||||
async def print_it():
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
print('print from py-repl')
|
|
||||||
|
|
||||||
|
|
||||||
async def display_it():
|
|
||||||
display('display from py-repl')
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
|
|
||||||
async def done():
|
|
||||||
await asyncio.sleep(3)
|
|
||||||
js.console.log("DONE")
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<py-repl output="repl-target">
|
|
||||||
asyncio.ensure_future(print_it());
|
|
||||||
asyncio.ensure_future(display_it());
|
|
||||||
asyncio.ensure_future(done());
|
|
||||||
</py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
py_repl = self.page.locator("py-repl")
|
|
||||||
py_repl.locator("button").click()
|
|
||||||
|
|
||||||
self.wait_for_console("DONE")
|
|
||||||
|
|
||||||
assert self.page.locator("#repl-target").text_content() == ""
|
|
||||||
self.assert_no_banners()
|
|
||||||
|
|
||||||
@skip_worker("FIXME: js.document")
|
|
||||||
def test_repl_stdio_dynamic_tags(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<div id="first"></div>
|
|
||||||
<div id="second"></div>
|
|
||||||
<py-repl output="first">
|
|
||||||
import js
|
|
||||||
|
|
||||||
print("first.")
|
|
||||||
|
|
||||||
# Using string, since no clean way to write to the
|
|
||||||
# code contents of the CodeMirror in a PyRepl
|
|
||||||
newTag = '<py-repl id="second-repl" output="second">print("second.")</py-repl>'
|
|
||||||
js.document.body.innerHTML += newTag
|
|
||||||
</py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
py_repl = self.page.locator("py-repl")
|
|
||||||
py_repl.locator("button").click()
|
|
||||||
|
|
||||||
assert self.page.wait_for_selector("#first").inner_text() == "first.\n"
|
|
||||||
|
|
||||||
second_repl = self.page.locator("py-repl#second-repl")
|
|
||||||
second_repl.locator("button").click()
|
|
||||||
assert self.page.wait_for_selector("#second").inner_text() == "second.\n"
|
|
||||||
|
|
||||||
def test_repl_output_id_errors(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-repl output="not-on-page">
|
|
||||||
print("bad.")
|
|
||||||
print("bad.")
|
|
||||||
</py-repl>
|
|
||||||
|
|
||||||
<py-repl output="not-on-page">
|
|
||||||
print("bad.")
|
|
||||||
</py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
py_repls = self.page.query_selector_all("py-repl")
|
|
||||||
for repl in py_repls:
|
|
||||||
repl.query_selector_all("button")[0].click()
|
|
||||||
|
|
||||||
banner = self.page.wait_for_selector(".py-warning")
|
|
||||||
|
|
||||||
banner_content = banner.inner_text()
|
|
||||||
expected = (
|
|
||||||
'output = "not-on-page" does not match the id of any element on the page.'
|
|
||||||
)
|
|
||||||
|
|
||||||
assert banner_content == expected
|
|
||||||
|
|
||||||
def test_repl_stderr_id_errors(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-repl stderr="not-on-page">
|
|
||||||
import sys
|
|
||||||
print("bad.", file=sys.stderr)
|
|
||||||
print("bad.", file=sys.stderr)
|
|
||||||
</py-repl>
|
|
||||||
|
|
||||||
<py-repl stderr="not-on-page">
|
|
||||||
print("bad.", file=sys.stderr)
|
|
||||||
</py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
py_repls = self.page.query_selector_all("py-repl")
|
|
||||||
for repl in py_repls:
|
|
||||||
repl.query_selector_all("button")[0].click()
|
|
||||||
|
|
||||||
banner = self.page.wait_for_selector(".py-warning")
|
|
||||||
|
|
||||||
banner_content = banner.inner_text()
|
|
||||||
expected = (
|
|
||||||
'stderr = "not-on-page" does not match the id of any element on the page.'
|
|
||||||
)
|
|
||||||
|
|
||||||
assert banner_content == expected
|
|
||||||
|
|
||||||
def test_repl_output_stderr(self):
|
|
||||||
# Test that stderr works, and routes to the same location as stdout
|
|
||||||
# Also, repls with the stderr attribute route to an additional location
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<div id="stdout-div"></div>
|
|
||||||
<div id="stderr-div"></div>
|
|
||||||
<py-repl output="stdout-div" stderr="stderr-div">
|
|
||||||
import sys
|
|
||||||
print("one.", file=sys.stderr)
|
|
||||||
print("two.")
|
|
||||||
</py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
py_repl = self.page.locator("py-repl")
|
|
||||||
py_repl.locator("button").click()
|
|
||||||
|
|
||||||
assert self.page.wait_for_selector("#stdout-div").inner_text() == "one.\ntwo.\n"
|
|
||||||
assert self.page.wait_for_selector("#stderr-div").inner_text() == "one.\n"
|
|
||||||
self.assert_no_banners()
|
|
||||||
|
|
||||||
@skip_worker("TIMEOUT")
|
|
||||||
def test_repl_output_attribute_change(self):
|
|
||||||
# If the user changes the 'output' attribute of a <py-repl> tag mid-execution,
|
|
||||||
# Output should no longer go to the selected div and a warning should appear
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<div id="first"></div>
|
|
||||||
<div id="second"></div>
|
|
||||||
<!-- There is no tag with id "third" -->
|
|
||||||
<py-repl id="repl-tag" output="first">
|
|
||||||
print("one.")
|
|
||||||
|
|
||||||
# Change the 'output' attribute of this tag
|
|
||||||
import js
|
|
||||||
this_tag = js.document.getElementById("repl-tag")
|
|
||||||
|
|
||||||
this_tag.setAttribute("output", "second")
|
|
||||||
print("two.")
|
|
||||||
|
|
||||||
this_tag.setAttribute("output", "third")
|
|
||||||
print("three.")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
py_repl = self.page.locator("py-repl")
|
|
||||||
py_repl.locator("button").click()
|
|
||||||
|
|
||||||
assert self.page.wait_for_selector("#first").inner_text() == "one.\n"
|
|
||||||
assert self.page.wait_for_selector("#second").inner_text() == "two.\n"
|
|
||||||
|
|
||||||
expected_alert_banner_msg = (
|
|
||||||
'output = "third" does not match the id of any element on the page.'
|
|
||||||
)
|
|
||||||
|
|
||||||
alert_banner = self.page.wait_for_selector(".alert-banner")
|
|
||||||
assert expected_alert_banner_msg in alert_banner.inner_text()
|
|
||||||
|
|
||||||
@skip_worker("TIMEOUT")
|
|
||||||
def test_repl_output_element_id_change(self):
|
|
||||||
# If the user changes the ID of the targeted DOM element mid-execution,
|
|
||||||
# Output should no longer go to the selected element and a warning should appear
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<div id="first"></div>
|
|
||||||
<div id="second"></div>
|
|
||||||
<!-- There is no tag with id "third" -->
|
|
||||||
<py-repl id="pyscript-tag" output="first">
|
|
||||||
print("one.")
|
|
||||||
|
|
||||||
# Change the ID of the targeted DIV to something else
|
|
||||||
import js
|
|
||||||
target_tag = js.document.getElementById("first")
|
|
||||||
|
|
||||||
# should fail and show banner
|
|
||||||
target_tag.setAttribute("id", "second")
|
|
||||||
print("two.")
|
|
||||||
|
|
||||||
# But changing both the 'output' attribute and the id of the target
|
|
||||||
# should work
|
|
||||||
target_tag.setAttribute("id", "third")
|
|
||||||
js.document.getElementById("pyscript-tag").setAttribute("output", "third")
|
|
||||||
print("three.")
|
|
||||||
</py-repl>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
py_repl = self.page.locator("py-repl")
|
|
||||||
py_repl.locator("button").click()
|
|
||||||
|
|
||||||
# Note the ID of the div has changed by the time of this assert
|
|
||||||
assert self.page.wait_for_selector("#third").inner_text() == "one.\nthree.\n"
|
|
||||||
|
|
||||||
expected_alert_banner_msg = (
|
|
||||||
'output = "first" does not match the id of any element on the page.'
|
|
||||||
)
|
|
||||||
alert_banner = self.page.wait_for_selector(".alert-banner")
|
|
||||||
assert expected_alert_banner_msg in alert_banner.inner_text()
|
|
||||||
|
|
||||||
def test_repl_load_content_from_src(self):
|
|
||||||
self.writefile("loadReplSrc1.py", "print('1')")
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-repl id="py-repl1" output="replOutput1" src="./loadReplSrc1.py"></py-repl>
|
|
||||||
<div id="replOutput1"></div>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
successMsg = "[py-repl] loading code from ./loadReplSrc1.py to repl...success"
|
|
||||||
assert self.console.info.lines[-1] == successMsg
|
|
||||||
|
|
||||||
py_repl = self.page.locator("py-repl")
|
|
||||||
code = py_repl.locator("div.cm-content").inner_text()
|
|
||||||
assert "print('1')" in code
|
|
||||||
|
|
||||||
@skip_worker("TIMEOUT")
|
|
||||||
def test_repl_src_change(self):
|
|
||||||
self.writefile("loadReplSrc2.py", "2")
|
|
||||||
self.writefile("loadReplSrc3.py", "print('3')")
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-repl id="py-repl2" output="replOutput2" src="./loadReplSrc2.py"></py-repl>
|
|
||||||
<div id="replOutput2"></div>
|
|
||||||
|
|
||||||
<py-repl id="py-repl3" output="replOutput3">
|
|
||||||
import js
|
|
||||||
target_tag = js.document.getElementById("py-repl2")
|
|
||||||
target_tag.setAttribute("src", "./loadReplSrc3.py")
|
|
||||||
</py-repl>
|
|
||||||
<div id="replOutput3"></div>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
successMsg1 = "[py-repl] loading code from ./loadReplSrc2.py to repl...success"
|
|
||||||
assert self.console.info.lines[-1] == successMsg1
|
|
||||||
|
|
||||||
py_repl3 = self.page.locator("py-repl#py-repl3")
|
|
||||||
py_repl3.locator("button").click()
|
|
||||||
py_repl2 = self.page.locator("py-repl#py-repl2")
|
|
||||||
py_repl2.locator("button").click()
|
|
||||||
self.page.wait_for_selector("py-terminal")
|
|
||||||
assert self.console.log.lines[-1] == "3"
|
|
||||||
|
|
||||||
successMsg2 = "[py-repl] loading code from ./loadReplSrc3.py to repl...success"
|
|
||||||
assert self.console.info.lines[-1] == successMsg2
|
|
||||||
|
|
||||||
def test_repl_src_path_that_do_not_exist(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-repl id="py-repl4" output="replOutput4" src="./loadReplSrc4.py"></py-repl>
|
|
||||||
<div id="replOutput4"></div>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
errorMsg = (
|
|
||||||
"(PY0404): Fetching from URL ./loadReplSrc4.py "
|
|
||||||
"failed with error 404 (Not Found). "
|
|
||||||
"Are your filename and path correct?"
|
|
||||||
)
|
|
||||||
assert self.console.error.lines[-1] == errorMsg
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import time
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from playwright.sync_api import expect
|
|
||||||
|
|
||||||
from .support import PageErrors, PyScriptTest, only_worker, skip_worker
|
|
||||||
|
|
||||||
|
|
||||||
class TestPyTerminal(PyScriptTest):
|
|
||||||
@skip_worker("We do support multiple worker terminal now")
|
|
||||||
def test_multiple_terminals(self):
|
|
||||||
"""
|
|
||||||
Multiple terminals are not currently supported
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py" terminal></script>
|
|
||||||
<script type="py" terminal></script>
|
|
||||||
""",
|
|
||||||
wait_for_pyscript=False,
|
|
||||||
check_js_errors=False,
|
|
||||||
)
|
|
||||||
assert self.assert_banner_message("You can use at most 1 main terminal")
|
|
||||||
|
|
||||||
with pytest.raises(PageErrors, match="You can use at most 1 main terminal"):
|
|
||||||
self.check_js_errors()
|
|
||||||
|
|
||||||
# TODO: interactive shell still unclear
|
|
||||||
# @only_worker
|
|
||||||
# def test_py_terminal_input(self):
|
|
||||||
# """
|
|
||||||
# Only worker py-terminal accepts an input
|
|
||||||
# """
|
|
||||||
# self.pyscript_run(
|
|
||||||
# """
|
|
||||||
# <script type="py" terminal></script>
|
|
||||||
# """,
|
|
||||||
# wait_for_pyscript=False,
|
|
||||||
# )
|
|
||||||
# self.page.get_by_text(">>> ", exact=True).wait_for()
|
|
||||||
# self.page.keyboard.type("'the answer is ' + str(6 * 7)")
|
|
||||||
# self.page.keyboard.press("Enter")
|
|
||||||
# self.page.get_by_text("the answer is 42").wait_for()
|
|
||||||
|
|
||||||
@only_worker
|
|
||||||
def test_py_terminal_os_write(self):
|
|
||||||
"""
|
|
||||||
An `os.write("text")` should land in the terminal
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py" terminal>
|
|
||||||
import os
|
|
||||||
os.write(1, str.encode("hello\\n"))
|
|
||||||
os.write(2, str.encode("world\\n"))
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
wait_for_pyscript=False,
|
|
||||||
)
|
|
||||||
self.page.get_by_text("hello\n").wait_for()
|
|
||||||
self.page.get_by_text("world\n").wait_for()
|
|
||||||
|
|
||||||
def test_py_terminal(self):
|
|
||||||
"""
|
|
||||||
1. <py-terminal> should redirect stdout and stderr to the DOM
|
|
||||||
|
|
||||||
2. they also go to the console as usual
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py" terminal>
|
|
||||||
import sys
|
|
||||||
print('hello world')
|
|
||||||
print('this goes to stderr', file=sys.stderr)
|
|
||||||
print('this goes to stdout')
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
wait_for_pyscript=False,
|
|
||||||
)
|
|
||||||
self.page.get_by_text("hello world").wait_for()
|
|
||||||
term = self.page.locator("py-terminal")
|
|
||||||
term_lines = term.inner_text().splitlines()
|
|
||||||
assert term_lines[0:3] == [
|
|
||||||
"hello world",
|
|
||||||
"this goes to stderr",
|
|
||||||
"this goes to stdout",
|
|
||||||
]
|
|
||||||
|
|
||||||
@skip_worker(
|
|
||||||
"Workers don't have events + two different workers don't share the same I/O"
|
|
||||||
)
|
|
||||||
def test_button_action(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
def greetings(event):
|
|
||||||
print('hello world')
|
|
||||||
</script>
|
|
||||||
<script type="py" terminal></script>
|
|
||||||
|
|
||||||
<button id="my-button" py-click="greetings">Click me</button>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
term = self.page.locator("py-terminal")
|
|
||||||
self.page.locator("button").click()
|
|
||||||
last_line = self.page.get_by_text("hello world")
|
|
||||||
last_line.wait_for()
|
|
||||||
assert term.inner_text().rstrip() == "hello world"
|
|
||||||
|
|
||||||
def test_xterm_function(self):
|
|
||||||
"""Test a few basic behaviors of the xtermjs terminal.
|
|
||||||
|
|
||||||
This test isn't meant to capture all of the behaviors of an xtermjs terminal;
|
|
||||||
rather, it confirms with a few basic formatting sequences that (1) the xtermjs
|
|
||||||
terminal is functioning/loaded correctly and (2) that output toward that terminal
|
|
||||||
isn't being escaped in a way that prevents it reacting to escape sequences. The
|
|
||||||
main goal is preventing regressions.
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py" terminal>
|
|
||||||
print("\x1b[33mYellow\x1b[0m")
|
|
||||||
print("\x1b[4mUnderline\x1b[24m")
|
|
||||||
print("\x1b[1mBold\x1b[22m")
|
|
||||||
print("\x1b[3mItalic\x1b[23m")
|
|
||||||
print("done")
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
wait_for_pyscript=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Wait for "done" to actually appear in the xterm; may be delayed,
|
|
||||||
# since xtermjs processes its input buffer in chunks
|
|
||||||
last_line = self.page.get_by_text("done")
|
|
||||||
last_line.wait_for()
|
|
||||||
|
|
||||||
# Yes, this is not ideal. However, per http://xtermjs.org/docs/guides/hooks/
|
|
||||||
# "It is not possible to conclude, whether or when a certain chunk of data
|
|
||||||
# will finally appear on the screen," which is what we'd really like to know.
|
|
||||||
# By waiting for the "done" test to appear above, we get close, however it is
|
|
||||||
# possible for the text to appear and not be 'processed' (i.e.) formatted. This
|
|
||||||
# small delay should avoid that.
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
rows = self.page.locator(".xterm-rows")
|
|
||||||
|
|
||||||
# The following use locator.evaluate() and getComputedStyle to get
|
|
||||||
# the computed CSS values; this tests that the lines are rendering
|
|
||||||
# properly in a better way than just testing whether they
|
|
||||||
# get the right css classes from xtermjs
|
|
||||||
|
|
||||||
# First line should be yellow
|
|
||||||
first_line = rows.locator("div").nth(0)
|
|
||||||
first_char = first_line.locator("span").nth(0)
|
|
||||||
color = first_char.evaluate(
|
|
||||||
"(element) => getComputedStyle(element).getPropertyValue('color')"
|
|
||||||
)
|
|
||||||
assert color == "rgb(196, 160, 0)"
|
|
||||||
|
|
||||||
# Second line should be underlined
|
|
||||||
second_line = rows.locator("div").nth(1)
|
|
||||||
first_char = second_line.locator("span").nth(0)
|
|
||||||
text_decoration = first_char.evaluate(
|
|
||||||
"(element) => getComputedStyle(element).getPropertyValue('text-decoration')"
|
|
||||||
)
|
|
||||||
assert "underline" in text_decoration
|
|
||||||
|
|
||||||
# We'll make sure the 'bold' font weight is more than the
|
|
||||||
# default font weight without specifying a specific value
|
|
||||||
baseline_font_weight = first_char.evaluate(
|
|
||||||
"(element) => getComputedStyle(element).getPropertyValue('font-weight')"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Third line should be bold
|
|
||||||
third_line = rows.locator("div").nth(2)
|
|
||||||
first_char = third_line.locator("span").nth(0)
|
|
||||||
font_weight = first_char.evaluate(
|
|
||||||
"(element) => getComputedStyle(element).getPropertyValue('font-weight')"
|
|
||||||
)
|
|
||||||
assert int(font_weight) > int(baseline_font_weight)
|
|
||||||
|
|
||||||
# Fourth line should be italic
|
|
||||||
fourth_line = rows.locator("div").nth(3)
|
|
||||||
first_char = fourth_line.locator("span").nth(0)
|
|
||||||
font_style = first_char.evaluate(
|
|
||||||
"(element) => getComputedStyle(element).getPropertyValue('font-style')"
|
|
||||||
)
|
|
||||||
assert font_style == "italic"
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from .support import PyScriptTest, with_execution_thread
|
|
||||||
|
|
||||||
|
|
||||||
# these tests don't need to run in 'main' and 'worker' modes: the workers are
|
|
||||||
# already tested explicitly by some of them (see e.g.
|
|
||||||
# test_script_type_py_worker_attribute)
|
|
||||||
@with_execution_thread(None)
|
|
||||||
class TestScriptTypePyScript(PyScriptTest):
|
|
||||||
def test_display_line_break(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
r"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
display('hello\nworld')
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
text_content = self.page.locator("script-py").text_content()
|
|
||||||
assert "hello\nworld" == text_content
|
|
||||||
|
|
||||||
def test_amp(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
r"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
display('a & b')
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
text_content = self.page.locator("script-py").text_content()
|
|
||||||
assert "a & b" == text_content
|
|
||||||
|
|
||||||
def test_quot(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
r"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
display('a " b')
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
text_content = self.page.locator("script-py").text_content()
|
|
||||||
assert "a " b" == text_content
|
|
||||||
|
|
||||||
def test_lt_gt(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
r"""
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import display
|
|
||||||
display('< < > >')
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
text_content = self.page.locator("script-py").text_content()
|
|
||||||
assert "< < > >" == text_content
|
|
||||||
|
|
||||||
def test_dynamically_add_script_type_py_tag(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script>
|
|
||||||
function addPyScriptTag() {
|
|
||||||
let tag = document.createElement('script');
|
|
||||||
tag.type = 'py';
|
|
||||||
tag.textContent = "print('hello world')";
|
|
||||||
document.body.appendChild(tag);
|
|
||||||
}
|
|
||||||
addPyScriptTag();
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
# please note the test here was on timeout
|
|
||||||
# incapable of finding a <button> after the script
|
|
||||||
self.page.locator("script-py")
|
|
||||||
|
|
||||||
assert self.console.log.lines[-1] == "hello world"
|
|
||||||
|
|
||||||
def test_script_type_py_src_attribute(self):
|
|
||||||
self.writefile("foo.py", "print('hello from foo')")
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py" src="foo.py"></script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert self.console.log.lines[-1] == "hello from foo"
|
|
||||||
|
|
||||||
def test_script_type_py_worker_attribute(self):
|
|
||||||
self.writefile("foo.py", "print('hello from foo')")
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py" src="foo.py" worker></script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert self.console.log.lines[-1] == "hello from foo"
|
|
||||||
|
|
||||||
@pytest.mark.skip("FIXME: output attribute is not implemented")
|
|
||||||
def test_script_type_py_output_attribute(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<div id="first"></div>
|
|
||||||
<script type="py" output="first">
|
|
||||||
print("<p>Hello</p>")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
text = self.page.locator("#first").text_content()
|
|
||||||
assert "<p>Hello</p>" in text
|
|
||||||
|
|
||||||
@pytest.mark.skip("FIXME: stderr attribute is not implemented")
|
|
||||||
def test_script_type_py_stderr_attribute(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<div id="stdout-div"></div>
|
|
||||||
<div id="stderr-div"></div>
|
|
||||||
<script type="py" output="stdout-div" stderr="stderr-div">
|
|
||||||
import sys
|
|
||||||
print("one.", file=sys.stderr)
|
|
||||||
print("two.")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert self.page.locator("#stdout-div").text_content() == "one.two."
|
|
||||||
assert self.page.locator("#stderr-div").text_content() == "one."
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from .support import PyScriptTest
|
|
||||||
|
|
||||||
|
|
||||||
class TestShadowRoot(PyScriptTest):
|
|
||||||
@pytest.mark.skip("NEXT: Element interface is gone. Replace with PyDom")
|
|
||||||
def test_reachable_shadow_root(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
r"""
|
|
||||||
<script>
|
|
||||||
// reason to wait for py-script is that it's the entry point for
|
|
||||||
// all patches and the MutationObserver, otherwise being this a synchronous
|
|
||||||
// script the constructor gets instantly invoked at the node before
|
|
||||||
// py-script gets a chance to initialize itself.
|
|
||||||
customElements.whenDefined('py-script').then(() => {
|
|
||||||
customElements.define('s-r', class extends HTMLElement {
|
|
||||||
constructor() {
|
|
||||||
super().attachShadow({mode: 'closed'}).innerHTML =
|
|
||||||
'<div id="shadowed">OK</div>';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<s-r></s-r>
|
|
||||||
<script type="py">
|
|
||||||
import js
|
|
||||||
js.console.log(Element("shadowed").innerHtml)
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
assert self.console.log.lines[-1] == "OK"
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from playwright.sync_api import expect
|
|
||||||
|
|
||||||
from .support import PyScriptTest, skip_worker
|
|
||||||
|
|
||||||
pytest.skip(reason="NEXT: Should we remove the splashscreen?", allow_module_level=True)
|
|
||||||
|
|
||||||
|
|
||||||
class TestSplashscreen(PyScriptTest):
|
|
||||||
def test_autoshow_and_autoclose(self):
|
|
||||||
"""
|
|
||||||
By default, we show the splashscreen and we close it when the loading is
|
|
||||||
complete.
|
|
||||||
|
|
||||||
XXX: this test is a bit fragile: now it works reliably because the
|
|
||||||
startup is so slow that when we do expect(div).to_be_visible(), the
|
|
||||||
splashscreen is still there. But in theory, if the startup become very
|
|
||||||
fast, it could happen that by the time we arrive in python lang, it
|
|
||||||
has already been removed.
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
print('hello pyscript')
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
wait_for_pyscript=False,
|
|
||||||
)
|
|
||||||
div = self.page.locator("py-splashscreen > div")
|
|
||||||
expect(div).to_be_visible()
|
|
||||||
expect(div).to_contain_text("Python startup...")
|
|
||||||
assert "Python startup..." in self.console.info.text
|
|
||||||
#
|
|
||||||
# now we wait for the startup to complete
|
|
||||||
self.wait_for_pyscript()
|
|
||||||
#
|
|
||||||
# and now the splashscreen should have been removed
|
|
||||||
expect(div).to_be_hidden()
|
|
||||||
assert self.page.locator("py-locator").count() == 0
|
|
||||||
|
|
||||||
assert "hello pyscript" in self.console.log.lines
|
|
||||||
|
|
||||||
def test_autoclose_false(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
[splashscreen]
|
|
||||||
autoclose = false
|
|
||||||
</py-config>
|
|
||||||
<script type="py">
|
|
||||||
print('hello pyscript')
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
div = self.page.locator("py-splashscreen > div")
|
|
||||||
expect(div).to_be_visible()
|
|
||||||
expect(div).to_contain_text("Python startup...")
|
|
||||||
expect(div).to_contain_text("Startup complete")
|
|
||||||
assert "hello pyscript" in self.console.log.lines
|
|
||||||
|
|
||||||
def test_autoclose_loader_deprecated(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
autoclose_loader = false
|
|
||||||
</py-config>
|
|
||||||
<script type="py">
|
|
||||||
print('hello pyscript')
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
warning = self.page.locator(".py-warning")
|
|
||||||
inner_text = warning.inner_html()
|
|
||||||
assert "The setting autoclose_loader is deprecated" in inner_text
|
|
||||||
|
|
||||||
div = self.page.locator("py-splashscreen > div")
|
|
||||||
expect(div).to_be_visible()
|
|
||||||
expect(div).to_contain_text("Python startup...")
|
|
||||||
expect(div).to_contain_text("Startup complete")
|
|
||||||
assert "hello pyscript" in self.console.log.lines
|
|
||||||
|
|
||||||
def test_splashscreen_disabled_option(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
[splashscreen]
|
|
||||||
enabled = false
|
|
||||||
</py-config>
|
|
||||||
|
|
||||||
<script type="py">
|
|
||||||
def test():
|
|
||||||
print("Hello pyscript!")
|
|
||||||
test()
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
assert self.page.locator("py-splashscreen").count() == 0
|
|
||||||
assert self.console.log.lines[-1] == "Hello pyscript!"
|
|
||||||
py_terminal = self.page.wait_for_selector("py-terminal")
|
|
||||||
assert py_terminal.inner_text() == "Hello pyscript!\n"
|
|
||||||
|
|
||||||
@skip_worker("FIXME: js.document")
|
|
||||||
def test_splashscreen_custom_message(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
[splashscreen]
|
|
||||||
autoclose = false
|
|
||||||
</py-config>
|
|
||||||
|
|
||||||
<script type="py">
|
|
||||||
from js import document
|
|
||||||
|
|
||||||
splashscreen = document.querySelector("py-splashscreen")
|
|
||||||
splashscreen.log("Hello, world!")
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
|
|
||||||
splashscreen = self.page.locator("py-splashscreen")
|
|
||||||
assert splashscreen.count() == 1
|
|
||||||
assert "Hello, world!" in splashscreen.inner_text()
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from .support import PyScriptTest, skip_worker
|
|
||||||
|
|
||||||
pytest.skip(reason="NEXT: entire stdio should be reviewed", allow_module_level=True)
|
|
||||||
|
|
||||||
|
|
||||||
class TestOutputHandling(PyScriptTest):
|
|
||||||
# Source of a script to test the TargetedStdio functionality
|
|
||||||
|
|
||||||
def test_targeted_stdio_solo(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<py-config>
|
|
||||||
terminal = true
|
|
||||||
</py-config>
|
|
||||||
<py-terminal></py-terminal>
|
|
||||||
<div id="container">
|
|
||||||
<div id="first"></div>
|
|
||||||
<div id="second"></div>
|
|
||||||
<div id="third"></div>
|
|
||||||
</div>
|
|
||||||
<script type="py" output="first">print("first 1.")</script>
|
|
||||||
<script type="py" output="second">print("second.")</script>
|
|
||||||
<script type="py" output="third">print("third.")</script>
|
|
||||||
<script type="py" output="first">print("first 2.")</script>
|
|
||||||
<script type="py">print("no output.")</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check that page has desired parent/child structure, and that
|
|
||||||
# Output divs are correctly located
|
|
||||||
assert (container := self.page.locator("#container")).count() > 0
|
|
||||||
assert (first_div := container.locator("#first")).count() > 0
|
|
||||||
assert (second_div := container.locator("#second")).count() > 0
|
|
||||||
assert (third_div := container.locator("#third")).count() > 0
|
|
||||||
|
|
||||||
# Check that output ends up in proper div
|
|
||||||
assert first_div.text_content() == "first 1.first 2."
|
|
||||||
assert second_div.text_content() == "second."
|
|
||||||
assert third_div.text_content() == "third."
|
|
||||||
|
|
||||||
# Check that tag with no otuput attribute doesn't end up in container at all
|
|
||||||
assert container.get_by_text("no output.").count() == 0
|
|
||||||
|
|
||||||
# Check that all output ends up in py-terminal
|
|
||||||
assert (
|
|
||||||
self.page.locator("py-terminal").text_content()
|
|
||||||
== "first 1.second.third.first 2.no output."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check that all output ends up in the dev console, in order
|
|
||||||
last_index = -1
|
|
||||||
for line in ["first 1.", "second.", "third.", "first 2.", "no output."]:
|
|
||||||
assert (line_index := self.console.log.lines.index(line)) > -1
|
|
||||||
assert line_index > last_index
|
|
||||||
last_index = line_index
|
|
||||||
|
|
||||||
self.assert_no_banners()
|
|
||||||
|
|
||||||
def test_stdio_escape(self):
|
|
||||||
# Test that text that looks like HTML tags is properly escaped in stdio
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<div id="first"></div>
|
|
||||||
<script type="py" output="first">
|
|
||||||
print("<p>Hello</p>")
|
|
||||||
print('<img src="https://example.net">')
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
text = self.page.locator("#first").text_content()
|
|
||||||
|
|
||||||
assert "<p>Hello</p>" in text
|
|
||||||
assert '<img src="https://example.net">' in text
|
|
||||||
|
|
||||||
self.assert_no_banners()
|
|
||||||
|
|
||||||
def test_targeted_stdio_linebreaks(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<div id="first"></div>
|
|
||||||
<script type="py" output="first">
|
|
||||||
print("one.")
|
|
||||||
print("two.")
|
|
||||||
print("three.")
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div id="second"></div>
|
|
||||||
<script type="py" output="second">
|
|
||||||
print("one.\\ntwo.\\nthree.")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# check line breaks at end of each input
|
|
||||||
assert self.page.locator("#first").inner_html() == "one.<br>two.<br>three.<br>"
|
|
||||||
|
|
||||||
# new lines are converted to line breaks
|
|
||||||
assert self.page.locator("#second").inner_html() == "one.<br>two.<br>three.<br>"
|
|
||||||
|
|
||||||
self.assert_no_banners()
|
|
||||||
|
|
||||||
def test_targeted_stdio_async(self):
|
|
||||||
# Test the behavior of stdio capture in async contexts
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
import asyncio
|
|
||||||
import js
|
|
||||||
|
|
||||||
async def coro(value, delay):
|
|
||||||
print(value)
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
js.console.log(f"DONE {value}")
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div id="first"></div>
|
|
||||||
<script type="py">
|
|
||||||
asyncio.ensure_future(coro("first", 1))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div id="second"></div>
|
|
||||||
<script type="py" output="second">
|
|
||||||
asyncio.ensure_future(coro("second", 1))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div id="third"></div>
|
|
||||||
<script type="py" output="third">
|
|
||||||
asyncio.ensure_future(coro("third", 0))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script type="py" output="third">
|
|
||||||
asyncio.ensure_future(coro("DONE", 3))
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
self.wait_for_console("DONE DONE")
|
|
||||||
|
|
||||||
# script tags without output parameter should not send
|
|
||||||
# stdout to element
|
|
||||||
assert self.page.locator("#first").text_content() == ""
|
|
||||||
|
|
||||||
# script tags with output parameter not expected to send
|
|
||||||
# std to element in coroutine
|
|
||||||
assert self.page.locator("#second").text_content() == ""
|
|
||||||
assert self.page.locator("#third").text_content() == ""
|
|
||||||
|
|
||||||
self.assert_no_banners()
|
|
||||||
|
|
||||||
def test_targeted_stdio_interleaved(self):
|
|
||||||
# Test that synchronous writes to stdout are placed correctly, even
|
|
||||||
# While interleaved with scheduling coroutines in the same tag
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<div id="good"></div>
|
|
||||||
<div id="bad"></div>
|
|
||||||
<script type="py" output="good">
|
|
||||||
import asyncio
|
|
||||||
import js
|
|
||||||
|
|
||||||
async def coro_bad(value, delay):
|
|
||||||
print(value)
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
|
|
||||||
print("one.")
|
|
||||||
asyncio.ensure_future(coro_bad("badone.", 0.1))
|
|
||||||
print("two.")
|
|
||||||
asyncio.ensure_future(coro_bad("badtwo.", 0.2))
|
|
||||||
print("three.")
|
|
||||||
asyncio.ensure_future(coro_bad("badthree.", 0))
|
|
||||||
asyncio.ensure_future(coro_bad("DONE", 1))
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Three prints should appear from synchronous writes
|
|
||||||
assert self.page.locator("#good").text_content() == "one.two.three."
|
|
||||||
|
|
||||||
# Check that all output ends up in the dev console, in order
|
|
||||||
last_index = -1
|
|
||||||
for line in ["one.", "two.", "three.", "badthree.", "badone.", "badtwo."]:
|
|
||||||
assert (line_index := self.console.log.lines.index(line)) > -1
|
|
||||||
assert line_index > last_index
|
|
||||||
|
|
||||||
self.assert_no_banners()
|
|
||||||
|
|
||||||
@skip_worker("FIXME: js.document")
|
|
||||||
def test_targeted_stdio_dynamic_tags(self):
|
|
||||||
# Test that creating py-script tags via Python still leaves
|
|
||||||
# stdio targets working
|
|
||||||
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<div id="first"></div>
|
|
||||||
<div id="second"></div>
|
|
||||||
<script type="py" output="first">
|
|
||||||
print("first.")
|
|
||||||
|
|
||||||
import js
|
|
||||||
tag = js.document.createElement("py-script")
|
|
||||||
tag.innerText = "print('second.')"
|
|
||||||
tag.setAttribute("output", "second")
|
|
||||||
js.document.body.appendChild(tag)
|
|
||||||
|
|
||||||
print("first.")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure second tag was added to page
|
|
||||||
assert (second_div := self.page.locator("#second")).count() > 0
|
|
||||||
|
|
||||||
# Ensure output when to correct locations
|
|
||||||
assert self.page.locator("#first").text_content() == "first.first."
|
|
||||||
assert second_div.text_content() == "second."
|
|
||||||
|
|
||||||
self.assert_no_banners()
|
|
||||||
|
|
||||||
def test_stdio_stdout_id_errors(self):
|
|
||||||
# Test that using an ID not present on the page as the Output
|
|
||||||
# Attribute creates exactly 1 warning banner per missing id
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py" output="not-on-page">
|
|
||||||
print("bad.")
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div id="on-page"></div>
|
|
||||||
<script type="py">
|
|
||||||
print("good.")
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script type="py" output="not-on-page">
|
|
||||||
print("bad.")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
banner = self.page.query_selector_all(".py-warning")
|
|
||||||
assert len(banner) == 1
|
|
||||||
banner_content = banner[0].inner_text()
|
|
||||||
expected = (
|
|
||||||
'output = "not-on-page" does not match the id of any element on the page.'
|
|
||||||
)
|
|
||||||
|
|
||||||
assert banner_content == expected
|
|
||||||
|
|
||||||
def test_stdio_stderr_id_errors(self):
|
|
||||||
# Test that using an ID not present on the page as the stderr
|
|
||||||
# attribute creates exactly 1 warning banner per missing id
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py" stderr="not-on-page">
|
|
||||||
import sys
|
|
||||||
print("bad.", file=sys.stderr)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div id="on-page"></div>
|
|
||||||
<script type="py">
|
|
||||||
print("good.", file=sys.stderr)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script type="py" stderr="not-on-page">
|
|
||||||
print("bad.", file=sys.stderr)
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
banner = self.page.query_selector_all(".py-warning")
|
|
||||||
assert len(banner) == 1
|
|
||||||
banner_content = banner[0].inner_text()
|
|
||||||
expected = (
|
|
||||||
'stderr = "not-on-page" does not match the id of any element on the page.'
|
|
||||||
)
|
|
||||||
|
|
||||||
assert banner_content == expected
|
|
||||||
|
|
||||||
def test_stdio_stderr(self):
|
|
||||||
# Test that stderr works, and routes to the same location as stdout
|
|
||||||
# Also, script tags with the stderr attribute route to an additional location
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<div id="stdout-div"></div>
|
|
||||||
<div id="stderr-div"></div>
|
|
||||||
<script type="py" output="stdout-div" stderr="stderr-div">
|
|
||||||
import sys
|
|
||||||
print("one.", file=sys.stderr)
|
|
||||||
print("two.")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert self.page.locator("#stdout-div").text_content() == "one.two."
|
|
||||||
assert self.page.locator("#stderr-div").text_content() == "one."
|
|
||||||
self.assert_no_banners()
|
|
||||||
|
|
||||||
@skip_worker("FIXME: js.document")
|
|
||||||
def test_stdio_output_attribute_change(self):
|
|
||||||
# If the user changes the 'output' attribute of a <script type="py"> tag mid-execution,
|
|
||||||
# Output should no longer go to the selected div and a warning should appear
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<div id="first"></div>
|
|
||||||
<div id="second"></div>
|
|
||||||
<!-- There is no tag with id "third" -->
|
|
||||||
<script type="py" id="pyscript-tag" output="first">
|
|
||||||
print("one.")
|
|
||||||
|
|
||||||
# Change the 'output' attribute of this tag
|
|
||||||
import js
|
|
||||||
this_tag = js.document.getElementById("pyscript-tag")
|
|
||||||
|
|
||||||
this_tag.setAttribute("output", "second")
|
|
||||||
print("two.")
|
|
||||||
|
|
||||||
this_tag.setAttribute("output", "third")
|
|
||||||
print("three.")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
assert self.page.locator("#first").text_content() == "one."
|
|
||||||
assert self.page.locator("#second").text_content() == "two."
|
|
||||||
expected_alert_banner_msg = (
|
|
||||||
'output = "third" does not match the id of any element on the page.'
|
|
||||||
)
|
|
||||||
|
|
||||||
alert_banner = self.page.locator(".alert-banner")
|
|
||||||
assert expected_alert_banner_msg in alert_banner.inner_text()
|
|
||||||
|
|
||||||
@skip_worker("FIXME: js.document")
|
|
||||||
def test_stdio_target_element_id_change(self):
|
|
||||||
# If the user changes the ID of the targeted DOM element mid-execution,
|
|
||||||
# Output should no longer go to the selected element and a warning should appear
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<div id="first"></div>
|
|
||||||
<div id="second"></div>
|
|
||||||
<!-- There is no tag with id "third" -->
|
|
||||||
<script type="py" id="pyscript-tag" output="first">
|
|
||||||
print("one.")
|
|
||||||
|
|
||||||
# Change the ID of the targeted DIV to something else
|
|
||||||
import js
|
|
||||||
target_tag = js.document.getElementById("first")
|
|
||||||
|
|
||||||
# should fail and show banner
|
|
||||||
target_tag.setAttribute("id", "second")
|
|
||||||
print("two.")
|
|
||||||
|
|
||||||
# But changing both the 'output' attribute and the id of the target
|
|
||||||
# should work
|
|
||||||
target_tag.setAttribute("id", "third")
|
|
||||||
js.document.getElementById("pyscript-tag").setAttribute("output", "third")
|
|
||||||
print("three.")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Note the ID of the div has changed by the time of this assert
|
|
||||||
assert self.page.locator("#third").text_content() == "one.three."
|
|
||||||
|
|
||||||
expected_alert_banner_msg = (
|
|
||||||
'output = "first" does not match the id of any element on the page.'
|
|
||||||
)
|
|
||||||
alert_banner = self.page.locator(".alert-banner")
|
|
||||||
assert expected_alert_banner_msg in alert_banner.inner_text()
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from playwright.sync_api import expect
|
|
||||||
|
|
||||||
from .support import PyScriptTest, with_execution_thread
|
|
||||||
|
|
||||||
|
|
||||||
@with_execution_thread(None)
|
|
||||||
class TestStyle(PyScriptTest):
|
|
||||||
def test_pyscript_not_defined(self):
|
|
||||||
"""Test raw elements that are not defined for display:none"""
|
|
||||||
doc = """
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<link rel="stylesheet" href="build/core.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<py-config>hello</py-config>
|
|
||||||
<py-script>hello</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
"""
|
|
||||||
self.writefile("test-not-defined-css.html", doc)
|
|
||||||
self.goto("test-not-defined-css.html")
|
|
||||||
expect(self.page.locator("py-config")).to_be_hidden()
|
|
||||||
expect(self.page.locator("py-script")).to_be_hidden()
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from .support import PyScriptTest, skip_worker
|
|
||||||
|
|
||||||
|
|
||||||
class TestWarningsAndBanners(PyScriptTest):
|
|
||||||
# Test the behavior of generated warning banners
|
|
||||||
|
|
||||||
def test_deprecate_loading_scripts_from_latest(self):
|
|
||||||
# Use a script tag with an invalid output attribute to generate a warning, but only one
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py">
|
|
||||||
print("whatever..")
|
|
||||||
</script>
|
|
||||||
""",
|
|
||||||
extra_head='<script type="ignore-me" src="https://pyscript.net/latest/any-path-triggers-the-warning-anyway.js"></script>',
|
|
||||||
)
|
|
||||||
|
|
||||||
# wait for the banner to appear (we could have a page.locater call but for some reason
|
|
||||||
# the worker takes to long to render on CI, since it's a test we can afford 2 calls)
|
|
||||||
loc = self.page.wait_for_selector(".py-error")
|
|
||||||
assert (
|
|
||||||
loc.inner_text()
|
|
||||||
== "Loading scripts from latest is deprecated and will be removed soon. Please use a specific version instead."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Only one banner should appear
|
|
||||||
loc = self.page.locator(".py-error")
|
|
||||||
assert loc.count() == 1
|
|
||||||
|
|
||||||
@pytest.mark.skip("NEXT: To check if behaviour is consistent with classic")
|
|
||||||
def test_create_singular_warning(self):
|
|
||||||
# Use a script tag with an invalid output attribute to generate a warning, but only one
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<script type="py" output="foo">
|
|
||||||
print("one.")
|
|
||||||
print("two.")
|
|
||||||
</script>
|
|
||||||
<script type="py" output="foo">
|
|
||||||
print("three.")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
loc = self.page.locator(".alert-banner")
|
|
||||||
|
|
||||||
# Only one banner should appear
|
|
||||||
assert loc.count() == 1
|
|
||||||
assert (
|
|
||||||
loc.text_content()
|
|
||||||
== 'output = "foo" does not match the id of any element on the page.'
|
|
||||||
)
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from .support import PyScriptTest, skip_worker
|
|
||||||
|
|
||||||
|
|
||||||
class TestEventHandler(PyScriptTest):
|
|
||||||
def test_when_decorator_with_event(self):
|
|
||||||
"""When the decorated function takes a single parameter,
|
|
||||||
it should be passed the event object
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<button id="foo_id">foo_button</button>
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import when
|
|
||||||
@when("click", selector="#foo_id")
|
|
||||||
def foo(evt):
|
|
||||||
print(f"clicked {evt.target.id}")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.page.locator("text=foo_button").click()
|
|
||||||
self.wait_for_console("clicked foo_id")
|
|
||||||
self.assert_no_banners()
|
|
||||||
|
|
||||||
def test_when_decorator_without_event(self):
|
|
||||||
"""When the decorated function takes no parameters (not including 'self'),
|
|
||||||
it should be called without the event object
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<button id="foo_id">foo_button</button>
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import when
|
|
||||||
@when("click", selector="#foo_id")
|
|
||||||
def foo():
|
|
||||||
print("The button was clicked")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.page.locator("text=foo_button").click()
|
|
||||||
self.wait_for_console("The button was clicked")
|
|
||||||
self.assert_no_banners()
|
|
||||||
|
|
||||||
def test_multiple_when_decorators_with_event(self):
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<button id="foo_id">foo_button</button>
|
|
||||||
<button id="bar_id">bar_button</button>
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import when
|
|
||||||
@when("click", selector="#foo_id")
|
|
||||||
def foo_click(evt):
|
|
||||||
print(f"foo_click! id={evt.target.id}")
|
|
||||||
@when("click", selector="#bar_id")
|
|
||||||
def bar_click(evt):
|
|
||||||
print(f"bar_click! id={evt.target.id}")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.page.locator("text=foo_button").click()
|
|
||||||
self.wait_for_console("foo_click! id=foo_id")
|
|
||||||
self.page.locator("text=bar_button").click()
|
|
||||||
self.wait_for_console("bar_click! id=bar_id")
|
|
||||||
self.assert_no_banners()
|
|
||||||
|
|
||||||
def test_two_when_decorators(self):
|
|
||||||
"""When decorating a function twice, both should function"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<button id="foo_id">foo_button</button>
|
|
||||||
<button class="bar_class">bar_button</button>
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import when
|
|
||||||
@when("click", selector="#foo_id")
|
|
||||||
@when("mouseover", selector=".bar_class")
|
|
||||||
def foo(evt):
|
|
||||||
print(f"got event: {evt.type}")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.page.locator("text=bar_button").hover()
|
|
||||||
self.wait_for_console("got event: mouseover")
|
|
||||||
self.page.locator("text=foo_button").click()
|
|
||||||
self.wait_for_console("got event: click")
|
|
||||||
self.assert_no_banners()
|
|
||||||
|
|
||||||
def test_two_when_decorators_same_element(self):
|
|
||||||
"""When decorating a function twice *on the same DOM element*, both should function"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<button id="foo_id">foo_button</button>
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import when
|
|
||||||
@when("click", selector="#foo_id")
|
|
||||||
@when("mouseover", selector="#foo_id")
|
|
||||||
def foo(evt):
|
|
||||||
print(f"got event: {evt.type}")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.page.locator("text=foo_button").hover()
|
|
||||||
self.wait_for_console("got event: mouseover")
|
|
||||||
self.page.locator("text=foo_button").click()
|
|
||||||
self.wait_for_console("got event: click")
|
|
||||||
self.assert_no_banners()
|
|
||||||
|
|
||||||
def test_when_decorator_multiple_elements(self):
|
|
||||||
"""The @when decorator's selector should successfully select multiple
|
|
||||||
DOM elements
|
|
||||||
"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<button class="bar_class">button1</button>
|
|
||||||
<button class="bar_class">button2</button>
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import when
|
|
||||||
@when("click", selector=".bar_class")
|
|
||||||
def foo(evt):
|
|
||||||
print(f"{evt.target.innerText} was clicked")
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.page.locator("text=button1").click()
|
|
||||||
self.page.locator("text=button2").click()
|
|
||||||
self.wait_for_console("button2 was clicked")
|
|
||||||
assert "button1 was clicked" in self.console.log.lines
|
|
||||||
assert "button2 was clicked" in self.console.log.lines
|
|
||||||
self.assert_no_banners()
|
|
||||||
|
|
||||||
def test_when_decorator_duplicate_selectors(self):
|
|
||||||
""" """
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<button id="foo_id">foo_button</button>
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import when
|
|
||||||
@when("click", selector="#foo_id")
|
|
||||||
@when("click", selector="#foo_id")
|
|
||||||
def foo(evt):
|
|
||||||
foo.n += 1
|
|
||||||
print(f"click {foo.n} on {evt.target.id}")
|
|
||||||
foo.n = 0
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.page.locator("text=foo_button").click()
|
|
||||||
self.wait_for_console("click 1 on foo_id")
|
|
||||||
self.wait_for_console("click 2 on foo_id")
|
|
||||||
self.assert_no_banners()
|
|
||||||
|
|
||||||
@skip_worker("NEXT: error banner not shown")
|
|
||||||
def test_when_decorator_invalid_selector(self):
|
|
||||||
"""When the selector parameter of @when is invalid, it should show an error"""
|
|
||||||
self.pyscript_run(
|
|
||||||
"""
|
|
||||||
<button id="foo_id">foo_button</button>
|
|
||||||
<script type="py">
|
|
||||||
from pyscript import when
|
|
||||||
@when("click", selector="#.bad")
|
|
||||||
def foo(evt):
|
|
||||||
...
|
|
||||||
</script>
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
self.page.locator("text=foo_button").click()
|
|
||||||
msg = "Failed to execute 'querySelectorAll' on 'Document': '#.bad' is not a valid selector."
|
|
||||||
error = self.page.wait_for_selector(".py-error")
|
|
||||||
banner_text = error.inner_text()
|
|
||||||
|
|
||||||
if msg not in banner_text:
|
|
||||||
raise AssertionError(
|
|
||||||
f"Expected message '{msg}' does not "
|
|
||||||
f"match banner text '{banner_text}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert msg in self.console.error.lines[-1]
|
|
||||||
self.check_py_errors(msg)
|
|
||||||
4
pyscript.core/tests/issue-7015/config.toml
Normal file
4
pyscript.core/tests/issue-7015/config.toml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
packages = [
|
||||||
|
"https://cdn.holoviz.org/panel/wheels/bokeh-3.5.0-py3-none-any.whl",
|
||||||
|
"https://cdn.holoviz.org/panel/1.5.0-b.2/dist/wheels/panel-1.5.0b2-py3-none-any.whl"
|
||||||
|
]
|
||||||
17
pyscript.core/tests/issue-7015/index.html
Normal file
17
pyscript.core/tests/issue-7015/index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="stylesheet" href="../../dist/core.css">
|
||||||
|
<script src="https://cdn.bokeh.org/bokeh/release/bokeh-3.5.0.js"></script>
|
||||||
|
<script src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-3.5.0.min.js"></script>
|
||||||
|
<script src="https://cdn.bokeh.org/bokeh/release/bokeh-tables-3.5.0.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@holoviz/panel@1.5.0-b.2/dist/panel.min.js"></script>
|
||||||
|
<script type="module" src="../../dist/core.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="py" src="main.py" config="config.toml" worker></script>
|
||||||
|
<div id="simple_app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
pyscript.core/tests/issue-7015/main.py
Normal file
12
pyscript.core/tests/issue-7015/main.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import panel as pn
|
||||||
|
|
||||||
|
pn.extension(sizing_mode="stretch_width")
|
||||||
|
|
||||||
|
slider = pn.widgets.FloatSlider(start=0, end=10, name="amplitude")
|
||||||
|
|
||||||
|
|
||||||
|
def callback(new):
|
||||||
|
return f"Amplitude is: {new}"
|
||||||
|
|
||||||
|
|
||||||
|
pn.Row(slider, pn.bind(callback, slider)).servable(target="simple_app")
|
||||||
24
pyscript.core/tests/javascript/async-listener.html
Normal file
24
pyscript.core/tests/javascript/async-listener.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="../../dist/core.css">
|
||||||
|
<script type="module" src="../../dist/core.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="mpy">
|
||||||
|
from pyscript import window, document, fetch, when
|
||||||
|
|
||||||
|
@when('click', '#click')
|
||||||
|
async def click(event):
|
||||||
|
text = await fetch(window.location.href).text()
|
||||||
|
document.getElementById('output').append(text)
|
||||||
|
document.documentElement.classList.add('ok')
|
||||||
|
|
||||||
|
document.getElementById('click').click()
|
||||||
|
</script>
|
||||||
|
<button id="click">click</button>
|
||||||
|
<pre id="output"></pre>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -4,13 +4,21 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>PyScript Next Plugin</title>
|
<title>PyScript Next Plugin</title>
|
||||||
<link rel="stylesheet" href="../dist/core.css">
|
<link rel="stylesheet" href="../../dist/core.css">
|
||||||
<script type="module" src="../dist/core.js"></script>
|
<script type="module" src="../../dist/core.js"></script>
|
||||||
<mpy-config src="config-url/config.json"></mpy-config>
|
<mpy-config src="config-url/config.json"></mpy-config>
|
||||||
<script type="mpy">
|
<script type="mpy">
|
||||||
|
from pyscript import config
|
||||||
|
if config["files"]["{TO}"] != "./runtime":
|
||||||
|
raise Exception("wrong config tree")
|
||||||
|
|
||||||
from runtime import test
|
from runtime import test
|
||||||
</script>
|
</script>
|
||||||
<script type="mpy" worker>
|
<script type="mpy" worker>
|
||||||
|
from pyscript import config
|
||||||
|
if config["files"]["{TO}"] != "./runtime":
|
||||||
|
raise Exception("wrong config tree")
|
||||||
|
|
||||||
from runtime import test
|
from runtime import test
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
23
pyscript.core/tests/javascript/config_type.html
Normal file
23
pyscript.core/tests/javascript/config_type.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="../../dist/core.css">
|
||||||
|
<script type="module" src="../../dist/core.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="mpy">
|
||||||
|
from pyscript import config, document
|
||||||
|
|
||||||
|
if config["type"] is "mpy":
|
||||||
|
document.documentElement.classList.add("mpy")
|
||||||
|
</script>
|
||||||
|
<script type="py">
|
||||||
|
from pyscript import config, document
|
||||||
|
|
||||||
|
if config["type"] is "py":
|
||||||
|
document.documentElement.classList.add("py")
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="stylesheet" href="../dist/core.css">
|
<link rel="stylesheet" href="../../../dist/core.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
document.createElement('script'),
|
document.createElement('script'),
|
||||||
{
|
{
|
||||||
type: 'module',
|
type: 'module',
|
||||||
src: '../dist/core.js'
|
src: '../../../dist/core.js'
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>PyScript FFI</title>
|
<title>PyScript FFI</title>
|
||||||
<link rel="stylesheet" href="../dist/core.css">
|
<link rel="stylesheet" href="../../dist/core.css">
|
||||||
<script type="module" src="../dist/core.js"></script>
|
<script type="module" src="../../dist/core.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script type="mpy">
|
<script type="mpy">
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user