mirror of
https://github.com/pyscript/pyscript.git
synced 2025-12-19 18:27:29 -05:00
Compare commits
120 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 | ||
|
|
83c2afeaf1 | ||
|
|
643b76479f | ||
|
|
cf92996071 | ||
|
|
c653296821 | ||
|
|
44cd6273ba | ||
|
|
d7d2dfb383 | ||
|
|
2d5cf096e0 | ||
|
|
6ee8217593 | ||
|
|
6d45728787 | ||
|
|
65954a627e | ||
|
|
2f1b764251 | ||
|
|
1fb6cddd70 | ||
|
|
239add4e20 | ||
|
|
4e4ac56729 | ||
|
|
1447cb3094 | ||
|
|
2f3659b676 | ||
|
|
910c666319 | ||
|
|
eee2f64c1d | ||
|
|
d080246a0f | ||
|
|
98c0f5e50d | ||
|
|
a1268f1aa2 | ||
|
|
69b8884045 | ||
|
|
df1d699fe6 | ||
|
|
84f197b657 | ||
|
|
5bed5ede52 | ||
|
|
f6d5cf06c8 | ||
|
|
30c6c830ae | ||
|
|
d7084f7f55 | ||
|
|
a87d2b3fea | ||
|
|
81a26363a3 | ||
|
|
53e945201d | ||
|
|
181d276c8b | ||
|
|
bcaab0eb93 | ||
|
|
3ff0f84391 | ||
|
|
2b411fc635 | ||
|
|
2128572ce5 | ||
|
|
63f2453091 | ||
|
|
f6470dcad5 | ||
|
|
a9717afeb7 | ||
|
|
cea52b4334 | ||
|
|
7ad7f0abfb | ||
|
|
1efd73af8f | ||
|
|
1e7fb9af44 | ||
|
|
154e00d320 | ||
|
|
0f788fa284 | ||
|
|
355866a1f1 | ||
|
|
6eca06ac0b | ||
|
|
a4aef0b530 | ||
|
|
136e95498f |
4
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
4
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -11,7 +11,9 @@ body:
|
||||
|
||||
There will always be more issues than there is time to do them, and so we will need to selectively close issues that don't provide enough information, so we can focus our time on helping people like you who fill out the issue form completely. Thank you for your collaboration!
|
||||
|
||||
There are also already a lot of open issues, so please take 2 minutes and search through existing ones to see if what you are experiencing already exists
|
||||
There are also already a lot of open issues, so please take 2 minutes and search through existing ones to see if what you are experiencing already exists.
|
||||
|
||||
Finally, if you are opening **a bug report related to PyScript.com** please [use this repository instead](https://github.com/anaconda/pyscript-dot-com-issues/issues/new/choose).
|
||||
|
||||
Thanks for helping PyScript be amazing. We are nothing without people like you helping build a better community 💐!
|
||||
- type: checkboxes
|
||||
|
||||
13
.github/dependabot.yml
vendored
Normal file
13
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
# Keep GitHub Actions up to date with GitHub's Dependabot...
|
||||
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
|
||||
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*" # Group all Actions updates into a single larger pull request
|
||||
schedule:
|
||||
interval: weekly
|
||||
25
.github/workflows/prepare-release.yml
vendored
25
.github/workflows/prepare-release.yml
vendored
@@ -17,12 +17,27 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
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
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
env:
|
||||
cache-name: cache-node-modules
|
||||
with:
|
||||
@@ -35,7 +50,7 @@ jobs:
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: NPM Install
|
||||
run: npm install && npx playwright install
|
||||
run: npm install && npx playwright install chromium
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
@@ -48,7 +63,7 @@ jobs:
|
||||
run: zip -r -q ./build.zip ./dist
|
||||
|
||||
- name: Prepare Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
draft: true
|
||||
prerelease: true
|
||||
|
||||
27
.github/workflows/publish-release.yml
vendored
27
.github/workflows/publish-release.yml
vendored
@@ -19,12 +19,27 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
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
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
env:
|
||||
cache-name: cache-node-modules
|
||||
with:
|
||||
@@ -37,7 +52,7 @@ jobs:
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: npm install
|
||||
run: npm install && npx playwright install
|
||||
run: npm install && npx playwright install chromium
|
||||
|
||||
- name: build
|
||||
run: npm run build
|
||||
@@ -46,6 +61,10 @@ jobs:
|
||||
working-directory: .
|
||||
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
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
|
||||
23
.github/workflows/publish-snapshot.yml
vendored
23
.github/workflows/publish-snapshot.yml
vendored
@@ -23,12 +23,27 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
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
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
env:
|
||||
cache-name: cache-node-modules
|
||||
with:
|
||||
@@ -41,7 +56,7 @@ jobs:
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install && npx playwright install
|
||||
run: npm install && npx playwright install chromium
|
||||
|
||||
- name: Build Pyscript.core
|
||||
run: npm run build
|
||||
|
||||
23
.github/workflows/publish-unstable.yml
vendored
23
.github/workflows/publish-unstable.yml
vendored
@@ -24,12 +24,27 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
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
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
env:
|
||||
cache-name: cache-node-modules
|
||||
with:
|
||||
@@ -42,7 +57,7 @@ jobs:
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: NPM Install
|
||||
run: npm install && npx playwright install
|
||||
run: npm install && npx playwright install chromium
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
22
.github/workflows/test.yml
vendored
22
.github/workflows/test.yml
vendored
@@ -37,12 +37,12 @@ jobs:
|
||||
run: git log --graph -3
|
||||
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
env:
|
||||
cache-name: cache-node-modules
|
||||
with:
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: setup Miniconda
|
||||
uses: conda-incubator/setup-miniconda@v2
|
||||
uses: conda-incubator/setup-miniconda@v3
|
||||
|
||||
- name: Create and activate virtual environment
|
||||
run: |
|
||||
@@ -69,24 +69,12 @@ jobs:
|
||||
make setup
|
||||
|
||||
- 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@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pyscript
|
||||
path: |
|
||||
pyscript.core/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
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.6.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
|
||||
|
||||
# @pyscript/core npm artifacts
|
||||
pyscript.core/test-results/*
|
||||
pyscript.core/core.*
|
||||
pyscript.core/dist
|
||||
pyscript.core/dist.zip
|
||||
|
||||
@@ -7,7 +7,7 @@ ci:
|
||||
default_stages: [commit]
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.4.0
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: check-builtin-literals
|
||||
- id: check-case-conflict
|
||||
@@ -25,13 +25,13 @@ repos:
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.1.0
|
||||
rev: 24.8.0
|
||||
hooks:
|
||||
- id: black
|
||||
exclude: pyscript\.core/src/stdlib/pyscript/__init__\.py
|
||||
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.2.4
|
||||
rev: v2.3.0
|
||||
hooks:
|
||||
- id: codespell # See 'pyproject.toml' for args
|
||||
exclude: \.js\.map$
|
||||
@@ -46,7 +46,7 @@ repos:
|
||||
args: [--tab-width, "4"]
|
||||
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.12.0
|
||||
rev: 5.13.2
|
||||
hooks:
|
||||
- id: isort
|
||||
name: isort (python)
|
||||
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,5 +1,15 @@
|
||||
# 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
|
||||
|
||||
### Features
|
||||
|
||||
104
CONTRIBUTING.md
104
CONTRIBUTING.md
@@ -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
|
||||
|
||||
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
|
||||
|
||||
@@ -79,3 +79,103 @@ The Project abides by the Organization's [trademark policy](https://github.com/p
|
||||
|
||||
Part of MVG-0.1-beta.
|
||||
Made with love by GitHub. Licensed under the [CC-BY 4.0 License](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
|
||||
# Quick guide to pytest
|
||||
|
||||
We make heavy usage of pytest. Here is a quick guide and collection of useful options:
|
||||
|
||||
- To run all tests in the current directory and subdirectories: pytest
|
||||
|
||||
- To run tests in a specific directory or file: pytest path/to/dir/test_foo.py
|
||||
|
||||
- -s: disables output capturing
|
||||
|
||||
- --pdb: in case of exception, enter a (Pdb) prompt so that you can inspect what went wrong.
|
||||
|
||||
- -v: verbose mode
|
||||
|
||||
- -x: stop the execution as soon as one test fails
|
||||
|
||||
- -k foo: run only the tests whose full name contains foo
|
||||
|
||||
- -k 'foo and bar'
|
||||
|
||||
- -k 'foo and not bar'
|
||||
|
||||
## Running integration tests under pytest
|
||||
|
||||
make test is useful to run all the tests, but during the development is useful to have more control on how tests are run. The following guide assumes that you are in the directory pyscriptjs/tests/integration/.
|
||||
|
||||
### To run all the integration tests, single or multi core
|
||||
|
||||
$ pytest -xv
|
||||
...
|
||||
|
||||
test_00_support.py::TestSupport::test_basic[chromium] PASSED [ 0%]
|
||||
test_00_support.py::TestSupport::test_console[chromium] PASSED [ 1%]
|
||||
test_00_support.py::TestSupport::test_check_js_errors_simple[chromium] PASSED [ 2%]
|
||||
test_00_support.py::TestSupport::test_check_js_errors_expected[chromium] PASSED [ 3%]
|
||||
test_00_support.py::TestSupport::test_check_js_errors_expected_but_didnt_raise[chromium] PASSED [ 4%]
|
||||
test_00_support.py::TestSupport::test_check_js_errors_multiple[chromium] PASSED [ 5%]
|
||||
...
|
||||
|
||||
-x means "stop at the first failure". -v means "verbose", so that you can see all the test names one by one. We try to keep tests in a reasonable order, from most basic to most complex. This way, if you introduced some bug in very basic things, you will notice immediately.
|
||||
|
||||
If you have the pytest-xdist plugin installed, you can run all the integration tests on 4 cores in parallel:
|
||||
|
||||
$ pytest -n 4
|
||||
|
||||
### To run a single test, headless
|
||||
|
||||
$ pytest test_01_basic.py -k test_pyscript_hello -s
|
||||
...
|
||||
[ 0.00 page.goto ] pyscript_hello.html
|
||||
[ 0.01 request ] 200 - fake_server - http://fake_server/pyscript_hello.html
|
||||
...
|
||||
[ 0.17 console.info ] [py-loader] Downloading pyodide-x.y.z...
|
||||
[ 0.18 request ] 200 - CACHED - https://cdn.jsdelivr.net/pyodide/vx.y.z/full/pyodide.js
|
||||
...
|
||||
[ 3.59 console.info ] [pyscript/main] PyScript page fully initialized
|
||||
[ 3.60 console.log ] hello pyscript
|
||||
|
||||
-k selects tests by pattern matching as described above. -s instructs pytest to show the output to the terminal instead of capturing it. In the output you can see various useful things, including network requests and JS console messages.
|
||||
|
||||
### To run a single test, headed
|
||||
|
||||
$ pytest test_01_basic.py -k test_pyscript_hello -s --headed
|
||||
...
|
||||
|
||||
Same as above, but with --headed the browser is shown in a window, and you can interact with it. The browser uses a fake server, which means that HTTP requests are cached.
|
||||
|
||||
Unfortunately, in this mode source maps does not seem to work, and you cannot debug the original typescript source code. This seems to be a bug in playwright, for which we have a workaround:
|
||||
|
||||
$ pytest test_01_basic.py -k test_pyscript_hello -s --headed --no-fake-server
|
||||
...
|
||||
|
||||
As the name implies, -no-fake-server disables the fake server: HTTP requests are not cached, but source-level debugging works.
|
||||
|
||||
Finally:
|
||||
|
||||
$ pytest test_01_basic.py -k test_pyscript_hello -s --dev
|
||||
...
|
||||
|
||||
--dev implies --headed --no-fake-server. In addition, it also automatically open chrome dev tools.
|
||||
|
||||
### To run only main thread or worker tests
|
||||
|
||||
By default, we run each test twice: one with execution_thread = "main" and one with execution_thread = "worker". If you want to run only half of them, you can use -m:
|
||||
|
||||
$ pytest -m main # run only the tests in the main thread
|
||||
$ pytest -m worker # ron only the tests in the web worker
|
||||
|
||||
## Fake server, HTTP cache
|
||||
|
||||
By default, our test machinery uses a playwright router which intercepts and cache HTTP requests, so that for example you don't have to download pyodide again and again. This also enables the possibility of running tests in parallel on multiple cores.
|
||||
|
||||
The cache is stored using the pytest-cache plugin, which means that it survives across sessions.
|
||||
|
||||
If you want to temporarily disable the cache, the easiest thing is to use --no-fake-server, which bypasses it completely.
|
||||
|
||||
If you want to clear the cache, you can use the special option --clear-http-cache:
|
||||
|
||||
NOTE: this works only if you are inside tests/integration, or if you explicitly specify tests/integration from the command line. This is due to how pytest decides to search for and load the various conftest.py.
|
||||
|
||||
6
LICENSE
6
LICENSE
@@ -186,7 +186,11 @@
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Copyright (c) 2022-present, PyScript Development Team
|
||||
|
||||
Originated at Anaconda, Inc. in 2022
|
||||
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
17
Makefile
17
Makefile
@@ -12,7 +12,7 @@ all:
|
||||
@echo "make clean - clean up auto-generated assets."
|
||||
@echo "make build - build PyScript."
|
||||
@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-check - check the code formatting.\n"
|
||||
|
||||
@@ -45,7 +45,6 @@ ifeq ($(VIRTUAL_ENV),)
|
||||
false
|
||||
else
|
||||
python -m pip install -r requirements.txt
|
||||
playwright install
|
||||
endif
|
||||
|
||||
# Clean up generated assets.
|
||||
@@ -56,21 +55,15 @@ clean:
|
||||
|
||||
# Build PyScript.
|
||||
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).
|
||||
precommit-check:
|
||||
pre-commit run --all-files
|
||||
|
||||
# Run all integration tests sequentially.
|
||||
test-integration:
|
||||
mkdir -p test_results
|
||||
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
|
||||
# Run all automated tests in playwright.
|
||||
test:
|
||||
cd pyscript.core && npm run test:integration
|
||||
|
||||
# Format the code.
|
||||
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>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://pyscript.net/releases/2023.11.2/core.css"
|
||||
href="https://pyscript.net/releases/2024.8.2/core.css"
|
||||
/>
|
||||
<script
|
||||
type="module"
|
||||
src="https://pyscript.net/releases/2023.11.2/core.js"
|
||||
src="https://pyscript.net/releases/2024.8.2/core.js"
|
||||
></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -67,10 +67,31 @@ Check out the [official docs](https://docs.pyscript.net/) for more detailed docu
|
||||
|
||||
## 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
|
||||
|
||||
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 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
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"],
|
||||
},
|
||||
},
|
||||
];
|
||||
2043
pyscript.core/package-lock.json
generated
2043
pyscript.core/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pyscript/core",
|
||||
"version": "0.3.9",
|
||||
"version": "0.5.15",
|
||||
"type": "module",
|
||||
"description": "PyScript",
|
||||
"module": "./index.js",
|
||||
@@ -8,6 +8,15 @@
|
||||
"jsdelivr": "./jsdelivr.js",
|
||||
"browser": "./index.js",
|
||||
"main": "./index.js",
|
||||
"files": [
|
||||
"./dist/",
|
||||
"./src/",
|
||||
"./types/",
|
||||
"./index.js",
|
||||
"./jsdelivr.js",
|
||||
"LICENSE",
|
||||
"README.md"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./types/core.d.ts",
|
||||
@@ -16,17 +25,23 @@
|
||||
"./css": {
|
||||
"import": "./dist/core.css"
|
||||
},
|
||||
"./storage": {
|
||||
"import": "./dist/storage.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"server": "npx static-handler --coi .",
|
||||
"build": "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",
|
||||
"server": "echo \"➡️ TESTS @ $(tput bold)http://localhost:8080/tests/$(tput sgr0)\"; npx static-handler --coi .",
|
||||
"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:flatted": "node rollup/flatted.cjs",
|
||||
"build:plugins": "node rollup/plugins.cjs",
|
||||
"build:stdlib": "node rollup/stdlib.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",
|
||||
"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",
|
||||
"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",
|
||||
@@ -41,32 +56,39 @@
|
||||
"license": "APACHE-2.0",
|
||||
"dependencies": {
|
||||
"@ungap/with-resolvers": "^0.1.0",
|
||||
"@webreflection/idb-map": "^0.3.1",
|
||||
"basic-devtools": "^0.1.6",
|
||||
"polyscript": "^0.6.2",
|
||||
"polyscript": "^0.15.11",
|
||||
"sabayon": "^0.5.2",
|
||||
"sticky-module": "^0.1.1",
|
||||
"to-json-callback": "^0.1.1",
|
||||
"type-checked-collections": "^0.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/commands": "^6.3.2",
|
||||
"@codemirror/lang-python": "^6.1.3",
|
||||
"@codemirror/language": "^6.9.3",
|
||||
"@codemirror/state": "^6.3.3",
|
||||
"@codemirror/view": "^6.22.1",
|
||||
"@playwright/test": "^1.40.1",
|
||||
"@rollup/plugin-commonjs": "^25.0.7",
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@codemirror/commands": "^6.6.2",
|
||||
"@codemirror/lang-python": "^6.1.6",
|
||||
"@codemirror/language": "^6.10.3",
|
||||
"@codemirror/state": "^6.4.1",
|
||||
"@codemirror/view": "^6.34.1",
|
||||
"@playwright/test": "1.45.3",
|
||||
"@rollup/plugin-commonjs": "^28.0.0",
|
||||
"@rollup/plugin-node-resolve": "^15.3.0",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@webreflection/toml-j0.4": "^1.1.3",
|
||||
"@xterm/addon-fit": "^0.9.0-beta.1",
|
||||
"chokidar": "^3.5.3",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"bun": "^1.1.29",
|
||||
"chokidar": "^4.0.1",
|
||||
"codedent": "^0.1.2",
|
||||
"codemirror": "^6.0.1",
|
||||
"eslint": "^8.55.0",
|
||||
"rollup": "^4.6.1",
|
||||
"eslint": "^9.11.1",
|
||||
"flatted": "^3.3.1",
|
||||
"rollup": "^4.22.5",
|
||||
"rollup-plugin-postcss": "^4.0.2",
|
||||
"rollup-plugin-string": "^3.0.0",
|
||||
"static-handler": "^0.4.3",
|
||||
"typescript": "^5.3.3",
|
||||
"static-handler": "^0.5.3",
|
||||
"string-width": "^7.2.0",
|
||||
"typescript": "^5.6.2",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-readline": "^1.1.1"
|
||||
},
|
||||
|
||||
@@ -51,6 +51,9 @@ const modules = {
|
||||
"xterm_addon-fit.js": fetch(`${CDN}/@xterm/addon-fit/+esm`).then((b) =>
|
||||
b.text(),
|
||||
),
|
||||
"xterm_addon-web-links.js": fetch(
|
||||
`${CDN}/@xterm/addon-web-links/+esm`,
|
||||
).then((b) => b.text()),
|
||||
"xterm.css": fetch(`${CDN}/xterm@${v("xterm")}/css/xterm.min.css`).then(
|
||||
(b) => b.text(),
|
||||
),
|
||||
|
||||
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);
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
writeFileSync,
|
||||
} = require("node:fs");
|
||||
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const { join } = require("node:path");
|
||||
|
||||
const dedent = require("codedent");
|
||||
|
||||
const crawl = (path, json) => {
|
||||
for (const file of readdirSync(path)) {
|
||||
const full = join(path, file);
|
||||
if (/\.py$/.test(file)) json[file] = readFileSync(full).toString();
|
||||
else if (statSync(full).isDirectory() && !file.endsWith("_"))
|
||||
if (/\.py$/.test(file)) {
|
||||
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] = {}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -45,6 +45,8 @@ const configDetails = async (config, type) => {
|
||||
|
||||
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 }) => {
|
||||
let str = `(${BAD_CONFIG}): Invalid ${type}`;
|
||||
if (url) str += ` @ ${url}`;
|
||||
@@ -63,6 +65,9 @@ for (const [TYPE] of TYPES) {
|
||||
/** @type {Error | undefined} The error thrown when parsing the PyScript config, if any.*/
|
||||
let error;
|
||||
|
||||
/** @type {string | undefined} The `configURL` field to normalize all config operations as opposite of guessing it once resolved */
|
||||
let configURL;
|
||||
|
||||
let config,
|
||||
type,
|
||||
pyElement,
|
||||
@@ -105,6 +110,7 @@ for (const [TYPE] of TYPES) {
|
||||
if (!error && config) {
|
||||
try {
|
||||
const { json, toml, text, url } = await configDetails(config, type);
|
||||
if (url) configURL = relative_url(url);
|
||||
config = text;
|
||||
if (json || type === "json") {
|
||||
try {
|
||||
@@ -146,7 +152,7 @@ for (const [TYPE] of TYPES) {
|
||||
// assign plugins as Promise.all only if needed
|
||||
plugins = Promise.all(toBeAwaited);
|
||||
|
||||
configs.set(TYPE, { config: parsed, 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 {
|
||||
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,
|
||||
defineProperty,
|
||||
dispatch,
|
||||
isSync,
|
||||
queryTarget,
|
||||
unescape,
|
||||
whenDefined,
|
||||
@@ -19,44 +20,56 @@ import {
|
||||
|
||||
import "./all-done.js";
|
||||
import TYPES from "./types.js";
|
||||
import configs from "./config.js";
|
||||
import { configs, relative_url } from "./config.js";
|
||||
import sync from "./sync.js";
|
||||
import bootstrapNodeAndPlugins from "./plugins-helper.js";
|
||||
import { ErrorCode } from "./exceptions.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";
|
||||
|
||||
// allows lazy element features on code evaluation
|
||||
let currentElement;
|
||||
import { stdlib, optional } from "./stdlib.js";
|
||||
export { stdlib, optional, inputFailure };
|
||||
|
||||
// generic helper to disambiguate between custom element and script
|
||||
const isScript = ({ tagName }) => tagName === "SCRIPT";
|
||||
|
||||
let shouldRegister = true;
|
||||
const registerModule = ({ XWorker: $XWorker, interpreter, io }) => {
|
||||
// automatically use the pyscript stderr (when/if defined)
|
||||
// this defaults to console.error
|
||||
function PyWorker(...args) {
|
||||
const worker = $XWorker(...args);
|
||||
worker.onerror = ({ error }) => io.stderr(error);
|
||||
return worker;
|
||||
}
|
||||
|
||||
// enrich the Python env with some JS utility for main
|
||||
interpreter.registerJsModule("_pyscript", {
|
||||
PyWorker,
|
||||
get target() {
|
||||
return isScript(currentElement)
|
||||
? currentElement.target.id
|
||||
: currentElement.id;
|
||||
// Used to create either Pyodide or MicroPython workers
|
||||
// with the PyScript module available within the code
|
||||
const [PyWorker, MPWorker] = [...TYPES.entries()].map(
|
||||
([TYPE, interpreter]) =>
|
||||
/**
|
||||
* A `Worker` facade able to bootstrap on the worker thread only a PyScript module.
|
||||
* @param {string} file the python file to run ina worker.
|
||||
* @param {{config?: string | object, async?: boolean}} [options] optional configuration for the worker.
|
||||
* @returns {Promise<Worker & {sync: object}>}
|
||||
*/
|
||||
async function PyScriptWorker(file, options) {
|
||||
await configs.get(TYPE).plugins;
|
||||
const xworker = XWorker.call(
|
||||
new Hook(null, hooked.get(TYPE)),
|
||||
file,
|
||||
{
|
||||
...options,
|
||||
type: interpreter,
|
||||
},
|
||||
);
|
||||
assign(xworker.sync, sync);
|
||||
return xworker.ready;
|
||||
},
|
||||
});
|
||||
};
|
||||
);
|
||||
|
||||
// avoid multiple initialization of the same library
|
||||
const [
|
||||
{
|
||||
PyWorker: exportedPyWorker,
|
||||
MPWorker: exportedMPWorker,
|
||||
hooks: exportedHooks,
|
||||
config: exportedConfig,
|
||||
whenDefined: exportedWhenDefined,
|
||||
@@ -64,6 +77,7 @@ const [
|
||||
alreadyLive,
|
||||
] = stickyModule("@pyscript/core", {
|
||||
PyWorker,
|
||||
MPWorker,
|
||||
hooks,
|
||||
config: {},
|
||||
whenDefined,
|
||||
@@ -71,12 +85,17 @@ const [
|
||||
|
||||
export {
|
||||
TYPES,
|
||||
relative_url,
|
||||
exportedPyWorker as PyWorker,
|
||||
exportedMPWorker as MPWorker,
|
||||
exportedHooks as hooks,
|
||||
exportedConfig as config,
|
||||
exportedWhenDefined as whenDefined,
|
||||
};
|
||||
|
||||
export const offline_interpreter = (config) =>
|
||||
config?.interpreter && relative_url(config.interpreter);
|
||||
|
||||
const hooked = new Map();
|
||||
|
||||
for (const [TYPE, interpreter] of TYPES) {
|
||||
@@ -88,7 +107,7 @@ for (const [TYPE, interpreter] of TYPES) {
|
||||
else dispatch(element, TYPE, "done");
|
||||
};
|
||||
|
||||
const { config, plugins, error } = configs.get(TYPE);
|
||||
const { config, configURL, plugins, error } = configs.get(TYPE);
|
||||
|
||||
// create a unique identifier when/if needed
|
||||
let id = 0;
|
||||
@@ -118,6 +137,37 @@ for (const [TYPE, interpreter] of TYPES) {
|
||||
return code;
|
||||
};
|
||||
|
||||
// register once any interpreter
|
||||
let alreadyRegistered = false;
|
||||
|
||||
// allows lazy element features on code evaluation
|
||||
let currentElement;
|
||||
|
||||
const registerModule = ({ XWorker, interpreter, io }) => {
|
||||
// avoid multiple registration of the same interpreter
|
||||
if (alreadyRegistered) return;
|
||||
alreadyRegistered = true;
|
||||
|
||||
// automatically use the pyscript stderr (when/if defined)
|
||||
// this defaults to console.error
|
||||
function PyWorker(...args) {
|
||||
const worker = XWorker(...args);
|
||||
worker.onerror = ({ error }) => io.stderr(error);
|
||||
return worker;
|
||||
}
|
||||
|
||||
// enrich the Python env with some JS utility for main
|
||||
interpreter.registerJsModule("_pyscript", {
|
||||
PyWorker,
|
||||
js_import: (...urls) => Promise.all(urls.map((url) => import(url))),
|
||||
get target() {
|
||||
return isScript(currentElement)
|
||||
? currentElement.target.id
|
||||
: currentElement.id;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// define the module as both `<script type="py">` and `<py-script>`
|
||||
// but only if the config didn't throw an error
|
||||
if (!error) {
|
||||
@@ -131,12 +181,9 @@ for (const [TYPE, interpreter] of TYPES) {
|
||||
// specific main and worker hooks
|
||||
const hooks = {
|
||||
main: {
|
||||
...codeFor(main),
|
||||
...codeFor(main, TYPE),
|
||||
async onReady(wrap, element) {
|
||||
if (shouldRegister) {
|
||||
shouldRegister = false;
|
||||
registerModule(wrap);
|
||||
}
|
||||
registerModule(wrap);
|
||||
|
||||
// allows plugins to do whatever they want with the element
|
||||
// before regular stuff happens in here
|
||||
@@ -156,15 +203,13 @@ for (const [TYPE, interpreter] of TYPES) {
|
||||
}
|
||||
|
||||
if (isScript(element)) {
|
||||
const {
|
||||
attributes: { async: isAsync, target },
|
||||
} = element;
|
||||
const hasTarget = !!target?.value;
|
||||
const show = hasTarget
|
||||
? queryTarget(element, target.value)
|
||||
const isAsync = !isSync(element);
|
||||
const target = element.getAttribute("target");
|
||||
const show = target
|
||||
? queryTarget(element, target)
|
||||
: document.createElement("script-py");
|
||||
|
||||
if (!hasTarget) {
|
||||
if (!target) {
|
||||
const { head, body } = document;
|
||||
if (head.contains(element)) body.append(show);
|
||||
else element.after(show);
|
||||
@@ -231,7 +276,7 @@ for (const [TYPE, interpreter] of TYPES) {
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
...codeFor(worker),
|
||||
...codeFor(worker, TYPE),
|
||||
// these are lazy getters that returns a composition
|
||||
// of the current hooks or undefined, if no hook is present
|
||||
get onReady() {
|
||||
@@ -256,10 +301,11 @@ for (const [TYPE, interpreter] of TYPES) {
|
||||
|
||||
define(TYPE, {
|
||||
config,
|
||||
configURL,
|
||||
interpreter,
|
||||
hooks,
|
||||
env: `${TYPE}-script`,
|
||||
version: config?.interpreter,
|
||||
version: offline_interpreter(config),
|
||||
onerror(error, element) {
|
||||
errors.set(element, error);
|
||||
},
|
||||
@@ -284,7 +330,7 @@ for (const [TYPE, interpreter] of TYPES) {
|
||||
async connectedCallback() {
|
||||
if (!this.executed) {
|
||||
this.executed = true;
|
||||
const isAsync = this.hasAttribute("async");
|
||||
const isAsync = !isSync(this);
|
||||
const { io, run, runAsync } = await this._wrap
|
||||
.promise;
|
||||
this.srcCode = await fetchSource(
|
||||
@@ -310,24 +356,3 @@ for (const [TYPE, interpreter] of TYPES) {
|
||||
// export the used config without allowing leaks through it
|
||||
exportedConfig[TYPE] = structuredClone(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* A `Worker` facade able to bootstrap on the worker thread only a PyScript module.
|
||||
* @param {string} file the python file to run ina worker.
|
||||
* @param {{config?: string | object, async?: boolean}} [options] optional configuration for the worker.
|
||||
* @returns {Worker & {sync: ProxyHandler<object>}}
|
||||
*/
|
||||
function PyWorker(file, options) {
|
||||
const hooks = hooked.get("py");
|
||||
// this propagates pyscript worker hooks without needing a pyscript
|
||||
// bootstrap + it passes arguments and enforces `pyodide`
|
||||
// as the interpreter to use in the worker, as all hooks assume that
|
||||
// and as `pyodide` is the only default interpreter that can deal with
|
||||
// all the features we need to deliver pyscript out there.
|
||||
const xworker = XWorker.call(new Hook(null, hooks), file, {
|
||||
type: "pyodide",
|
||||
...options,
|
||||
});
|
||||
assign(xworker.sync, sync);
|
||||
return xworker;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { FetchError, ErrorCode } from "./exceptions.js";
|
||||
import { getText } from "polyscript/exports";
|
||||
|
||||
export { getText };
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns
|
||||
*/
|
||||
export const getText = (response) => response.text();
|
||||
|
||||
/**
|
||||
* This is a fetch wrapper that handles any non 200 responses and throws a
|
||||
|
||||
@@ -2,7 +2,7 @@ import { typedSet } from "type-checked-collections";
|
||||
import { dedent } from "polyscript/exports";
|
||||
import toJSONCallback from "to-json-callback";
|
||||
|
||||
import stdlib from "./stdlib.js";
|
||||
import { stdlib, optional } from "./stdlib.js";
|
||||
|
||||
export const main = (name) => hooks.main[name];
|
||||
export const worker = (name) => hooks.worker[name];
|
||||
@@ -15,10 +15,11 @@ const code = (hooks, branch, key, lib) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const codeFor = (branch) => {
|
||||
export const codeFor = (branch, type) => {
|
||||
const pylib = type === "mpy" ? stdlib.replace(optional, "") : stdlib;
|
||||
const hooks = {};
|
||||
code(hooks, branch, `codeBeforeRun`, stdlib);
|
||||
code(hooks, branch, `codeBeforeRunAsync`, stdlib);
|
||||
code(hooks, branch, `codeBeforeRun`, pylib);
|
||||
code(hooks, branch, `codeBeforeRunAsync`, pylib);
|
||||
code(hooks, branch, `codeAfterRun`);
|
||||
code(hooks, branch, `codeAfterRunAsync`);
|
||||
return hooks;
|
||||
@@ -45,7 +46,7 @@ export const createFunction = (self, name) => {
|
||||
const SetFunction = typedSet({ typeof: "function" });
|
||||
const SetString = typedSet({ typeof: "string" });
|
||||
|
||||
const inputFailure = `
|
||||
export const inputFailure = `
|
||||
import builtins
|
||||
def input(prompt=""):
|
||||
raise Exception("\\n ".join([
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// PyScript py-editor plugin
|
||||
import { Hook, XWorker, dedent } from "polyscript/exports";
|
||||
import { TYPES } from "../core.js";
|
||||
import { Hook, XWorker, dedent, defineProperties } from "polyscript/exports";
|
||||
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>`;
|
||||
|
||||
@@ -8,30 +9,73 @@ let id = 0;
|
||||
const getID = (type) => `${type}-editor-${id++}`;
|
||||
|
||||
const envs = new Map();
|
||||
const configs = new Map();
|
||||
|
||||
const hooks = {
|
||||
worker: {
|
||||
codeBeforeRun: () => stdlib,
|
||||
// works on both Pyodide and MicroPython
|
||||
onReady: ({ runAsync, io }, { sync }) => {
|
||||
io.stdout = (line) => sync.write(line);
|
||||
io.stderr = (line) => sync.writeErr(line);
|
||||
io.stdout = io.buffered(sync.write);
|
||||
io.stderr = io.buffered(sync.writeErr);
|
||||
sync.revoke();
|
||||
sync.runAsync = runAsync;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const validate = (config, result) => {
|
||||
if (typeof result === "boolean") throw `Invalid source: ${config}`;
|
||||
return result;
|
||||
};
|
||||
|
||||
async function execute({ currentTarget }) {
|
||||
const { env, pySrc, outDiv } = this;
|
||||
const hasRunButton = !!currentTarget;
|
||||
|
||||
currentTarget.disabled = true;
|
||||
outDiv.innerHTML = "";
|
||||
if (hasRunButton) {
|
||||
currentTarget.disabled = true;
|
||||
outDiv.innerHTML = "";
|
||||
}
|
||||
|
||||
if (!envs.has(env)) {
|
||||
const srcLink = URL.createObjectURL(new Blob([""]));
|
||||
const xworker = XWorker.call(new Hook(null, hooks), srcLink, {
|
||||
const details = {
|
||||
type: this.interpreter,
|
||||
});
|
||||
serviceWorker: this.serviceWorker,
|
||||
};
|
||||
const { config } = this;
|
||||
if (config) {
|
||||
// verify that config can be parsed and used
|
||||
try {
|
||||
details.configURL = relative_url(config);
|
||||
if (config.endsWith(".toml")) {
|
||||
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 { sync } = xworker;
|
||||
const { promise, resolve } = Promise.withResolvers();
|
||||
@@ -44,43 +88,60 @@ async function execute({ currentTarget }) {
|
||||
|
||||
// wait for the env then set the target div
|
||||
// before executing the current code
|
||||
envs.get(env).then((xworker) => {
|
||||
return envs.get(env).then((xworker) => {
|
||||
xworker.onerror = ({ error }) => {
|
||||
outDiv.innerHTML += `<span style='color:red'>${
|
||||
error.message || error
|
||||
}</span>\n`;
|
||||
if (hasRunButton) {
|
||||
outDiv.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
`<span style='color:red'>${
|
||||
error.message || error
|
||||
}</span>\n`,
|
||||
);
|
||||
}
|
||||
console.error(error);
|
||||
};
|
||||
|
||||
const enable = () => {
|
||||
currentTarget.disabled = false;
|
||||
if (hasRunButton) currentTarget.disabled = false;
|
||||
};
|
||||
const { sync } = xworker;
|
||||
sync.write = (str) => {
|
||||
outDiv.innerText += `${str}\n`;
|
||||
if (hasRunButton) outDiv.innerText += `${str}\n`;
|
||||
else console.log(str);
|
||||
};
|
||||
sync.writeErr = (str) => {
|
||||
outDiv.innerHTML += `<span style='color:red'>${str}</span>\n`;
|
||||
if (hasRunButton) {
|
||||
outDiv.insertAdjacentHTML(
|
||||
"beforeend",
|
||||
`<span style='color:red'>${str}</span>\n`,
|
||||
);
|
||||
} else {
|
||||
notify(str);
|
||||
console.error(str);
|
||||
}
|
||||
};
|
||||
sync.runAsync(pySrc).then(enable, enable);
|
||||
});
|
||||
}
|
||||
|
||||
const makeRunButton = (listener, type) => {
|
||||
const makeRunButton = (handler, type) => {
|
||||
const runButton = document.createElement("button");
|
||||
runButton.className = `absolute ${type}-editor-run-button`;
|
||||
runButton.innerHTML = 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;
|
||||
};
|
||||
|
||||
const makeEditorDiv = (listener, type) => {
|
||||
const makeEditorDiv = (handler, type) => {
|
||||
const editorDiv = document.createElement("div");
|
||||
editorDiv.className = `${type}-editor-input`;
|
||||
editorDiv.setAttribute("aria-label", "Python Script Area");
|
||||
|
||||
const runButton = makeRunButton(listener, type);
|
||||
const runButton = makeRunButton(handler, type);
|
||||
const editorShadowContainer = document.createElement("div");
|
||||
|
||||
// avoid outer elements intercepting key events (reveal as example)
|
||||
@@ -100,15 +161,15 @@ const makeOutDiv = (type) => {
|
||||
return outDiv;
|
||||
};
|
||||
|
||||
const makeBoxDiv = (listener, type) => {
|
||||
const makeBoxDiv = (handler, type) => {
|
||||
const boxDiv = document.createElement("div");
|
||||
boxDiv.className = `${type}-editor-box`;
|
||||
|
||||
const editorDiv = makeEditorDiv(listener, type);
|
||||
const editorDiv = makeEditorDiv(handler, type);
|
||||
const outDiv = makeOutDiv(type);
|
||||
boxDiv.append(editorDiv, outDiv);
|
||||
|
||||
return [boxDiv, outDiv];
|
||||
return [boxDiv, outDiv, editorDiv.querySelector("button")];
|
||||
};
|
||||
|
||||
const init = async (script, type, interpreter) => {
|
||||
@@ -118,9 +179,8 @@ const init = async (script, type, interpreter) => {
|
||||
{ python },
|
||||
{ indentUnit },
|
||||
{ keymap },
|
||||
{ defaultKeymap },
|
||||
{ defaultKeymap, indentWithTab },
|
||||
] = await Promise.all([
|
||||
// TODO: find a way to actually produce these bundles locally
|
||||
import(/* webpackIgnore: true */ "../3rd-party/codemirror.js"),
|
||||
import(/* webpackIgnore: true */ "../3rd-party/codemirror_state.js"),
|
||||
import(
|
||||
@@ -131,9 +191,138 @@ const init = async (script, type, interpreter) => {
|
||||
import(/* webpackIgnore: true */ "../3rd-party/codemirror_commands.js"),
|
||||
]);
|
||||
|
||||
const selector = script.getAttribute("target");
|
||||
let isSetup = script.hasAttribute("setup");
|
||||
const hasConfig = script.hasAttribute("config");
|
||||
const serviceWorker = script.getAttribute("service-worker");
|
||||
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)) {
|
||||
throw new SyntaxError(
|
||||
configs.get(env)
|
||||
? `duplicated config for env: ${env}`
|
||||
: `unable to add a config to the env: ${env}`,
|
||||
);
|
||||
}
|
||||
|
||||
configs.set(env, hasConfig);
|
||||
|
||||
let source = 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 = {
|
||||
// allow the listener to be overridden at distance
|
||||
handleEvent: execute,
|
||||
serviceWorker,
|
||||
interpreter,
|
||||
env,
|
||||
config: hasConfig && script.getAttribute("config"),
|
||||
get pySrc() {
|
||||
return isSetup ? source : editor.state.doc.toString();
|
||||
},
|
||||
get outDiv() {
|
||||
return isSetup ? null : outDiv;
|
||||
},
|
||||
};
|
||||
|
||||
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) {
|
||||
await context.handleEvent({ currentTarget: null });
|
||||
notifyEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
const selector = script.getAttribute("target");
|
||||
|
||||
if (selector) {
|
||||
target =
|
||||
document.getElementById(selector) ||
|
||||
@@ -149,21 +338,8 @@ const init = async (script, type, interpreter) => {
|
||||
if (!target.hasAttribute("exec-id")) target.setAttribute("exec-id", 0);
|
||||
if (!target.hasAttribute("root")) target.setAttribute("root", target.id);
|
||||
|
||||
const env = `${interpreter}-${script.getAttribute("env") || getID(type)}`;
|
||||
const context = {
|
||||
interpreter,
|
||||
env,
|
||||
get pySrc() {
|
||||
return editor.state.doc.toString();
|
||||
},
|
||||
get outDiv() {
|
||||
return outDiv;
|
||||
},
|
||||
};
|
||||
|
||||
// @see https://github.com/JeffersGlass/mkdocs-pyscript/blob/main/mkdocs_pyscript/js/makeblocks.js
|
||||
const listener = execute.bind(context);
|
||||
const [boxDiv, outDiv] = makeBoxDiv(listener, type);
|
||||
const [boxDiv, outDiv, runButton] = makeBoxDiv(context, type);
|
||||
boxDiv.dataset.env = script.hasAttribute("env") ? env : interpreter;
|
||||
|
||||
const inputChild = boxDiv.querySelector(`.${type}-editor-input > div`);
|
||||
@@ -176,8 +352,9 @@ const init = async (script, type, interpreter) => {
|
||||
const doc = dedent(script.textContent).trim();
|
||||
|
||||
// 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({
|
||||
extensions: [
|
||||
indentUnit.of(indentation),
|
||||
@@ -187,19 +364,27 @@ const init = async (script, type, interpreter) => {
|
||||
{ key: "Ctrl-Enter", run: listener, preventDefault: true },
|
||||
{ key: "Cmd-Enter", run: listener, preventDefault: true },
|
||||
{ key: "Shift-Enter", run: listener, preventDefault: true },
|
||||
// @see https://codemirror.net/examples/tab/
|
||||
indentWithTab,
|
||||
]),
|
||||
basicSetup,
|
||||
],
|
||||
foldGutter: true,
|
||||
gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"],
|
||||
parent,
|
||||
doc,
|
||||
});
|
||||
|
||||
editor.focus();
|
||||
notifyEditor();
|
||||
};
|
||||
|
||||
// avoid too greedy MutationObserver operations at distance
|
||||
let timeout = 0;
|
||||
|
||||
// avoid delayed initialization
|
||||
let queue = Promise.resolve();
|
||||
|
||||
// reset interval value then check for new scripts
|
||||
const resetTimeout = () => {
|
||||
timeout = 0;
|
||||
@@ -207,17 +392,20 @@ const resetTimeout = () => {
|
||||
};
|
||||
|
||||
// triggered both ASAP on the living DOM and via MutationObserver later
|
||||
const pyEditor = async () => {
|
||||
const pyEditor = () => {
|
||||
if (timeout) return;
|
||||
timeout = setTimeout(resetTimeout, 250);
|
||||
for (const [type, interpreter] of TYPES) {
|
||||
const selector = `script[type="${type}-editor"]`;
|
||||
for (const script of document.querySelectorAll(selector)) {
|
||||
// avoid any further bootstrap
|
||||
// avoid any further bootstrap by changing the type as active
|
||||
script.type += "-active";
|
||||
await init(script, type, interpreter);
|
||||
// don't await in here or multiple calls might happen
|
||||
// while the first script is being initialized
|
||||
queue = queue.then(() => init(script, type, interpreter));
|
||||
}
|
||||
}
|
||||
return queue;
|
||||
};
|
||||
|
||||
new MutationObserver(pyEditor).observe(document, {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// PyScript py-terminal plugin
|
||||
import { TYPES, hooks } from "../core.js";
|
||||
import { TYPES, relative_url } from "../core.js";
|
||||
import { notify } from "./error.js";
|
||||
import { customObserver } from "polyscript/exports";
|
||||
|
||||
const SELECTOR = [...TYPES.keys()]
|
||||
.map((type) => `script[type="${type}"][terminal],${type}-script[terminal]`)
|
||||
.join(",");
|
||||
// will contain all valid selectors
|
||||
const SELECTORS = [];
|
||||
|
||||
// avoid processing same elements twice
|
||||
const processed = new WeakSet();
|
||||
|
||||
// show the error on main and
|
||||
// stops the module from keep executing
|
||||
@@ -13,138 +16,45 @@ const notifyAndThrow = (message) => {
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
const pyTerminal = async () => {
|
||||
const terminals = document.querySelectorAll(SELECTOR);
|
||||
const onceOnMain = ({ attributes: { worker } }) => !worker;
|
||||
|
||||
// no results will look further for runtime nodes
|
||||
if (!terminals.length) return;
|
||||
let addStyle = true;
|
||||
|
||||
// if we arrived this far, let's drop the MutationObserver
|
||||
// as we only support one terminal per page (right now).
|
||||
mo.disconnect();
|
||||
for (const type of TYPES.keys()) {
|
||||
const selector = `script[type="${type}"][terminal],${type}-script[terminal]`;
|
||||
SELECTORS.push(selector);
|
||||
customObserver.set(selector, async (element) => {
|
||||
// we currently support only one terminal on main as in "classic"
|
||||
const terminals = document.querySelectorAll(SELECTORS.join(","));
|
||||
if ([].filter.call(terminals, onceOnMain).length > 1)
|
||||
notifyAndThrow("You can use at most 1 main terminal");
|
||||
|
||||
// we currently support only one terminal as in "classic"
|
||||
if (terminals.length > 1) notifyAndThrow("You can use at most 1 terminal.");
|
||||
|
||||
const [element] = terminals;
|
||||
// hopefully to be removed in the near future!
|
||||
if (element.matches('script[type="mpy"],mpy-script'))
|
||||
notifyAndThrow("Unsupported terminal.");
|
||||
|
||||
// import styles lazily
|
||||
document.head.append(
|
||||
Object.assign(document.createElement("link"), {
|
||||
rel: "stylesheet",
|
||||
href: new URL("./xterm.css", import.meta.url),
|
||||
}),
|
||||
);
|
||||
|
||||
// lazy load these only when a valid terminal is found
|
||||
const [{ Terminal }, { Readline }, { FitAddon }] = 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"),
|
||||
]);
|
||||
|
||||
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);
|
||||
// import styles lazily
|
||||
if (addStyle) {
|
||||
addStyle = false;
|
||||
document.head.append(
|
||||
Object.assign(document.createElement("link"), {
|
||||
rel: "stylesheet",
|
||||
href: relative_url("./xterm.css", import.meta.url),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const terminal = new Terminal({
|
||||
theme: {
|
||||
background: "#191A19",
|
||||
foreground: "#F5F2E7",
|
||||
},
|
||||
...options,
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(readline);
|
||||
terminal.open(target);
|
||||
fitAddon.fit();
|
||||
terminal.focus();
|
||||
};
|
||||
|
||||
// branch logic for the worker
|
||||
if (element.hasAttribute("worker")) {
|
||||
// when the remote thread onReady triggers:
|
||||
// setup the interpreter stdout and stderr
|
||||
const workerReady = ({ interpreter }, { sync }) => {
|
||||
sync.pyterminal_drop_hooks();
|
||||
const decoder = new TextDecoder();
|
||||
let data = "";
|
||||
const generic = {
|
||||
isatty: true,
|
||||
write(buffer) {
|
||||
data = decoder.decode(buffer);
|
||||
sync.pyterminal_write(data);
|
||||
return buffer.length;
|
||||
},
|
||||
};
|
||||
interpreter.setStdout(generic);
|
||||
interpreter.setStderr(generic);
|
||||
interpreter.setStdin({
|
||||
isatty: true,
|
||||
stdin: () => sync.pyterminal_read(data),
|
||||
});
|
||||
};
|
||||
if (processed.has(element)) return;
|
||||
processed.add(element);
|
||||
|
||||
// add a hook on the main thread to setup all sync helpers
|
||||
// also bootstrapping the XTerm target on main
|
||||
hooks.main.onWorker.add(function worker(_, xworker) {
|
||||
hooks.main.onWorker.delete(worker);
|
||||
init({
|
||||
disableStdin: false,
|
||||
cursorBlink: true,
|
||||
cursorStyle: "block",
|
||||
});
|
||||
xworker.sync.pyterminal_read = readline.read.bind(readline);
|
||||
xworker.sync.pyterminal_write = readline.write.bind(readline);
|
||||
// allow a worker to drop main thread hooks ASAP
|
||||
xworker.sync.pyterminal_drop_hooks = () => {
|
||||
hooks.worker.onReady.delete(workerReady);
|
||||
};
|
||||
});
|
||||
const bootstrap = (module) => module.default(element);
|
||||
|
||||
// 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({ io }) {
|
||||
console.warn("py-terminal is read only on main thread");
|
||||
hooks.main.onReady.delete(main);
|
||||
init({
|
||||
disableStdin: true,
|
||||
cursorBlink: false,
|
||||
cursorStyle: "underline",
|
||||
});
|
||||
io.stdout = (value) => {
|
||||
readline.write(`${value}\n`);
|
||||
};
|
||||
io.stderr = (error) => {
|
||||
readline.write(`${error.message || error}\n`);
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const mo = new MutationObserver(pyTerminal);
|
||||
mo.observe(document, { childList: true, subtree: true });
|
||||
|
||||
// try to check the current document ASAP
|
||||
export default pyTerminal();
|
||||
// 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),
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -8,6 +8,27 @@
|
||||
|
||||
import pyscript from "./stdlib/pyscript.js";
|
||||
|
||||
class Ignore extends Array {
|
||||
#add = false;
|
||||
#paths;
|
||||
#array;
|
||||
constructor(array, ...paths) {
|
||||
super();
|
||||
this.#array = array;
|
||||
this.#paths = paths;
|
||||
}
|
||||
push(...values) {
|
||||
if (this.#add) super.push(...values);
|
||||
return this.#array.push(...values);
|
||||
}
|
||||
path(path) {
|
||||
for (const _path of this.#paths) {
|
||||
// bails out at the first `true` value
|
||||
if ((this.#add = path.startsWith(_path))) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { entries } = Object;
|
||||
|
||||
const python = [
|
||||
@@ -16,16 +37,19 @@ const python = [
|
||||
"_path = None",
|
||||
];
|
||||
|
||||
const ignore = new Ignore(python, "-");
|
||||
|
||||
const write = (base, literal) => {
|
||||
for (const [key, value] of entries(literal)) {
|
||||
python.push(`_path = _Path("${base}/${key}")`);
|
||||
ignore.path(`${base}/${key}`);
|
||||
ignore.push(`_path = _Path("${base}/${key}")`);
|
||||
if (typeof value === "string") {
|
||||
const code = JSON.stringify(value);
|
||||
python.push(`_path.write_text(${code})`);
|
||||
ignore.push(`_path.write_text(${code},encoding="utf-8")`);
|
||||
} else {
|
||||
// @see https://github.com/pyscript/pyscript/pull/1813#issuecomment-1781502909
|
||||
python.push(`if not _os.path.exists("${base}/${key}"):`);
|
||||
python.push(" _path.mkdir(parents=True, exist_ok=True)");
|
||||
ignore.push(`if not _os.path.exists("${base}/${key}"):`);
|
||||
ignore.push(" _path.mkdir(parents=True, exist_ok=True)");
|
||||
write(`${base}/${key}`, value);
|
||||
}
|
||||
}
|
||||
@@ -42,4 +66,5 @@ python.push(
|
||||
);
|
||||
python.push("\n");
|
||||
|
||||
export default python.join("\n");
|
||||
export const stdlib = python.join("\n");
|
||||
export const optional = ignore.join("\n");
|
||||
|
||||
@@ -29,20 +29,31 @@
|
||||
# pyscript.magic_js. This is the blessed way to access them from pyscript,
|
||||
# as it works transparently in both the main thread and worker cases.
|
||||
|
||||
from polyscript import lazy_py_modules as py_import
|
||||
from pyscript.display import HTML, display
|
||||
from pyscript.fetch import fetch
|
||||
from pyscript.magic_js import (
|
||||
RUNNING_IN_WORKER,
|
||||
PyWorker,
|
||||
config,
|
||||
current_target,
|
||||
document,
|
||||
js_import,
|
||||
js_modules,
|
||||
sync,
|
||||
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:
|
||||
from pyscript.event_handling import when
|
||||
except:
|
||||
# TODO: should we remove this? Or at the very least, we should capture
|
||||
# the traceback otherwise it's very hard to debug
|
||||
from pyscript.util import NotSupported
|
||||
|
||||
when = NotSupported(
|
||||
|
||||
@@ -6,17 +6,17 @@ import re
|
||||
from pyscript.magic_js import current_target, document, window
|
||||
|
||||
_MIME_METHODS = {
|
||||
"__repr__": "text/plain",
|
||||
"_repr_html_": "text/html",
|
||||
"_repr_markdown_": "text/markdown",
|
||||
"_repr_svg_": "image/svg+xml",
|
||||
"_repr_pdf_": "application/pdf",
|
||||
"_repr_jpeg_": "image/jpeg",
|
||||
"_repr_png_": "image/png",
|
||||
"_repr_latex": "text/latex",
|
||||
"_repr_json_": "application/json",
|
||||
"_repr_javascript_": "application/javascript",
|
||||
"savefig": "image/png",
|
||||
"_repr_javascript_": "application/javascript",
|
||||
"_repr_json_": "application/json",
|
||||
"_repr_latex": "text/latex",
|
||||
"_repr_png_": "image/png",
|
||||
"_repr_jpeg_": "image/jpeg",
|
||||
"_repr_pdf_": "application/pdf",
|
||||
"_repr_svg_": "image/svg+xml",
|
||||
"_repr_markdown_": "text/markdown",
|
||||
"_repr_html_": "text/html",
|
||||
"__repr__": "text/plain",
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ def _format_mime(obj):
|
||||
format_dict = mimebundle
|
||||
|
||||
output, not_available = None, []
|
||||
for method, mime_type in reversed(_MIME_METHODS.items()):
|
||||
for method, mime_type in _MIME_METHODS.items():
|
||||
if mime_type in format_dict:
|
||||
output = format_dict[mime_type]
|
||||
else:
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import inspect
|
||||
|
||||
from pyodide.ffi.wrappers import add_event_listener
|
||||
try:
|
||||
from pyodide.ffi.wrappers import add_event_listener
|
||||
|
||||
except ImportError:
|
||||
|
||||
def add_event_listener(el, event_type, func):
|
||||
el.addEventListener(event_type, func)
|
||||
|
||||
|
||||
from pyscript.magic_js import document
|
||||
|
||||
|
||||
@@ -11,35 +19,58 @@ def when(event_type=None, selector=None):
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
|
||||
from pyscript.web import Element, ElementCollection
|
||||
|
||||
if isinstance(selector, str):
|
||||
elements = document.querySelectorAll(selector)
|
||||
# TODO: This is a hack that will be removed when pyscript becomes a package
|
||||
# and we can better manage the imports without circular dependencies
|
||||
elif isinstance(selector, Element):
|
||||
elements = [selector._dom_element]
|
||||
elif isinstance(selector, ElementCollection):
|
||||
elements = [el._dom_element for el in selector]
|
||||
else:
|
||||
# TODO: This is a hack that will be removed when pyscript becomes a package
|
||||
# and we can better manage the imports without circular dependencies
|
||||
from pyweb import pydom
|
||||
|
||||
if isinstance(selector, pydom.Element):
|
||||
elements = [selector._js]
|
||||
elif isinstance(selector, pydom.ElementCollection):
|
||||
elements = [el._js for el in selector]
|
||||
if isinstance(selector, list):
|
||||
elements = selector
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid selector: {selector}. Selector must"
|
||||
" be a string, a pydom.Element or a pydom.ElementCollection."
|
||||
)
|
||||
elements = [selector]
|
||||
|
||||
sig = inspect.signature(func)
|
||||
# Function doesn't receive events
|
||||
if not sig.parameters:
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
# Function doesn't receive events
|
||||
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):
|
||||
func()
|
||||
|
||||
else:
|
||||
wrapper = func
|
||||
|
||||
except AttributeError:
|
||||
# TODO: this is very ugly hack to get micropython working because inspect.signature
|
||||
# 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):
|
||||
func()
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except TypeError as e:
|
||||
if "takes" in str(e) and "positional arguments" in str(e):
|
||||
return func()
|
||||
|
||||
raise
|
||||
|
||||
for el in elements:
|
||||
add_event_listener(el, event_type, wrapper)
|
||||
|
||||
for el in elements:
|
||||
add_event_listener(el, event_type, wrapper)
|
||||
else:
|
||||
for el in elements:
|
||||
add_event_listener(el, event_type, func)
|
||||
return func
|
||||
|
||||
return decorator
|
||||
|
||||
87
pyscript.core/src/stdlib/pyscript/fetch.py
Normal file
87
pyscript.core/src/stdlib/pyscript/fetch.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import json
|
||||
|
||||
import js
|
||||
from pyscript.util import as_bytearray
|
||||
|
||||
|
||||
### wrap the response to grant Pythonic results
|
||||
class _Response:
|
||||
def __init__(self, response):
|
||||
self._response = response
|
||||
|
||||
# grant access to response.ok and other fields
|
||||
def __getattr__(self, attr):
|
||||
return getattr(self._response, attr)
|
||||
|
||||
# exposed methods with Pythonic results
|
||||
async def arrayBuffer(self):
|
||||
buffer = await self._response.arrayBuffer()
|
||||
# works in Pyodide
|
||||
if hasattr(buffer, "to_py"):
|
||||
return buffer.to_py()
|
||||
# shims in MicroPython
|
||||
return memoryview(as_bytearray(buffer))
|
||||
|
||||
async def blob(self):
|
||||
return await self._response.blob()
|
||||
|
||||
async def bytearray(self):
|
||||
buffer = await self._response.arrayBuffer()
|
||||
return as_bytearray(buffer)
|
||||
|
||||
async def json(self):
|
||||
return json.loads(await self.text())
|
||||
|
||||
async def text(self):
|
||||
return await self._response.text()
|
||||
|
||||
|
||||
### allow direct await to _Response methods
|
||||
class _DirectResponse:
|
||||
@staticmethod
|
||||
def setup(promise, response):
|
||||
promise._response = _Response(response)
|
||||
return promise._response
|
||||
|
||||
def __init__(self, promise):
|
||||
self._promise = promise
|
||||
promise._response = None
|
||||
promise.arrayBuffer = self.arrayBuffer
|
||||
promise.blob = self.blob
|
||||
promise.bytearray = self.bytearray
|
||||
promise.json = self.json
|
||||
promise.text = self.text
|
||||
|
||||
async def _response(self):
|
||||
if not self._promise._response:
|
||||
await self._promise
|
||||
return self._promise._response
|
||||
|
||||
async def arrayBuffer(self):
|
||||
response = await self._response()
|
||||
return await response.arrayBuffer()
|
||||
|
||||
async def blob(self):
|
||||
response = await self._response()
|
||||
return await response.blob()
|
||||
|
||||
async def bytearray(self):
|
||||
response = await self._response()
|
||||
return await response.bytearray()
|
||||
|
||||
async def json(self):
|
||||
response = await self._response()
|
||||
return await response.json()
|
||||
|
||||
async def text(self):
|
||||
response = await self._response()
|
||||
return await response.text()
|
||||
|
||||
|
||||
def fetch(url, **kw):
|
||||
# workaround Pyodide / MicroPython dict <-> js conversion
|
||||
options = js.JSON.parse(json.dumps(kw))
|
||||
awaited = lambda response, *args: _DirectResponse.setup(promise, response)
|
||||
promise = js.fetch(url, options).then(awaited)
|
||||
_DirectResponse(promise)
|
||||
return promise
|
||||
18
pyscript.core/src/stdlib/pyscript/ffi.py
Normal file
18
pyscript.core/src/stdlib/pyscript/ffi.py
Normal file
@@ -0,0 +1,18 @@
|
||||
try:
|
||||
import js
|
||||
from pyodide.ffi import create_proxy as _cp
|
||||
from pyodide.ffi import to_js as _py_tjs
|
||||
|
||||
from_entries = js.Object.fromEntries
|
||||
|
||||
def _tjs(value, **kw):
|
||||
if not hasattr(kw, "dict_converter"):
|
||||
kw["dict_converter"] = from_entries
|
||||
return _py_tjs(value, **kw)
|
||||
|
||||
except:
|
||||
from jsffi import create_proxy as _cp
|
||||
from jsffi import to_js as _tjs
|
||||
|
||||
create_proxy = _cp
|
||||
to_js = _tjs
|
||||
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,20 +1,62 @@
|
||||
import json
|
||||
import sys
|
||||
|
||||
import js as globalThis
|
||||
from polyscript import config as _config
|
||||
from polyscript import js_modules
|
||||
from pyscript.util import NotSupported
|
||||
|
||||
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`
|
||||
class JSModule:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __getattr__(self, field):
|
||||
# avoid pyodide looking for non existent fields
|
||||
if not field.startswith("_"):
|
||||
return getattr(getattr(js_modules, self.name), field)
|
||||
|
||||
|
||||
# generate N modules in the system that will proxy the real value
|
||||
for name in globalThis.Reflect.ownKeys(js_modules):
|
||||
sys.modules[f"pyscript.js_modules.{name}"] = JSModule(name)
|
||||
sys.modules["pyscript.js_modules"] = js_modules
|
||||
|
||||
if RUNNING_IN_WORKER:
|
||||
import js
|
||||
import polyscript
|
||||
|
||||
PyWorker = NotSupported(
|
||||
"pyscript.PyWorker",
|
||||
"pyscript.PyWorker works only when running in the main thread",
|
||||
)
|
||||
window = polyscript.xworker.window
|
||||
document = window.document
|
||||
js.document = document
|
||||
|
||||
try:
|
||||
import js
|
||||
|
||||
window = polyscript.xworker.window
|
||||
document = window.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:
|
||||
message = "Unable to use `window` or `document` -> https://docs.pyscript.net/latest/faq/#sharedarraybuffer"
|
||||
globalThis.console.warn(message)
|
||||
window = NotSupported("pyscript.window", message)
|
||||
document = NotSupported("pyscript.document", message)
|
||||
js_import = None
|
||||
|
||||
sync = polyscript.xworker.sync
|
||||
|
||||
# in workers the display does not have a default ID
|
||||
@@ -24,7 +66,7 @@ if RUNNING_IN_WORKER:
|
||||
|
||||
else:
|
||||
import _pyscript
|
||||
from _pyscript import PyWorker
|
||||
from _pyscript import PyWorker, js_import
|
||||
|
||||
window = globalThis
|
||||
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:
|
||||
"""
|
||||
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,433 +0,0 @@
|
||||
import sys
|
||||
import warnings
|
||||
from functools import cached_property
|
||||
from typing import Any
|
||||
|
||||
from pyodide.ffi import JsProxy
|
||||
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 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()
|
||||
|
||||
|
||||
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) -> Any:
|
||||
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):
|
||||
super().__init__(document)
|
||||
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):
|
||||
if isinstance(key, int):
|
||||
indices = range(*key.indices(len(self.list)))
|
||||
return [self.list[i] for i in indices]
|
||||
|
||||
elements = self._js.querySelectorAll(key)
|
||||
if not elements:
|
||||
return None
|
||||
return ElementCollection([Element(el) for el in elements])
|
||||
|
||||
|
||||
dom = PyDom()
|
||||
|
||||
sys.modules[__name__] = dom
|
||||
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,4 +1,7 @@
|
||||
export default {
|
||||
// allow pyterminal checks to bootstrap
|
||||
is_pyterminal: () => false,
|
||||
|
||||
/**
|
||||
* 'Sleep' for the given number of seconds. Used to implement Python's time.sleep in Worker threads.
|
||||
* @param {number} seconds The number of seconds to sleep.
|
||||
|
||||
@@ -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,11 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PyScript Next Plugin</title>
|
||||
<link rel="stylesheet" href="../dist/core.css">
|
||||
<script type="module" src="../dist/core.js"></script>
|
||||
<py-config src="bad.toml" type="toml"></py-config>
|
||||
</head>
|
||||
</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,37 +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'));
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PyTerminal</title>
|
||||
<link rel="stylesheet" href="../dist/core.css">
|
||||
<script type="module" src="../dist/core.js"></script>
|
||||
<style>.xterm { padding: .5rem; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<script type="py">
|
||||
def greetings(event):
|
||||
print('hello world')
|
||||
</script>
|
||||
<py-script worker terminal>
|
||||
import sys
|
||||
from pyscript import display, document
|
||||
display("Hello", "PyScript Next - PyTerminal", append=False)
|
||||
print("this should go to the terminal")
|
||||
print("another line")
|
||||
|
||||
# this works as expected
|
||||
print("this goes to stderr", file=sys.stderr)
|
||||
document.addEventListener('click', lambda event: print(event.type));
|
||||
</py-script>
|
||||
<button id="my-button" py-click="greetings">Click me</button>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>PyScript Next Plugin</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,27 +0,0 @@
|
||||
import random
|
||||
from datetime import datetime as dt
|
||||
|
||||
from pyscript import display
|
||||
from pyweb import pydom
|
||||
from pyweb.base import when
|
||||
|
||||
|
||||
@when("click", "#just-a-button")
|
||||
def on_click(event):
|
||||
print(f"Hello from Python! {dt.now()}")
|
||||
display(f"Hello from Python! {dt.now()}", append=False, target="result")
|
||||
|
||||
|
||||
@when("click", "#color-button")
|
||||
def on_color_click(event):
|
||||
print("1")
|
||||
btn = pydom["#result"]
|
||||
print("2")
|
||||
btn.style["background-color"] = f"#{random.randrange(0x1000000):06x}"
|
||||
|
||||
|
||||
def reset_color():
|
||||
pydom["#result"].style["background-color"] = "white"
|
||||
|
||||
|
||||
# btn_reset = pydom["#color-reset-button"][0].when('click', reset_color)
|
||||
@@ -1,128 +0,0 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>PyperCard PyTest 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>
|
||||
|
||||
|
||||
<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,435 +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
|
||||
|
||||
|
||||
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,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,187 +0,0 @@
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from .support import PageErrors, PyScriptTest, only_worker, skip_worker
|
||||
|
||||
|
||||
class TestPyTerminal(PyScriptTest):
|
||||
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 terminal")
|
||||
|
||||
with pytest.raises(PageErrors, match="You can use at most 1 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 seqeunces. 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>
|
||||
25
pyscript.core/tests/javascript/config-url.html
Normal file
25
pyscript.core/tests/javascript/config-url.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PyScript Next Plugin</title>
|
||||
<link rel="stylesheet" href="../../dist/core.css">
|
||||
<script type="module" src="../../dist/core.js"></script>
|
||||
<mpy-config src="config-url/config.json"></mpy-config>
|
||||
<script type="mpy">
|
||||
from pyscript import config
|
||||
if config["files"]["{TO}"] != "./runtime":
|
||||
raise Exception("wrong config tree")
|
||||
|
||||
from runtime import test
|
||||
</script>
|
||||
<script type="mpy" worker>
|
||||
from pyscript import config
|
||||
if config["files"]["{TO}"] != "./runtime":
|
||||
raise Exception("wrong config tree")
|
||||
|
||||
from runtime import test
|
||||
</script>
|
||||
</head>
|
||||
</html>
|
||||
7
pyscript.core/tests/javascript/config-url/config.json
Normal file
7
pyscript.core/tests/javascript/config-url/config.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files":{
|
||||
"{FROM}": "./src",
|
||||
"{TO}": "./runtime",
|
||||
"{FROM}/test.py": "{TO}/test.py"
|
||||
}
|
||||
}
|
||||
8
pyscript.core/tests/javascript/config-url/src/test.py
Normal file
8
pyscript.core/tests/javascript/config-url/src/test.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from pyscript import RUNNING_IN_WORKER, document
|
||||
|
||||
classList = document.documentElement.classList
|
||||
|
||||
if RUNNING_IN_WORKER:
|
||||
classList.add("worker")
|
||||
else:
|
||||
classList.add("main")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user