Compare commits

..

69 Commits

Author SHA1 Message Date
Nicholas Tollervey
a02ff691d2 pyscript API/docstring refactor with comprehensive tests (#2414)
* Revise display module. TODO: more comprehensive tests. Especially around mimebundles.

* Markdown corrections in example code in display.py docstrings.

* Minor adjustments and a much more comprehensive test-suite for the display module.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Updated docstring in __init__.py.

* Remove unused imports and black-ify the source.

* Refactor, docs and tests for the Event class in events.py.

* Refactored, simplified and documented @when decorator.

* Extensive test suite for @when decorator.

* Documentation and minor refactoring of the fetch.py module. TODO: Check tests.

* Refactored and more comprehensive tests for the fetch module.

* Add/clarify Event related interactions. Thanks @Neon22 for the suggestion.

* Refactor, document ffi.py module.

* More complete passing tests for ffi.py.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Add docstrings to flatted.py. Since this is actually an external(ish) module, tests for it should be in the external repository from which this code is derived.

* Minor docstring cleanup in ffi.py.

* Added docstrings and clarifications to fs.py.

* Add very limited test suite for fs.py.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Rename magic_js.py to context.py, add comprehensive docstrings, and rename certain internal things for readability and comprehension.

* Fix dict check in ffi.py.

* Rename test_js_modules to test_context.

* Fix test configuration aftert rename.

* Docs and refactor of media.py.

* Comprehensive tests for media.py.

* Refactor and docstrings for storage.py

* Appease the ruff gods.

* Further storage.py changes and a more complete test suite for storage.

* Refactor and docstrings for the util.py module. Fixed a problem with is_awaitable not handling async bound methods.

* More comprehensive test suite for util.py. Updated to latest upytest.

* A major refactoring, documenting and simplification of the web.py module substantially reducing it in size and complexity with only a few minor (edge) behavioural changes.

Softly breaking changes include:

- An element's classes are just a set.
- An element's styles are just a dict.
- Explicitly use `update_all` with ElementCollections (simpler and greater flexibility).
- Extract a child element by id with `my_container["#an-id"]`

* Updates and additions for a more comprehensive test suite for the web.py module. All code paths are exercised and checked.

* Black tidy-ups in test suite.

* Refactor and documentation for websocket.py module.

* Tests for websocket.py. Disabled due to playwright flakiness, but they all pass in a local browser.

* Refactor and documentation of workers.py module.

* Added tests for workers.py module. Updated related test suite to account for the new named worker in the test HTML.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Refactor away remaining "is not None" not caught before.

* Remove check-docstring-first because it interferes with the auto-generated documentation (where triple quoted strings are used to document module attributes).

* Careful Markdown changes so the docstrings render properly in the PyScript docs.

* Typo correction.

* More typo corrections and clarifications.

* Add clarification about SVG handling to _render_image docstring.

* Add DOM event options to the @when decorator (with new tests to exercise this functionality).

* Fixes default value for options if no options passed into @when.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-12-11 17:19:24 +00:00
Nicholas Tollervey
90ae3cea95 Rename offline zip (#2413)
* Add release calver to offline.zip

* Update release template for correct offline.zip filename.

* Update sed for global search/replace of _VERSION_

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-11-26 15:02:30 +00:00
Andrea Giammarchi
97f5922973 Updated release version (#2412) 2025-11-25 12:35:50 +01:00
Nicholas Tollervey
4a801fa3a2 Update publish-release GH workflow to automatically include the offline.zip asset. (#2411)
* Update publish-release GH workflow to include offline.zip asset.

* Adjust upload offline.zip to use gh CLI instead of a third party GH action.

* Update release page template to reference offline.zip.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-11-25 09:37:14 +00:00
Andrea Giammarchi
7afe5c55e1 Fix #2404 - avoid throwing on string config (#2405) 2025-11-25 09:26:14 +01:00
Andrea Giammarchi
7785e17bb5 Added logic to create offline.zip artifact (#2410)
* Added logic to create offline.zip artifact

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-11-24 15:30:59 +01:00
Andrea Giammarchi
c37c3a1f9a Added .sh utility to generate PyScript <-> Pyodide versions map (#2406)
* Added .sh utility to generate PyScript <-> Pyodide versions map

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-11-19 15:36:20 +01:00
Andrea Giammarchi
d8250f2c8c Updated README to 2025.11.1 (#2402) 2025-11-10 11:10:39 +01:00
Andrea Giammarchi
83b41f9928 Updated dev/dependencies + Polyscript (#2400) 2025-11-10 10:48:14 +01:00
Andrea Giammarchi
a8684a2168 Updated README with latest release (#2396) 2025-10-23 16:22:57 +02:00
Andrea Giammarchi
f8cf58d6c4 Fixed FS permission handler need to be asked twice (#2395)
* Fixed FS permission handler need to be asked twice

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-10-23 16:14:21 +02:00
Andrea Giammarchi
8cd9c4c382 Updated to latest release version (#2394) 2025-10-23 11:01:10 +02:00
Andrea Giammarchi
1f609233e7 Fixed issue in Pyodide remote packages (#2393) 2025-10-23 10:51:17 +02:00
Andrea Giammarchi
66966a732e Updated Polyscript to its latest (#2392)
* Updated Polyscript to its latest

* forgot to update the README for the next release
2025-10-21 12:53:35 +02:00
Andrea Giammarchi
ec090922cb Fix #2372 - Allow custom TOML parser (#2390)
* Fix #2372 - Allow custom TOML parser

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-10-10 17:07:36 +02:00
Andrea Giammarchi
f769f215b2 Updated Pyodide to its latest (#2389) 2025-10-08 12:35:21 +02:00
Nicholas Tollervey
ffc78ab6a2 Remove superfluous code now MicroPython supports inspect API for function signature inspection. (#2387)
* Remove superfluous code now MicroPython supports inspect API for function signature inspection. 
* Added test to ensure all callables are covered.
2025-10-08 09:27:53 +01:00
Jeremy Kawahara
b609b605f5 Fix py-editor execute code on ctrl-enter (#2385)
* Fix dist path

* Remove defaultKeymap

* Return true from listener

* Put defaultKeymap after custom key map
2025-10-07 22:57:14 +02:00
Andrea Giammarchi
100a1e4bc1 Updated MicroPython one more time (#2386) 2025-10-07 10:57:29 +02:00
pre-commit-ci[bot]
c848061a44 [pre-commit.ci] pre-commit autoupdate (#2384)
updates:
- https://github.com/psf/blackhttps://github.com/psf/black-pre-commit-mirror
- [github.com/psf/black-pre-commit-mirror: 25.1.0 → 25.9.0](https://github.com/psf/black-pre-commit-mirror/compare/25.1.0...25.9.0)
- [github.com/astral-sh/ruff-pre-commit: v0.12.11 → v0.13.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.11...v0.13.3)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-10-06 21:43:10 -04:00
Andrea Giammarchi
2647e78480 Updated polyscript to bring in latest MicroPython (#2383)
* Updated polyscript to bring in latest MicroPython

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-10-06 11:13:10 +02:00
Andrea Giammarchi
482d57c27c Amend on MicroPython latest (#2382) 2025-10-02 15:32:38 +02:00
Andrea Giammarchi
4ce989acf3 Updated Polyscript (#2376) 2025-10-02 13:43:24 +02:00
Andrea Giammarchi
1e62d0b1fe Follow up on autostart (#2380) 2025-09-30 15:46:37 +02:00
dependabot[bot]
2d3ad0ab2d Bump the github-actions group with 2 updates (#2378)
Bumps the github-actions group with 2 updates: [actions/setup-node](https://github.com/actions/setup-node) and [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials).


Updates `actions/setup-node` from 4 to 5
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v5)

Updates `aws-actions/configure-aws-credentials` from 4 to 5
- [Release notes](https://github.com/aws-actions/configure-aws-credentials/releases)
- [Changelog](https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md)
- [Commits](https://github.com/aws-actions/configure-aws-credentials/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: aws-actions/configure-aws-credentials
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-15 09:53:25 +02:00
Andrea Giammarchi
3657492c52 Simplify even further the bridge with a fallback (#2379) 2025-09-12 09:40:23 +02:00
pre-commit-ci[bot]
a8b8e1de36 [pre-commit.ci] pre-commit autoupdate (#2377)
updates:
- [github.com/pre-commit/pre-commit-hooks: v5.0.0 → v6.0.0](https://github.com/pre-commit/pre-commit-hooks/compare/v5.0.0...v6.0.0)
- [github.com/astral-sh/ruff-pre-commit: v0.12.8 → v0.12.11](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.8...v0.12.11)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-09-01 21:31:15 -04:00
Andrea Giammarchi
726009029a Updated Pyodide to its 0.28.2 version (#2374) 2025-08-21 16:51:25 -04:00
Christian Clauss
8b35304ab4 Fix undefined names in Python code (#2371)
Co-authored-by: Andrea Giammarchi <andrea.giammarchi@gmail.com>
2025-08-18 13:57:33 +02:00
dependabot[bot]
9e4cb44d73 Bump actions/checkout from 4 to 5 in the github-actions group (#2373)
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 4 to 5
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Andrea Giammarchi <andrea.giammarchi@gmail.com>
2025-08-18 13:51:33 +02:00
Christian Clauss
4bf3651c9a pre-commit: Upgrade the Python linter ruff (#2370) 2025-08-18 13:36:36 +02:00
Andrea Giammarchi
67fa31e4ea Bumped version to 2025.8.1 (#2369) 2025-08-07 09:59:11 +02:00
Andrea Giammarchi
4937a46731 Updated Polyscript to its latest (#2364)
* Fix #2360 - Better shared env/setup handling (#2361)

* Updated Polyscript to its latest

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* changed is_null to a more Pythonic is_none

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-08-06 19:23:24 +02:00
Andrea Giammarchi
b4e9a3093c Fix #2338 - Added explicit fs.revoke(path) (#2368) 2025-08-06 14:40:52 +02:00
Andrea Giammarchi
a129be8136 WebSocket and PyWorker fixes (#2366)
* WebSocket and PyWorker fixes

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-07-24 15:03:51 +02:00
Andrea Giammarchi
eaa6711756 Fix #2360 - Better shared env/setup handling (#2361) 2025-07-11 10:56:14 +02:00
Andrea Giammarchi
b528ba67a9 Intermediate release with async worker handler fixes (#2359) 2025-07-10 15:21:50 +02:00
Andrea Giammarchi
71ad1a40cb Update Polyscript with latest Micropython (#2357) 2025-07-04 09:34:12 +02:00
Andrea Giammarchi
e433275938 Readme update (#2356)
* Updated the README with latest PyScript version

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-07-01 14:01:51 +02:00
Andrea Giammarchi
87256a662b Updated Polyscript to its latest (#2355)
* Updated Polyscript to its latest
* added tests for `experimental_ffi_timeout`
2025-07-01 13:07:28 +02:00
Andrea Giammarchi
7336ae545e The PyScript Bridge Helper (#2353)
* The PyScript Bridge Helper

* added importmap to test latest versions with ease
2025-06-26 12:41:29 +02:00
Nicholas Tollervey
d68260c0c7 Fix a bug in <label> handling where 'for_' attribute should be 'htmlFor' on underlying HTML element. (#2352)
* Fix bug in label handling where 'for_' attribute should be 'htmlFor' on underlying HTML element.

* Fix comment.
2025-06-18 15:01:33 +01:00
Nicholas Tollervey
14cc05fb80 Fix code example problem in the release HTML. (#2345) 2025-05-21 16:27:27 +01:00
Andrea Giammarchi
42c6cb775e Added all 3rd party licenses to this project (#2343) 2025-05-21 14:30:55 +01:00
Andrea Giammarchi
b11fb2e893 Added polyscript/service-worker as workaround for missing sabayon (#2334)
* Added polyscript/service-worker as workaround for missing sabayon

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* updated polyscript to its latest

* Updated polyscript one mor time

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-05-15 18:20:38 +02:00
pre-commit-ci[bot]
3223a9c7e9 [pre-commit.ci] pre-commit autoupdate (#2336)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.11.4 → v0.11.8](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.4...v0.11.8)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-05-06 09:22:05 -04:00
Andrea Giammarchi
139ce9b5fb Updated Polyscript & Coincident to their latest (#2327) 2025-04-23 17:56:06 +02:00
pre-commit-ci[bot]
3b1af0688c [pre-commit.ci] pre-commit autoupdate (#2307)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.6 → v0.11.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.6...v0.11.4)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Dan Yeaw <dan@yeaw.me>
2025-04-18 12:02:24 -04:00
Andrea Giammarchi
7284f7f15f Fix #2320 - Dispatch an event on code run in PyEditor (#2329)
* Fix #2320 - Dispatch an event on code run in PyEditor
2025-04-16 22:01:40 +02:00
Andrea Giammarchi
16ebc50481 Fix #2326 - Redirect to localhost when on 0.0.0.0 (#2328)
* Fix #2326 - Redirect to localhost when on 0.0.0.0
2025-04-14 15:55:10 +02:00
Dan Yeaw
b911ea99fb Add media module tests (#2306)
* Add media Python tests

* Add media js test

* Remove try except blocks

* Make Python tests more end-to-end

* Add media Python tests

* Add media js test

* Remove try except blocks

* Make Python tests more end-to-end

* MicroPython explorations.

* Fix websocket tests, so they just skip.

* Fix MicroPython media tests, if no permission is given for a video device.

---------

Co-authored-by: Nicholas H.Tollervey <ntoll@ntoll.org>
Co-authored-by: Andrea Giammarchi <andrea.giammarchi@gmail.com>
2025-03-20 14:35:01 +01:00
Dan Yeaw
46ca9154c4 Add conda as a make setup option (#2305)
Co-authored-by: Andrea Giammarchi <andrea.giammarchi@gmail.com>
2025-03-20 12:10:31 +01:00
Andrea Giammarchi
afd7a8eb00 Updated Pyodide to v0.27.4 (#2318) 2025-03-19 15:00:20 +01:00
Andrea Giammarchi
b22f384d73 PyGame - TOML + JSON absolute URL + input patch (#2313)
* Make config URL canonical

* Better baseURL + input patch
2025-03-11 11:32:30 +01:00
Andrea Giammarchi
caeab77a8e Fix #2304 - Make pyimport work as expected (#2311)
* Fix #2304 - Make pyimport work as expected

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-03-10 16:28:42 +01:00
Andrea Giammarchi
f2bbc6ed5f Fix #2309 - Use all config options (#2310)
* Fix #2309 - Use all config options

* dropped websocket test as it takes forever even locally
2025-03-10 15:57:12 +01:00
Andrea Giammarchi
1d666b92a2 Dispatch py-game event right before executing code (#2287)
deal, thanks 🙏
2025-02-27 12:39:58 +01:00
Andrea Giammarchi
290eb03388 Fix #2302 - Updated Polyscript to its latest (#2303)
* Fix #2302 - Updated Polyscript to its latest
2025-02-27 11:09:46 +01:00
Nicholas Tollervey
55031f2347 Update README to include acknowledgement of Anaconda's role in the project. (#2297) 2025-02-26 16:50:51 +00:00
Andrea Giammarchi
8168383653 Updated Pyodide to v0.27.3 (#2300)
This has been published on *npm* as `https://cdn.jsdelivr.net/npm/@pyscript/core@0.6.33/dist/core.js` and `https://cdn.jsdelivr.net/npm/@pyscript/core@0.6.33/dist/core.css`
2025-02-26 15:06:17 +01:00
Andrea Giammarchi
3ff2c171bc PyEditor kill switch (#2295) 2025-02-26 14:39:17 +01:00
Andrea Giammarchi
edbac13713 Splitting integration tests (#2296) 2025-02-20 15:16:36 +01:00
Christian Clauss
46239caa19 Re ruff (#2292)
* Ruff fixes

* Ruff fixes

* from __future__ import annotations breaks MicroPython

* noqa: FURB188 because there is no str.replacesuffix() in MicroPython

* Add ruff to pre-commit
2025-02-20 09:43:09 +01:00
Andrea Giammarchi
0366e48fad Introducing pyscript.fs namespace/module (#2289)
* introducing pyscript.fs namespace/module

* Added proper rejection when showDirectoryPicker is not supported

* Improved exports to make explicit import in 3rd party modules easier

* implemented `fs.unmount(path)`:

  * verified that RAM gets freed
  * allowed to mount different handlers within the same path through different `id` as that's the Web best way to do so
2025-02-17 14:45:43 +01:00
Andrea Giammarchi
b13317d32f Updated interpreters (#2283) 2025-02-07 09:50:01 +01:00
Andrea Giammarchi
57b1440a10 Latest 2024 (#2270)
* Introducing <script type="py-game">

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-02-05 14:36:45 +01:00
Andrea Giammarchi
fc53356a1d Introducing <script type="py-game"> (#2265)
* Introducing <script type="py-game">

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-02-05 13:24:35 +01:00
pre-commit-ci[bot]
5be99456f0 [pre-commit.ci] pre-commit autoupdate (#2278)
updates:
- [github.com/psf/black: 24.10.0 → 25.1.0](https://github.com/psf/black/compare/24.10.0...25.1.0)
- [github.com/codespell-project/codespell: v2.3.0 → v2.4.1](https://github.com/codespell-project/codespell/compare/v2.3.0...v2.4.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-02-04 09:45:22 +01:00
Joshua Lowe
7adedcc704 Enable service-worker attribute for donkey worker (#2263)
* Enable service-worker attribute for Donkey worker

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-12-19 17:08:38 +01:00
139 changed files with 9354 additions and 2442 deletions

View File

@@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install node
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20.x

View File

@@ -13,13 +13,13 @@ jobs:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install node
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20.x
@@ -57,22 +57,32 @@ jobs:
- name: build
run: npm run build
- name: build offline
run: npm run build:offline
- name: Rename offline.zip with version metadata
run: mv ./dist/offline.zip ./dist/offline_${{ github.ref_name }}.zip
- name: Generate index.html in snapshot
working-directory: .
run: sed -e 's#_PATH_#https://pyscript.net/releases/${{ github.ref_name }}/#' -e 's#_DOC_VERSION_#${{ github.ref_name }}#' -e 's#_TAG_VERSION_#/tag/${{ github.ref_name }}#' -e 's#_VERSION_#${{ github.ref_name }}#' ./public/index.html > ./core/dist/index.html
run: sed -e 's#_PATH_#https://pyscript.net/releases/${{ github.ref_name }}/#g' -e 's#_DOC_VERSION_#${{ github.ref_name }}#g' -e 's#_TAG_VERSION_#/tag/${{ github.ref_name }}#g' -e 's#_VERSION_#${{ github.ref_name }}#g' ./public/index.html > ./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: Upload offline.zip to release
env:
GH_TOKEN: ${{ github.token }}
run: gh release upload ${{ github.ref_name }} ./dist/offline_${{ github.ref_name }}.zip
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
uses: aws-actions/configure-aws-credentials@v5
with:
aws-region: ${{ secrets.AWS_REGION }}
role-to-assume: ${{ secrets.AWS_OIDC_RUNNER_ROLE }}
- name: Sync to S3
run:
| # Update /latest and create an explicitly versioned directory under releases/YYYY.MM.MICRO/
aws s3 sync --quiet ./dist/ s3://pyscript.net/latest/
| # Create an explicitly versioned directory under releases/YYYY.MM.MICRO/
aws s3 sync --quiet ./dist/ s3://pyscript.net/releases/${{ github.ref_name }}/

View File

@@ -20,10 +20,10 @@ jobs:
id-token: write
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install node
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20.x
@@ -62,7 +62,7 @@ jobs:
run: npm run build
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
uses: aws-actions/configure-aws-credentials@v5
with:
aws-region: ${{ secrets.AWS_REGION }}
role-to-assume: ${{ secrets.AWS_OIDC_RUNNER_ROLE }}

View File

@@ -21,10 +21,10 @@ jobs:
working-directory: ./core
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install node
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20.x
@@ -67,7 +67,7 @@ jobs:
run: sed -e 's#_PATH_#./#' -e 's#_DOC_VERSION_#latest#' -e 's#_TAG_VERSION_##' -e 's#_VERSION_#latest#' ./public/index.html > ./core/dist/index.html
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
uses: aws-actions/configure-aws-credentials@v5
with:
aws-region: ${{ secrets.AWS_REGION }}
role-to-assume: ${{ secrets.AWS_OIDC_RUNNER_ROLE }}

View File

@@ -24,7 +24,7 @@ jobs:
MINICONDA_VERSION: 4.11.0
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 3
@@ -37,7 +37,7 @@ jobs:
run: git log --graph -3
- name: Install node
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20.x

View File

@@ -7,11 +7,10 @@ ci:
default_stages: [pre-commit]
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: check-builtin-literals
- id: check-case-conflict
- id: check-docstring-first
- id: check-executables-have-shebangs
- id: check-json
exclude: tsconfig\.json
@@ -24,23 +23,30 @@ repos:
exclude: core/dist|\.min\.js$
- id: trailing-whitespace
- repo: https://github.com/psf/black
rev: 24.10.0
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 25.9.0
hooks:
- id: black
exclude: core/tests
args: ["-l", "88", "--skip-string-normalization"]
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
rev: v2.4.1
hooks:
- id: codespell # See 'pyproject.toml' for args
exclude: \.js\.map$
exclude: fs\.py|\.js\.map$
additional_dependencies:
- tomli
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.13.3
hooks:
- id: ruff
exclude: core/tests
- repo: https://github.com/hoodmane/pyscript-prettier-precommit
rev: "v3.0.0-alpha.6"
hooks:
- id: prettier
exclude: core/test|core/dist|core/types|core/src/stdlib/pyscript.js|pyscript\.sw/|core/src/3rd-party
exclude: core/tests|core/dist|core/types|core/src/stdlib/pyscript.js|pyscript\.sw/|core/src/3rd-party
args: [--tab-width, "4"]

View File

@@ -1,3 +1,4 @@
ISSUE_TEMPLATE
*.min.*
package-lock.json
bridge/

View File

@@ -41,8 +41,8 @@ check-python:
# Check the environment, install the dependencies.
setup: check-node check-npm check-python
cd core && npm ci && cd ..
ifeq ($(VIRTUAL_ENV),)
echo "\n\n\033[0;31mCannot install Python dependencies. Your virtualenv is not activated.\033[0m"
ifeq (,$(VIRTUAL_ENV)$(CONDA_PREFIX))
echo "\n\n\033[0;31mCannot install Python dependencies. Your virtualenv or conda env is not activated.\033[0m"
false
else
python -m pip install -r requirements.txt

View File

@@ -13,15 +13,15 @@ Using PyScript is as simple as:
<title>PyScript!</title>
<link
rel="stylesheet"
href="https://pyscript.net/snapshots/2024.9.2/core.css"
href="https://pyscript.net/releases/2025.11.2/core.css"
/>
<script
type="module"
src="https://pyscript.net/snapshots/2024.9.2/core.js"
src="https://pyscript.net/releases/2025.11.2/core.js"
></script>
</head>
<body>
<!-- Use MicroPython to evaluate some Python -->
<!-- type mpy (MicroPython) or py (Pyodide) to run some Python -->
<script type="mpy" terminal>
print("Hello, world!")
</script>
@@ -83,3 +83,12 @@ documentation for more information on how to setup your development environment.
The [PyScript organization governance](https://github.com/pyscript/governance)
is documented in a separate repository.
## Supporters
PyScript is an independent open source project.
However, PyScript was born at [Anaconda Inc](https://anaconda.com/) and its
core contributors are currently employed by Anaconda to work on PyScript. We
would like to acknowledge and celebrate Anaconda's continued support of this
project. Thank you [Anaconda Inc](https://anaconda.com/)!

59
bridge/README.md Normal file
View File

@@ -0,0 +1,59 @@
# @pyscript/bridge
Import Python utilities directly in JS
```js
// main thread
const { ffi: { func_a, func_b } } = await import('./test.js');
// test.js
import bridge from 'https://esm.run/@pyscript/bridge';
export const ffi = bridge(import.meta.url, { type: 'mpy', worker: false });
// test.py
def func_a(value):
print(f"hello {value}")
def func_b():
import sys
return sys.version
```
### Options
* **pyscript**: the release version to automatically import if not already available on the page. If no version is provided the *developers' channel* version will be used instead (for developers' purposes only).
* **type**: `py` by default to bootstrap *Pyodide*.
* **worker**: `true` by default to bootstrap in a *Web Worker*.
* **config**: either a *string* or a PyScript compatible config *JS literal* to make it possible to bootstrap files and whatnot. If specified, the `worker` becomes implicitly `true` to avoid multiple configs conflicting on the main thread.
* **env**: to share the same environment across multiple modules loaded at different times.
## Tests
Run `npx mini-coi .` within this folder to then reach out `http://localhost:8080/test/` that will show:
```
PyScript Bridge
------------------
no config
```
The [test.js](./test/test.js) files uses the following defaults:
* `pyscript` as `"2025.8.1"`
* `type` as `"mpy"`
* `worker` as `false`
* `config` as `undefined`
* `env` as `undefined`
To test any variant use query string parameters so that `?type=py` will use `py` instead, `worker` will use a worker and `config` will use a basic *config* that brings in another file from the same folder which exposes the version.
To recap: `http://localhost:8080/test/?type=py&worker&config` will show this instead:
```
PyScript Bridge
------------------
3.12.7 (main, May 15 2025, 18:47:24) ...
```
Please note when a *config* is used, the `worker` attribute is always `true`.

163
bridge/index.js Normal file
View File

@@ -0,0 +1,163 @@
/*! (c) PyScript Development Team */
const { stringify } = JSON;
const { assign, create, entries } = Object;
const el = (name, props) => assign(document.createElement(name), props);
/**
* Transform a list of keys into a Python dictionary.
* `['a', 'b']` => `{ "a": a, "b": b }`
* @param {Iterable<string>} keys
* @returns {string}
*/
const dictionary = keys => {
const fields = [];
for (const key of keys)
fields.push(`${stringify(key)}: ${key}`);
return `{ ${fields.join(',')} }`;
};
/**
* Resolve properly config files relative URLs.
* @param {string|Object} config - The configuration to normalize.
* @param {string} base - The base URL to resolve relative URLs against.
* @returns {string} - The JSON serialized config.
*/
const normalize = async (config, base) => {
if (typeof config === 'string') {
base = config;
config = await fetch(config).then(res => res.json());
}
if (typeof config.files === 'object') {
const files = {};
for (const [key, value] of entries(config.files)) {
files[key.startsWith('{') ? key : new URL(key, base)] = value;
}
config.files = files;
}
return stringify(config);
};
// this logic is based on a 3 levels cache ...
const cache = new Map;
/**
* Return a bridge to a Python module via a `.js` file that has a `.py` alter ego.
* @param {string} url - The URL of the JS module that has a Python counterpart.
* @param {Object} options - The options for the bridge.
* @param {string} [options.type='py'] - The `py` or `mpy` interpreter type, `py` by default.
* @param {boolean} [options.worker=true] - Whether to use a worker, `true` by default.
* @param {string|Object} [options.config=null] - The configuration for the bridge, `null` by default.
* @param {string} [options.env=null] - The optional shared environment to use.
* @param {string} [options.serviceWorker=null] - The optional service worker to use as fallback.
* @returns {Object} - The bridge to the Python module.
*/
export default (url, {
type = 'py',
worker = true,
config = null,
env = null,
serviceWorker = null,
pyscript = null,
} = {}) => {
const { protocol, host, pathname } = new URL(url);
const py = pathname.replace(/\.m?js(?:\/\+\w+)?$/, '.py');
const file = `${protocol}//${host}${py}`;
// the first cache is about the desired file in the wild ...
if (!cache.has(file)) {
// the second cache is about all fields one needs to access out there
const exports = new Map;
let python;
cache.set(file, new Proxy(create(null), {
get(_, field) {
if (!exports.has(field)) {
// create an async callback once and always return the same later on
exports.set(field, async (...args) => {
// the third cache is about reaching lazily the code only once
// augmenting its content with exports once and drop it on done
if (!python) {
// do not await or multiple calls will fetch multiple times
// just assign the fetch `Promise` once and return it
python = fetch(file).then(async response => {
const code = await response.text();
// create a unique identifier for the Python context
const identifier = pathname.replace(/[^a-zA-Z0-9_]/g, '');
const name = `__pyscript_${identifier}${Date.now()}`;
// create a Python dictionary with all accessed fields
const detail = `{"detail":${dictionary(exports.keys())}}`;
// create the arguments for the `dispatchEvent` call
const eventArgs = `${stringify(name)},${name}to_ts(${detail})`;
// bootstrap the script element type and its attributes
const script = el('script', { type, textContent: [
'\n', code, '\n',
// this is to avoid local scope name clashing
`from pyscript import window as ${name}`,
`from pyscript.ffi import to_js as ${name}to_ts`,
`${name}.dispatchEvent(${name}.CustomEvent.new(${eventArgs}))`,
// remove these references even if non-clashing to keep
// the local scope clean from undesired entries
`del ${name}`,
`del ${name}to_ts`,
].join('\n') });
// if config is provided it needs to be a worker to avoid
// conflicting with main config on the main thread (just like always)
script.toggleAttribute('worker', !!config || !!worker);
if (config) {
const attribute = await normalize(config, file);
script.setAttribute('config', attribute);
}
if (env) script.setAttribute('env', env);
if (serviceWorker) script.setAttribute('service-worker', serviceWorker);
// let PyScript resolve and execute this script
document.body.appendChild(script);
// intercept once the unique event identifier with all exports
globalThis.addEventListener(
name,
event => {
resolve(event.detail);
script.remove();
},
{ once: true }
);
// return a promise that will resolve only once the event
// has been emitted and the interpreter evaluated the code
const { promise, resolve } = Promise.withResolvers();
if (!(Symbol.for('@pyscript/core') in globalThis)) {
// bring in PyScript if not available already
const cdn = pyscript ?
`https://pyscript.net/releases/${pyscript}` :
// ⚠️ fallback to developers' channel !!!
'https://cdn.jsdelivr.net/npm/@pyscript/core/dist'
;
document.head.appendChild(
el('link', { rel: 'stylesheet', href: `${cdn}/core.css` }),
);
try { await import(`${cdn}/core.js`) }
catch {}
}
return promise;
});
}
// return the `Promise` that will after invoke the exported field
return python.then(foreign => foreign[field](...args));
});
}
// return the lazily to be resolved once callback to invoke
return exports.get(field);
}
}));
}
return cache.get(file);
};

31
bridge/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "@pyscript/bridge",
"version": "0.2.2",
"description": "A JS based way to use PyScript modules",
"type": "module",
"module": "./index.js",
"unpkg": "./index.js",
"jsdelivr": "./jsdelivr.js",
"browser": "./index.js",
"main": "./index.js",
"keywords": [
"PyScript",
"JS",
"Python",
"bridge"
],
"files": [
"index.js",
"README.md"
],
"author": "Anaconda Inc.",
"license": "APACHE-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/pyscript/pyscript.git"
},
"bugs": {
"url": "https://github.com/pyscript/pyscript/issues"
},
"homepage": "https://github.com/pyscript/pyscript#readme"
}

31
bridge/test/index.html Normal file
View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PyScript Bridge</title>
<style>body { font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; }</style>
<!-- for local testing purpose only-->
<script type="importmap">{"imports":{"https://esm.run/@pyscript/bridge":"../index.js"}}</script>
<script type="module">
const { ffi: { test_func, test_other, version } } = await import('./test.js');
console.time("⏱️ first invoke");
const result = await test_func("PyScript Bridge");
console.timeEnd("⏱️ first invoke");
document.body.append(
Object.assign(
document.createElement("h3"),
{ textContent: result },
),
document.createElement("hr"),
await version(),
);
console.time("⏱️ other invokes");
await test_other("🐍");
console.timeEnd("⏱️ other invokes");
</script>
</head>
</html>

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PyScript Bridge</title>
<script type="importmap">
{
"imports": {
"https://esm.run/@pyscript/bridge": "https://esm.run/@pyscript/bridge@latest",
"https://esm.run/@pyscript/bridge/test/test.js": "https://esm.run/@pyscript/bridge@latest/test/test.js"
}
}
</script>
<style>body { font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; }</style>
<link rel="stylesheet" href="https://pyscript.net/releases/2025.5.1/core.css" />
<script type="module" src="https://pyscript.net/releases/2025.5.1/core.js"></script>
<script type="module">
const cdn_test = 'https://esm.run/@pyscript/bridge/test/test.js';
const { ffi: { test_func, test_other, version } } = await import(cdn_test);
console.time("⏱️ first invoke");
const result = await test_func("PyScript Bridge");
console.timeEnd("⏱️ first invoke");
document.body.append(
Object.assign(
document.createElement("h3"),
{ textContent: result },
),
document.createElement("hr"),
await version(),
);
console.time("⏱️ other invokes");
await test_other("🐍");
console.timeEnd("⏱️ other invokes");
</script>
</head>
</html>

View File

@@ -0,0 +1,5 @@
import sys
def version():
return sys.version

18
bridge/test/test.js Normal file
View File

@@ -0,0 +1,18 @@
import bridge from "https://esm.run/@pyscript/bridge";
// for local testing purpose only
const { searchParams } = new URL(location.href);
// the named (or default) export for test.py
export const ffi = bridge(import.meta.url, {
pyscript: "2025.8.1",
env: searchParams.get("env"),
type: searchParams.get("type") || "mpy",
worker: searchParams.has("worker"),
config: searchParams.has("config") ?
({
files: {
"./sys_version.py": "./sys_version.py",
},
}) : undefined,
});

22
bridge/test/test.py Normal file
View File

@@ -0,0 +1,22 @@
from pyscript import config, RUNNING_IN_WORKER
type = config["type"]
print(f"{type}-script", RUNNING_IN_WORKER and "worker" or "main")
def test_func(message):
print("Python", message)
return message
def test_other(message):
print("Python", message)
return message
def version():
try:
from sys_version import version
except ImportError:
version = lambda: "no config"
return version()

205
core/LICENSE Normal file
View File

@@ -0,0 +1,205 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
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.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

1086
core/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@pyscript/core",
"version": "0.6.22",
"version": "0.7.11",
"type": "module",
"description": "PyScript",
"module": "./index.js",
@@ -25,25 +25,33 @@
"types": "./types/core.d.ts",
"import": "./src/core.js"
},
"./js": {
"types": "./types/core.d.ts",
"import": "./dist/core.js"
},
"./css": {
"import": "./dist/core.css"
},
"./storage": {
"import": "./dist/storage.js"
},
"./service-worker": {
"import": "./dist/service-worker.js"
},
"./package.json": "./package.json"
},
"scripts": {
"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": "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 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:offline": "node rollup/offline.cjs | bash",
"build:tests-index": "node rollup/build_test_index.cjs",
"clean:3rd-party": "rm src/3rd-party/*.js && rm src/3rd-party/*.css",
"test:integration": "npm run test:ws; static-handler --coi . 2>/dev/null & SH_PID=$!; EXIT_CODE=0; playwright test tests/js_tests.spec.js tests/py_tests.spec.js || EXIT_CODE=$?; kill $SH_PID 2>/dev/null; exit $EXIT_CODE",
"test:integration": "npm run test:ws; static-handler --coi . 2>/dev/null & SH_PID=$!; EXIT_CODE=0; (playwright test tests/js_tests.spec.js && playwright test tests/py_tests.main.spec.js && playwright test tests/py_tests.worker.spec.js) || EXIT_CODE=$?; kill $SH_PID 2>/dev/null; exit $EXIT_CODE",
"test:ws": "bun tests/javascript/ws/index.js & playwright test tests/javascript/ws/index.spec.js",
"dev": "node dev.cjs",
"release": "npm run build && npm run zip",
@@ -60,40 +68,40 @@
"dependencies": {
"@ungap/with-resolvers": "^0.1.0",
"@webreflection/idb-map": "^0.3.2",
"@webreflection/utils": "^0.1.1",
"add-promise-listener": "^0.1.3",
"basic-devtools": "^0.1.6",
"polyscript": "^0.16.10",
"sabayon": "^0.6.1",
"polyscript": "^0.20.0",
"sticky-module": "^0.1.1",
"to-json-callback": "^0.1.1",
"type-checked-collections": "^0.1.7"
},
"devDependencies": {
"@codemirror/commands": "^6.7.1",
"@codemirror/lang-python": "^6.1.6",
"@codemirror/language": "^6.10.6",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.35.0",
"@playwright/test": "1.45.3",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-node-resolve": "^15.3.0",
"@codemirror/commands": "^6.10.0",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/language": "^6.11.3",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.8",
"@playwright/test": "^1.56.1",
"@rollup/plugin-commonjs": "^29.0.0",
"@rollup/plugin-node-resolve": "^16.0.3",
"@rollup/plugin-terser": "^0.4.4",
"@webreflection/toml-j0.4": "^1.1.3",
"@webreflection/toml-j0.4": "^1.1.4",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"bun": "^1.1.38",
"chokidar": "^4.0.1",
"bun": "^1.3.3",
"chokidar": "^4.0.3",
"codedent": "^0.1.2",
"codemirror": "^6.0.1",
"eslint": "^9.16.0",
"flatted": "^3.3.2",
"rollup": "^4.28.1",
"codemirror": "^6.0.2",
"eslint": "^9.39.1",
"flatted": "^3.3.3",
"rollup": "^4.53.3",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-string": "^3.0.0",
"static-handler": "^0.5.3",
"string-width": "^7.2.0",
"typescript": "^5.7.2",
"string-width": "^8.1.0",
"typescript": "^5.9.3",
"xterm-readline": "^1.1.2"
},
"repository": {

48
core/pyodide.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# This script assumes the following folder structure:
# ./pyscript - it must be a GitHub clone/fork
# ./polyscript - it must be a GitHub clone/fork
#
# Running from ./pyscript/core via:
#
# cd ./pyscript/core
# bash ./pyodide.sh
#
# will print a JSON compatible string like:
#
# {
# "2024.10.1": "0.26.2",
# ...
# "2025.11.1": "0.29.0",
# "": null
# }
#
# Each key represents the PyScript release and each
# value represents the Pyodide version used by that PyScript release.
#
# The last empty key with `null` value is used just to close the JSON object.
# One could remove manually that entry as long as there are no dangling commas.
#
current_pyscript=$(git branch | grep \\* | cut -d ' ' -f2)
echo "{"
for release in $(git tag --list --sort=version:refname); do
git checkout ${release} > /dev/null 2>&1
if test -e "package.json"; then
polyscript=$(cat package.json | jq -r '.dependencies.polyscript')
tag="v${polyscript:1:${#polyscript}-1}"
cd ../../polyscript > /dev/null 2>&1
current_polyscript=$(git branch | grep \\* | cut -d ' ' -f2)
git checkout ${tag} > /dev/null 2>&1
if test -e "versions/pyodide"; then
echo " \"${release}\": \"$(cat versions/pyodide)\","
fi
git checkout ${current_polyscript} > /dev/null 2>&1
cd - > /dev/null 2>&1
fi
git checkout ${current_pyscript} > /dev/null 2>&1
done
echo " \"\": null"
echo "}"

View File

@@ -53,4 +53,15 @@ export default [
sourcemap: true,
},
},
{
input: "./src/service-worker.js",
plugins: plugins.concat(
process.env.NO_MIN
? [nodeResolve(), commonjs()]
: [nodeResolve(), commonjs(), terser()],
),
output: {
file: "./dist/service-worker.js",
},
},
];

79
core/rollup/offline.cjs Normal file
View File

@@ -0,0 +1,79 @@
const { readFileSync, writeFileSync } = require("node:fs");
const { join, resolve } = require("node:path");
const versions = resolve(
__dirname,
"..",
"node_modules",
"polyscript",
"versions",
);
let pyodide = String(readFileSync(join(versions, "pyodide"), "utf8")).trim();
let micropython = String(
readFileSync(join(versions, "micropython"), "utf8"),
).trim();
writeFileSync(
join(process.cwd(), "offline.html"),
`<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PyScript Offline</title>
<script src="./mini-coi-fd.js"></script>
<script type="module" src="./pyscript/core.js" offline></script>
<link rel="stylesheet" href="./pyscript/core.css">
</head>
<body>
<script type="mpy">
from pyscript import document
document.body.append("MicroPython Offline", document.createElement("hr"))
</script>
<script type="py" worker>
from pyscript import document
document.body.append("Pyodide Offline")
</script>
</body>
</html>
`,
"utf8",
);
let bash = `#!/usr/bin/env bash
rm -rf dist/offline
mkdir -p dist/offline/node_modules
echo '{"dependencies":{"pyodide":"${pyodide}","@micropython/micropython-webassembly-pyscript":"${micropython}"}}' > dist/offline/package.json
cd dist/offline
curl -sLO https://raw.githubusercontent.com/WebReflection/mini-coi/refs/heads/main/mini-coi-fd.js
npm i
cd -
mkdir -p dist/offline/pyscript/pyodide
cd dist/offline/pyscript/pyodide
cp ../../node_modules/pyodide/pyodide* ./
cp ../../node_modules/pyodide/python_stdlib.zip ./
cd -
mkdir -p dist/offline/pyscript/micropython
cd dist/offline/pyscript/micropython
cp ../../node_modules/@micropython/micropython-webassembly-pyscript/micropython.* ./
cd -
rm -rf dist/offline/node_modules
rm -rf dist/offline/*.json
mv offline.html dist/offline/index.html
cp dist/*.* dist/offline/pyscript/
rm -f dist/offline/pyscript/offline.zip
cd dist
zip -r offline.zip offline
rm -rf offline
cd -
`;
console.log(bash);

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijnh@gmail.com> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2015 Jak Wings
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,25 @@
Copyright 2021 Erik Bremen
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,21 @@
Copyright (c) 2017-2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
Copyright (c) 2014-2016, SourceLair Private Company (https://www.sourcelair.com)
Copyright (c) 2012-2013, Christopher Jeffrey (https://github.com/chjj/)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,19 @@
Copyright (c) 2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,19 @@
Copyright (c) 2017, The xterm.js authors (https://github.com/xtermjs/xterm.js)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -5,3 +5,7 @@ This folder contains artifacts created via [3rd-party.cjs](../../rollup/3rd-part
As we would like to offer a way to run PyScript offline, and we already offer a `dist` folder with all the necessary scripts, we have created a foreign dependencies resolver that allow to lazy-load CDN dependencies out of the box.
Please **note** these dependencies are **not interpreters**, because interpreters have their own mechanism, folders structure, WASM files, and whatnot, to work locally, but at least XTerm or the TOML parser, among other lazy dependencies, should be available within the dist folder.
## Licenses
All licenses provided by 3rd-party authors can be found in [3rd-party-licenses](../3rd-party-licenses/) folder.

View File

@@ -1,5 +1,5 @@
/**
* Bundled by jsDelivr using Rollup v2.79.1 and Terser v5.19.2.
* Bundled by jsDelivr using Rollup v2.79.2 and Terser v5.39.0.
* Original file: /npm/xterm-readline@1.1.2/lib/readline.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files

View File

@@ -1,5 +1,5 @@
/**
* Minified by jsDelivr using clean-css v5.3.2.
* Minified by jsDelivr using clean-css v5.3.3.
* Original file: /npm/@xterm/xterm@5.5.0/css/xterm.css
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
/**
* Bundled by jsDelivr using Rollup v2.79.1 and Terser v5.19.2.
* Bundled by jsDelivr using Rollup v2.79.2 and Terser v5.39.0.
* Original file: /npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files

View File

@@ -1,5 +1,5 @@
/**
* Bundled by jsDelivr using Rollup v2.79.1 and Terser v5.19.2.
* Bundled by jsDelivr using Rollup v2.79.2 and Terser v5.39.0.
* Original file: /npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files

View File

@@ -1,3 +1,4 @@
import withResolvers from "@webreflection/utils/with-resolvers";
import TYPES from "./types.js";
const waitForIt = [];
@@ -5,7 +6,7 @@ const waitForIt = [];
for (const [TYPE] of TYPES) {
const selectors = [`script[type="${TYPE}"]`, `${TYPE}-script`];
for (const element of document.querySelectorAll(selectors.join(","))) {
const { promise, resolve } = Promise.withResolvers();
const { promise, resolve } = withResolvers();
waitForIt.push(promise);
element.addEventListener(`${TYPE}:done`, resolve, { once: true });
}

View File

@@ -25,7 +25,7 @@ const badURL = (url, expected = "") => {
* @param {string?} type the optional type to enforce
* @returns {{json: boolean, toml: boolean, text: string}}
*/
const configDetails = async (config, type) => {
export const configDetails = async (config, type) => {
let text = config?.trim();
// we only support an object as root config
let url = "",
@@ -70,6 +70,7 @@ for (const [TYPE] of TYPES) {
let config,
type,
parser,
pyElement,
pyConfigs = $$(`${TYPE}-config`),
attrConfigs = $$(
@@ -92,9 +93,11 @@ for (const [TYPE] of TYPES) {
[pyElement] = pyConfigs;
config = pyElement.getAttribute("src") || pyElement.textContent;
type = pyElement.getAttribute("type");
parser = pyElement.getAttribute("config-parser");
} else if (attrConfigs.length) {
[pyElement, ...attrConfigs] = attrConfigs;
config = pyElement.getAttribute("config");
parser = pyElement.getAttribute("config-parser");
// throw an error if dirrent scripts use different configs
if (
attrConfigs.some((el) => el.getAttribute("config") !== config)
@@ -120,9 +123,12 @@ for (const [TYPE] of TYPES) {
}
} else if (toml || type === "toml") {
try {
const { parse } = await import(
const module = parser
? await import(parser)
: await import(
/* webpackIgnore: true */ "./3rd-party/toml.js"
);
const parse = module.parse || module.default;
parsed = parse(text);
} catch (e) {
error = syntaxError("TOML", url, e);
@@ -154,6 +160,9 @@ for (const [TYPE] of TYPES) {
return await Promise.all(toBeAwaited);
};
if (Number.isSafeInteger(parsed?.experimental_ffi_timeout))
globalThis.reflected_ffi_timeout = parsed?.experimental_ffi_timeout;
configs.set(TYPE, { config: parsed, configURL, plugins, error });
}

View File

@@ -28,53 +28,34 @@ mpy-config {
.py-editor-run-button,
.mpy-editor-run-button {
position: absolute;
display: flex;
right: 0.5rem;
bottom: 0.5rem;
opacity: 0;
transition: opacity 0.25s;
z-index: 1;
padding: 0;
}
.py-editor-box:hover .py-editor-run-button,
.mpy-editor-box:hover .mpy-editor-run-button,
.py-editor-run-button:focus,
.py-editor-run-button:disabled,
.py-editor-run-button.running,
.mpy-editor-run-button:focus,
.mpy-editor-run-button:disabled {
.mpy-editor-run-button.running {
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;
}
py-terminal span,
mpy-terminal span {
letter-spacing: 0 !important;
}
dialog.pyscript-fs {
border-radius: 8px;
border-width: 1px;
}
dialog.pyscript-fs > div {
display: flex;
justify-content: space-between;
}

View File

@@ -1,7 +1,8 @@
/*! (c) PyScript Development Team */
import "./zero-redirect.js";
import stickyModule from "sticky-module";
import "@ungap/with-resolvers";
import withResolvers from "@webreflection/utils/with-resolvers";
import {
INVALID_CONTENT,
@@ -33,6 +34,7 @@ import {
createFunction,
inputFailure,
} from "./hooks.js";
import * as fs from "./fs.js";
import codemirror from "./plugins/codemirror.js";
export { codemirror };
@@ -115,7 +117,7 @@ for (const [TYPE, interpreter] of TYPES) {
else dispatch(element, TYPE, "done");
};
const { config, configURL, plugins, error } = configs.get(TYPE);
let { config, configURL, plugins, error } = configs.get(TYPE);
// create a unique identifier when/if needed
let id = 0;
@@ -167,6 +169,8 @@ for (const [TYPE, interpreter] of TYPES) {
// enrich the Python env with some JS utility for main
interpreter.registerJsModule("_pyscript", {
PyWorker,
fs,
interpreter,
js_import: (...urls) => Promise.all(urls.map((url) => import(url))),
get target() {
return isScript(currentElement)
@@ -223,13 +227,6 @@ for (const [TYPE, interpreter] of TYPES) {
else element.after(show);
}
if (!show.id) show.id = getID();
if (TYPE === "py") {
const canvas2D = element.getAttribute("canvas2d") || element.getAttribute("canvas");
if (canvas2D) {
const canvas = queryTarget(document, canvas2D);
wrap.interpreter.canvas.setCanvas2D(canvas);
}
}
// allows the code to retrieve the target element via
// document.currentScript.target if needed
@@ -314,13 +311,24 @@ for (const [TYPE, interpreter] of TYPES) {
hooked.set(TYPE, hooks);
// allow offline interpreter detection via [offline] attribute
let version = offline_interpreter(config);
if (!version) {
const css = "script[type='module'][offline]";
const s = document.querySelector(css)?.src;
if (s && import.meta.url.startsWith(s.replace(/\.js$/, ""))) {
version = `./pyscript/${interpreter}/${interpreter}.mjs`;
version = offline_interpreter({ interpreter: version });
}
}
define(TYPE, {
config,
configURL,
interpreter,
hooks,
version,
env: `${TYPE}-script`,
version: offline_interpreter(config),
onerror(error, element) {
errors.set(element, error);
},
@@ -331,7 +339,7 @@ for (const [TYPE, interpreter] of TYPES) {
class extends HTMLElement {
constructor() {
assign(super(), {
_wrap: Promise.withResolvers(),
_wrap: withResolvers(),
srcCode: "",
executed: false,
});

82
core/src/fs.js Normal file
View File

@@ -0,0 +1,82 @@
import IDBMap from "@webreflection/idb-map";
import withResolvers from "@webreflection/utils/with-resolvers";
import { assign } from "polyscript/exports";
import { $$ } from "basic-devtools";
const stop = (event) => {
event.preventDefault();
event.stopImmediatePropagation();
};
// ⚠️ these two constants MUST be passed as `fs`
// within the worker onBeforeRunAsync hook!
export const NAMESPACE = "@pyscript.fs";
export const ERROR = "storage permissions not granted";
export const idb = new IDBMap(NAMESPACE);
/**
* Ask a user action via dialog and returns the directory handler once granted.
* @param {{id?:string, mode?:"read"|"readwrite", hint?:"desktop"|"documents"|"downloads"|"music"|"pictures"|"videos"}} options
* @returns {Promise<FileSystemDirectoryHandle>}
*/
export const getFileSystemDirectoryHandle = async (options) => {
if (!("showDirectoryPicker" in globalThis)) {
return Promise.reject(
new Error("showDirectoryPicker is not supported"),
);
}
const { promise, resolve, reject } = withResolvers();
const how = { id: "pyscript", mode: "readwrite", ...options };
if (options.hint) how.startIn = options.hint;
const transient = async () => {
try {
/* eslint-disable */
const handler = await showDirectoryPicker(how);
/* eslint-enable */
if ((await handler.requestPermission(how)) === "granted") {
resolve(handler);
return true;
}
} catch ({ message }) {
console.warn(message);
}
return false;
};
// in case the user decided to attach the event itself
// as opposite of relying our dialog walkthrough
if (navigator.userActivation?.isActive) {
if (!(await transient())) reject(new Error(ERROR));
} else {
const dialog = assign(document.createElement("dialog"), {
className: "pyscript-fs",
innerHTML: [
"<strong> Persistent FileSystem</strong><hr>",
"<p><small>PyScript would like to access a local folder.</small></p>",
"<div><button title='ok'>✅ Authorize</button>",
"<button title='cancel'>❌</button></div>",
].join(""),
});
const [ok, cancel] = $$("button", dialog);
ok.addEventListener("click", async (event) => {
stop(event);
if (await transient()) dialog.close();
});
cancel.addEventListener("click", async (event) => {
stop(event);
reject(new Error(ERROR));
dialog.close();
});
document.body.appendChild(dialog).showModal();
}
return promise;
};

View File

@@ -84,23 +84,23 @@ export const hooks = {
},
worker: {
/** @type {Set<function>} */
onReady: new SetFunction([
(wrap, xworker) => {
if (wrap.type === "py") {
const { interpreter } = wrap;
const element = wrap.run('from polyscript import currentScript;currentScript');
const canvas2D = element.getAttribute("canvas2d") || element.getAttribute("canvas");
if (canvas2D) {
const canvas = element.ownerDocument.getElementById(canvas2D);
interpreter.canvas.setCanvas2D(canvas);
}
}
}
]),
onReady: new SetFunction(),
/** @type {Set<function>} */
onBeforeRun: new SetFunction(),
/** @type {Set<function>} */
onBeforeRunAsync: new SetFunction(),
onBeforeRunAsync: new SetFunction([
({ interpreter }) => {
interpreter.registerJsModule("_pyscript", {
// cannot be imported from fs.js
// because this code is stringified
fs: {
ERROR: "storage permissions not granted",
NAMESPACE: "@pyscript.fs",
},
interpreter,
});
},
]),
/** @type {Set<function>} */
onAfterRun: new SetFunction(),
/** @type {Set<function>} */

View File

@@ -25,6 +25,11 @@ export default {
/* webpackIgnore: true */
"./plugins/py-editor.js"
),
["py-game"]: () =>
import(
/* webpackIgnore: true */
"./plugins/py-game.js"
),
["py-terminal"]: () =>
import(
/* webpackIgnore: true */

View File

@@ -5,7 +5,13 @@ const { stringify } = JSON;
const invoke = (name, args) => `${name}(code, ${args.join(", ")})`;
const donkey = ({ type = "py", persistent, terminal, config }) => {
const donkey = ({
type = "py",
persistent,
terminal,
config,
serviceWorker,
}) => {
const globals = terminal ? '{"__terminal__":__terminal__}' : "{}";
const args = persistent ? ["globals()", "__locals__"] : [globals, "{}"];
@@ -46,6 +52,7 @@ const donkey = ({ type = "py", persistent, terminal, config }) => {
typeof config === "string" ? config : stringify(config),
);
}
if (serviceWorker) script.setAttribute("service-worker", serviceWorker);
return addPromiseListener(
document.body.appendChild(script),

View File

@@ -1,16 +1,19 @@
// PyScript py-editor plugin
import withResolvers from "@webreflection/utils/with-resolvers";
import { Hook, XWorker, dedent, defineProperties } from "polyscript/exports";
import { TYPES, offline_interpreter, relative_url, stdlib } from "../core.js";
import { notify } from "./error.js";
import codemirror from "./codemirror.js";
const RUN_BUTTON = `<svg style="height:20px;width:20px;vertical-align:-.125em;transform-origin:center;overflow:visible;color:green" viewBox="0 0 384 512" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg"><g transform="translate(192 256)" transform-origin="96 0"><g transform="translate(0,0) scale(1,1)"><path d="M361 215C375.3 223.8 384 239.3 384 256C384 272.7 375.3 288.2 361 296.1L73.03 472.1C58.21 482 39.66 482.4 24.52 473.9C9.377 465.4 0 449.4 0 432V80C0 62.64 9.377 46.63 24.52 38.13C39.66 29.64 58.21 29.99 73.03 39.04L361 215z" fill="currentColor" transform="translate(-192 -256)"></path></g></g></svg>`;
const RUN_BUTTON = `<svg style="height:24px;width:24px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19,12a1,1,0,0,1-.55.89l-10,5A1,1,0,0,1,8,18a1,1,0,0,1-.53-.15A1,1,0,0,1,7,17V7a1,1,0,0,1,1.45-.89l10,5A1,1,0,0,1,19,12Z" fill="#464646"/></svg>`;
const STOP_BUTTON = `<svg style="height:24px;width:24px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7 7h10v10H7z" style="fill:#464646;stroke:#464646;stroke-width:1;stroke-linecap:butt;stroke-linejoin:round;stroke-dasharray:none;paint-order:normal"/></svg>`;
let id = 0;
const getID = (type) => `${type}-editor-${id++}`;
const envs = new Map();
const configs = new Map();
const editors = new WeakMap();
const hooks = {
worker: {
@@ -30,12 +33,18 @@ const validate = (config, result) => {
return result;
};
async function execute({ currentTarget }) {
const getRelatedScript = (target, type) => {
const editor = target.closest(`.${type}-editor-box`);
return editor?.parentNode?.previousElementSibling;
};
async function execute({ currentTarget, script }) {
const { env, pySrc, outDiv } = this;
const hasRunButton = !!currentTarget;
if (hasRunButton) {
currentTarget.disabled = true;
currentTarget.classList.add("running");
currentTarget.innerHTML = STOP_BUTTON;
outDiv.innerHTML = "";
}
@@ -82,17 +91,15 @@ async function execute({ currentTarget }) {
// creation and destruction of editors on the fly
if (hasRunButton) {
for (const type of TYPES.keys()) {
const editor = currentTarget.closest(`.${type}-editor-box`);
const script = editor?.parentNode?.previousElementSibling;
if (script) {
defineProperties(script, { xworker: { value: xworker } });
break;
}
script = getRelatedScript(currentTarget, type);
if (script) break;
}
}
defineProperties(script, { xworker: { value: xworker } });
const { sync } = xworker;
const { promise, resolve } = Promise.withResolvers();
const { promise, resolve } = withResolvers();
envs.set(env, promise);
sync.revoke = () => {
URL.revokeObjectURL(srcLink);
@@ -116,7 +123,18 @@ async function execute({ currentTarget }) {
};
const enable = () => {
if (hasRunButton) currentTarget.disabled = false;
if (hasRunButton) {
currentTarget.classList.remove("running");
currentTarget.innerHTML = RUN_BUTTON;
const { previousElementSibling } =
currentTarget.closest("[data-env]").parentElement;
previousElementSibling?.dispatchEvent(
new Event("py-editor:done", {
bubbles: true,
cancelable: true,
}),
);
}
};
const { sync } = xworker;
sync.write = (str) => {
@@ -138,12 +156,54 @@ async function execute({ currentTarget }) {
});
}
const replaceScript = (script, type) => {
script.xworker?.terminate();
const clone = script.cloneNode(true);
clone.type = `${type}-editor`;
const editor = editors.get(script);
if (editor) {
const content = editor.state.doc.toString();
clone.textContent = content;
editors.delete(script);
script.nextElementSibling.remove();
}
script.replaceWith(clone);
};
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", async (event) => {
if (
runButton.classList.contains("running") &&
confirm("Stop evaluating this code?")
) {
const script = getRelatedScript(runButton, type);
if (script) {
const env = script.getAttribute("env");
// remove the bootstrapped env which could be one or shared
if (env) {
for (const [key, value] of TYPES) {
if (key === type) {
configs.delete(`${value}-${env}`);
envs.delete(`${value}-${env}`);
break;
}
}
}
// lonley script without setup node should be replaced
if (script.xworker) replaceScript(script, type);
// all scripts sharing the same env should be replaced
else {
const sel = `script[type^="${type}-editor"][env="${env}"]`;
for (const script of document.querySelectorAll(sel))
replaceScript(script, type);
}
}
return;
}
runButton.blur();
await handler.handleEvent(event);
});
@@ -328,7 +388,7 @@ const init = async (script, type, interpreter) => {
};
if (isSetup) {
await context.handleEvent({ currentTarget: null });
await context.handleEvent({ currentTarget: null, script });
notifyEditor();
return;
}
@@ -366,16 +426,17 @@ const init = async (script, type, interpreter) => {
// preserve user indentation, if any
const indentation = /^([ \t]+)/m.test(doc) ? RegExp.$1 : " ";
const listener = () => runButton.click();
const listener = () => !runButton.click();
const editor = new EditorView({
extensions: [
indentUnit.of(indentation),
new Compartment().of(python()),
keymap.of([
...defaultKeymap,
{ key: "Ctrl-Enter", run: listener, preventDefault: true },
{ key: "Cmd-Enter", run: listener, preventDefault: true },
{ key: "Shift-Enter", run: listener, preventDefault: true },
// Consider removing defaultKeymap as likely redundant with basicSetup
...defaultKeymap,
// @see https://codemirror.net/examples/tab/
indentWithTab,
]),
@@ -387,6 +448,7 @@ const init = async (script, type, interpreter) => {
doc,
});
editors.set(script, editor);
editor.focus();
notifyEditor();
};

112
core/src/plugins/py-game.js Normal file
View File

@@ -0,0 +1,112 @@
import {
dedent,
define,
createProgress,
loadProgress,
} from "polyscript/exports";
import { stdlib } from "../core.js";
import { configDetails } from "../config.js";
import { getText } from "../fetch.js";
const progress = createProgress("py-game");
const inputPatch = `
import builtins
def input(prompt=""):
import js
return js.prompt(prompt)
builtins.input = input
del builtins
del input
`;
let toBeWarned = true;
const hooks = {
main: {
onReady: async (wrap, script) => {
if (toBeWarned) {
toBeWarned = false;
console.warn("⚠️ EXPERIMENTAL `py-game` FEATURE");
}
let config = {};
if (script.hasAttribute("config")) {
const value = script.getAttribute("config");
const { json, toml, text, url } = await configDetails(value);
if (json) config = JSON.parse(text);
else if (toml) {
const { parse } = await import(
/* webpackIgnore: true */ "../3rd-party/toml.js"
);
config = parse(text);
}
if (config.packages) {
await wrap.interpreter.loadPackage("micropip");
const micropip = wrap.interpreter.pyimport("micropip");
await micropip.install(config.packages, {
keep_going: true,
});
micropip.destroy();
}
await loadProgress(
"py-game",
progress,
wrap.interpreter,
config,
url ? new URL(url, location.href).href : location.href,
);
}
wrap.interpreter.registerJsModule("_pyscript", {
PyWorker() {
throw new Error(
"Unable to use PyWorker in py-game scripts",
);
},
js_import: (...urls) =>
Promise.all(urls.map((url) => import(url))),
get target() {
return script.id;
},
});
await wrap.interpreter.runPythonAsync(stdlib);
wrap.interpreter.runPython(inputPatch);
let code = dedent(script.textContent);
if (script.src) code = await fetch(script.src).then(getText);
const target = script.getAttribute("target") || "canvas";
const canvas = document.getElementById(target);
wrap.interpreter.canvas.setCanvas2D(canvas);
// allow 3rd party to hook themselves right before
// the code gets executed
const event = new CustomEvent("py-game", {
bubbles: true,
cancelable: true,
detail: {
canvas,
code,
config,
wrap,
},
});
script.dispatchEvent(event);
// run only if the default was not prevented
if (!event.defaultPrevented)
await wrap.interpreter.runPythonAsync(code);
},
},
};
define("py-game", {
config: { packages: ["pygame-ce"] },
configURL: new URL("./config.txt", location.href).href,
interpreter: "pyodide",
env: "py-game",
hooks,
});

View File

@@ -1,4 +1,5 @@
// PyScript pyodide terminal plugin
import withResolvers from "@webreflection/utils/with-resolvers";
import { defineProperties } from "polyscript/exports";
import { hooks, inputFailure } from "../../core.js";
@@ -146,7 +147,7 @@ export default async (element) => {
// frees the worker on \r
sync.pyterminal_read = (buffer) => {
terminal.write(buffer);
promisedChunks = Promise.withResolvers();
promisedChunks = withResolvers();
return promisedChunks.promise;
};

View File

@@ -0,0 +1 @@
import "polyscript/service-worker";

File diff suppressed because one or more lines are too long

View File

@@ -1,36 +1,105 @@
# Some notes about the naming conventions and the relationship between various
# similar-but-different names.
#
# import pyscript
# this package contains the main user-facing API offered by pyscript. All
# the names which are supposed be used by end users should be made
# available in pyscript/__init__.py (i.e., this file)
#
# import _pyscript
# this is an internal module implemented in JS. It is used internally by
# the pyscript package, end users should not use it directly. For its
# implementation, grep for `interpreter.registerJsModule("_pyscript",
# ...)` in core.js
#
# import js
# this is the JS globalThis, as exported by pyodide and/or micropython's
# FFIs. As such, it contains different things in the main thread or in a
# worker.
#
# import pyscript.magic_js
# this submodule abstracts away some of the differences between the main
# thread and the worker. In particular, it defines `window` and `document`
# in such a way that these names work in both cases: in the main thread,
# they are the "real" objects, in the worker they are proxies which work
# thanks to coincident.
#
# from pyscript import window, document
# these are just the window and document objects as defined by
# 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.
"""
This is the main `pyscript` namespace. It provides the primary Pythonic API
for users to interact with the
[browser's own API](https://developer.mozilla.org/en-US/docs/Web/API). It
includes utilities for common activities such as displaying content, handling
events, fetching resources, managing local storage, and coordinating with
web workers.
The most important names provided by this namespace can be directly imported
from `pyscript`, for example:
```python
from pyscript import display, HTML, fetch, when, storage, WebSocket
```
The following names are available in the `pyscript` namespace:
- `RUNNING_IN_WORKER`: Boolean indicating if the code is running in a Web
Worker.
- `PyWorker`: Class for creating Web Workers running Python code.
- `config`: Configuration object for pyscript settings.
- `current_target`: The element in the DOM that is the current target for
output.
- `document`: The standard `document` object, proxied in workers.
- `window`: The standard `window` object, proxied in workers.
- `js_import`: Function to dynamically import JS modules.
- `js_modules`: Object containing JS modules available to Python.
- `sync`: Utility for synchronizing between worker and main thread.
- `display`: Function to render Python objects in the web page.
- `HTML`: Helper class to create HTML content for display.
- `fetch`: Function to perform HTTP requests.
- `Storage`: Class representing browser storage (local/session).
- `storage`: Object to interact with browser's local storage.
- `WebSocket`: Class to create and manage WebSocket connections.
- `when`: Function to register event handlers on DOM elements.
- `Event`: Class representing user defined or DOM events.
- `py_import`: Function to lazily import Pyodide related Python modules.
If running in the main thread, the following additional names are available:
- `create_named_worker`: Function to create a named Web Worker.
- `workers`: Object to manage and interact with existing Web Workers.
All of these names are defined in the various submodules of `pyscript` and
are imported and re-exported here for convenience. Please refer to the
respective submodule documentation for more details on each component.
!!! Note
Some notes about the naming conventions and the relationship between
various similar-but-different names found within this code base.
```python
import pyscript
```
The `pyscript` package contains the main user-facing API offered by
PyScript. All the names which are supposed be used by end users should
be made available in `pyscript/__init__.py` (i.e., this source file).
```python
import _pyscript
```
The `_pyscript` module is an internal API implemented in JS. **End users
should not use it directly**. For its implementation, grep for
`interpreter.registerJsModule("_pyscript",...)` in `core.js`.
```python
import js
```
The `js` object is
[the JS `globalThis`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/globalThis),
as exported by Pyodide and/or Micropython's foreign function interface
(FFI). As such, it contains different things in the main thread or in a
worker, as defined by web standards.
```python
import pyscript.context
```
The `context` submodule abstracts away some of the differences between
the main thread and a worker. Its most important features are made
available in the root `pyscript` namespace. All other functionality is
mostly for internal PyScript use or advanced users. In particular, it
defines `window` and `document` in such a way that these names work in
both cases: in the main thread, they are the "real" objects, in a worker
they are proxies which work thanks to
[coincident](https://github.com/WebReflection/coincident).
```python
from pyscript import window, document
```
These are just the `window` and `document` objects as defined by
`pyscript.context`. 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.magic_js import (
from pyscript.context import (
RUNNING_IN_WORKER,
PyWorker,
config,

View File

@@ -0,0 +1,198 @@
"""
Execution context management for PyScript.
This module handles the differences between running in the
[main browser thread](https://developer.mozilla.org/en-US/docs/Glossary/Main_thread)
versus running in a
[Web Worker](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers),
providing a consistent API regardless of the execution context.
Key features:
- Detects whether code is running in a worker or main thread. Read this via
the boolean `pyscript.context.RUNNING_IN_WORKER`.
- Parses and normalizes configuration from `polyscript.config` and adds the
Python interpreter type via the `type` key in `pyscript.context.config`.
- Provides appropriate implementations of `window`, `document`, and `sync`.
- Sets up JavaScript module import system, including a lazy `js_import`
function.
- Manages `PyWorker` creation.
- Provides access to the current display target via
`pyscript.context.display_target`.
!!! warning
These are key differences between the main thread and worker contexts:
Main thread context:
- `window` and `document` are available directly.
- `PyWorker` can be created to spawn worker threads.
- `sync` is not available (raises `NotSupported`).
Worker context:
- `window` and `document` are proxied from main thread (if SharedArrayBuffer
available).
- `PyWorker` is not available (raises `NotSupported`).
- `sync` utilities are available for main thread communication.
"""
import json
import sys
import js
from polyscript import config as _polyscript_config
from polyscript import js_modules
from pyscript.util import NotSupported
RUNNING_IN_WORKER = not hasattr(js, "document")
"""Detect execution context: True if running in a worker, False if main thread."""
config = json.loads(js.JSON.stringify(_polyscript_config))
"""Parsed and normalized configuration."""
if isinstance(config, str):
config = {}
js_import = None
"""Function to import JavaScript modules dynamically."""
window = None
"""The `window` object (proxied if in a worker)."""
document = None
"""The `document` object (proxied if in a worker)."""
sync = None
"""Sync utilities for worker-main thread communication (only in workers)."""
# Detect and add Python interpreter type to config.
if "MicroPython" in sys.version:
config["type"] = "mpy"
else:
config["type"] = "py"
class _JSModuleProxy:
"""
Proxy for JavaScript modules imported via js_modules.
This allows Python code to import JavaScript modules using Python's
import syntax:
```python
from pyscript.js_modules lodash import debounce
```
The proxy lazily retrieves the actual JavaScript module when accessed.
"""
def __init__(self, name):
"""
Create a proxy for the named JavaScript module.
"""
self.name = name
def __getattr__(self, field):
"""
Retrieve a JavaScript object/function from the proxied JavaScript
module via the given `field` name.
"""
# Avoid Pyodide looking for non-existent special methods.
if not field.startswith("_"):
return getattr(getattr(js_modules, self.name), field)
return None
# Register all available JavaScript modules in Python's module system.
# This enables: from pyscript.js_modules.xxx import yyy
for module_name in js.Reflect.ownKeys(js_modules):
sys.modules[f"pyscript.js_modules.{module_name}"] = _JSModuleProxy(module_name)
sys.modules["pyscript.js_modules"] = js_modules
# Context-specific setup: Worker vs Main Thread.
if RUNNING_IN_WORKER:
import polyscript
# PyWorker cannot be created from within a worker.
PyWorker = NotSupported(
"pyscript.PyWorker",
"pyscript.PyWorker works only when running in the main thread",
)
# Attempt to access main thread's window and document via SharedArrayBuffer.
try:
window = polyscript.xworker.window
document = window.document
js.document = document
# Create js_import function that runs imports on the main thread.
js_import = window.Function(
"return (...urls) => Promise.all(urls.map((url) => import(url)))"
)()
except:
# SharedArrayBuffer not available - window/document cannot be proxied.
sab_error_message = (
"Unable to use `window` or `document` in worker. "
"This requires SharedArrayBuffer support. "
"See: https://docs.pyscript.net/latest/faq/#sharedarraybuffer"
)
js.console.warn(sab_error_message)
window = NotSupported("pyscript.window", sab_error_message)
document = NotSupported("pyscript.document", sab_error_message)
# Worker-specific utilities for main thread communication.
sync = polyscript.xworker.sync
def current_target():
"""
Get the current output target in worker context.
"""
return polyscript.target
else:
# Main thread context setup.
import _pyscript
from _pyscript import PyWorker as _PyWorker
from pyscript.ffi import to_js
js_import = _pyscript.js_import
def PyWorker(url, **options):
"""
Create a Web Worker running Python code.
This spawns a new worker thread that can execute Python code
found at the `url`, independently of the main thread. The
`**options` can be used to configure the worker.
```python
from pyscript import PyWorker
# Create a worker to run background tasks.
# (`type` MUST be either `micropython` or `pyodide`)
worker = PyWorker("./worker.py", type="micropython")
```
PyWorker **can only be created from the main thread**, not from
within another worker.
"""
return _PyWorker(url, to_js(options))
# Main thread has direct access to window and document.
window = js
document = js.document
# sync is not available in main thread (only in workers).
sync = NotSupported(
"pyscript.sync", "pyscript.sync works only when running in a worker"
)
def current_target():
"""
Get the current output target in main thread context.
"""
return _pyscript.target

View File

@@ -1,63 +1,104 @@
"""
Display Pythonic content in the browser.
This module provides the `display()` function for rendering Python objects
in the web page. The function introspects objects to determine the appropriate
[MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types/Common_types)
and rendering method.
Supported MIME types:
- `text/plain`: Plain text (HTML-escaped)
- `text/html`: HTML content
- `image/png`: PNG images as data URLs
- `image/jpeg`: JPEG images as data URLs
- `image/svg+xml`: SVG graphics
- `application/json`: JSON data
- `application/javascript`: JavaScript code (discouraged)
The `display()` function uses standard Python representation methods
(`_repr_html_`, `_repr_png_`, etc.) to determine how to render objects.
Objects can provide a `_repr_mimebundle_` method to specify preferred formats
like this:
```python
def _repr_mimebundle_(self):
return {
"text/html": "<b>Bold HTML</b>",
"image/png": "<base64-encoded-png-data>",
}
```
Heavily inspired by
[IPython's rich display system](https://ipython.readthedocs.io/en/stable/api/generated/IPython.display.html).
"""
import base64
import html
import io
import re
from pyscript.magic_js import current_target, document, window
_MIME_METHODS = {
"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",
}
from collections import OrderedDict
from pyscript.context import current_target, document, window
from pyscript.ffi import is_none
def _render_image(mime, value, meta):
# If the image value is using bytes we should convert it to base64
# otherwise it will return raw bytes and the browser will not be able to
# render it.
"""
Render image (`mime`) data (`value`) as an HTML img element with data URL.
Any `meta` attributes are added to the img tag.
Accepts both raw bytes and base64-encoded strings for flexibility. This
only handles PNG and JPEG images. SVG images are handled separately as
their raw XML content (which the browser can render directly).
"""
if isinstance(value, bytes):
value = base64.b64encode(value).decode("utf-8")
# This is the pattern of base64 strings
base64_pattern = re.compile(
r"^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$"
)
# If value doesn't match the base64 pattern we should encode it to base64
if len(value) > 0 and not base64_pattern.match(value):
value = base64.b64encode(value.encode("utf-8")).decode("utf-8")
data = f"data:{mime};charset=utf-8;base64,{value}"
attrs = " ".join(['{k}="{v}"' for k, v in meta.items()])
return f'<img src="{data}" {attrs}></img>'
attrs = "".join([f' {k}="{v}"' for k, v in meta.items()])
return f'<img src="data:{mime};base64,{value}"{attrs}>'
def _identity(value, meta):
return value
_MIME_RENDERERS = {
"text/plain": html.escape,
"text/html": _identity,
"image/png": lambda value, meta: _render_image("image/png", value, meta),
"image/jpeg": lambda value, meta: _render_image("image/jpeg", value, meta),
"image/svg+xml": _identity,
"application/json": _identity,
"application/javascript": lambda value, meta: f"<script>{value}<\\/script>",
# Maps MIME types to rendering functions.
_MIME_TO_RENDERERS = {
"text/plain": lambda v, m: html.escape(v),
"text/html": lambda v, m: v,
"image/png": lambda v, m: _render_image("image/png", v, m),
"image/jpeg": lambda v, m: _render_image("image/jpeg", v, m),
"image/svg+xml": lambda v, m: v,
"application/json": lambda v, m: v,
"application/javascript": lambda v, m: f"<script>{v}<\\/script>",
}
# Maps Python representation methods to MIME types. This is an ordered dict
# because the order defines preference when multiple methods are available,
# and MicroPython's limited dicts don't preserve insertion order.
_METHOD_TO_MIME = OrderedDict(
[
("savefig", "image/png"),
("_repr_png_", "image/png"),
("_repr_jpeg_", "image/jpeg"),
("_repr_svg_", "image/svg+xml"),
("_repr_html_", "text/html"),
("_repr_json_", "application/json"),
("_repr_javascript_", "application/javascript"),
("__repr__", "text/plain"),
]
)
class HTML:
"""
Wrap a string so that display() can render it as plain HTML
Wrap a string to render as unescaped HTML in `display()`. This is
necessary because plain strings are automatically HTML-escaped for safety:
```python
from pyscript import HTML, display
display(HTML("<h1>Hello World</h1>"))
```
Inspired by
[`IPython.display.HTML`](https://ipython.readthedocs.io/en/stable/api/generated/IPython.display.html#IPython.display.HTML).
"""
def __init__(self, html):
@@ -67,111 +108,156 @@ class HTML:
return self._html
def _eval_formatter(obj, print_method):
def _get_representation(obj, method):
"""
Evaluates a formatter method.
Call the given representation `method` on an object (`obj`).
Handles special cases like matplotlib's `savefig`. Returns `None`
if the `method` doesn't exist.
"""
if print_method == "__repr__":
if method == "__repr__":
return repr(obj)
elif hasattr(obj, print_method):
if print_method == "savefig":
if not hasattr(obj, method):
return None
if method == "savefig":
buf = io.BytesIO()
obj.savefig(buf, format="png")
buf.seek(0)
return base64.b64encode(buf.read()).decode("utf-8")
return getattr(obj, print_method)()
elif print_method == "_repr_mimebundle_":
return {}, {}
return None
return getattr(obj, method)()
def _format_mime(obj):
def _get_content_and_mime(obj):
"""
Formats object using _repr_x_ methods.
Returns the formatted raw content to be inserted into the DOM representing
the given object, along with the object's detected MIME type.
Returns a tuple of (html_string, mime_type).
Prefers _repr_mimebundle_ if available, otherwise tries individual
representation methods, falling back to __repr__ (with a warning in
the console).
Implements a subset of IPython's rich display system (mimebundle support,
etc...).
"""
if isinstance(obj, str):
return html.escape(obj), "text/plain"
mimebundle = _eval_formatter(obj, "_repr_mimebundle_")
# Prefer an object's mimebundle.
mimebundle = _get_representation(obj, "_repr_mimebundle_")
if mimebundle:
if isinstance(mimebundle, tuple):
format_dict, _ = mimebundle
# Grab global metadata.
format_dict, global_meta = mimebundle
else:
format_dict = mimebundle
output, not_available = None, []
for method, mime_type in _MIME_METHODS.items():
if mime_type in format_dict:
output = format_dict[mime_type]
else:
output = _eval_formatter(obj, method)
format_dict, global_meta = mimebundle, {}
# Try to render using mimebundle formats.
for mime_type, output in format_dict.items():
if mime_type in _MIME_TO_RENDERERS:
meta = global_meta.get(mime_type, {})
# If output is a tuple, merge format-specific metadata.
if isinstance(output, tuple):
output, format_meta = output
meta.update(format_meta)
return _MIME_TO_RENDERERS[mime_type](output, meta), mime_type
# No mimebundle or no available renderers therein, so try individual
# methods.
for method, mime_type in _METHOD_TO_MIME.items():
if mime_type not in _MIME_TO_RENDERERS:
continue
output = _get_representation(obj, method)
if output is None:
continue
elif mime_type not in _MIME_RENDERERS:
not_available.append(mime_type)
continue
break
if output is None:
if not_available:
window.console.warn(
f"Rendered object requested unavailable MIME renderers: {not_available}"
)
output = repr(output)
mime_type = "text/plain"
elif isinstance(output, tuple):
output, meta = output
else:
meta = {}
return _MIME_RENDERERS[mime_type](output, meta), mime_type
if isinstance(output, tuple):
output, meta = output
return _MIME_TO_RENDERERS[mime_type](output, meta), mime_type
# Ultimate fallback to repr with warning.
window.console.warn(
f"Object {type(obj).__name__} has no supported representation method. "
"Using __repr__ as fallback."
)
output = repr(obj)
return html.escape(output), "text/plain"
def _write(element, value, append=False):
html, mime_type = _format_mime(value)
if html == "\\n":
def _write_to_dom(element, value, append):
"""
Given an `element` and a `value`, write formatted content to the referenced
DOM element. If `append` is True, content is added to the existing content;
otherwise, the existing content is replaced.
Creates a wrapper `div` when appending multiple items to preserve
structure.
"""
html_content, mime_type = _get_content_and_mime(value)
if not html_content.strip():
return
if append:
out_element = document.createElement("div")
element.append(out_element)
container = document.createElement("div")
element.append(container)
else:
out_element = element.lastElementChild
if out_element is None:
out_element = element
container = element
if mime_type in ("application/javascript", "text/html"):
script_element = document.createRange().createContextualFragment(html)
out_element.append(script_element)
container.append(document.createRange().createContextualFragment(html_content))
else:
out_element.innerHTML = html
container.innerHTML = html_content
def display(*values, target=None, append=True):
if target is None:
"""
Display Python objects in the web page.
* `*values`: Python objects to display. Each object is introspected to
determine the appropriate rendering method.
* `target`: DOM element ID where content should be displayed. If `None`
(default), uses the current script tag's designated output area. This
can start with '#' (which will be stripped for compatibility).
* `append`: If `True` (default), add content to existing output. If
`False`, replace existing content before displaying.
When used in a worker, `display()` requires an explicit `target` parameter
to identify where content will be displayed. If used on the main thread,
it automatically uses the current `<script>` tag as the target. If the
script tag has a `target` attribute, that element will be used instead.
A ValueError is raised if a valid target cannot be found for the current
context.
```python
from pyscript import display, HTML
# Display raw HTML.
display(HTML("<h1>Hello, World!</h1>"))
# Display in current script's output area.
display("Hello, World!")
# Display in a specific element.
display("Hello", target="my-div")
# Replace existing content (note the `#`).
display("New content", target="#my-div", append=False)
# Display multiple values in the default target.
display("First", "Second", "Third")
```
"""
if isinstance(target, str):
# There's a valid target.
target = target[1:] if target.startswith("#") else target
elif is_none(target):
target = current_target()
elif not isinstance(target, str):
raise TypeError(f"target must be str or None, not {target.__class__.__name__}")
elif target == "":
raise ValueError("Cannot have an empty target")
elif target.startswith("#"):
# note: here target is str and not None!
# align with @when behavior
target = target[1:]
element = document.getElementById(target)
# If target cannot be found on the page, a ValueError is raised
if element is None:
raise ValueError(
f"Invalid selector with id={target}. Cannot be found in the page."
)
# if element is a <script type="py">, it has a 'target' attribute which
# points to the visual element holding the displayed values. In that case,
# use that.
if is_none(element):
raise ValueError(f"Cannot find element with id='{target}' in the page.")
# If possible, use a script tag's target attribute.
if element.tagName == "SCRIPT" and hasattr(element, "target"):
element = element.target
for v in values:
# Clear before displaying all values when not appending.
if not append:
element.replaceChildren()
_write(element, v, append=append)
# Add each value.
for value in values:
_write_to_dom(element, value, append)

View File

@@ -1,17 +1,47 @@
"""
Event handling for PyScript.
This module provides two complementary systems:
1. The `Event` class: A simple publish-subscribe pattern for custom events
within *your* Python code.
2. The `@when` decorator: Connects Python functions to browser DOM events,
or instances of the `Event` class, allowing you to respond to user
interactions like clicks, key presses and form submissions, or to custom
events defined in your Python code.
"""
import asyncio
import inspect
import sys
from functools import wraps
from pyscript.magic_js import document
from pyscript.ffi import create_proxy
from pyscript.context import document
from pyscript.ffi import create_proxy, to_js
from pyscript.util import is_awaitable
from pyscript import config
class Event:
"""
Represents something that may happen at some point in the future.
A custom event that can notify multiple listeners when triggered.
Use this class to create your own event system within Python code.
Listeners can be either regular functions or async functions.
```python
from pyscript.events import Event
# Create a custom event.
data_loaded = Event()
# Add a listener.
def on_data_loaded(result):
print(f"Data loaded: {result}")
data_loaded.add_listener(on_data_loaded)
# Time passes.... trigger the event.
data_loaded.trigger({"data": 123})
```
"""
def __init__(self):
@@ -19,148 +49,189 @@ class Event:
def trigger(self, result):
"""
Trigger the event with a result to pass into the handlers.
Trigger the event and notify all listeners with the given `result`.
"""
for listener in self._listeners:
if is_awaitable(listener):
# Use create task to avoid making this an async function.
asyncio.create_task(listener(result))
else:
listener(result)
def add_listener(self, listener):
"""
Add a callable/awaitable to listen to when this event is triggered.
Add a function to be called when this event is triggered.
The `listener` must be callable. It can be either a regular function
or an async function. Duplicate listeners are ignored.
"""
if is_awaitable(listener) or callable(listener):
if not callable(listener):
msg = "Listener must be callable."
raise ValueError(msg)
if listener not in self._listeners:
self._listeners.append(listener)
else:
raise ValueError("Listener must be callable or awaitable.")
def remove_listener(self, *args):
def remove_listener(self, *listeners):
"""
Clear the specified handler functions in *args. If no handlers
provided, clear all handlers.
Remove specified `listeners`. If none specified, remove all listeners.
"""
if args:
for listener in args:
if listeners:
for listener in listeners:
try:
self._listeners.remove(listener)
except ValueError:
pass # Silently ignore listeners not in the list.
else:
self._listeners = []
def when(target, *args, **kwargs):
def when(event_type, selector=None, **options):
"""
Add an event listener to the target element(s) for the specified event type.
A decorator to handle DOM events or custom `Event` objects.
The target can be a string representing the event type, or an Event object.
If the target is an Event object, the event listener will be added to that
object. If the target is a string, the event listener will be added to the
element(s) that match the (second) selector argument.
For DOM events, specify the `event_type` (e.g. `"click"`) and a `selector`
for target elements. For custom `Event` objects, just pass the `Event`
instance as the `event_type`. It's also possible to pass a list of `Event`
objects. The `selector` is required only for DOM events. It should be a
[CSS selector string](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Selectors),
`Element`, `ElementCollection`, or list of DOM elements.
If a (third) handler argument is provided, it will be called when the event
is triggered; thus allowing this to be used as both a function and a
decorator.
For DOM events only, you can specify optional
[addEventListener options](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#options):
`capture`, `once`, `passive`, or `signal`.
The decorated function can be either a regular function or an async
function. If the function accepts an argument, it will receive the event
object (for DOM events) or the Event's result (for custom events). A
function does not need to accept any arguments if it doesn't require them.
```python
from pyscript import when, display
# Handle DOM events.
@when("click", "#my-button")
def handle_click(event):
display("Button clicked!")
# Handle DOM events with options.
@when("click", "#my-button", once=True)
def handle_click_once(event):
display("Button clicked once!")
# Handle custom events.
my_event = Event()
@when(my_event)
def handle_custom(): # No event argument needed.
display("Custom event triggered!")
# Handle multiple custom events.
another_event = Event()
def another_handler():
display("Another custom event handler.")
# Attach the same handler to multiple events but not as a decorator.
when([my_event, another_event])(another_handler)
# Trigger an Event instance from a DOM event via @when.
@when("click", "#my-button")
def handle_click(event):
another_event.trigger("Button clicked!")
# Stacked decorators also work.
@when("mouseover", "#my-div")
@when(my_event)
def handle_both(event):
display("Either mouseover or custom event triggered!")
```
"""
# If "when" is called as a function, try to grab the handler from the
# arguments. If there's no handler, this must be a decorator based call.
handler = None
if args and (callable(args[0]) or is_awaitable(args[0])):
handler = args[0]
elif callable(kwargs.get("handler")) or is_awaitable(kwargs.get("handler")):
handler = kwargs.pop("handler")
# If the target is a string, it is the "older" use of `when` where it
# represents the name of a DOM event.
if isinstance(target, str):
# Extract the selector from the arguments or keyword arguments.
selector = args[0] if args else kwargs.pop("selector")
if isinstance(event_type, str):
# This is a DOM event to handle, so check and use the selector.
if not selector:
raise ValueError("No selector provided.")
# Grab the DOM elements to which the target event will be attached.
raise ValueError("Selector required for DOM event handling.")
elements = _get_elements(selector)
if not elements:
raise ValueError(f"No elements found for selector: {selector}")
def decorator(func):
wrapper = _create_wrapper(func)
if isinstance(event_type, Event):
# Custom Event - add listener.
event_type.add_listener(wrapper)
elif isinstance(event_type, list) and all(
isinstance(t, Event) for t in event_type
):
# List of custom Events - add listener to each.
for event in event_type:
event.add_listener(wrapper)
else:
# DOM event - attach to all matched elements.
for element in elements:
element.addEventListener(
event_type,
create_proxy(wrapper),
to_js(options) if options else False,
)
return wrapper
return decorator
def _get_elements(selector):
"""
Convert various `selector` types into a list of DOM elements.
"""
from pyscript.web import Element, ElementCollection
if isinstance(selector, str):
elements = document.querySelectorAll(selector)
return list(document.querySelectorAll(selector))
elif isinstance(selector, Element):
elements = [selector._dom_element]
return [selector._dom_element]
elif isinstance(selector, ElementCollection):
elements = [el._dom_element for el in selector]
return [el._dom_element for el in selector]
elif isinstance(selector, list):
return selector
else:
elements = selector if isinstance(selector, list) else [selector]
return [selector]
def decorator(func):
if config["type"] == "mpy": # Is MicroPython?
if is_awaitable(func):
async def wrapper(*args, **kwargs):
"""
This is a very ugly hack to get micropython working because
`inspect.signature` doesn't exist. It may be actually better
to not try any magic for now and raise the error.
"""
try:
return await func(*args, **kwargs)
except TypeError as e:
if "takes" in str(e) and "positional arguments" in str(e):
return await func()
raise
else:
def wrapper(*args, **kwargs):
"""
This is a very ugly hack to get micropython working because
`inspect.signature` doesn't exist. It may be actually better
to not try any magic for now and raise the error.
"""
try:
return func(*args, **kwargs)
except TypeError as e:
if "takes" in str(e) and "positional arguments" in str(e):
return func()
raise
else:
sig = inspect.signature(func)
if sig.parameters:
def _create_wrapper(func):
"""
Create an appropriate wrapper for the given function, `func`.
The wrapper handles both sync and async functions, and respects whether
the function expects to receive event arguments.
"""
# Get the original function if it's been wrapped. This avoids wrapper
# loops when stacking decorators.
original_func = func
while hasattr(original_func, "__wrapped__"):
original_func = original_func.__wrapped__
# Inspect the original function signature.
sig = inspect.signature(original_func)
accepts_args = bool(sig.parameters)
if is_awaitable(func):
if accepts_args:
async def wrapper(event):
return await func(event)
else:
wrapper = func
else:
# Function doesn't receive events.
if is_awaitable(func):
async def wrapper(*args, **kwargs):
return await func()
else:
if accepts_args:
# Always create a new wrapper function to avoid issues with
# stacked decorators getting into an infinite loop.
def wrapper(event):
return func(event)
else:
def wrapper(*args, **kwargs):
return func()
wrapper = wraps(func)(wrapper)
if isinstance(target, Event):
# The target is a single Event object.
target.add_listener(wrapper)
elif isinstance(target, list) and all(isinstance(t, Event) for t in target):
# The target is a list of Event objects.
for evt in target:
evt.add_listener(wrapper)
else:
# The target is a string representing an event type, and so a
# DOM element or collection of elements is found in "elements".
for el in elements:
el.addEventListener(target, create_proxy(wrapper))
return wrapper
# If "when" was called as a decorator, return the decorator function,
# otherwise just call the internal decorator function with the supplied
# handler.
return decorator(handler) if handler else decorator
return wraps(func)(wrapper)

View File

@@ -1,87 +1,218 @@
import json
"""
This module provides a Python-friendly interface to the
[browser's fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API),
returning native Python data types and supporting directly awaiting the promise
and chaining method calls directly on the promise.
```python
from pyscript.fetch import fetch
url = "https://api.example.com/data"
# Pattern 1: Await the response, then extract data.
response = await fetch(url)
if response.ok:
data = await response.json()
else:
raise NetworkError(f"Fetch failed: {response.status}")
# Pattern 2: Chain method calls directly on the promise.
data = await fetch(url).json()
```
"""
import json
import js
from pyscript.util import as_bytearray
### wrap the response to grant Pythonic results
class _Response:
class _FetchResponse:
"""
Wraps a JavaScript Response object with Pythonic data extraction methods.
This wrapper ensures that data returned from fetch is, if possible, in
native Python types rather than JavaScript types.
"""
def __init__(self, response):
self._response = response
# grant access to response.ok and other fields
def __getattr__(self, attr):
"""
Provide access to underlying Response properties like ok, status, etc.
"""
return getattr(self._response, attr)
# exposed methods with Pythonic results
async def arrayBuffer(self):
"""
Get response body as a buffer (memoryview or bytes).
Returns a memoryview in MicroPython or bytes in Pyodide, representing
the raw binary data.
"""
buffer = await self._response.arrayBuffer()
# works in Pyodide
if hasattr(buffer, "to_py"):
# Pyodide conversion.
return buffer.to_py()
# shims in MicroPython
# MicroPython conversion.
return memoryview(as_bytearray(buffer))
async def blob(self):
"""
Get response body as a JavaScript Blob object.
Returns the raw JS Blob for use with other JS APIs.
"""
return await self._response.blob()
async def bytearray(self):
"""
Get response body as a Python bytearray.
Returns a mutable bytearray containing the response data.
"""
buffer = await self._response.arrayBuffer()
return as_bytearray(buffer)
async def json(self):
"""
Parse response body as JSON and return Python objects.
Returns native Python dicts, lists, strings, numbers, etc.
"""
return json.loads(await self.text())
async def text(self):
"""
Get response body as a text string.
"""
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
class _FetchPromise:
"""
Wraps the fetch promise to enable direct method chaining.
This allows calling response methods directly on the fetch promise:
`await fetch(url).json()` instead of requiring two separate awaits.
This feels more Pythonic since it matches typical usage patterns
Python developers have got used to via libraries like `requests`.
"""
def __init__(self, promise):
self._promise = promise
# To be resolved in the future via the setup() static method.
promise._response = None
# Add convenience methods directly to the promise.
promise.arrayBuffer = self.arrayBuffer
promise.blob = self.blob
promise.bytearray = self.bytearray
promise.json = self.json
promise.text = self.text
async def _response(self):
@staticmethod
def setup(promise, response):
"""
Store the resolved response on the promise for later access.
"""
promise._response = _FetchResponse(response)
return promise._response
async def _get_response(self):
"""
Get the cached response, or await the promise if not yet resolved.
"""
if not self._promise._response:
await self._promise
return self._promise._response
async def arrayBuffer(self):
response = await self._response()
response = await self._get_response()
return await response.arrayBuffer()
async def blob(self):
response = await self._response()
response = await self._get_response()
return await response.blob()
async def bytearray(self):
response = await self._response()
response = await self._get_response()
return await response.bytearray()
async def json(self):
response = await self._response()
response = await self._get_response()
return await response.json()
async def text(self):
response = await self._response()
response = await self._get_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)
def fetch(url, **options):
"""
Fetch a resource from the network using a Pythonic interface.
This wraps JavaScript's fetch API, returning Python-native data types
and supporting both direct promise awaiting and method chaining.
The function takes a `url` and optional fetch `options` as keyword
arguments. The `options` correspond to the JavaScript fetch API's
[RequestInit dictionary](https://developer.mozilla.org/en-US/docs/Web/API/RequestInit),
and commonly include:
- `method`: HTTP method (e.g., `"GET"`, `"POST"`, `"PUT"` etc.)
- `headers`: Dict of request headers.
- `body`: Request body (string, dict for JSON, etc.)
The function returns a promise that resolves to a Response-like object
with Pythonic methods to extract data:
- `await response.json()` to get JSON as Python objects.
- `await response.text()` to get text data.
- `await response.bytearray()` to get raw data as a bytearray.
- `await response.arrayBuffer()` to get raw data as a memoryview or bytes.
- `await response.blob()` to get the raw JS Blob object.
It's also possible to chain these methods directly on the fetch promise:
`data = await fetch(url).json()`
The returned response object also exposes standard properties like
`ok`, `status`, and `statusText` for checking response status.
```python
# Simple GET request.
response = await fetch("https://api.example.com/data")
data = await response.json()
# Method chaining.
data = await fetch("https://api.example.com/data").json()
# POST request with JSON.
response = await fetch(
"https://api.example.com/users",
method="POST",
headers={"Content-Type": "application/json"},
body=json.dumps({"name": "Alice"})
)
result = await response.json()
# Check response status codes.
response = await fetch("https://api.example.com/data")
if response.ok:
# Status in the range 200-299.
data = await response.json()
elif response.status == 404:
print("Resource not found")
else:
print(f"Error: {response.status} {response.statusText}")
```
"""
# Convert Python dict to JavaScript object.
js_options = js.JSON.parse(json.dumps(options))
# Setup response handler to wrap the result.
def on_response(response, *_):
return _FetchPromise.setup(promise, response)
promise = js.fetch(url, js_options).then(on_response)
_FetchPromise(promise)
return promise

View File

@@ -1,18 +1,164 @@
"""
This module provides a unified
[Foreign Function Interface (FFI)](https://en.wikipedia.org/wiki/Foreign_function_interface)
layer for Python/JavaScript interactions, that works consistently across both
Pyodide and MicroPython, and in a worker or main thread context, abstracting
away the differences in their JavaScript interop APIs.
The following utilities work on both the main thread and in worker contexts:
- `create_proxy`: Create a persistent JavaScript proxy of a Python function.
- `to_js`: Convert Python objects to JavaScript objects.
- `is_none`: Check if a value is Python `None` or JavaScript `null`.
- `assign`: Merge objects (like JavaScript's `Object.assign`).
The following utilities are specific to worker contexts:
- `direct`: Mark objects for direct JavaScript access.
- `gather`: Collect multiple values from worker contexts.
- `query`: Query objects in worker contexts.
More details of the `direct`, `gather`, and `query` utilities
[can be found here](https://github.com/WebReflection/reflected-ffi?tab=readme-ov-file#remote-extra-utilities).
"""
try:
# Attempt to import Pyodide's FFI utilities.
import js
from pyodide.ffi import create_proxy as _cp
from pyodide.ffi import to_js as _py_tjs
from pyodide.ffi import jsnull
from_entries = js.Object.fromEntries
def _tjs(value, **kw):
if not hasattr(kw, "dict_converter"):
def _to_js_wrapper(value, **kw):
if "dict_converter" not in kw:
kw["dict_converter"] = from_entries
return _py_tjs(value, **kw)
except:
# Fallback to jsffi for MicroPython.
from jsffi import create_proxy as _cp
from jsffi import to_js as _tjs
from jsffi import to_js as _to_js_wrapper
import js
create_proxy = _cp
to_js = _tjs
jsnull = js.Object.getPrototypeOf(js.Object.prototype)
def create_proxy(func):
"""
Create a persistent JavaScript proxy of a Python function.
This proxy allows JavaScript code to call the Python function
seamlessly, maintaining the correct context and argument handling.
This is especially useful when passing Python functions as callbacks
to JavaScript APIs (without `create_proxy`, the function would be
garbage collected after the declaration of the callback).
```python
from pyscript import ffi
from pyscript import document
my_button = document.getElementById("my-button")
def py_callback(x):
print(f"Callback called with {x}")
my_button.addEventListener("click", ffi.create_proxy(py_callback))
```
"""
return _cp(func)
def to_js(value, **kw):
"""
Convert Python objects to JavaScript objects.
This ensures a Python `dict` becomes a
[proper JavaScript object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object)
rather a JavaScript [`Map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map),
which is more intuitive for most use cases.
Where required, the underlying `to_js` uses `Object.fromEntries` for
`dict` conversion.
```python
from pyscript import ffi
import js
note = {
"body": "This is a notification",
"icon": "icon.png"
}
js.Notification.new("Hello!", ffi.to_js(note))
```
"""
return _to_js_wrapper(value, **kw)
def is_none(value):
"""
Check if a value is `None` or JavaScript `null`.
In Pyodide, JavaScript `null` is represented by the `jsnull` object,
so we check for both Python `None` and `jsnull`. This function ensures
consistent behavior across Pyodide and MicroPython for null-like
values.
```python
from pyscript import ffi
import js
val1 = None
val2 = js.null
val3 = 42
print(ffi.is_none(val1)) # True
print(ffi.is_none(val2)) # True
print(ffi.is_none(val3)) # False
```
"""
return value is None or value is jsnull
try:
# Worker context utilities from reflected-ffi.
# See https://github.com/WebReflection/reflected-ffi for more details.
from polyscript import ffi as _ffi
_assign = _ffi.assign
direct = _ffi.direct
gather = _ffi.gather
query = _ffi.query
except:
# Fallback implementations for main thread context.
import js
_assign = js.Object.assign
direct = lambda source: source
def assign(source, *args):
"""
Merge JavaScript objects (like
[Object.assign](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign)).
Takes a target object and merges properties from one or more source
objects into it, returning the modified target.
```python
obj = js.Object.new()
ffi.assign(obj, {"a": 1}, {"b": 2})
# obj now has properties a=1 and b=2
```
"""
for arg in args:
_assign(source, to_js(arg))
return source

View File

@@ -1,4 +1,36 @@
# https://www.npmjs.com/package/flatted
"""
This module is a Python implementation of the
[Flatted JavaScript library](https://www.npmjs.com/package/flatted), which
provides a light and fast way to serialize and deserialize JSON structures
that contain circular references.
Standard JSON cannot handle circular references - attempting to serialize an
object that references itself will cause an error. Flatted solves this by
transforming circular structures into a flat array format that can be safely
serialized and later reconstructed.
Common use cases:
- Serializing complex object graphs with circular references.
- Working with DOM-like structures that contain parent/child references.
- Preserving object identity when serializing data structures.
```python
from pyscript import flatted
# Create a circular structure.
obj = {"name": "parent"}
obj["self"] = obj # Circular reference!
# Standard json.dumps would fail here.
serialized = flatted.stringify(obj)
# Reconstruct the original structure.
restored = flatted.parse(serialized)
assert restored["self"] is restored # Circular reference preserved!
```
"""
import json as _json
@@ -31,7 +63,7 @@ def _object_keys(value):
def _is_array(value):
return isinstance(value, list) or isinstance(value, tuple)
return isinstance(value, (list, tuple))
def _is_object(value):
@@ -60,10 +92,10 @@ def _loop(keys, input, known, output):
def _ref(key, value, input, known, output):
if _is_array(value) and not value in known:
if _is_array(value) and value not in known:
known.append(value)
value = _loop(_array_keys(value), input, known, value)
elif _is_object(value) and not value in known:
elif _is_object(value) and value not in known:
known.append(value)
value = _loop(_object_keys(value), input, known, value)
@@ -114,6 +146,26 @@ def _wrap(value):
def parse(value, *args, **kwargs):
"""
Parse a Flatted JSON string and reconstruct the original structure.
This function takes a `value` containing a JSON string created by
Flatted's stringify() and reconstructs the original Python object,
including any circular references. The `*args` and `**kwargs` are passed
to json.loads() for additional customization.
```python
from pyscript import flatted
# Parse a Flatted JSON string.
json_string = '[{"name": "1", "self": "0"}, "parent"]'
obj = flatted.parse(json_string)
# Circular references are preserved.
assert obj["self"] is obj
```
"""
json = _json.loads(value, *args, **kwargs)
wrapped = []
for value in json:
@@ -138,6 +190,31 @@ def parse(value, *args, **kwargs):
def stringify(value, *args, **kwargs):
"""
Serialize a Python object to a Flatted JSON string.
This function converts `value`, a Python object (including those with
circular references), into a JSON string that can be safely transmitted
or stored. The resulting string can be reconstructed using Flatted's
parse(). The `*args` and `**kwargs` are passed to json.dumps() for
additional customization.
```python
from pyscript import flatted
# Create an object with a circular reference.
parent = {"name": "parent", "children": []}
child = {"name": "child", "parent": parent}
parent["children"].append(child)
# Serialize it (standard json.dumps would fail here).
json_string = flatted.stringify(parent)
# Can optionally pretty-print via JSON indentation etc.
pretty = flatted.stringify(parent, indent=2)
```
"""
known = _Known()
input = []
output = []

View File

@@ -0,0 +1,258 @@
"""
This module provides an API for mounting directories from the user's local
filesystem into the browser's virtual filesystem. This means Python code,
running in the browser, can read and write files on the user's local machine.
!!! warning
**This API only works in Chromium-based browsers** (Chrome, Edge,
Vivaldi, Brave, etc.) that support the
[File System Access API](https://wicg.github.io/file-system-access/).
The module maintains a `mounted` dictionary that tracks all currently mounted
paths and their associated filesystem handles.
```python
from pyscript import fs, document, when
# Mount a local directory to the `/local` mount point in the browser's
# virtual filesystem (may prompt user for permission).
await fs.mount("/local")
# Alternatively, mount on a button click event. This is important because
# if the call to `fs.mount` happens after a click or other transient event,
# the confirmation dialog will not be shown.
@when("click", "#mount-button")
async def handler(event):
await fs.mount("/another_dir")
# Work with files in the mounted directory as usual.
with open("/local/example.txt", "w") as f:
f.write("Hello from PyScript!")
# Ensure changes are written to local filesystem.
await fs.sync("/local")
# Clean up when done.
await fs.unmount("/local")
```
"""
import js
from _pyscript import fs as _fs, interpreter
from pyscript import window
from pyscript.ffi import to_js
from pyscript.context import RUNNING_IN_WORKER
# Worker-specific imports.
if RUNNING_IN_WORKER:
from pyscript.context import sync as sync_with_worker
from polyscript import IDBMap
mounted = {}
"""Global dictionary tracking mounted paths and their filesystem handles."""
async def _check_permission(details):
"""
Check if permission has been granted for a filesystem handler. Returns
the handler if permission is granted, otherwise None.
"""
handler = details.handler
options = details.options
permission = await handler.queryPermission(options)
return handler if permission == "granted" else None
async def mount(path, mode="readwrite", root="", id="pyscript"):
"""
Mount a directory from the local filesystem to the virtual filesystem
at the specified `path` mount point. The `mode` can be "readwrite" or
"read" to specify access level. The `root` parameter provides a hint
for the file picker starting location. The `id` parameter allows multiple
distinct mounts at the same path.
On first use, the browser will prompt the user to select a directory
and grant permission.
```python
from pyscript import fs
# Basic mount with default settings.
await fs.mount("/local")
# Mount with read-only access.
await fs.mount("/readonly", mode="read")
# Mount with a hint to start in Downloads folder.
await fs.mount("/downloads", root="downloads")
# Mount with a custom ID to track different directories.
await fs.mount("/project", id="my-project")
```
If called during a user interaction (like a button click), the
permission dialog may be skipped if permission was previously granted.
"""
js.console.warn("experimental pyscript.fs ⚠️")
# Check if path is already mounted with a different ID.
mount_key = f"{path}@{id}"
if path in mounted:
# Path already mounted - check if it's the same ID.
for existing_key in mounted.keys():
if existing_key.startswith(f"{path}@") and existing_key != mount_key:
raise ValueError(
f"Path '{path}' is already mounted with a different ID. "
f"Unmount it first or use a different path."
)
details = None
handler = None
options = {"id": id, "mode": mode}
if root != "":
options["startIn"] = root
if RUNNING_IN_WORKER:
fs_handler = sync_with_worker.storeFSHandler(mount_key, to_js(options))
# Handle both async and SharedArrayBuffer use cases.
if isinstance(fs_handler, bool):
success = fs_handler
else:
success = await fs_handler
if success:
idbm = IDBMap.new(_fs.NAMESPACE)
details = await idbm.get(mount_key)
handler = await _check_permission(details)
if handler is None:
# Force await in either async or sync scenario.
await js.Promise.resolve(sync_with_worker.getFSHandler(details.options))
handler = details.handler
else:
raise RuntimeError(_fs.ERROR)
else:
success = await _fs.idb.has(mount_key)
if success:
details = await _fs.idb.get(mount_key)
handler = await _check_permission(details)
if handler is None:
handler = await _fs.getFileSystemDirectoryHandle(details.options)
else:
js_options = to_js(options)
handler = await _fs.getFileSystemDirectoryHandle(js_options)
details = {"handler": handler, "options": js_options}
await _fs.idb.set(mount_key, to_js(details))
mounted[path] = await interpreter.mountNativeFS(path, handler)
async def sync(path):
"""
Synchronise the virtual and local filesystems for a mounted `path`.
This ensures all changes made in the browser's virtual filesystem are
written to the user's local filesystem, and vice versa.
```python
from pyscript import fs
await fs.mount("/local")
# Make changes to files.
with open("/local/data.txt", "w") as f:
f.write("Important data")
# Ensure changes are written to local disk.
await fs.sync("/local")
```
This is automatically called by unmount(), but you may want to call
it explicitly to ensure data persistence at specific points.
"""
if path not in mounted:
raise KeyError(
f"Path '{path}' is not mounted. " f"Use fs.mount() to mount it first."
)
await mounted[path].syncfs()
async def unmount(path):
"""
Unmount a directory, specified by `path`, from the virtual filesystem.
This synchronises any pending changes and then removes the mount point,
freeing up memory. The `path` can be reused for mounting a different
directory.
```python
from pyscript import fs
await fs.mount("/local")
# ... work with files ...
await fs.unmount("/local")
# Path can now be reused.
await fs.mount("/local", id="different-folder")
```
This automatically calls `sync()` before unmounting to ensure no data
is lost.
"""
if path not in mounted:
raise KeyError(f"Path '{path}' is not mounted. Cannot unmount.")
await sync(path)
interpreter._module.FS.unmount(path)
del mounted[path]
async def revoke(path, id="pyscript"):
"""
Revoke filesystem access permission and unmount for a given
`path` and `id` combination.
This removes the stored permission for accessing the user's local
filesystem at the specified path and ID. Unlike `unmount()`, which only
removes the mount point, `revoke()` also clears the permission so the
user will be prompted again on next mount.
```python
from pyscript import fs
await fs.mount("/local", id="my-app")
# ... work with files ...
# Revoke permission (user will be prompted again next time).
revoked = await fs.revoke("/local", id="my-app")
if revoked:
print("Permission revoked successfully")
```
After revoking, the user will need to grant permission again and
select a directory when `mount()` is called next time.
"""
mount_key = f"{path}@{id}"
if RUNNING_IN_WORKER:
handler_exists = sync_with_worker.deleteFSHandler(mount_key)
else:
handler_exists = await _fs.idb.has(mount_key)
if handler_exists:
handler_exists = await _fs.idb.delete(mount_key)
if handler_exists:
interpreter._module.FS.unmount(path)
if path in mounted:
del mounted[path]
return handler_exists

View File

@@ -1,81 +0,0 @@
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 polyscript
PyWorker = NotSupported(
"pyscript.PyWorker",
"pyscript.PyWorker works only when running in the main thread",
)
try:
import js
window = polyscript.xworker.window
document = window.document
# weird + not worth it as it does not work anyway
js.screen = window.screen
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
# but there is a sync utility from xworker
def current_target():
return polyscript.target
else:
import _pyscript
from _pyscript import PyWorker, js_import
window = globalThis
document = globalThis.document
sync = NotSupported(
"pyscript.sync", "pyscript.sync works only when running in a worker"
)
# in MAIN the current element target exist, just use it
def current_target():
return _pyscript.target

View File

@@ -1,91 +1,247 @@
"""
This module provides classes and functions for interacting with
[media devices and streams](https://developer.mozilla.org/en-US/docs/Web/API/Media_Capture_and_Streams_API)
in the browser, enabling you to work with cameras, microphones,
and other media input/output devices directly from Python.
Use this module for:
- Accessing webcams for video capture.
- Recording audio from microphones.
- Enumerating available media devices.
- Applying constraints to media streams (resolution, frame rate, etc.).
```python
from pyscript import document
from pyscript.media import Device, list_devices
# Get a video stream from the default camera.
stream = await Device.request_stream(video=True)
# Display in a video element.
video = document.getElementById("my-video")
video.srcObject = stream
# Or list all available devices.
devices = await list_devices()
for device in devices:
print(f"{device.kind}: {device.label}")
```
Using media devices requires user permission. Browsers will show a
permission dialog when accessing devices for the first time.
"""
from pyscript import window
from pyscript.ffi import to_js
class Device:
"""Device represents a media input or output device, such as a microphone,
camera, or headset.
"""
Represents a media input or output device.
This class wraps a browser
[MediaDeviceInfo object](https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo),
providing Pythonic access to device properties like `ID`, `label`, and
`kind` (audio/video, input/output).
Devices are typically obtained via the `list_devices()` function in this
module, rather than constructed directly.
```python
from pyscript.media import list_devices
# Get all available devices.
devices = await list_devices()
# Find video input devices (cameras).
cameras = [d for d in devices if d.kind == "videoinput"]
# Get a stream from a specific camera.
if cameras:
stream = await cameras[0].get_stream()
```
"""
def __init__(self, device):
self._dom_element = device
"""
Create a Device wrapper around a MediaDeviceInfo `device`.
"""
self._device_info = device
@property
def id(self):
return self._dom_element.deviceId
"""
Unique identifier for this device.
This `ID` persists across sessions but is reset when the user clears
cookies. It's unique to the origin of the calling application.
"""
return self._device_info.deviceId
@property
def group(self):
return self._dom_element.groupId
"""
Group identifier for related devices.
Devices belonging to the same physical device (e.g., a monitor with
both a camera and microphone) share the same `group ID`.
"""
return self._device_info.groupId
@property
def kind(self):
return self._dom_element.kind
"""
Device type: `"videoinput"`, `"audioinput"`, or `"audiooutput"`.
"""
return self._device_info.kind
@property
def label(self):
return self._dom_element.label
"""
Human-readable description of the device.
Example: `"External USB Webcam"` or `"Built-in Microphone"`.
"""
return self._device_info.label
def __getitem__(self, key):
"""
Support bracket notation for JavaScript interop.
Allows accessing properties via `device["id"]` syntax. Necessary
when Device instances are proxied to JavaScript.
"""
return getattr(self, key)
@classmethod
async def load(cls, audio=False, video=True):
"""Load the device stream."""
options = window.Object.new()
options.audio = audio
async def request_stream(cls, audio=False, video=True):
"""
Request a media stream with the specified constraints.
This is a class method that requests access to media devices matching
the given `audio` and `video` constraints. The browser will prompt the
user for permission if needed and return a `MediaStream` object that
can be assigned to video/audio elements.
Simple boolean constraints for `audio` and `video` can be used to
request default devices. More complex constraints can be specified as
dictionaries conforming to
[the MediaTrackConstraints interface](https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints).
```python
from pyscript import document
from pyscript.media import Device
# Get default video stream.
stream = await Device.request_stream()
# Get stream with specific constraints.
stream = await Device.request_stream(
video={"width": 1920, "height": 1080}
)
# Get audio and video.
stream = await Device.request_stream(audio=True, video=True)
# Use the stream.
video_el = document.getElementById("camera")
video_el.srcObject = stream
```
This method will trigger a browser permission dialog on first use.
"""
options = {}
if isinstance(audio, bool):
options["audio"] = audio
elif isinstance(audio, dict):
# audio is a dict of constraints (sampleRate, echoCancellation etc...).
options["audio"] = audio
if isinstance(video, bool):
options.video = video
else:
# TODO: Think this can be simplified but need to check it on the pyodide side
options["video"] = video
elif isinstance(video, dict):
# video is a dict of constraints (width, height etc...).
options["video"] = video
return await window.navigator.mediaDevices.getUserMedia(to_js(options))
# TODO: this is pyodide specific. shouldn't be!
options.video = window.Object.new()
for k in video:
setattr(options.video, k, to_js(video[k]))
@classmethod
async def load(cls, audio=False, video=True):
"""
!!! warning
**Deprecated: Use `request_stream()` instead.**
stream = await window.navigator.mediaDevices.getUserMedia(options)
return stream
This method is retained for backwards compatibility but will be
removed in a future release. Please use `request_stream()` instead.
"""
return await cls.request_stream(audio=audio, video=video)
async def get_stream(self):
key = self.kind.replace("input", "").replace("output", "")
options = {key: {"deviceId": {"exact": self.id}}}
return await self.load(**options)
async def list_devices() -> list[dict]:
"""
Return the list of the currently available media input and output devices,
such as microphones, cameras, headsets, and so forth.
Get a media stream from this specific device.
Output:
```python
from pyscript.media import list_devices
list(dict) - list of dictionaries representing the available media devices.
Each dictionary has the following keys:
* deviceId: a string that is an identifier for the represented device
that is persisted across sessions. It is un-guessable by other
applications and unique to the origin of the calling application.
It is reset when the user clears cookies (for Private Browsing, a
different identifier is used that is not persisted across sessions).
* groupId: a string that is a group identifier. Two devices have the same
group identifier if they belong to the same physical device — for
example a monitor with both a built-in camera and a microphone.
# List all devices.
devices = await list_devices()
* kind: an enumerated value that is either "videoinput", "audioinput"
or "audiooutput".
# Find a specific camera.
my_camera = None
for device in devices:
if device.kind == "videoinput" and "USB" in device.label:
my_camera = device
break
* label: a string describing this device (for example "External USB
Webcam").
# Get a stream from that specific camera.
if my_camera:
stream = await my_camera.get_stream()
```
Note: the returned list will omit any devices that are blocked by the document
Permission Policy: microphone, camera, speaker-selection (for output devices),
and so on. Access to particular non-default devices is also gated by the
Permissions API, and the list will omit devices for which the user has not
granted explicit permission.
This will trigger a permission dialog if the user hasn't already
granted permission for this device type.
"""
# https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices
return [
Device(obj) for obj in await window.navigator.mediaDevices.enumerateDevices()
]
# Extract media type from device kind (e.g., "videoinput" -> "video").
media_type = self.kind.replace("input", "").replace("output", "")
# Request stream with exact device ID constraint.
options = {media_type: {"deviceId": {"exact": self.id}}}
return await self.request_stream(**options)
async def list_devices():
"""
Returns a list of all media devices currently available to the browser,
such as microphones, cameras, and speakers.
```python
from pyscript.media import list_devices
# Get all devices.
devices = await list_devices()
# Print device information.
for device in devices:
print(f"{device.kind}: {device.label} (ID: {device.id})")
# Filter for specific device types.
cameras = [d for d in devices if d.kind == "videoinput"]
microphones = [d for d in devices if d.kind == "audioinput"]
speakers = [d for d in devices if d.kind == "audiooutput"]
```
The returned list will omit devices that are blocked by the document
[Permission Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Permissions_Policy)
(microphone, camera, speaker-selection) or for
which the user has not granted explicit permission.
For security and privacy, device labels may be empty strings until
permission is granted. See
[this document](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices)
for more information about this web standard.
"""
device_infos = await window.navigator.mediaDevices.enumerateDevices()
return [Device(device_info) for device_info in device_infos]

View File

@@ -1,60 +1,250 @@
from polyscript import storage as _storage
"""
This module wraps the browser's
[IndexedDB persistent storage](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)
to provide a familiar Python dictionary API. Data is automatically
serialized and persisted, surviving page reloads and browser restarts.
Storage is persistent per origin (domain), isolated between different sites
for security. Browsers typically allow each origin to store up to 10-60% of
total disk space, depending on browser and configuration.
What this module provides:
- A `dict`-like API (get, set, delete, iterate).
- Automatic serialization of common Python types.
- Background persistence with optional explicit `sync()`.
- Support for custom `Storage` subclasses.
```python
from pyscript import storage
# Create or open a named storage.
my_data = await storage("user-preferences")
# Use like a regular dictionary.
my_data["theme"] = "dark"
my_data["font_size"] = 14
my_data["settings"] = {"notifications": True, "sound": False}
# Changes are queued automatically.
# To ensure immediate write, sync explicitly.
await my_data.sync()
# Read values (survives page reload).
theme = my_data.get("theme", "light")
```
Common types are automatically serialized: `bool`, `int`, `float`, `str`, `None`,
`list`, `dict`, `tuple`. Binary data (`bytearray`, `memoryview`) can be stored as
single values but not nested in structures.
Tuples are deserialized as lists due to IndexedDB limitations.
!!! info
Browsers typically allow 10-60% of total disk space per origin. Chrome
and Edge allow up to 60%, Firefox up to 10 GiB (or 10% of disk, whichever
is smaller). Safari varies by app type. These limits are unlikely to be
reached in typical usage.
"""
from polyscript import storage as _polyscript_storage
from pyscript.flatted import parse as _parse
from pyscript.flatted import stringify as _stringify
from pyscript.ffi import is_none
# convert a Python value into an IndexedDB compatible entry
def _to_idb(value):
if value is None:
def _convert_to_idb(value):
"""
Convert a Python `value` to an IndexedDB-compatible format.
Values are serialized using Flatted (for circular reference support)
with type information to enable proper deserialization. It returns a
JSON string representing the serialized value.
Will raise a TypeError if the value type is not supported.
"""
if is_none(value):
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]])
return _stringify(["bytearray", list(value)])
if isinstance(value, memoryview):
return _stringify(["memoryview", [v for v in value]])
raise TypeError(f"Unexpected value: {value}")
return _stringify(["memoryview", list(value)])
raise TypeError(f"Cannot serialize type {type(value).__name__} for storage.")
# convert an IndexedDB compatible entry into a Python value
def _from_idb(value):
(
kind,
result,
) = _parse(value)
def _convert_from_idb(value):
"""
Convert an IndexedDB `value` back to its Python representation.
Uses type information stored during serialization to reconstruct the
original Python type.
"""
kind, data = _parse(value)
if kind == "null":
return None
if kind == "generic":
return result
return data
if kind == "bytearray":
return bytearray(result)
return bytearray(data)
if kind == "memoryview":
return memoryview(bytearray(result))
return memoryview(bytearray(data))
# Fallback for all other types.
return value
class Storage(dict):
"""
A persistent dictionary backed by the browser's IndexedDB.
This class provides a dict-like interface with automatic persistence.
Changes are queued for background writing, with optional explicit
synchronization via `sync()`.
Inherits from `dict`, so all standard dictionary methods work as expected.
```python
from pyscript import storage
# Open a storage.
prefs = await storage("preferences")
# Use as a dictionary.
prefs["color"] = "blue"
prefs["count"] = 42
# Iterate like a dict.
for key, value in prefs.items():
print(f"{key}: {value}")
# Ensure writes complete immediately.
await prefs.sync()
```
Sometimes you may need to subclass `Storage` to add custom behavior:
```python
from pyscript import storage, Storage, window
class LoggingStorage(Storage):
def __setitem__(self, key, value):
window.console.log(f"Setting {key} = {value}")
super().__setitem__(key, value)
my_store = await storage("app-data", storage_class=LoggingStorage)
my_store["test"] = 123 # Logs to console.
```
"""
def __init__(self, store):
super().__init__({k: _from_idb(v) for k, v in store.entries()})
self.__store__ = store
"""
Create a Storage instance wrapping an IndexedDB `store` (a JS
proxy).
"""
super().__init__(
{key: _convert_from_idb(value) for key, value in store.entries()}
)
self._store = store
def __delitem__(self, attr):
self.__store__.delete(attr)
super().__delitem__(attr)
def __delitem__(self, key):
"""
Delete an item from storage via its `key`.
def __setitem__(self, attr, value):
self.__store__.set(attr, _to_idb(value))
super().__setitem__(attr, value)
The deletion is queued for persistence. Use `sync()` to ensure
immediate completion.
"""
self._store.delete(key)
super().__delitem__(key)
def __setitem__(self, key, value):
"""
Set a `key` to a `value` in storage.
The change is queued for persistence. Use `sync()` to ensure
immediate completion. The `value` must be a supported type for
serialization.
"""
self._store.set(key, _convert_to_idb(value))
super().__setitem__(key, value)
def clear(self):
self.__store__.clear()
"""
Remove all items from storage.
The `clear()` operation is queued for persistence. Use `sync()` to ensure
immediate completion.
"""
self._store.clear()
super().clear()
async def sync(self):
await self.__store__.sync()
"""
Force immediate synchronization to IndexedDB.
By default, storage operations are queued and written asynchronously.
Call `sync()` when you need to guarantee changes are persisted immediately,
such as before critical operations or page unload.
```python
store = await storage("important-data")
store["critical_value"] = data
# Ensure it's written before proceeding.
await store.sync()
```
This is a blocking operation that waits for IndexedDB to complete
the write.
"""
await self._store.sync()
async def storage(name="", storage_class=Storage):
"""
Open or create persistent storage with a unique `name` and optional
`storage_class` (used to extend the default `Storage` based behavior).
Each storage is isolated by name within the current origin (domain).
If the storage doesn't exist, it will be created. If it does exist,
its current contents will be loaded.
This function returns a `Storage` instance (or custom subclass instance)
acting as a persistent dictionary. A `ValueError` is raised if `name` is
empty or not provided.
```python
from pyscript import storage
# Basic usage.
user_data = await storage("user-profile")
user_data["name"] = "Alice"
user_data["age"] = 30
# Multiple independent storages.
settings = await storage("app-settings")
cache = await storage("api-cache")
# With custom Storage class.
class ValidatingStorage(Storage):
def __setitem__(self, key, value):
if not isinstance(key, str):
raise TypeError("Keys must be strings")
super().__setitem__(key, value)
validated = await storage("validated-data", ValidatingStorage)
```
Storage names are automatically prefixed with `"@pyscript/"` to
namespace them within IndexedDB.
"""
if not name:
raise ValueError("The storage name must be defined")
return storage_class(await _storage(f"@pyscript/{name}"))
raise ValueError("Storage name must be a non-empty string")
underlying_store = await _polyscript_storage(f"@pyscript/{name}")
return storage_class(underlying_store)

View File

@@ -1,17 +1,30 @@
"""
This module contains general-purpose utility functions that don't fit into
more specific modules. These utilities handle cross-platform compatibility
between Pyodide and MicroPython, feature detection, and common type
conversions:
- `as_bytearray`: Convert JavaScript `ArrayBuffer` to Python `bytearray`.
- `NotSupported`: Placeholder for unavailable features in specific contexts.
- `is_awaitable`: Detect `async` functions across Python implementations.
These utilities are primarily used internally by PyScript but are available
for use in application code when needed.
"""
import js
import sys
import inspect
def as_bytearray(buffer):
"""
Given a JavaScript ArrayBuffer, convert it to a Python bytearray in a
Given a JavaScript `ArrayBuffer`, convert it to a Python `bytearray` in a
MicroPython friendly manner.
"""
ui8a = js.Uint8Array.new(buffer)
size = ui8a.length
ba = bytearray(size)
for i in range(0, size):
for i in range(size):
ba[i] = ui8a[i]
return ba
@@ -42,17 +55,25 @@ class NotSupported:
def is_awaitable(obj):
"""
Returns a boolean indication if the passed in obj is an awaitable
function. (MicroPython treats awaitables as generator functions, and if
the object is a closure containing an async function we need to work
carefully.)
function. This is interpreter agnostic.
!!! info
MicroPython treats awaitables as generator functions, and if
the object is a closure containing an async function or a bound method
we need to work carefully.
"""
from pyscript import config
if config["type"] == "mpy": # Is MicroPython?
if config["type"] == "mpy":
# MicroPython doesn't appear to have a way to determine if a closure is
# an async function except via the repr. This is a bit hacky.
if "<closure <generator>" in repr(obj):
r = repr(obj)
if "<closure <generator>" in r:
return True
# Same applies to bound methods.
if "<bound_method" in r and "<generator>" in r:
return True
# In MicroPython, generator functions are awaitable.
return inspect.isgeneratorfunction(obj)
return inspect.iscoroutinefunction(obj)

File diff suppressed because it is too large Load Diff

View File

@@ -1,71 +1,295 @@
"""
This module provides a Pythonic wrapper around the browser's
[WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket),
enabling two-way communication with WebSocket servers.
Use this for real-time applications:
- Pythonic interface to browser WebSockets.
- Automatic handling of async event handlers.
- Support for receiving text (`str`) and binary (`memoryview`) data.
- Support for sending text (`str`) and binary (`bytes` and `bytearray`) data.
- Compatible with Pyodide and MicroPython.
- Works in webworker contexts.
- Naming deliberately follows the JavaScript WebSocket API closely for
familiarity.
See the Python docs for
[an explanation of memoryview](https://docs.python.org/3/library/stdtypes.html#memoryview).
```python
from pyscript import WebSocket
def on_open(event):
print("Connected!")
ws.send("Hello server")
def on_message(event):
print(f"Received: {event.data}")
def on_close(event):
print("Connection closed")
ws = WebSocket(url="ws://localhost:8080/")
ws.onopen = on_open
ws.onmessage = on_message
ws.onclose = on_close
```
"""
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"]
from pyscript.util import as_bytearray, is_awaitable
class EventMessage:
def _attach_event_handler(websocket, handler_name, handler_function):
"""
Given a `websocket`, and `handler_name`, attach the `handler_function`
to the `WebSocket` instance, handling both synchronous and asynchronous
handler functions.
Creates a JavaScript proxy for the handler and wraps async handlers
appropriately. Handles the `WebSocketEvent` wrapping for all handlers.
"""
if is_awaitable(handler_function):
async def async_wrapper(event):
await handler_function(WebSocketEvent(event))
wrapped_handler = create_proxy(async_wrapper)
else:
wrapped_handler = create_proxy(
lambda event: handler_function(WebSocketEvent(event))
)
# Note: Direct assignment (websocket[handler_name]) fails in Pyodide.
setattr(websocket, handler_name, wrapped_handler)
class WebSocketEvent:
"""
A read-only wrapper for
[WebSocket event objects](https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent).
This class wraps browser WebSocket events and provides convenient access
to event properties. It handles the conversion of binary data from
JavaScript typed arrays to Python bytes-like objects.
The most commonly used property is `event.data`, which contains the
message data for "message" events.
```python
def on_message(event): # The event is a WebSocketEvent instance.
# For text messages.
if isinstance(event.data, str):
print(f"Text: {event.data}")
else:
# For binary messages.
print(f"Binary: {len(event.data)} bytes")
```
"""
def __init__(self, event):
"""
Create a WebSocketEvent wrapper from an underlying JavaScript
`event`.
"""
self._event = event
def __getattr__(self, attr):
value = getattr(self._event, attr)
"""
Get an attribute `attr` from the underlying event object.
Handles special conversion of binary data from JavaScript typed
arrays to Python `memoryview` objects.
"""
value = getattr(self._event, attr)
if attr == "data" and not isinstance(value, str):
if hasattr(value, "to_py"):
# Pyodide - convert JavaScript typed array to Python.
return value.to_py()
# shims in MicroPython
else:
# MicroPython - manually convert JS ArrayBuffer.
return memoryview(as_bytearray(value))
return value
class WebSocket(object):
class WebSocket:
"""
This class provides a Python-friendly interface to WebSocket connections,
handling communication with WebSocket servers. It supports both text and
binary data transmission.
Access the underlying WebSocket methods and properties directly if needed.
However, the wrapper provides a more Pythonic API. If you need to work
with the raw JavaScript WebSocket instance, you can access it via the
`_js_websocket` attribute.
Using textual (`str`) data:
```python
from pyscript import WebSocket
# Create WebSocket with handlers as arguments.
def handle_message(event):
print(f"Got: {event.data}")
ws = WebSocket(
url="ws://echo.websocket.org/",
onmessage=handle_message
)
# Or assign handlers after creation.
def handle_open(event):
ws.send("Hello!")
ws.onopen = handle_open
```
Using binary (`memoryview`) data:
```python
def handle_message(event):
if isinstance(event.data, str):
print(f"Text: {event.data}")
else:
# Binary data as memoryview.
print(f"Binary: {len(event.data)} bytes")
ws = WebSocket(url="ws://example.com/", onmessage=handle_message)
# Send binary data.
data = bytearray([0x01, 0x02, 0x03])
ws.send(data)
```
Read more about Python's
[`memoryview` here](https://docs.python.org/3/library/stdtypes.html#memoryview).
"""
# WebSocket ready state constants.
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)
def __init__(self, url, protocols=None, **handlers):
"""
Create a new WebSocket connection from the given `url` (`ws://` or
`wss://`). Optionally specify `protocols` (a string or a list of
protocol strings) and event handlers (`onopen`, `onmessage`, etc.) as
keyword arguments.
for t in methods:
if t in kw:
# Pyodide fails at setting socket[t] directly
setattr(socket, t, create_proxy(kw[t]))
These arguments and naming conventions mirror those of the
[underlying JavaScript WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
for familiarity.
If you need access to the underlying JavaScript WebSocket instance,
you can get it via the `_js_websocket` attribute.
```python
# Basic connection.
ws = WebSocket(url="ws://localhost:8080/")
# With protocol.
ws = WebSocket(
url="wss://example.com/socket",
protocols="chat"
)
# With handlers.
ws = WebSocket(
url="ws://localhost:8080/",
onopen=lambda e: print("Connected"),
onmessage=lambda e: print(e.data)
)
```
"""
# Create underlying JavaScript WebSocket.
if protocols:
js_websocket = js.WebSocket.new(url, protocols)
else:
js_websocket = js.WebSocket.new(url)
# Set binary type to arraybuffer for easier Python handling.
js_websocket.binaryType = "arraybuffer"
# Store the underlying WebSocket.
# Use object.__setattr__ to bypass our custom __setattr__.
object.__setattr__(self, "_js_websocket", js_websocket)
# Attach any event handlers passed as keyword arguments.
for handler_name, handler in handlers.items():
setattr(self, handler_name, handler)
def __getattr__(self, attr):
return getattr(self._ws, attr)
"""
Get an attribute `attr` from the underlying WebSocket.
This allows transparent access to WebSocket properties like
`readyState`, `url`, `bufferedAmount`, etc.
"""
return getattr(self._js_websocket, 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)
"""
Set an attribute `attr` on the WebSocket to the given `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])
Event handler attributes (`onopen`, `onmessage`, etc.) are specially
handled to create proper proxies. Other attributes are set on the
underlying WebSocket directly.
"""
if attr in ["onclose", "onerror", "onmessage", "onopen"]:
_attach_event_handler(self._js_websocket, attr, value)
else:
self._ws.close()
setattr(self._js_websocket, attr, value)
def send(self, data):
"""
Send `data` through the WebSocket.
Accepts both text (`str`) and binary data (`bytes`, `bytearray`, etc.).
Binary data is automatically converted to a JavaScript `Uint8Array`.
```python
# Send text.
ws.send("Hello server!")
# Send binary.
ws.send(bytes([1, 2, 3, 4]))
ws.send(bytearray([5, 6, 7, 8]))
```
!!! warning
The WebSocket **must be in the OPEN state to send data**.
"""
if isinstance(data, str):
self._ws.send(data)
self._js_websocket.send(data)
else:
buffer = js.Uint8Array.new(len(data))
for pos, b in enumerate(data):
buffer[pos] = b
self._ws.send(buffer)
for index, byte_value in enumerate(data):
buffer[index] = byte_value
self._js_websocket.send(buffer)
def close(self, code=None, reason=None):
"""
Close the WebSocket connection. Optionally specify a `code` (`int`)
and a `reason` (`str`) for closing the connection.
```python
# Normal close.
ws.close()
# Close with code and reason.
ws.close(code=1000, reason="Task completed")
```
Usage and values for `code` and `reasons`
[are explained here](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close).
"""
if code and reason:
self._js_websocket.close(code, reason)
elif code:
self._js_websocket.close(code)
else:
self._js_websocket.close()

View File

@@ -1,43 +1,194 @@
import js as _js
from polyscript import workers as _workers
"""
This module provides access to named
[web workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API)
defined in `<script>` tags, and utilities for dynamically creating workers
from Python code.
_get = _js.Reflect.get
Named workers are Python web workers defined in HTML with a `name` attribute
that can be referenced from the main thread or other workers. This module
provides the `workers` object for accessing named workers and the
`create_named_worker()` function for dynamically creating them.
Accessing named workers:
```html
<!-- Define a named worker -->
<script type="py" worker name="calculator">
def add(a, b):
return a + b
__export__ = ["add"]
</script>
<!-- Access from main thread -->
<script type="mpy">
from pyscript import workers
def _set(script, name, value=""):
script.setAttribute(name, value)
calc = await workers["calculator"]
result = await calc.add(5, 3)
print(result) # 8
</script>
```
Dynamically creating named workers:
```python
from pyscript import create_named_worker
# this solves an inconsistency between Pyodide and MicroPython
# @see https://github.com/pyscript/pyscript/issues/2106
class _ReadOnlyProxy:
# Create a worker from a Python file.
worker = await create_named_worker(
src="./background_tasks.py",
name="task-processor"
)
# Use the worker's exported functions.
result = await worker.process_data([1, 2, 3, 4, 5])
print(result)
```
Key features:
- Access (`await`) named workers via dictionary-like syntax.
- Dynamically create workers from Python.
- Cross-interpreter support (Pyodide and MicroPython).
Worker access is asynchronous - you must `await workers[name]` to get
a reference to the worker. This is because workers may not be ready
immediately at startup.
"""
import js
import json
from polyscript import workers as _polyscript_workers
class _ReadOnlyWorkersProxy:
"""
A read-only proxy for accessing named web workers. Use
`create_named_worker()` to create new workers found in this proxy.
This provides dictionary-like access to named workers defined in
the page. It handles differences between Pyodide and MicroPython
implementations transparently.
(See: https://github.com/pyscript/pyscript/issues/2106 for context.)
The proxy is read-only to prevent accidental modification of the
underlying workers registry. Both item access and attribute access are
supported for convenience (especially since HTML attribute names may
not be valid Python identifiers).
```python
from pyscript import workers
# Access a named worker.
my_worker = await workers["worker-name"]
result = await my_worker.some_function()
# Alternatively, if the name works, access via attribute notation.
my_worker = await workers.worker_name
result = await my_worker.some_function()
```
**This is a proxy object, not a dict**. You cannot iterate over it or
get a list of worker names. This is intentional because worker
startup timing is non-deterministic.
"""
def __getitem__(self, name):
return _get(_workers, name)
"""
Get a named worker by `name`. It returns a promise that resolves to
the worker reference when ready.
This is useful if the underlying worker name is not a valid Python
identifier.
```python
worker = await workers["my-worker"]
```
"""
return js.Reflect.get(_polyscript_workers, name)
def __getattr__(self, name):
return _get(_workers, name)
"""
Get a named worker as an attribute. It returns a promise that resolves
to the worker reference when ready.
This allows accessing workers via dot notation as an alternative
to bracket notation.
```python
worker = await workers.my_worker
```
"""
return js.Reflect.get(_polyscript_workers, name)
workers = _ReadOnlyProxy()
# Global workers proxy for accessing named workers.
workers = _ReadOnlyWorkersProxy()
"""Global proxy for accessing named web workers."""
async def create_named_worker(src="", name="", config=None, type="py"):
from json import dumps
async def create_named_worker(src, name, config=None, type="py"):
"""
Dynamically create a web worker with a `src` Python file, a unique
`name` and optional `config` (dict or JSON string) and `type` (`py`
for Pyodide or `mpy` for MicroPython, the default is `py`).
if not src:
raise ValueError("Named workers require src")
This function creates a new web worker by injecting a `<script>` tag into
the document. The worker will be accessible via the `workers` proxy once
it's ready.
if not name:
raise ValueError("Named workers require a name")
It returns a promise that resolves to the worker reference when ready.
s = _js.document.createElement("script")
s.type = type
s.src = src
_set(s, "worker")
_set(s, "name", name)
```python
from pyscript import create_named_worker
# Create a Pyodide worker.
worker = await create_named_worker(
src="./my_worker.py",
name="background-worker"
)
# Use the worker.
result = await worker.process_data()
# Create with standard PyScript configuration.
worker = await create_named_worker(
src="./processor.py",
name="data-processor",
config={"packages": ["numpy", "pandas"]}
)
# Use MicroPython instead.
worker = await create_named_worker(
src="./lightweight_worker.py",
name="micro-worker",
type="mpy"
)
```
!!! info
**The worker script should define** `__export__` to specify which
functions or objects are accessible from the main thread.
"""
# Create script element for the worker.
script = js.document.createElement("script")
script.type = type
script.src = src
# Mark as a worker with a name.
script.setAttribute("worker", "")
script.setAttribute("name", name)
# Add configuration if provided.
if config:
_set(s, "config", isinstance(config, str) and config or dumps(config))
_js.document.body.append(s)
if isinstance(config, str):
config_str = config
else:
config_str = json.dumps(config)
script.setAttribute("config", config_str)
# Inject the script into the document and await the result.
js.document.body.append(script)
return await workers[name]

View File

@@ -1,14 +1,14 @@
import { ArrayBuffer, TypedArray } from "sabayon/shared";
import IDBMapSync from "@webreflection/idb-map/sync";
import { parse, stringify } from "flatted";
const { isView } = ArrayBuffer;
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 (isView(value)) return stringify(["memoryview", [...value]]);
if (value instanceof ArrayBuffer)
return stringify(["bytearray", [...new Uint8Array(value)]]);
}

View File

@@ -1,3 +1,5 @@
import { idb, getFileSystemDirectoryHandle } from "./fs.js";
export default {
// allow pyterminal checks to bootstrap
is_pyterminal: () => false,
@@ -9,4 +11,34 @@ export default {
sleep(seconds) {
return new Promise(($) => setTimeout($, seconds * 1000));
},
getFSHandler: (options) => getFileSystemDirectoryHandle(options),
/**
* Ask a user action via dialog and returns the directory handler once granted.
* @param {string} uid
* @param {{id?:string, mode?:"read"|"readwrite", hint?:"desktop"|"documents"|"downloads"|"music"|"pictures"|"videos"}} options
* @returns {Promise<boolean>}
*/
async storeFSHandler(uid, options = {}) {
if (await idb.has(uid)) return true;
return getFileSystemDirectoryHandle(options).then(
async (handler) => {
await idb.set(uid, { handler, options });
return true;
},
() => false,
);
},
/**
* Explicitly remove the unique identifier for the FS handler.
* @param {string} uid
* @returns {Promise<boolean>}
*/
async deleteFSHandler(uid) {
const had = await idb.has(uid);
if (had) await idb.delete(uid);
return had;
},
};

View File

@@ -0,0 +1,7 @@
/* eslint no-unused-vars: 0 */
try {
crypto.randomUUID();
} catch (_) {
if (location.href.startsWith("http://0.0.0.0"))
location.href = location.href.replace("0.0.0.0", "localhost");
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
print("OK")

View File

@@ -0,0 +1,21 @@
<!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>
<py-config config-parser="https://esm.run/basic-toml">
[files]
'file.py' = ""
</py-config>
<script type="py">
import file
from pyscript import document
document.documentElement.classList.add("done")
document.body.append("OK")
</script>
</body>
</html>

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<title>Pyodide Media Module Test</title>
<link rel="stylesheet" href="../../dist/core.css">
<script type="module" src="../../dist/core.js"></script>
</head>
<body>
<h1>Pyodide Media Module Test</h1>
<div id="test-results">Running tests...</div>
<script type="py" terminal>
from pyscript import window, document
from pyscript import media
async def run_tests():
# Test basic module structure
assert hasattr(media, "Device"), "media module should have Device class"
assert hasattr(media, "list_devices"), "media module should have list_devices function"
# Test device enumeration
devices = await media.list_devices()
assert isinstance(devices, list), "list_devices should return a list"
# If we have devices, test properties of one
if devices:
device = devices[0]
assert hasattr(device, "id"), "Device should have id property"
assert hasattr(device, "group"), "Device should have group property"
assert hasattr(device, "kind"), "Device should have kind property"
assert hasattr(device, "label"), "Device should have label property"
document.getElementById('test-results').innerText = "Success!"
document.documentElement.classList.add('media-ok')
await run_tests()
</script>
</body>
</html>

View File

@@ -1,6 +1,6 @@
import numpy
import matplotlib
import numpy as np
import matplotlib as mpl
# just do something with the packages
print(len(dir(numpy)))
print(len(dir(matplotlib)))
print(len(dir(np)))
print(len(dir(mpl)))

View File

@@ -2,7 +2,7 @@ from pyscript import document
classList = document.documentElement.classList
if not __terminal__:
if not __terminal__: # noqa: F821 __terminal__ is defined in core/src/plugins/donkey.js
classList.add("error")
else:
classList.add("ok")

View File

@@ -0,0 +1,3 @@
{
"packages": ["numpy"]
}

View File

@@ -0,0 +1,16 @@
<!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 PyWorker
worker = PyWorker("./worker.py", type="pyodide", config="./config.json")
await worker.ready
</script>
</body>
</html>

View File

@@ -0,0 +1,5 @@
import numpy
from pyscript import document
document.body.append(numpy.__version__)
document.documentElement.classList.add("done")

View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PyScript VS Symbols</title>
<script>
globalThis.hasSymbol = (symbol, ref) => symbol in ref;
globalThis.getSymbol = (symbol, ref) => ref[symbol];
// some 3rd party JS library might use symbols to brand-check
// so it's not about symbols traveling from MicroPython
// it's about MicroPython proxies traps not understanding symbols
globalThis.hasIterator = ref => Symbol.iterator in ref;
</script>
<link rel="stylesheet" href="../../dist/core.css">
<script type="module" src="../../dist/core.js"></script>
<script type="mpy">
import js
symbol = js.Symbol.iterator
if js.getSymbol(symbol, []) and js.hasSymbol(symbol, []) and js.hasIterator([]):
js.document.documentElement.classList.add("main")
</script>
<script type="mpy" worker>
from pyscript import window
import js
symbol = js.Symbol.iterator
if window.getSymbol(symbol, []) and window.hasSymbol(symbol, []) and window.hasIterator([]):
window.document.documentElement.classList.add("worker")
</script>
</head>
</html>

View File

@@ -4,4 +4,4 @@ def runtime_version():
return sys.version
__export__ = ['runtime_version']
__export__ = ["runtime_version"]

View File

@@ -14,7 +14,7 @@
print(event.type)
ws.send("hello")
def onmessage(event):
async def onmessage(event):
print(event.type, event.data)
ws.close()

View File

@@ -2,6 +2,18 @@ import { test, expect } from '@playwright/test';
test.setTimeout(120 * 1000);
test('Worker config as relative URL', async ({ page }) => {
await page.goto('http://localhost:8080/tests/javascript/worker-config/index.html');
await page.waitForSelector('html.done');
});
test('config-parser custom TOML', async ({ page }) => {
await page.goto('http://localhost:8080/tests/javascript/config-parser/index.html');
await page.waitForSelector('html.done');
const body = await page.evaluate(() => document.body.innerText);
await expect(body.trim()).toBe('OK');
});
test('MicroPython display', async ({ page }) => {
await page.goto('http://localhost:8080/tests/javascript/mpy.html');
await page.waitForSelector('html.done.worker');
@@ -59,6 +71,11 @@ test('MicroPython + configURL', async ({ page }) => {
await page.waitForSelector('html.main.worker');
});
test('MicroPython + Symbols', async ({ page }) => {
await page.goto('http://localhost:8080/tests/javascript/worker-symbols.html');
await page.waitForSelector('html.main.worker');
});
test('Pyodide + terminal on Main', async ({ page }) => {
await page.goto('http://localhost:8080/tests/javascript/py-terminal-main.html');
await page.waitForSelector('html.ok');
@@ -171,3 +188,24 @@ test('MicroPython buffered NO error', async ({ page }) => {
const body = await page.evaluate(() => document.body.textContent.trim());
await expect(body).toBe('');
});
test('Pyodide media module', async ({ page }) => {
await page.context().grantPermissions(['camera', 'microphone']);
await page.context().addInitScript(() => {
const originalEnumerateDevices = navigator.mediaDevices.enumerateDevices;
navigator.mediaDevices.enumerateDevices = async function() {
const realDevices = await originalEnumerateDevices.call(this);
if (!realDevices || realDevices.length === 0) {
return [
{ deviceId: 'camera1', groupId: 'group1', kind: 'videoinput', label: 'Simulated Camera' },
{ deviceId: 'mic1', groupId: 'group2', kind: 'audioinput', label: 'Simulated Microphone' }
];
}
return realDevices;
};
});
await page.goto('http://localhost:8080/tests/javascript/media.html');
await page.waitForSelector('html.media-ok', { timeout: 10000 });
const isSuccess = await page.evaluate(() => document.documentElement.classList.contains('media-ok'));
expect(isSuccess).toBe(true);
});

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="../../../dist/core.css">
<script>
window.Worker = class extends Worker {
constructor(url, ...rest) {
console.log(rest[0]);
return super(url, ...rest);
}
};
window.start = Date.now();
</script>
<script type="module" src="../../../dist/core.js"></script>
</head>
<body>
<script type="py" config="./index.toml" src="index.py" worker></script>
</body>
</html>

View File

@@ -0,0 +1,3 @@
from pyscript import document, window
document.body.append(window.Date.now() - window.start)

View File

@@ -0,0 +1,2 @@
experimental_ffi_timeout = 0
package_cache = "passthrough"

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<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>
</head>
<body>
<script type="mpy" src="index.py"></script>
</body>
</html>

View File

@@ -0,0 +1,48 @@
import os
from pyscript import RUNNING_IN_WORKER, fs
TEST = "implicit"
if TEST == "implicit":
await fs.mount("/persistent")
print(
(RUNNING_IN_WORKER and "Worker") or "Main",
os.listdir("/persistent"),
)
from random import random
with open("/persistent/random.txt", "w") as f:
f.write(str(random()))
await fs.sync("/persistent")
# await fs.revoke("/persistent")
elif not RUNNING_IN_WORKER:
from pyscript import document
button = document.createElement("button")
button.textContent = "mount"
document.body.append(button)
async def mount(event):
try:
await fs.mount("/persistent")
print(os.listdir("/persistent"))
button.textContent = "unmount"
button.onclick = unmount
except:
import js
js.alert("unable to grant access")
async def unmount(event):
await fs.revoke("/persistent")
button.textContent = "mount"
button.onclick = mount
button.onclick = mount

View File

@@ -1,4 +1,4 @@
""" (c) https://github.com/ryanking13/pyodide-pygame-demo/blob/main/examples/aliens.html
"""(c) https://github.com/ryanking13/pyodide-pygame-demo/blob/main/examples/aliens.html
pygame.examples.aliens
Shows a mini game where you have to defend against aliens.
@@ -22,17 +22,21 @@ Controls
* f key to toggle between fullscreen.
"""
import asyncio
import random
import os
import pathlib
import pyscript
# import basic pygame modules
import pygame
# see if we can load more than standard BMP
if not pygame.image.get_extended():
raise SystemExit("Sorry, extended image module required")
msg = "Sorry, extended image module required"
raise SystemExit(msg)
# game constants
@@ -46,13 +50,15 @@ SCORE = 0
main_dir = str(pathlib.Path(pygame.__file__).parent / "examples")
def load_image(file):
"""loads an image, prepares it for play"""
file = os.path.join(main_dir, "data", file)
try:
surface = pygame.image.load(file)
except pygame.error:
raise SystemExit(f'Could not load image "{file}" {pygame.get_error()}')
msg = f'Could not load image "{file}" {pygame.get_error()}'
raise SystemExit(msg)
return surface.convert()
@@ -62,8 +68,7 @@ def load_sound(file):
return None
file = os.path.join(main_dir, "data", file)
try:
sound = pygame.mixer.Sound(file)
return sound
return pygame.mixer.Sound(file)
except pygame.error:
print(f"Warning, unable to load, {file}")
return None
@@ -223,7 +228,7 @@ class Score(pygame.sprite.Sprite):
def update(self):
"""We only update the score in update() when it has changed."""
if SCORE != self.lastscore:
if self.lastscore != SCORE:
self.lastscore = SCORE
msg = "Score: %d" % SCORE
self.image = self.font.render(msg, 0, self.color)
@@ -292,7 +297,7 @@ async def main(winstyle=0):
# Create Some Starting Values
global score
alienreload = ALIEN_RELOAD
clock = pygame.Clock()
_clock = pygame.Clock()
# initialize our starting sprites
global SCORE
@@ -309,21 +314,16 @@ async def main(winstyle=0):
return
if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE:
return
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_f:
if event.type == pygame.KEYDOWN and event.key == pygame.K_f:
if not fullscreen:
print("Changing to FULLSCREEN")
screen_backup = screen.copy()
screen = pygame.display.set_mode(
SCREENRECT.size, winstyle | pygame.FULLSCREEN, bestdepth
)
screen = pygame.display.set_mode(SCREENRECT.size, winstyle | pygame.FULLSCREEN)
screen.blit(screen_backup, (0, 0))
else:
print("Changing to windowed mode")
screen_backup = screen.copy()
screen = pygame.display.set_mode(
SCREENRECT.size, winstyle, bestdepth
)
screen = pygame.display.set_mode(SCREENRECT.size, winstyle)
screen.blit(screen_backup, (0, 0))
pygame.display.flip()
fullscreen = not fullscreen
@@ -367,7 +367,7 @@ async def main(winstyle=0):
player.kill()
# See if shots hit the aliens.
for alien in pygame.sprite.groupcollide(aliens, shots, 1, 1).keys():
for alien in pygame.sprite.groupcollide(aliens, shots, 1, 1):
if pygame.mixer:
boom_sound.play()
Explosion(alien)
@@ -391,4 +391,5 @@ async def main(winstyle=0):
if pygame.mixer:
pygame.mixer.music.fadeout(1000)
main()

View File

View File

@@ -8,12 +8,7 @@
<script type="module" src="../../../dist/core.js"></script>
</head>
<body>
<script
type="py"
src="aliens.py"
canvas="canvas"
config='{"packages":["pygame-ce"]}'
></script>
<script type="py-game" src="aliens.py" config="./config.toml"></script>
<div class="demo">
<div class="demo-header">pygame.examples.aliens</div>
<div class="demo-content">

Binary file not shown.

View File

@@ -0,0 +1,20 @@
from pyscript import config
MICROPYTHON = config["type"] == "mpy"
if MICROPYTHON:
def new(obj, *args, **kwargs):
return obj.new(*args, kwargs) if kwargs else obj.new(*args)
def call(obj, *args, **kwargs):
return obj(*args, kwargs) if kwargs else obj(*args)
else:
def new(obj, *args, **kwargs):
return obj.new(*args, **kwargs)
def call(obj, *args, **kwargs):
return obj(*args, **kwargs)
if not MICROPYTHON:
import pyodide_js
pyodide_js.setDebug(True)
from pyscript.ffi import to_js, create_proxy

View File

@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Genuary</title>
<!-- Recommended meta tags -->
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<!-- PyScript CSS -->
<link rel="stylesheet" href="../../../dist/core.css">
<style>
body {
margin: 0;
overflow: hidden;
background-color: #4a315e;
color: white;
font-family: Inconsolata, Consolas, Monaco, Courier New;
}
.gutter {
background-color: #eee;
background-repeat: no-repeat;
background-position: 50%;
}
.gutter.gutter-vertical {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAFAQMAAABo7865AAAABlBMVEVHcEzMzMzyAv2sAAAAAXRSTlMAQObYZgAAABBJREFUeF5jOAMEEAIEEFwAn3kMwcB6I2AAAAAASUVORK5CYII=');
cursor: row-resize;
}
py-terminal {
max-height: 7em;
max-width: calc(100vw - 90px);
}
#pyterm {
background-color: #191a1a;
}
#pyterm,
#threejs {
position: relative;
overflow: hidden;
}
</style>
<!-- This script tag bootstraps PyScript -->
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@v0.173.0/build/three.module.js"
}
}
</script>
<script type="module" src="../../../dist/core.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/split.js/1.6.5/split.min.js"></script>
</head>
<body>
<div id="stats"></div>
<div id="stats-off"></div>
<div class="split">
<div id="pyterm"></div>
<div id="threejs"></div>
</div>
<script type="py" src="./main.py" config="./pyscript.toml" async terminal></script>
</body>
</html>

View File

@@ -0,0 +1,83 @@
from dataclasses import dataclass, field
import sys
@dataclass
class BeatSync:
fft_res: int = field()
on_beat: bool = False
beat: int = -1
since_last_beat: float = sys.maxsize
_prev: int = 0
_count: int = 0
_bins: list[int] = field(default_factory=list)
_last_detection: float = -1.0
_threshold: int = 50
_diff: int = 40
_cooldown: float = 0.2
_highest: int = 0
def __post_init__(self):
self._bins = [int(13/16*self.fft_res/2)+17, int(13/16*self.fft_res/2)+18]
def reset(self):
self.beat = -1
self._prev = 0
self._count = 0
self._last_detection = -1.0
self.since_last_beat = sys.maxsize
# print('bs reset')
def update(self, data, running_time):
self._count += 1
self.since_last_beat = running_time - self._last_detection
d = sum(data[bin] for bin in self._bins)
if d < self._threshold:
self.on_beat = False
elif d - self._prev < self._diff:
self.on_beat = False
elif self.since_last_beat < self._cooldown:
self.on_beat = False
else:
self._last_detection = running_time
self.since_last_beat = 0
self.on_beat = True
self.beat += 1
self._prev = d
@dataclass
class FreqIntensity:
freq: float = field()
fft_res: int = field()
intensity: float = 0.0
intensity_slew: float = 0.0
scale_min: float = 0.0
scale_max: float = 350
max: float = 0.0
_sample_rate: int = 48000
_bin_indexes: list[int] = field(default_factory=list)
_harmonics: int = 8
_slew_factor: float = 0.8
def __post_init__(self):
self._bin_indexes = [
round((harmonic+1) * self.freq / self._sample_rate * self.fft_res / 2)
for harmonic in range(self._harmonics)
]
print(self._bin_indexes)
def update(self, data):
intensity = 0.0
for bin in range(self._harmonics):
intensity += data[self._bin_indexes[bin]]/(bin+1)
self.intensity = intensity
self.intensity_slew = self._slew_factor * self.intensity_slew + (1 - self._slew_factor) * intensity
self.max = max(intensity, self.max)
@property
def intensity_scaled(self):
raw = max(0, min(1.0, (self.intensity_slew - self.scale_min)/(self.scale_max - self.scale_min)))
return raw * raw

View File

@@ -0,0 +1,189 @@
import asyncio
from dataclasses import dataclass, field
from typing import Callable
from pyscript import document, window
from pyscript.js_modules import three as THREE
from pyscript.js_modules.stats_gl import default as StatsGL
from pyscript.js_modules import lsgeo, line2, linemat
from multipyjs import MICROPYTHON, new, call, to_js, create_proxy
@dataclass
class SoundPlayer:
sound: THREE.Audio = field()
on_start: Callable[[], None] = field()
on_stop: Callable[[], None] = field(default=lambda: None)
_start_time: float = -1.0
def play(self):
self.sound.stop()
self.on_start()
self._start_time = self.sound.context.currentTime
self.sound.play()
def stop(self):
self.sound.stop()
self.on_stop()
self._start_time = -1.0
def toggle(self):
if self.sound.isPlaying:
self.stop()
else:
self.play()
@property
def running_time(self):
if self.sound.isPlaying:
return self.sound.context.currentTime - self._start_time
elif self._start_time != -1.0:
self.stop()
return 0.0
def get_renderer():
renderer = new(THREE.WebGLRenderer, antialias=True)
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setClearColor(0xF5F0DC)
pyterms = list(document.getElementsByTagName("py-terminal"))
if pyterms:
pyterm = pyterms[0]
pyterm.parentNode.removeChild(pyterm)
document.getElementById("pyterm").appendChild(pyterm)
document.getElementById("threejs").appendChild(renderer.domElement)
initial = {0: "115px", 1: "calc(100vh - 120px)"}
@create_proxy
def split_element_style(dimension, size, gutter_size, index):
if index in initial:
result = {dimension: initial.pop(index)}
else:
result = {dimension: f"calc({int(size)}vh - {gutter_size}px)"}
return to_js(result)
call(
window.Split,
["#pyterm", "#threejs"],
direction="vertical",
elementStyle=split_element_style,
minSize=0,
maxSize=to_js([120, 10000]),
)
return renderer
def get_ortho_camera(view_size):
aspect_ratio = window.innerWidth / window.innerHeight
camera = new(
THREE.OrthographicCamera,
-view_size * aspect_ratio, # Left
view_size * aspect_ratio, # Right
view_size, # Top
-view_size, # Bottom
-view_size, # Near plane
view_size, # Far plane
)
camera.updateProjectionMatrix()
camera.position.set(0, 0, 0)
return camera
def get_loading_manager():
loading_mgr = new(THREE.LoadingManager)
ev = asyncio.Event()
@create_proxy
def on_start(url, itemsLoaded, itemsTotal):
print(f'[{itemsLoaded}/{itemsTotal}] Started loading file: {url}')
loading_mgr.onStart = on_start
@create_proxy
def on_progress(url, itemsLoaded, itemsTotal):
print(f'[{itemsLoaded}/{itemsTotal}] Loading file: {url}')
loading_mgr.onProgress = on_progress
@create_proxy
def on_error(url):
print(f'There was a problem loading {url}')
loading_mgr.onError = on_error
@create_proxy
def on_load():
print('Loading assets complete!')
ev.set()
loading_mgr.onLoad = on_load
return loading_mgr, ev
def get_perspective_camera():
aspect_ratio = window.innerWidth / window.innerHeight
camera = new(
THREE.PerspectiveCamera,
45, # fov
aspect_ratio,
0.25, # near plane
300, # far plane
)
camera.position.set(0, 0, 30)
return camera
def get_stats_gl(renderer):
stats = new(StatsGL, trackGPU=True, horizontal=False)
stats.init(renderer)
stats.dom.style.removeProperty("left")
stats.dom.style.right = "90px"
document.getElementById("stats").appendChild(stats.dom)
return stats
def bg_from_v(*vertices):
geometry = new(THREE.BufferGeometry)
vertices_f32a = new(Float32Array, vertices) # noqa: F821 Float32Array is defined in js
attr = new(THREE.Float32BufferAttribute, vertices_f32a, 3)
return geometry.setAttribute('position', attr)
def bg_from_p(*points):
buf = new(THREE.BufferGeometry)
buf.setFromPoints(
[new(THREE.Vector3, p[0], p[1], p[2]) for p in points]
)
return buf
def clear():
# toggle stats and terminal?
stats_style = document.getElementById("stats-off").style
if stats_style.display == "none":
# turn stuff back on
stats_style.removeProperty("display")
document.getElementById("pyterm").style.height = "115px"
document.getElementById("threejs").style.height = "calc(100vh - 120px)"
for e in document.getElementsByClassName("gutter"):
e.style.removeProperty("display")
for e in document.getElementsByClassName("xterm-helper-textarea"):
e.focus()
break
return
# no longer focus on xterm
document.activeElement.blur()
# hide stats
document.getElementById("stats-off").style.display = "none"
# hide pyterm and split gutter
document.getElementById("pyterm").style.height = "0vh"
document.getElementById("threejs").style.height = "100vh"
for e in document.getElementsByClassName("gutter"):
e.style.display = "none"
# hide ltk ad
for e in document.getElementsByClassName("ltk-built-with"):
e.style.display = "none"
# hide pyscript ad
for e in document.getElementsByTagName("div"):
style = e.getAttribute("style")
if style and style.startswith("z-index:999"):
e.style.display = "none"
for e in document.getElementsByTagName("svg"):
style = e.getAttribute("style")
if style and style.startswith("z-index:999"):
e.style.display = "none"

View File

@@ -0,0 +1,285 @@
print("Starting up...")
from array import array
import asyncio
import math
import time
from pyscript import document, window, PyWorker
from libthree import THREE, clear, SoundPlayer
from libthree import get_renderer, get_ortho_camera
from libthree import get_loading_manager, get_stats_gl
from libthree import line2, linemat, lsgeo
from libfft import BeatSync
from multipyjs import MICROPYTHON, new, call, to_js, create_proxy
from js import Float32Array
scene = new(THREE.Scene)
view_size = 1
renderer = get_renderer()
camera = get_ortho_camera(view_size)
loading_mgr, loaded_event = get_loading_manager()
t_loader = new(THREE.TextureLoader, loading_mgr)
t_loader.setPath('assets/')
light = new(THREE.AmbientLight, 0xffffff, 1.0)
scene.add(light)
fft_res = 2048
audio_listener = new(THREE.AudioListener)
camera.add(audio_listener)
sound = new(THREE.Audio, audio_listener)
audio_loader = new(THREE.AudioLoader, loading_mgr)
analyser = new(THREE.AudioAnalyser, sound, fft_res)
@create_proxy
def on_audio_load(buffer):
sound.setBuffer(buffer)
sound.setVolume(0.9)
sound.setLoop(False)
audio_loader.load("assets/genuary25-18.m4a", on_audio_load)
spheres = new(THREE.Group)
scene.add(spheres)
line_basic_mat = new(
THREE.LineBasicMaterial,
color=0xffffff,
)
zero_mat = new(
linemat.LineMaterial,
color=0x662503,
linewidth=3,
)
other_mat = new(
linemat.LineMaterial,
color=0x662503,
linewidth=1.5,
)
grid_mat = new(
linemat.LineMaterial,
color=0x662503,
linewidth=1,
dashed=True,
dashScale=1,
dashSize=0.5,
gapSize=1,
dashOffset=0,
)
lines = [new(THREE.Group), new(THREE.Group)]
scene.add(lines[0])
scene.add(lines[1])
def draw_lines(line_coords, mat_name, spy=False):
if spy:
line_coords_f32a = new(Float32Array, line_coords.length)
_it = line_coords.items
for i in range(line_coords.length):
line_coords_f32a[i] = _it[i]
else:
line_coords_f32a = new(Float32Array, line_coords)
if mat_name == 'zero':
mat = zero_mat
elif mat_name == 'grid':
mat = grid_mat
else:
mat = other_mat
geo = new(THREE.BufferGeometry)
geo.setAttribute('position', new(THREE.BufferAttribute, line_coords_f32a, 3))
seg = new(THREE.LineSegments, geo, line_basic_mat)
lsg = new(lsgeo.LineSegmentsGeometry)
lsg.fromLineSegments(seg)
l1 = new(line2.Line2, lsg, mat)
l1.computeLineDistances()
l2 = new(line2.Line2, lsg, mat)
l2.computeLineDistances()
lines[0].add(l1)
lines[1].add(l2)
seg.geometry.dispose()
del geo
del seg
def drawing_done():
maybe_with_spy = "with SPy" if USE_SPY else "with pure Python"
print(f"Time elapsed computing {maybe_with_spy}:", time.time() - start_ts)
drawing_event.set()
grid_width = 0
grid_height = 0
scroll_offset = 0
def scale_lines(grid_ws=None, grid_hs=None, offset=None):
global grid_width, grid_height, scroll_offset
if grid_ws:
grid_width = grid_ws
else:
grid_ws = grid_width
if grid_hs:
grid_height = grid_hs
else:
grid_hs = grid_height
if offset:
scroll_offset = offset
else:
offset = scroll_offset
scale = 2.04/grid_hs
lines[0].scale.set(scale, scale, scale)
lines[1].scale.set(scale, scale, scale)
lines[0].position.set((offset - grid_ws/2) * scale, -grid_hs/2 * scale, 0)
lines[1].position.set((offset + grid_ws/2) * scale, -grid_hs/2 * scale, 0)
def append_p(lines, p1, p2):
lines.append(p1[0])
lines.append(p1[1])
lines.append(0)
lines.append(p2[0])
lines.append(p2[1])
lines.append(0)
def initial_calc():
grid_w = int(1920 * 4)
grid_h = 1080 * 2
grid_scale = 10
noise_factor = 500
grid_hs = int(grid_h/grid_scale)
grid_ws = int(grid_w/grid_scale)
crossfade_range = int(grid_ws/12.5)
def grid_lines():
lines = array("d")
grid_goal = 24
grid_size_i = int(round((grid_ws - crossfade_range) / grid_goal))
grid_actual = (grid_ws - crossfade_range) / grid_size_i
for i in range(0, grid_size_i):
x = i * grid_actual
append_p(lines, (x, 0), (x, grid_hs))
for y in range(0, grid_hs, grid_goal):
append_p(lines, (0, y), (grid_ws-crossfade_range, y))
return lines
import perlin
spy_perlin = perlin.lib
spy_perlin.init()
spy_perlin.seed(44)
scale_lines(grid_ws - crossfade_range, grid_hs)
print("Computing the height map")
spy_perlin.make_height_map(grid_ws, grid_hs)
spy_perlin.update_height_map(grid_ws, grid_hs, grid_scale / noise_factor, 0)
print("Cross-fading the height map")
spy_perlin.crossfade_height_map(grid_ws, grid_hs, crossfade_range)
print("Drawing grid")
draw_lines(grid_lines(), 'grid')
print("Marching squares")
draw_lines(spy_perlin.marching_squares(grid_ws, grid_hs, 0), 'zero', spy=True)
draw_lines(spy_perlin.marching_squares(grid_ws, grid_hs, 0.3), 'positive', spy=True)
draw_lines(spy_perlin.marching_squares(grid_ws, grid_hs, -0.3), 'negative', spy=True)
draw_lines(spy_perlin.marching_squares(grid_ws, grid_hs, 0.45), 'positive', spy=True)
draw_lines(spy_perlin.marching_squares(grid_ws, grid_hs, -0.45), 'negative', spy=True)
draw_lines(spy_perlin.marching_squares(grid_ws, grid_hs, 0.6), 'positive', spy=True)
draw_lines(spy_perlin.marching_squares(grid_ws, grid_hs, -0.6), 'negative', spy=True)
draw_lines(spy_perlin.marching_squares(grid_ws, grid_hs, -0.8), 'negative', spy=True)
draw_lines(spy_perlin.marching_squares(grid_ws, grid_hs, 0.8), 'positive', spy=True)
drawing_done()
drawing_event = asyncio.Event()
start_ts = time.time()
USE_SPY = True
if USE_SPY:
initial_calc()
else:
worker = PyWorker("./worker.py", type="pyodide", configURL="./pyscript.toml")
worker.sync.draw_lines = draw_lines
worker.sync.drawing_done = drawing_done
worker.sync.scale_lines = scale_lines
worker.sync.print = print
@create_proxy
def on_tap(event):
clear()
player.toggle()
document.addEventListener("click", on_tap)
@create_proxy
def on_key_down(event):
element = document.activeElement
_class = element.getAttribute("class")
in_xterm = element.tagName != "BODY" and _class and "xterm" in _class
if event.code == "Backquote":
# Screenshot mode.
clear()
elif not in_xterm:
# Don't react to those bindings when typing code.
if event.code == "Space":
player.toggle()
document.addEventListener("keydown", on_key_down)
@create_proxy
def on_window_resize(event):
aspect_ratio = window.innerWidth / window.innerHeight
if camera.type == "OrthographicCamera":
camera.left = -view_size * aspect_ratio
camera.right = view_size * aspect_ratio
camera.top = view_size
camera.bottom = -view_size
camera.updateProjectionMatrix()
elif camera.type == "PerspectiveCamera":
camera.aspect = window.innerWidth / window.innerHeight
camera.updateProjectionMatrix()
else:
raise ValueError("Unknown camera type")
renderer.setSize(window.innerWidth, window.innerHeight)
scale_lines()
window.addEventListener("resize", on_window_resize)
@create_proxy
def animate(now=0.0):
data = analyser.getFrequencyData()#.to_py() in Pyodide
audio_now = player.running_time
bs.update(data, audio_now)
if grid_width:
offset = -((20 * audio_now) % grid_width)
scale_lines(offset=offset)
renderer.render(scene, camera)
stats_gl.update()
def reset():
global scroll_offset
bs.reset()
scale_lines()
def on_stop():
global scroll_offset
bs.reset()
scale_lines()
await loaded_event.wait()
stats_gl = get_stats_gl(renderer)
player = SoundPlayer(sound=sound, on_start=reset, on_stop=on_stop)
bs = BeatSync(fft_res=fft_res)
renderer.setAnimationLoop(animate)
print("Waiting for the contours...")
await drawing_event.wait()
print("Tap the map to start...")

View File

@@ -0,0 +1,110 @@
# Translated from https://github.com/josephg/noisejs.
from libthree import THREE
from multipyjs import new
class V3:
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
def __repr__(self):
return f"V3({self.x}, {self.y}, {self.z})"
def dot2(self, x, y):
return self.x * x + self.y * y
def dot3(self, x, y, z):
return self.x * x + self.y * y + self.z * z
def to_js(self, scale=1.0):
return new(THREE.Vector3, self.x * scale, self.y * scale, self.z * scale)
PERM = [0] * 512
V3_P = [0] * 512 # assigned V3s in seed()
P = [151, 160, 137, 91, 90, 15,
131, 13, 201, 95, 96, 53, 194, 233, 7, 225, 140, 36, 103, 30, 69, 142, 8, 99, 37, 240, 21, 10, 23,
190, 6, 148, 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, 57, 177, 33,
88, 237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, 74, 165, 71, 134, 139, 48, 27, 166,
77, 146, 158, 231, 83, 111, 229, 122, 60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244,
102, 143, 54, 65, 25, 63, 161, 1, 216, 80, 73, 209, 76, 132, 187, 208, 89, 18, 169, 200, 196,
135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186, 3, 64, 52, 217, 226, 250, 124, 123,
5, 202, 38, 147, 118, 126, 255, 82, 85, 212, 207, 206, 59, 227, 47, 16, 58, 17, 182, 189, 28, 42,
223, 183, 170, 213, 119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9,
129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104, 218, 246, 97, 228,
251, 34, 242, 193, 238, 210, 144, 12, 191, 179, 162, 241, 81, 51, 145, 235, 249, 14, 239, 107,
49, 192, 214, 31, 181, 199, 106, 157, 184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254,
138, 236, 205, 93, 222, 114, 67, 29, 24, 72, 243, 141, 128, 195, 78, 66, 215, 61, 156, 180]
V3_I = [V3(1, 1, 0), V3(-1, 1, 0), V3(1, -1, 0), V3(-1, -1, 0),
V3(1, 0, 1), V3(-1, 0, 1), V3(1, 0, -1), V3(-1, 0, -1),
V3(0, 1, 1), V3(0, -1, 1), V3(0, 1, -1), V3(0, -1, -1)]
def seed(s):
if isinstance(s, float) and 0.0 < s < 1.0:
s *= 65536
s = int(s)
if s < 256:
s |= s << 8
for i in range(256):
if i & 1:
v = P[i] ^ (s & 255)
else:
v = P[i] ^ ((s >> 8) & 255)
PERM[i] = PERM[i + 256] = v
V3_P[i] = V3_P[i + 256] = V3_I[v % 12]
seed(0)
def fade(t):
return t * t * t * (t * (t * 6 - 15) + 10)
def lerp(a, b, t):
return (1 - t) * a + t * b
def perlin3(x, y, z):
# grid cells
x_c = int(x)
y_c = int(y)
z_c = int(z)
# relative coords within the cell
x -= x_c
y -= y_c
z -= z_c
# wrap cells
x_c &= 255
y_c &= 255
z_c &= 255
# noise contributions to corners
n000 = V3_P[x_c + PERM[y_c + PERM[z_c]]].dot3(x, y, z)
n001 = V3_P[x_c + PERM[y_c + PERM[z_c + 1]]].dot3(x, y, z - 1)
n010 = V3_P[x_c + PERM[y_c + 1 + PERM[z_c]]].dot3(x, y - 1, z)
n011 = V3_P[x_c + PERM[y_c + 1 + PERM[z_c + 1]]].dot3(x, y - 1, z - 1)
n100 = V3_P[x_c + 1 + PERM[y_c + PERM[z_c]]].dot3(x - 1, y, z)
n101 = V3_P[x_c + 1 + PERM[y_c + PERM[z_c + 1]]].dot3(x - 1, y, z - 1)
n110 = V3_P[x_c + 1 + PERM[y_c + 1 + PERM[z_c]]].dot3(x - 1, y - 1, z)
n111 = V3_P[x_c + 1 + PERM[y_c + 1 + PERM[z_c + 1]]].dot3(x - 1, y - 1, z - 1)
# fade curve
u = fade(x)
v = fade(y)
w = fade(z)
# interpolation
return lerp(
lerp(lerp(n000, n100, u), lerp(n001, n101, u), w),
lerp(lerp(n010, n110, u), lerp(n011, n111, u), w),
v,
)
def curl2(x, y, z):
# https://www.bit-101.com/2017/2021/07/curl-noise/
delta = 0.01
n1 = perlin3(x + delta, y, z)
n2 = perlin3(x - delta, y, z)
cy = -(n1 - n2) / (delta * 2)
n1 = perlin3(x, y + delta, z)
n2 = perlin3(x, y - delta, z)
cx = -(n1 - n2) / (delta * 2)
print(n1, n2)
return V3(cx, cy, 0)

Some files were not shown because too many files have changed in this diff Show More